Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974
This commit is contained in:
commit
c737a37f71
531 files changed
+119270
No files matched your search
@@ -0,0 +1,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 |
@@ -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.
|
||||
Vendored
+85
@@ -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';
|
||||
});
|
||||
})();
|
||||
@@ -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 · 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>
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// ── PS5 Autoloader ─────────────────────────────────────────────────────────────
|
||||
let _alSequence = []; // { type: 'payload'|'delay', filename?: string, ms?: number }
|
||||
let _alHeader = ''; // comment block preserved from original file
|
||||
|
||||
const _AL_DEFAULT_HEADER =
|
||||
`#
|
||||
# ps5_autoloader - autoload.txt
|
||||
# Edited with Chimera
|
||||
# -----------------------------------------------------------------------------------------
|
||||
# Usage:
|
||||
# - Put one filename per line (e.g., payload.elf or script.js).
|
||||
# - Supported payload types: .elf, .bin, .js
|
||||
# - Lines starting with '!' are sleep commands (example: !1000 sleeps for 1000 ms).
|
||||
# -----------------------------------------------------------------------------------------
|
||||
`;
|
||||
|
||||
function _alParse(content) {
|
||||
if (!content) return { header: _AL_DEFAULT_HEADER, entries: [] };
|
||||
const lines = content.split(/\r?\n/);
|
||||
const headerLines = [];
|
||||
const entries = [];
|
||||
let headerDone = false;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!headerDone) {
|
||||
if (t.startsWith('#') || t === '') { headerLines.push(line); continue; }
|
||||
headerDone = true;
|
||||
}
|
||||
if (t === '' || t.startsWith('#')) continue;
|
||||
if (t.startsWith('!')) {
|
||||
const ms = parseInt(t.slice(1), 10);
|
||||
if (!isNaN(ms)) entries.push({ type: 'delay', ms });
|
||||
} else {
|
||||
entries.push({ type: 'payload', filename: t });
|
||||
}
|
||||
}
|
||||
const header = headerLines.join('\n').trimEnd() + '\n\n';
|
||||
return { header: header || _AL_DEFAULT_HEADER, entries };
|
||||
}
|
||||
|
||||
function _alSerialize(header, entries) {
|
||||
const lines = [header.trimEnd(), ''];
|
||||
for (const e of entries)
|
||||
lines.push(e.type === 'delay' ? `!${e.ms}` : e.filename);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
let _alDragIdx = -1;
|
||||
|
||||
function _alSecsFmt(ms) {
|
||||
const s = ms / 1000;
|
||||
return s === Math.floor(s) ? s.toFixed(1) + 's' : s.toFixed(2) + 's';
|
||||
}
|
||||
|
||||
function _alBuildRow(entry, i) {
|
||||
const del = `<button class="al-del" data-al="remove" data-idx="${i}" title="Remove">✕</button>`;
|
||||
if (entry.type === 'delay') {
|
||||
const capped = Math.min(entry.ms, 10000);
|
||||
const presets = [250, 500, 1000, 2000, 3000, 5000].map(p => {
|
||||
const lbl = p < 1000 ? `${p}ms` : `${p / 1000}s`;
|
||||
const active = entry.ms === p ? ' al-preset-active' : '';
|
||||
return `<button class="al-preset${active}" data-al="preset" data-idx="${i}" data-ms="${p}">${lbl}</button>`;
|
||||
}).join('');
|
||||
return `<div class="al-row al-delay" draggable="true" data-idx="${i}">
|
||||
<span class="al-grip" title="Drag to reorder">⋮⋮</span>
|
||||
<span class="al-delay-clock">⏱</span>
|
||||
<div class="al-delay-body">
|
||||
<div class="al-delay-top">
|
||||
<span class="al-delay-lbl">Wait</span>
|
||||
<input class="al-delay-num" type="number" min="0" max="60000" step="50"
|
||||
value="${entry.ms}" data-al="delay-num" data-idx="${i}">
|
||||
<span class="al-delay-ms">ms</span>
|
||||
<span class="al-delay-secs" data-idx="${i}">(${_alSecsFmt(entry.ms)})</span>
|
||||
<div class="al-presets">${presets}</div>
|
||||
</div>
|
||||
<input class="al-slider" type="range" min="0" max="10000" step="50"
|
||||
value="${capped}" data-al="delay-slider" data-idx="${i}">
|
||||
</div>
|
||||
${del}
|
||||
</div>`;
|
||||
}
|
||||
const ext = (entry.filename.split('.').pop() || '').toLowerCase();
|
||||
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
|
||||
return `<div class="al-row al-payload" draggable="true" data-idx="${i}">
|
||||
<span class="al-grip" title="Drag to reorder">⋮⋮</span>
|
||||
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
|
||||
<span class="al-name" title="${escHtml(entry.filename)}">${escHtml(entry.filename)}</span>
|
||||
<button class="al-ins-delay" data-al="ins-delay" data-idx="${i}" title="Insert delay after this payload">+⏱</button>
|
||||
${del}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _alRenderSequence() {
|
||||
const seq = $('autoloader-sequence');
|
||||
if (!seq) return;
|
||||
if (!_alSequence.length) {
|
||||
seq.innerHTML = `<div class="al-empty">
|
||||
<div class="al-empty-icon">📄</div>
|
||||
<div>No entries yet.</div>
|
||||
<div class="al-empty-hint">Click a file on the left to add it to the sequence.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
seq.innerHTML = _alSequence.map(_alBuildRow).join('');
|
||||
seq.querySelectorAll('.al-row[draggable]').forEach(row => {
|
||||
row.addEventListener('dragstart', e => {
|
||||
_alDragIdx = parseInt(row.dataset.idx, 10);
|
||||
row.classList.add('al-dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
row.classList.remove('al-dragging');
|
||||
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
|
||||
});
|
||||
row.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
|
||||
if (parseInt(row.dataset.idx, 10) !== _alDragIdx) row.classList.add('al-drag-over');
|
||||
});
|
||||
row.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
let toIdx = parseInt(row.dataset.idx, 10);
|
||||
if (_alDragIdx === -1 || _alDragIdx === toIdx) return;
|
||||
const [item] = _alSequence.splice(_alDragIdx, 1);
|
||||
if (_alDragIdx < toIdx) toIdx--;
|
||||
_alSequence.splice(toIdx, 0, item);
|
||||
_alDragIdx = -1;
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _alRenderFiles(files) {
|
||||
const list = $('autoloader-file-list');
|
||||
if (!list) return;
|
||||
const payloads = files.filter(f => !f.isDir && /\.(elf|bin|js)$/i.test(f.name));
|
||||
if (!payloads.length) {
|
||||
list.innerHTML = '<p class="hint autoloader-empty-hint">No .elf / .bin / .js files found in /data/ps5_autoloader.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = payloads.map(f => {
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||||
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
|
||||
return `<div class="al-file-card" data-al-add="${escHtml(f.name)}" title="Click to add to sequence">
|
||||
<span class="al-ext ${extClass}">${escHtml(ext)}</span>
|
||||
<span class="al-file-name">${escHtml(f.name)}</span>
|
||||
<button class="btn-al-save-local" data-filename="${escHtml(f.name)}" title="Save to local payloads folder">⇣</button>
|
||||
<span class="al-file-add">+</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadAutoloader() {
|
||||
const btn = $('btn-autoloader-refresh');
|
||||
const status = $('autoloader-status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Loading…';
|
||||
try {
|
||||
const files = await window.pork.autoloaderList();
|
||||
const content = await window.pork.autoloaderRead();
|
||||
const { header, entries } = _alParse(content);
|
||||
_alHeader = header;
|
||||
_alSequence = entries;
|
||||
_alRenderFiles(files);
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
status.textContent = content
|
||||
? `Loaded \u2014 ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`
|
||||
: 'autoload.txt not found \u2014 sequence is empty';
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutoloader() {
|
||||
const btn = $('btn-autoloader-save');
|
||||
const status = $('autoloader-status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Saving\u2026';
|
||||
try {
|
||||
const content = _alSerialize(_alHeader, _alSequence);
|
||||
await window.pork.autoloaderSave(content);
|
||||
status.textContent = 'Saved to PS5 \u2713';
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-autoloader-refresh').addEventListener('click', loadAutoloader);
|
||||
$('btn-autoloader-save').addEventListener('click', saveAutoloader);
|
||||
|
||||
const _persistButton = $('btn-pldmgr-persist-repair');
|
||||
if (_persistButton && window.pork.payloadManagerAutoloadRepairPlan) {
|
||||
$('pldmgr-persistence-panel').hidden = false;
|
||||
_persistButton.addEventListener('click', async () => {
|
||||
const status = $('pldmgr-persistence-status');
|
||||
_persistButton.disabled = true;
|
||||
status.textContent = window.t('payload.persistence.preflight');
|
||||
try {
|
||||
const plan = await window.pork.payloadManagerAutoloadRepairPlan();
|
||||
if (plan.blocked) {
|
||||
throw new Error(window.tf('payload.persistence.blocked', 'Other boot entries found: {entries}. Chimera will not remove them.', { entries: plan.unsupportedEntries.join(', ') }));
|
||||
}
|
||||
const confirmation = await showExactConfirmation(
|
||||
window.t('payload.persistence.confirm'), plan.confirmation
|
||||
);
|
||||
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
status.textContent = window.t('payload.persistence.applying');
|
||||
const result = await window.pork.payloadManagerAutoloadRepairApply({ planDigest: plan.planDigest, confirmation });
|
||||
status.textContent = window.tf('payload.persistence.success', 'Persistent v0.5.1 verified. Snapshot: {snapshot}. Reboot check required.', { snapshot: result.snapshotId });
|
||||
await loadAutoloader();
|
||||
} catch (error) {
|
||||
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
|
||||
} finally {
|
||||
_persistButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const _rollbackButton = $('btn-pldmgr-persist-rollback');
|
||||
if (_rollbackButton && window.pork.payloadManagerAutoloadRollbackPlan) {
|
||||
_rollbackButton.addEventListener('click', async () => {
|
||||
const status = $('pldmgr-persistence-status');
|
||||
_rollbackButton.disabled = true;
|
||||
status.textContent = window.t('payload.persistence.rollbackPreflight');
|
||||
try {
|
||||
const plan = await window.pork.payloadManagerAutoloadRollbackPlan();
|
||||
if (plan.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
const confirmation = await showExactConfirmation(window.t('payload.persistence.rollbackConfirm'), plan.confirmation);
|
||||
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
status.textContent = window.t('payload.persistence.rollbackApplying');
|
||||
const result = await window.pork.payloadManagerAutoloadRollbackApply({ planDigest: plan.planDigest, confirmation });
|
||||
status.textContent = window.tf('payload.persistence.rollbackSuccess', 'Original boot state restored. Safety snapshot: {snapshot}. Reboot required.', { snapshot: result.safetySnapshotId });
|
||||
await loadAutoloader();
|
||||
} catch (error) {
|
||||
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
|
||||
} finally { _rollbackButton.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
function _etaHenPersistenceAction({ buttonId, plan, apply, preflight, confirm, success, snapshot = 'snapshotId' }) {
|
||||
const button = $(buttonId);
|
||||
if (!button || !window.pork[plan]) return;
|
||||
button.addEventListener('click', async () => {
|
||||
const status = $('etahen-persistence-status');
|
||||
button.disabled = true;
|
||||
status.textContent = window.t(preflight);
|
||||
try {
|
||||
const packet = await window.pork[plan]();
|
||||
const entries = [...(packet.unexpectedFiles || []), ...(packet.unsupportedEntries || [])];
|
||||
if (packet.blocked) throw new Error(window.tf('payload.etahenPersistence.blocked', 'Other boot content found: {entries}.', { entries: entries.join(', ') }));
|
||||
if (packet.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
const confirmation = await showExactConfirmation(window.t(confirm), packet.confirmation);
|
||||
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
const result = await window.pork[apply]({ planDigest: packet.planDigest, confirmation });
|
||||
status.textContent = window.tf(success, 'etaHEN 2.6B boot state updated. Snapshot: {snapshot}. Reboot required.', { snapshot: result[snapshot] });
|
||||
await loadAutoloader();
|
||||
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', 'etaHEN persistence failed: {message}', { message: error.message }); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist', plan: 'etaHenAutoloadPersistencePlan', apply: 'etaHenAutoloadPersistenceApply', preflight: 'payload.etahenPersistence.preflight', confirm: 'payload.etahenPersistence.confirm', success: 'payload.etahenPersistence.success' });
|
||||
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist-rollback', plan: 'etaHenAutoloadPersistenceRollbackPlan', apply: 'etaHenAutoloadPersistenceRollback', preflight: 'payload.etahenPersistence.rollbackPreflight', confirm: 'payload.etahenPersistence.rollbackConfirm', success: 'payload.etahenPersistence.rollbackSuccess', snapshot: 'safetySnapshotId' });
|
||||
|
||||
function _fixedPayloadStart({ buttonId, api, exact, confirmKey, startingKey, startedKey }) {
|
||||
const button = $(buttonId);
|
||||
if (!button || !window.pork[api]) return;
|
||||
button.addEventListener('click', async () => {
|
||||
const status = $('etahen-persistence-status');
|
||||
const confirmation = await showExactConfirmation(window.t(confirmKey), exact);
|
||||
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
|
||||
button.disabled = true;
|
||||
status.textContent = window.t(startingKey);
|
||||
try {
|
||||
const result = await window.pork[api]({ confirmation });
|
||||
status.textContent = window.tf(startedKey, '', { bytes: result.bytes });
|
||||
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', '', { message: error.message }); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
_fixedPayloadStart({ buttonId: 'btn-etahen-direct-start', api: 'etaHenDirectStart', exact: 'START DIRECT ETAHEN 2.6B', confirmKey: 'payload.etahenPersistence.startConfirm', startingKey: 'payload.etahenPersistence.starting', startedKey: 'payload.etahenPersistence.started' });
|
||||
_fixedPayloadStart({ buttonId: 'btn-pldmgr-verified-start', api: 'payloadManagerVerifiedStart', exact: 'START VERIFIED PS5 PAYLOAD MANAGER 0.5.1', confirmKey: 'payload.persistence.startConfirm', startingKey: 'payload.persistence.starting', startedKey: 'payload.persistence.started' });
|
||||
|
||||
$('btn-autoloader-add-delay').addEventListener('click', () => {
|
||||
_alSequence.push({ type: 'delay', ms: 1000 });
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
});
|
||||
|
||||
$('autoloader-sequence').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-al]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.al;
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
if (action === 'remove') {
|
||||
_alSequence.splice(idx, 1);
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
} else if (action === 'preset') {
|
||||
const ms = parseInt(btn.dataset.ms, 10);
|
||||
_alSequence[idx].ms = ms;
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
} else if (action === 'ins-delay') {
|
||||
_alSequence.splice(idx + 1, 0, { type: 'delay', ms: 1000 });
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('autoloader-sequence').addEventListener('input', e => {
|
||||
const el = e.target;
|
||||
const al = el.dataset.al;
|
||||
if (!al) return;
|
||||
const idx = parseInt(el.dataset.idx, 10);
|
||||
if (al === 'delay-num' || al === 'delay-slider') {
|
||||
const ms = Math.max(0, parseInt(el.value, 10) || 0);
|
||||
_alSequence[idx].ms = ms;
|
||||
const row = el.closest('.al-row');
|
||||
const num = row.querySelector('[data-al="delay-num"]');
|
||||
const slider = row.querySelector('[data-al="delay-slider"]');
|
||||
const secs = row.querySelector('.al-delay-secs');
|
||||
if (num) num.value = ms;
|
||||
if (slider) slider.value = Math.min(ms, 10000);
|
||||
if (secs) secs.textContent = `(${_alSecsFmt(ms)})`;
|
||||
row.querySelectorAll('.al-preset').forEach(p =>
|
||||
p.classList.toggle('al-preset-active', parseInt(p.dataset.ms, 10) === ms)
|
||||
);
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Autoloader Snapshots ─────────────────────────────────────────────────────────────────────────────
|
||||
async function renderAutoloaderSnapshots() {
|
||||
const list = $('snapshot-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<p class="hint autoloader-empty-hint">Loading…</p>';
|
||||
try {
|
||||
const snaps = await window.pork.snapshotList();
|
||||
if (!snaps.length) {
|
||||
list.innerHTML = '<p class="hint autoloader-empty-hint">No snapshots yet. Take one above.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = snaps.map(s => {
|
||||
const d = new Date(s.createdAt);
|
||||
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const fc = (s.files || []).length;
|
||||
const alStr = s.hasAutoload ? ' + autoload.txt' : '';
|
||||
return `<div class="snapshot-card">
|
||||
<div class="snapshot-card-info">
|
||||
<span class="snapshot-card-label">${escHtml(s.label || 'Snapshot')}</span>
|
||||
<span class="snapshot-card-meta">${dateStr} \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}</span>
|
||||
</div>
|
||||
<div class="snapshot-card-actions">
|
||||
<button class="btn btn-teal btn-sm btn-snap-restore" data-snap-id="${escHtml(s.id)}">↑ Restore</button>
|
||||
<button class="btn btn-sm btn-snap-export" data-snap-id="${escHtml(s.id)}" title="Export snapshot to .porksnap file">↘ Export</button>
|
||||
<button class="btn btn-sm btn-snap-delete" data-snap-id="${escHtml(s.id)}" title="Delete snapshot">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.btn-snap-restore').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.snapId;
|
||||
const confirmation = await showExactConfirmation(
|
||||
'Restore this snapshot to your PS5?\n\nAll payload files and autoload.txt will be uploaded back to /data/ps5_autoloader, overwriting whatever is currently there.',
|
||||
'RESTORE AUTOLOADER'
|
||||
);
|
||||
if (!confirmation) return;
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⋯ Uploading…';
|
||||
try {
|
||||
const res = await window.pork.snapshotRestore(id, confirmation);
|
||||
btn.textContent = '✓ Restored';
|
||||
const errNote = res.errors?.length ? ` (${res.errors.length} file${res.errors.length !== 1 ? 's' : ''} failed)` : '';
|
||||
showToast(`Snapshot restored ✓ — ${res.fileCount} file${res.fileCount !== 1 ? 's' : ''} uploaded to PS5${errNote}`, res.errors?.length ? 'info' : 'ok');
|
||||
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 3000);
|
||||
} catch (er) {
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
showToast('Restore failed: ' + er.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.btn-snap-export').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.snapId;
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⋯';
|
||||
try {
|
||||
const res = await window.pork.snapshotExport(id);
|
||||
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
|
||||
btn.textContent = '✓ Exported';
|
||||
showToast('Snapshot exported ✓', 'ok');
|
||||
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2500);
|
||||
} catch (er) {
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
showToast('Export failed: ' + er.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.btn-snap-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.snapId;
|
||||
const confirmation = await showExactConfirmation('Delete this snapshot? This cannot be undone.', 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
try {
|
||||
await window.pork.snapshotDelete(id, confirmation);
|
||||
await renderAutoloaderSnapshots();
|
||||
} catch (er) {
|
||||
showToast('Delete failed: ' + er.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
list.innerHTML = `<p class="hint autoloader-empty-hint" style="color:var(--red)">Error: ${escHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-snapshot-import').addEventListener('click', async () => {
|
||||
const btn = $('btn-snapshot-import');
|
||||
const status = $('snapshot-take-status');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⋯ Importing…';
|
||||
status.textContent = '';
|
||||
try {
|
||||
let res = await window.pork.snapshotImport();
|
||||
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
|
||||
if (res?.needsLabel) {
|
||||
const label = await showPrompt('Enter a label for this snapshot:', 'Imported ZIP');
|
||||
if (label === null) { btn.textContent = orig; btn.disabled = false; return; } // user cancelled
|
||||
res = await window.pork.snapshotImport({ label: label.trim() || 'Imported ZIP', filePath: res.filePath });
|
||||
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
|
||||
}
|
||||
status.textContent = `Imported ✓ — "${res.meta.label}"`;
|
||||
await renderAutoloaderSnapshots();
|
||||
} catch (e) {
|
||||
status.textContent = 'Import failed: ' + e.message;
|
||||
} finally {
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-snapshot-take').addEventListener('click', async () => {
|
||||
const btn = $('btn-snapshot-take');
|
||||
const status = $('snapshot-take-status');
|
||||
const label = $('snapshot-label').value.trim() || 'Snapshot';
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Downloading from PS5\u2026';
|
||||
try {
|
||||
const { meta } = await window.pork.snapshotTake(label);
|
||||
const fc = meta.files.length;
|
||||
const alStr = meta.hasAutoload ? ' + autoload.txt' : '';
|
||||
status.textContent = `Saved \u2713 \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}`;
|
||||
$('snapshot-label').value = '';
|
||||
await renderAutoloaderSnapshots();
|
||||
} catch (e) {
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
// ── Auto-Backpork Generator — Renderer Module ─────────────────────────────────
|
||||
// Note: $ is declared in state.js and shared across all non-module scripts.
|
||||
|
||||
let _sdkPairs = [];
|
||||
let _gameList = []; // current candidate list shown in picker
|
||||
let _selectedGames = new Set(); // game_ids explicitly selected
|
||||
let _running = false;
|
||||
let _bpgenProgressState = { startedAt: 0, currentGameIndex: 0, totalGames: 0 };
|
||||
let _knownFirmwareLabels = [];
|
||||
let _bpgenSettingsHydrated = false;
|
||||
|
||||
// ── Fakelib Manager state ─────────────────────────────────────────────────────
|
||||
let _flBaseDir = '';
|
||||
let _flPairs = []; // Array<{ pair, dir, libCount, hasLibs }>
|
||||
let _flSelectedPair = null; // which pair the user wants to import for
|
||||
let _flExtracting = false;
|
||||
|
||||
function getSdkPairValue() {
|
||||
const raw = $('bpgen-sdk-pair')?.value;
|
||||
if (!raw) return null;
|
||||
if (raw === 'all') return 'all';
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function getActiveFakelibDir() {
|
||||
const sdkPair = getSdkPairValue();
|
||||
if (!sdkPair || sdkPair === 'all') return '';
|
||||
const pair = _flPairs.find(p => p.pair === sdkPair);
|
||||
return pair?.hasLibs ? pair.dir : '';
|
||||
}
|
||||
|
||||
function getPairStatus(pairNumber) {
|
||||
return _flPairs.find(p => p.pair === pairNumber) || null;
|
||||
}
|
||||
|
||||
function getPreferredFirmwareLabel(pairNumber) {
|
||||
return `${pairNumber}.xx`;
|
||||
}
|
||||
|
||||
async function saveBpgenSettings(patch = {}) {
|
||||
const payload = {
|
||||
bpgenOutputDir: $('bpgen-output-dir')?.value.trim() || '',
|
||||
bpgenFakelibBaseDir: $('bpgen-fl-basedir')?.value.trim() || '',
|
||||
bpgenFakelibDir: $('bpgen-fakelib-dir')?.value.trim() || '',
|
||||
bpgenSdkPair: $('bpgen-sdk-pair')?.value || '',
|
||||
bpgenFirmwareLabel: $('bpgen-fw-label')?.value.trim() || '',
|
||||
...patch,
|
||||
};
|
||||
try {
|
||||
await window.pork.setSettings(payload);
|
||||
} catch (e) {
|
||||
console.error('[bpgen] save settings error', e);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBpgenSettings(settings = {}) {
|
||||
if (!settings || typeof settings !== 'object') return;
|
||||
|
||||
if (settings.bpgenOutputDir) $('bpgen-output-dir').value = settings.bpgenOutputDir;
|
||||
|
||||
if (settings.bpgenFakelibBaseDir) {
|
||||
_flBaseDir = settings.bpgenFakelibBaseDir;
|
||||
$('bpgen-fl-basedir').value = settings.bpgenFakelibBaseDir;
|
||||
}
|
||||
|
||||
if (settings.bpgenFakelibDir) {
|
||||
const input = $('bpgen-fakelib-dir');
|
||||
input.value = settings.bpgenFakelibDir;
|
||||
input.dataset.autoDir = '';
|
||||
}
|
||||
|
||||
if (settings.bpgenSdkPair && [...$('bpgen-sdk-pair').options].some(o => o.value === String(settings.bpgenSdkPair))) {
|
||||
$('bpgen-sdk-pair').value = String(settings.bpgenSdkPair);
|
||||
}
|
||||
|
||||
if (settings.bpgenFirmwareLabel) {
|
||||
const input = $('bpgen-fw-label');
|
||||
input.value = settings.bpgenFirmwareLabel;
|
||||
input.dataset.autoValue = settings.bpgenFirmwareLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFirmwareLabel(force = false) {
|
||||
const input = $('bpgen-fw-label');
|
||||
const sdkPair = getSdkPairValue();
|
||||
if (!input || !sdkPair) return;
|
||||
if (sdkPair === 'all') {
|
||||
input.dataset.autoValue = 'All';
|
||||
if (force || !input.value.trim() || input.value === input.dataset.autoValue) input.value = 'All';
|
||||
return;
|
||||
}
|
||||
const autoLabel = getPreferredFirmwareLabel(sdkPair);
|
||||
const current = input.value.trim();
|
||||
const shouldReplace = force || !current || current === input.dataset.autoValue;
|
||||
|
||||
input.dataset.autoValue = autoLabel;
|
||||
if (shouldReplace) {
|
||||
input.value = autoLabel;
|
||||
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFirmwareLabel: autoLabel });
|
||||
}
|
||||
}
|
||||
|
||||
function autoSelectSdkPairFromFakelibs(force = false) {
|
||||
const available = _flPairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
|
||||
if (!available.length) return;
|
||||
|
||||
const currentPair = getSdkPairValue();
|
||||
if (currentPair === 'all') return;
|
||||
const currentReady = available.some(p => p.pair === currentPair);
|
||||
if (!force && currentReady) return;
|
||||
|
||||
$('bpgen-sdk-pair').value = String(available[0].pair);
|
||||
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenSdkPair: String(available[0].pair) });
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
}
|
||||
|
||||
function syncFakelibInput(force = false) {
|
||||
const input = $('bpgen-fakelib-dir');
|
||||
if (!input) return;
|
||||
const current = input.value.trim();
|
||||
const autoDir = getActiveFakelibDir();
|
||||
const shouldReplace = force || !current || current === input.dataset.autoDir;
|
||||
|
||||
input.dataset.autoDir = autoDir || '';
|
||||
if (shouldReplace) {
|
||||
input.value = autoDir || '';
|
||||
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFakelibDir: autoDir || '' });
|
||||
}
|
||||
}
|
||||
|
||||
function resetBpgenActionButtons() {
|
||||
[
|
||||
'bpgen-btn-output',
|
||||
'bpgen-btn-fakelib',
|
||||
'bpgen-btn-run',
|
||||
'bpgen-btn-cancel',
|
||||
'bpgen-select-all',
|
||||
'bpgen-select-none',
|
||||
'bpgen-fl-btn-base',
|
||||
'bpgen-fl-btn-pick-pup',
|
||||
'bpgen-fl-btn-extract',
|
||||
].forEach(id => {
|
||||
const el = $(id);
|
||||
if (!el || !el.parentNode) return;
|
||||
const clean = el.cloneNode(true);
|
||||
clean.removeAttribute('onclick');
|
||||
clean.dataset.boundInBackporks = '';
|
||||
el.parentNode.replaceChild(clean, el);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
async function initBackporkGen() {
|
||||
try {
|
||||
const [sdkPairs, knownFirmwareLabels, savedSettings] = await Promise.all([
|
||||
window.pork.bpgenSdkPairs(),
|
||||
window.pork.firmwareLabels().catch(() => []),
|
||||
window.pork.getSettings().catch(() => ({})),
|
||||
]);
|
||||
_sdkPairs = sdkPairs;
|
||||
_knownFirmwareLabels = knownFirmwareLabels;
|
||||
renderSdkPairDropdown();
|
||||
restoreBpgenSettings(savedSettings);
|
||||
_bpgenSettingsHydrated = true;
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
} catch (e) {
|
||||
console.error('[bpgen] init error', e);
|
||||
}
|
||||
|
||||
// Selection mode toggle
|
||||
$('bpgen-mode').addEventListener('change', () => updateGamePickerVisibility());
|
||||
$('bpgen-sdk-pair').addEventListener('change', () => {
|
||||
saveBpgenSettings({ bpgenSdkPair: $('bpgen-sdk-pair').value });
|
||||
syncFakelibInput();
|
||||
syncFirmwareLabel();
|
||||
});
|
||||
$('bpgen-output-dir').addEventListener('change', () => saveBpgenSettings());
|
||||
$('bpgen-fakelib-dir').addEventListener('change', () => saveBpgenSettings());
|
||||
$('bpgen-fl-basedir').addEventListener('change', () => saveBpgenSettings());
|
||||
|
||||
// Firmware filter change → refresh game list
|
||||
$('bpgen-fw-filter').addEventListener('change', () => refreshGameList());
|
||||
|
||||
// Buttons
|
||||
$('bpgen-btn-output').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const p = await window.pork.selectFolder();
|
||||
if (p) {
|
||||
$('bpgen-output-dir').value = p;
|
||||
saveBpgenSettings({ bpgenOutputDir: p });
|
||||
}
|
||||
} catch (err) {
|
||||
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
$('bpgen-btn-fakelib').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const p = await window.pork.selectFolder();
|
||||
if (p) {
|
||||
const input = $('bpgen-fakelib-dir');
|
||||
input.value = p;
|
||||
input.dataset.autoDir = '';
|
||||
saveBpgenSettings({ bpgenFakelibDir: p });
|
||||
}
|
||||
} catch (err) {
|
||||
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
$('bpgen-btn-run').addEventListener('click', () => runBackporkGen());
|
||||
$('bpgen-btn-cancel').addEventListener('click', () => {
|
||||
window.pork.bpgenCancel();
|
||||
appendLog('Cancelling…');
|
||||
});
|
||||
|
||||
// Select all / none in game picker
|
||||
$('bpgen-select-all').addEventListener('click', () => toggleAllGames(true));
|
||||
$('bpgen-select-none').addEventListener('click', () => toggleAllGames(false));
|
||||
|
||||
// Listen for progress events
|
||||
window.pork.on('bpgen:progress', handleProgress);
|
||||
|
||||
// ── Fakelib Manager ────────────────────────────────────────────────────────
|
||||
initFakelibManager();
|
||||
resetBpgenActionButtons();
|
||||
}
|
||||
|
||||
// ── SDK Pair Dropdown ─────────────────────────────────────────────────────────
|
||||
function renderSdkPairDropdown() {
|
||||
const sel = $('bpgen-sdk-pair');
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = 'all';
|
||||
allOpt.textContent = 'All SDK Targets';
|
||||
sel.appendChild(allOpt);
|
||||
for (const p of _sdkPairs) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.pair;
|
||||
const pairStatus = getPairStatus(p.pair);
|
||||
const suffix = pairStatus?.hasLibs ? ` [libs ready: ${pairStatus.libCount}]` : '';
|
||||
opt.textContent = `SDK Pair ${p.pair} — PS5: ${p.ps5Ver} / PS4: ${p.ps4Ver}${suffix}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (current && [...sel.options].some(o => o.value === current)) sel.value = current;
|
||||
}
|
||||
|
||||
// ── Game Picker ───────────────────────────────────────────────────────────────
|
||||
function updateGamePickerVisibility() {
|
||||
const mode = $('bpgen-mode').value;
|
||||
$('bpgen-game-picker').hidden = (mode !== 'specific');
|
||||
$('bpgen-fw-filter-row').hidden = (mode !== 'fw-missing');
|
||||
if (mode === 'specific') refreshGameList();
|
||||
}
|
||||
|
||||
async function refreshGameList() {
|
||||
const mode = $('bpgen-mode').value;
|
||||
const fw = $('bpgen-fw-filter').value.trim();
|
||||
|
||||
try {
|
||||
if (mode === 'specific' || mode === 'fw-missing') {
|
||||
$('bpgen-game-list').innerHTML = '<div class="bpgen-loading">Loading…</div>';
|
||||
const games = mode === 'specific'
|
||||
? await window.pork.bpgenListAll()
|
||||
: await window.pork.bpgenListMissing(fw);
|
||||
_gameList = games;
|
||||
renderGamePicker(games);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[bpgen] list error', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGamePicker(games) {
|
||||
const el = $('bpgen-game-list');
|
||||
if (!games.length) {
|
||||
el.innerHTML = '<div style="color:var(--text-dim);font-size:12px;padding:6px">No games found</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = games.map(g => {
|
||||
const title = escHtml(g.prospero_name || g.title || g.game_id);
|
||||
const checked = _selectedGames.has(g.game_id) ? 'checked' : '';
|
||||
return `<label class="bpgen-game-row">
|
||||
<input type="checkbox" data-game-id="${escHtml(g.game_id)}" ${checked}/>
|
||||
<span class="bpgen-game-title">${title}</span>
|
||||
<span class="bpgen-game-id">${escHtml(g.game_id)}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
el.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', e => {
|
||||
const id = e.target.dataset.gameId;
|
||||
if (e.target.checked) _selectedGames.add(id);
|
||||
else _selectedGames.delete(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllGames(select) {
|
||||
_selectedGames = select ? new Set(_gameList.map(g => g.game_id)) : new Set();
|
||||
$('bpgen-game-list').querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
cb.checked = select;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
async function runBackporkGen() {
|
||||
const mode = $('bpgen-mode').value;
|
||||
const sdkPair = getSdkPairValue();
|
||||
const outputDir = $('bpgen-output-dir').value.trim();
|
||||
const firmwareLabel = $('bpgen-fw-label').value.trim();
|
||||
const fakelibDir = $('bpgen-fakelib-dir').value.trim() || null;
|
||||
const overwrite = $('bpgen-overwrite').checked;
|
||||
|
||||
if (!outputDir) { bpgenSetStatus('Select an output folder first.', 'error'); return; }
|
||||
if (sdkPair !== 'all' && !firmwareLabel) { bpgenSetStatus('Enter a firmware label (e.g. 4.xx).', 'error'); return; }
|
||||
if (mode === 'fw-missing' && !$('bpgen-fw-filter').value.trim()) {
|
||||
bpgenSetStatus('Enter the firmware label to check for missing backporks.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let gameIds = [];
|
||||
let filterFw = '';
|
||||
|
||||
if (mode === 'specific') {
|
||||
gameIds = [..._selectedGames];
|
||||
if (!gameIds.length) { bpgenSetStatus('Select at least one game.', 'error'); return; }
|
||||
} else if (mode === 'fw-missing') {
|
||||
filterFw = $('bpgen-fw-filter').value.trim();
|
||||
}
|
||||
// mode === 'all-missing' → gameIds empty, filterFw empty → default all-missing logic
|
||||
|
||||
_running = true;
|
||||
$('bpgen-btn-run').disabled = true;
|
||||
$('bpgen-btn-cancel').disabled = false;
|
||||
$('bpgen-log').innerHTML = '';
|
||||
$('bpgen-progress-wrap').hidden = false;
|
||||
_bpgenProgressState = { startedAt: Date.now(), currentGameIndex: 0, totalGames: 0 };
|
||||
setProgress(0, 'Starting…');
|
||||
|
||||
try {
|
||||
const result = await window.pork.bpgenRun({
|
||||
gameIds, sdkPair, outputDir, firmwareLabel, fakelibDir, filterFw, overwrite,
|
||||
});
|
||||
bpgenSetStatus(`Done — ${result.ok} file(s) processed, ${result.failed} failed`, result.failed ? 'warn' : 'ok');
|
||||
} catch (e) {
|
||||
bpgenSetStatus(`Error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
_running = false;
|
||||
$('bpgen-btn-run').disabled = false;
|
||||
$('bpgen-btn-cancel').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress handling ─────────────────────────────────────────────────────────
|
||||
function handleProgress({ type, ...data }) {
|
||||
switch (type) {
|
||||
case 'start': {
|
||||
_bpgenProgressState.totalRuns = data.total || 0;
|
||||
_bpgenProgressState.totalGames = data.games || 0;
|
||||
_bpgenProgressState.totalPairs = data.pairs || 1;
|
||||
_bpgenProgressState.currentGameIndex = 0;
|
||||
const pairsLabel = data.pairs > 1 ? ` × ${data.pairs} SDK pairs = ${data.total} total operations` : '';
|
||||
setProgress(0, `Processing ${data.games} game(s)${pairsLabel}…`);
|
||||
appendLog(`Starting: ${data.games} game(s)${pairsLabel}`);
|
||||
if (data.noLocalFolder > 0) {
|
||||
appendLog(` ⚠ ${data.noLocalFolder} game(s) skipped — no local folder recorded in DB`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'run-config':
|
||||
appendLog(`Target firmware: ${data.firmwareLabel}`);
|
||||
appendLog(`SDK pair: ${data.sdkPair}`);
|
||||
appendLog(`Output: ${data.outputDir}`);
|
||||
appendLog(`Fakelib: ${data.fakelibDir || 'none'}`);
|
||||
appendLog(`Overwrite existing output: ${data.overwrite ? 'yes' : 'no'}`);
|
||||
break;
|
||||
case 'pair-start': {
|
||||
const pct = Math.round(((data.pairIndex - 1) / (data.totalPairs || 1)) * 100);
|
||||
const pairLabel = `pair ${data.pairIndex}/${data.totalPairs}`;
|
||||
const gamesNote = data.gamesInPair === 0 ? ' — all games already done, skipping' : ` — ${data.gamesInPair} game(s) to process`;
|
||||
// Update progress bar with pair info so the counter always advances visibly
|
||||
_bpgenProgressState.currentGameLabel = `[${pairLabel}] SDK ${data.sdkPair} (FW ${data.firmwareLabel})`;
|
||||
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
|
||||
appendLog(`\u27A4 ${pairLabel}: SDK ${data.sdkPair}${gamesNote}`);
|
||||
break;
|
||||
}
|
||||
case 'game-start': {
|
||||
_bpgenProgressState.currentGameIndex = data.index || 0;
|
||||
// Use per-pair counters when available (all-pairs mode), else fall back to overall index
|
||||
const gi = data.pairGameIndex || data.index;
|
||||
const gt = data.pairTotal || _bpgenProgressState.totalGames;
|
||||
const pct = Math.round(((data.index - 1) / (data.total || 1)) * 100);
|
||||
// Always show pair counter so single-pair runs show "pair 1/1" and all-pairs show "pair 3/10"
|
||||
const pairLabel = data.totalPairs >= 1 ? ` · pair ${data.pairIndex}/${data.totalPairs}` : '';
|
||||
// Store sticky label so file-progress never overwrites it with a file path
|
||||
_bpgenProgressState.currentGameLabel = `[${gi}/${gt}${pairLabel}] ${data.title || data.gameId}`;
|
||||
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
|
||||
appendLog(`\u25B6 [${gi}/${gt}${pairLabel}] ${data.gameId} — ${data.title || ''} (SDK ${data.sdkPair}, FW ${data.firmwareLabel})`);
|
||||
break;
|
||||
}
|
||||
case 'file-progress':
|
||||
handleFileProgress(data);
|
||||
break;
|
||||
case 'game-done':
|
||||
appendLog(` \u2713 OK (${data.ok} files processed)`);
|
||||
break;
|
||||
case 'game-skip':
|
||||
appendLog(` \u23ED Skipped: ${data.reason}`);
|
||||
break;
|
||||
case 'game-error':
|
||||
appendLog(` \u2717 Error: ${data.msg}`, 'error');
|
||||
break;
|
||||
case 'done':
|
||||
setProgress(100, `Complete — ${data.ok} files OK, ${data.failed} failed`);
|
||||
appendLog('');
|
||||
appendLog(`Complete: total=${data.total} ok=${data.ok} failed=${data.failed} skipped=${data.skipped}`);
|
||||
appendLog(`Elapsed: ${formatElapsed()}`);
|
||||
// Trigger a backpork rescan so the UI updates
|
||||
window.pork.backporksScanAll().catch(() => {});
|
||||
break;
|
||||
case 'cancelled':
|
||||
setProgress(0, 'Cancelled');
|
||||
appendLog(`Cancelled after ${data.processed} game(s)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileProgress(data) {
|
||||
if (typeof data.fileIndex === 'number' && typeof data.totalFiles === 'number' && _bpgenProgressState.totalRuns > 0) {
|
||||
const currentGameZeroBased = Math.max(0, (_bpgenProgressState.currentGameIndex || 1) - 1);
|
||||
const perGameFraction = data.totalFiles > 0 ? (data.fileIndex / data.totalFiles) : 0;
|
||||
const pct = Math.round(((currentGameZeroBased + perGameFraction) / _bpgenProgressState.totalRuns) * 100);
|
||||
// Always show game name + pair info — never the file path
|
||||
const label = _bpgenProgressState.currentGameLabel || `[${_bpgenProgressState.currentGameIndex}/${_bpgenProgressState.totalGames}]`;
|
||||
setProgress(Math.min(99, Math.max(0, pct)), `${label} · ${formatElapsed()}`);
|
||||
}
|
||||
|
||||
switch (data.step) {
|
||||
case 'scan':
|
||||
appendLog(` ⟳ ${data.msg}`);
|
||||
break;
|
||||
case 'inventory':
|
||||
appendLog(` Found ${data.executableFiles} ELF/SELF file(s) to process`);
|
||||
break;
|
||||
case 'copy':
|
||||
appendLog(` → Copy ${data.file}`);
|
||||
break;
|
||||
case 'process':
|
||||
appendLog(` • ${data.file}`);
|
||||
break;
|
||||
case 'decrypt':
|
||||
case 'patch':
|
||||
case 'sign':
|
||||
appendLog(` ${data.step}: ${data.file} — ${data.msg}`);
|
||||
break;
|
||||
case 'skip':
|
||||
appendLog(` ↷ ${data.file}: ${data.msg}`);
|
||||
break;
|
||||
case 'warn':
|
||||
appendLog(` ! ${data.file || 'step'}: ${data.msg}`);
|
||||
break;
|
||||
case 'error':
|
||||
appendLog(` \u2717 ${data.file}: ${data.msg}`, 'error');
|
||||
break;
|
||||
case 'libc':
|
||||
appendLog(` libc: ${data.msg}${typeof data.totalFiles === 'number' ? ` (${data.totalFiles} file(s))` : ''}`);
|
||||
break;
|
||||
case 'libc-progress':
|
||||
appendLog(` libc [${data.fileIndex}/${data.totalFiles}]: ${data.file} — ${data.msg}`);
|
||||
break;
|
||||
case 'fakelib':
|
||||
appendLog(` fakelib: ${data.msg}`);
|
||||
break;
|
||||
case 'fakelib-progress':
|
||||
appendLog(` fakelib [${data.fileIndex}/${data.totalFiles}]: ${data.file}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────────────────────
|
||||
function bpgenSetStatus(msg, type = '') {
|
||||
const el = $('bpgen-status');
|
||||
el.textContent = msg;
|
||||
el.className = 'hint bpgen-status ' + (type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
|
||||
}
|
||||
|
||||
function setProgress(pct, label) {
|
||||
$('bpgen-progress-bar').style.width = `${pct}%`;
|
||||
$('bpgen-progress-label').textContent = label;
|
||||
}
|
||||
|
||||
function appendLog(line, type = '') {
|
||||
const el = $('bpgen-log');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'bpgen-log-row' + (type === 'error' ? ' bpgen-log-error' : '');
|
||||
row.textContent = line;
|
||||
el.appendChild(row);
|
||||
while (el.childNodes.length > 800) el.removeChild(el.firstChild);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Fakelib Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
function initFakelibManager() {
|
||||
// Base dir picker
|
||||
$('bpgen-fl-btn-base').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const dir = await window.pork.selectFolder();
|
||||
if (!dir) return;
|
||||
const result = await window.pork.bpgenFakelibSetBasedir(dir);
|
||||
if (!result) return;
|
||||
_flBaseDir = result.baseDir;
|
||||
_flPairs = result.pairs;
|
||||
$('bpgen-fl-basedir').value = _flBaseDir;
|
||||
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
|
||||
renderSdkPairDropdown();
|
||||
renderFlGrid();
|
||||
updateFlSummaryBadge();
|
||||
autoSelectSdkPairFromFakelibs(true);
|
||||
syncFirmwareLabel(true);
|
||||
syncFakelibInput(true);
|
||||
} catch (err) {
|
||||
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Pick PUP file
|
||||
$('bpgen-fl-btn-pick-pup').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const p = await window.pork.bpgenFakelibPickPup();
|
||||
if (p) $('bpgen-fl-pup-path').value = p;
|
||||
} catch (err) {
|
||||
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Extract button
|
||||
$('bpgen-fl-btn-extract').addEventListener('click', () => runFlExtract());
|
||||
|
||||
// Progress events from main process
|
||||
window.pork.on('bpgen:fakelib-progress', handleFlProgress);
|
||||
|
||||
// Load saved state
|
||||
window.pork.bpgenFakelibScan().then(({ baseDir, pairs }) => {
|
||||
if (!baseDir) return;
|
||||
_flBaseDir = baseDir;
|
||||
_flPairs = pairs;
|
||||
$('bpgen-fl-basedir').value = _flBaseDir;
|
||||
renderSdkPairDropdown();
|
||||
renderFlGrid();
|
||||
updateFlSummaryBadge();
|
||||
autoSelectSdkPairFromFakelibs();
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function renderFlGrid() {
|
||||
const grid = $('bpgen-fl-grid');
|
||||
if (!_flBaseDir || !_flPairs.length) {
|
||||
_flSelectedPair = null;
|
||||
grid.hidden = true;
|
||||
$('bpgen-fl-import-row').hidden = true;
|
||||
renderSdkPairDropdown();
|
||||
return;
|
||||
}
|
||||
grid.hidden = false;
|
||||
grid.innerHTML = _flPairs.map(p => {
|
||||
const cls = p.hasLibs ? 'bpgen-fl-pair-ok' : 'bpgen-fl-pair-missing';
|
||||
const icon = p.hasLibs ? '✓' : '+';
|
||||
const label = p.hasLibs ? `${p.libCount} lib${p.libCount !== 1 ? 's' : ''}` : 'No libs';
|
||||
return `<button class="bpgen-fl-pair ${cls}" data-pair="${p.pair}" title="${escHtml(p.dir)}">
|
||||
<span class="bpgen-fl-pair-num">${p.pair}</span>
|
||||
<span class="bpgen-fl-pair-icon">${icon}</span>
|
||||
<span class="bpgen-fl-pair-label">${label}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.bpgen-fl-pair').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_flSelectedPair = Number(btn.dataset.pair);
|
||||
grid.querySelectorAll('.bpgen-fl-pair').forEach(b => b.classList.remove('bpgen-fl-pair-active'));
|
||||
btn.classList.add('bpgen-fl-pair-active');
|
||||
$('bpgen-fl-import-pair-label').textContent = _flSelectedPair;
|
||||
$('bpgen-fl-import-row').hidden = false;
|
||||
$('bpgen-sdk-pair').value = String(_flSelectedPair);
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateFlSummaryBadge() {
|
||||
const badge = $('bpgen-fl-summary-status');
|
||||
if (!_flPairs.length) { badge.textContent = ''; return; }
|
||||
const ok = _flPairs.filter(p => p.hasLibs).length;
|
||||
badge.textContent = `${ok}/10 pairs ready`;
|
||||
badge.className = 'bpgen-fl-badge ' + (ok > 0 ? 'bpgen-fl-badge-ok' : 'bpgen-fl-badge-warn');
|
||||
}
|
||||
|
||||
async function runFlExtract() {
|
||||
if (_flExtracting) return;
|
||||
const pupPath = $('bpgen-fl-pup-path').value.trim();
|
||||
if (!pupPath) { flSetStatus('Select a .PUP.dec file first.'); return; }
|
||||
if (!_flSelectedPair) { flSetStatus('Select an SDK pair above first.'); return; }
|
||||
if (!_flBaseDir) { flSetStatus('Set a Fakelib Base Directory first.'); return; }
|
||||
|
||||
_flExtracting = true;
|
||||
$('bpgen-fl-btn-extract').disabled = true;
|
||||
$('bpgen-fl-progress-wrap').hidden = false;
|
||||
flSetProgress(0, 'Starting…');
|
||||
|
||||
try {
|
||||
const result = await window.pork.bpgenFakelibExtract({
|
||||
pupPath,
|
||||
sdkPair: _flSelectedPair,
|
||||
});
|
||||
flSetStatus(`Done — ${result.libCount} libraries extracted to pair ${_flSelectedPair}`, 'ok');
|
||||
// Refresh grid
|
||||
const scan = await window.pork.bpgenFakelibScan();
|
||||
_flPairs = scan.pairs;
|
||||
renderSdkPairDropdown();
|
||||
renderFlGrid();
|
||||
updateFlSummaryBadge();
|
||||
autoSelectSdkPairFromFakelibs(true);
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
// Re-select same pair to keep import row visible
|
||||
const btn = $('bpgen-fl-grid')?.querySelector(`[data-pair="${_flSelectedPair}"]`);
|
||||
if (btn) { btn.classList.add('bpgen-fl-pair-active'); $('bpgen-fl-import-row').hidden = false; }
|
||||
} catch (e) {
|
||||
flSetProgress(0, '');
|
||||
flSetStatus(`Error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
_flExtracting = false;
|
||||
$('bpgen-fl-btn-extract').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFlProgress({ type, ...data }) {
|
||||
switch (type) {
|
||||
case 'start':
|
||||
flSetProgress(0, `Preparing extraction for pair ${data.sdkPair}…`);
|
||||
break;
|
||||
case 'tool-progress':
|
||||
flSetProgress(data.pct, data.msg);
|
||||
break;
|
||||
case 'tool-manual':
|
||||
flSetStatus(`Manual download required — ${data.msg}`, 'error');
|
||||
break;
|
||||
case 'extract-progress':
|
||||
flSetProgress(data.pct, data.msg);
|
||||
break;
|
||||
case 'done':
|
||||
flSetProgress(100, `Complete — ${data.libCount} libraries in pair ${data.sdkPair}`);
|
||||
break;
|
||||
case 'error':
|
||||
flSetProgress(0, '');
|
||||
flSetStatus(`Error: ${data.msg}`, 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function flSetProgress(pct, label) {
|
||||
$('bpgen-fl-progress-bar').style.width = `${pct}%`;
|
||||
$('bpgen-fl-progress-label').textContent = label;
|
||||
}
|
||||
|
||||
function flSetStatus(msg, type = '') {
|
||||
// Reuse bpgen-status element for simplicity, prefixed with [FL]
|
||||
const el = $('bpgen-status');
|
||||
el.textContent = msg ? `[Fakelib] ${msg}` : '';
|
||||
el.className = 'hint bpgen-status ' +
|
||||
(type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
|
||||
}
|
||||
|
||||
function formatElapsed() {
|
||||
if (!_bpgenProgressState.startedAt) return '0s';
|
||||
const totalSeconds = Math.max(0, Math.floor((Date.now() - _bpgenProgressState.startedAt) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
async function browseBpgenOutputFolder() {
|
||||
try {
|
||||
const p = await window.pork.selectFolder();
|
||||
if (p) {
|
||||
$('bpgen-output-dir').value = p;
|
||||
saveBpgenSettings({ bpgenOutputDir: p });
|
||||
}
|
||||
} catch (err) {
|
||||
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function browseBpgenFakelibFolder() {
|
||||
try {
|
||||
const p = await window.pork.selectFolder();
|
||||
if (p) {
|
||||
const input = $('bpgen-fakelib-dir');
|
||||
input.value = p;
|
||||
input.dataset.autoDir = '';
|
||||
saveBpgenSettings({ bpgenFakelibDir: p });
|
||||
}
|
||||
} catch (err) {
|
||||
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function browseBpgenFakelibBaseDir() {
|
||||
try {
|
||||
const dir = await window.pork.selectFolder();
|
||||
if (!dir) return;
|
||||
const result = await window.pork.bpgenFakelibSetBasedir(dir);
|
||||
if (!result) return;
|
||||
_flBaseDir = result.baseDir;
|
||||
_flPairs = result.pairs;
|
||||
$('bpgen-fl-basedir').value = _flBaseDir;
|
||||
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
|
||||
renderSdkPairDropdown();
|
||||
renderFlGrid();
|
||||
updateFlSummaryBadge();
|
||||
autoSelectSdkPairFromFakelibs(true);
|
||||
syncFakelibInput(true);
|
||||
syncFirmwareLabel(true);
|
||||
} catch (err) {
|
||||
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function browseBpgenPupFile() {
|
||||
try {
|
||||
const p = await window.pork.bpgenFakelibPickPup();
|
||||
if (p) $('bpgen-fl-pup-path').value = p;
|
||||
} catch (err) {
|
||||
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('#bpgen-btn-output, #bpgen-btn-fakelib, #bpgen-btn-run, #bpgen-btn-cancel, #bpgen-select-all, #bpgen-select-none, #bpgen-fl-btn-base, #bpgen-fl-btn-pick-pup, #bpgen-fl-btn-extract');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
switch (btn.id) {
|
||||
case 'bpgen-btn-output':
|
||||
browseBpgenOutputFolder();
|
||||
break;
|
||||
case 'bpgen-btn-fakelib':
|
||||
browseBpgenFakelibFolder();
|
||||
break;
|
||||
case 'bpgen-btn-run':
|
||||
runBackporkGen();
|
||||
break;
|
||||
case 'bpgen-btn-cancel':
|
||||
window.pork.bpgenCancel();
|
||||
appendLog('Cancelling…');
|
||||
break;
|
||||
case 'bpgen-select-all':
|
||||
toggleAllGames(true);
|
||||
break;
|
||||
case 'bpgen-select-none':
|
||||
toggleAllGames(false);
|
||||
break;
|
||||
case 'bpgen-fl-btn-base':
|
||||
browseBpgenFakelibBaseDir();
|
||||
break;
|
||||
case 'bpgen-fl-btn-pick-pup':
|
||||
browseBpgenPupFile();
|
||||
break;
|
||||
case 'bpgen-fl-btn-extract':
|
||||
runFlExtract();
|
||||
break;
|
||||
}
|
||||
}, true);
|
||||
|
||||
window._bpgenButtonClick = action => {
|
||||
switch (action) {
|
||||
case 'output':
|
||||
browseBpgenOutputFolder();
|
||||
break;
|
||||
case 'fakelib':
|
||||
browseBpgenFakelibFolder();
|
||||
break;
|
||||
case 'baseDir':
|
||||
browseBpgenFakelibBaseDir();
|
||||
break;
|
||||
case 'pup':
|
||||
browseBpgenPupFile();
|
||||
break;
|
||||
case 'extract':
|
||||
runFlExtract();
|
||||
break;
|
||||
case 'run':
|
||||
runBackporkGen();
|
||||
break;
|
||||
case 'cancel':
|
||||
window.pork.bpgenCancel();
|
||||
appendLog('Cancelling…');
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Export for use by main renderer
|
||||
window._bpgenInit = initBackporkGen;
|
||||
@@ -0,0 +1,624 @@
|
||||
// ── Backporks page ─────────────────────────────────────────────────────────────
|
||||
let _bpgenPageInitStarted = false;
|
||||
let _bpgenPageInitPromise = null;
|
||||
|
||||
let _bpApplyUpdater = null;
|
||||
window.pork.on('transfer:update', st => { _bpApplyUpdater?.(st); });
|
||||
|
||||
function renderBpgenSdkOptions(pairs, currentValue = '') {
|
||||
const sdkSelect = $('bpgen-sdk-pair');
|
||||
if (!sdkSelect) return;
|
||||
sdkSelect.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = 'all';
|
||||
allOpt.textContent = 'All SDK Targets';
|
||||
sdkSelect.appendChild(allOpt);
|
||||
for (const pair of pairs || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(pair.pair);
|
||||
opt.textContent = `SDK Pair ${pair.pair} - PS5: ${pair.ps5Ver} / PS4: ${pair.ps4Ver}`;
|
||||
sdkSelect.appendChild(opt);
|
||||
}
|
||||
if (currentValue && [...sdkSelect.options].some(o => o.value === String(currentValue))) {
|
||||
sdkSelect.value = String(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
function pickReadyBpgenPair(pairs, preferredPair = '') {
|
||||
const ready = (pairs || []).filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
|
||||
if (!ready.length) return null;
|
||||
return ready.find(p => String(p.pair) === String(preferredPair)) || ready[0];
|
||||
}
|
||||
|
||||
async function hydrateBpgenCard() {
|
||||
const sdkSelect = $('bpgen-sdk-pair');
|
||||
if (!sdkSelect) return;
|
||||
|
||||
const [settings, sdkPairs, scan] = await Promise.all([
|
||||
window.pork.getSettings().catch(() => ({})),
|
||||
window.pork.bpgenSdkPairs().catch(() => []),
|
||||
window.pork.bpgenFakelibScan().catch(() => ({ baseDir: '', pairs: [] })),
|
||||
]);
|
||||
|
||||
const savedPair = settings.bpgenSdkPair || '';
|
||||
renderBpgenSdkOptions(sdkPairs, savedPair);
|
||||
|
||||
$('bpgen-output-dir').value = settings.bpgenOutputDir || '';
|
||||
$('bpgen-fl-basedir').value = scan.baseDir || settings.bpgenFakelibBaseDir || '';
|
||||
$('bpgen-fakelib-dir').value = settings.bpgenFakelibDir || '';
|
||||
$('bpgen-fw-label').value = settings.bpgenFirmwareLabel || '';
|
||||
|
||||
const activePair = pickReadyBpgenPair(scan.pairs, savedPair);
|
||||
if (savedPair === 'all') {
|
||||
sdkSelect.value = 'all';
|
||||
$('bpgen-fw-label').value = 'All';
|
||||
} else if (savedPair && [...sdkSelect.options].some(o => o.value === String(savedPair))) {
|
||||
sdkSelect.value = String(savedPair);
|
||||
} else if (activePair) {
|
||||
// Prefer a ready fakelib pair for the initial UI state, but do not
|
||||
// overwrite persisted settings just because the page was opened.
|
||||
sdkSelect.value = String(activePair.pair);
|
||||
if (!$('bpgen-fw-label').value.trim()) $('bpgen-fw-label').value = `${activePair.pair}.xx`;
|
||||
if (!$('bpgen-fakelib-dir').value.trim()) $('bpgen-fakelib-dir').value = activePair.dir || '';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBpgenPageInit() {
|
||||
if (_bpgenPageInitPromise) return _bpgenPageInitPromise;
|
||||
_bpgenPageInitPromise = hydrateBpgenCard().catch(err => {
|
||||
console.error('[backporks] bpgen hydrate failed', err);
|
||||
});
|
||||
return _bpgenPageInitPromise;
|
||||
}
|
||||
|
||||
async function loadBackporks() {
|
||||
ensureBpgenPageInit();
|
||||
const folders = await window.pork.backporksList();
|
||||
try {
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
} catch (_) {}
|
||||
const list = $('backpork-list');
|
||||
const empty = $('backpork-empty');
|
||||
|
||||
list.innerHTML = '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">Loading firmware folders…</div>';
|
||||
empty.style.display = folders.length ? 'none' : 'block';
|
||||
if (!folders.length) { list.innerHTML = ''; return; }
|
||||
|
||||
const gamesByFolder = await Promise.all(
|
||||
folders.map(async f => ({ folder: f, games: await window.pork.backporksGames(f.name) }))
|
||||
);
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
for (const { folder: f, games } of gamesByFolder) {
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'backpork-card';
|
||||
|
||||
const gamesHtml = games.length ? games.map(g => {
|
||||
const title = g.prospero_name || g.title || g.game_id;
|
||||
const porked = g.porked_firmware === f.name;
|
||||
const _bhs = _backporkHashSummary.get(g.game_id + '|' + f.name) || _gameHashSummary.get(g.game_id);
|
||||
const hashPill = _bhs
|
||||
? _bhs.community_mismatch
|
||||
? `<span class="backpork-porked-pill hash-pill-fail" title="⚠ Community hash check FAILED — may contain modified or malicious files">⚠ Hash Fail</span>`
|
||||
: _bhs.community_matches > 0
|
||||
? `<span class="backpork-porked-pill hash-pill-ok" title="Community verified: ${_bhs.community_matches}/${_bhs.hash_count} files">✓ Verified</span>`
|
||||
: `<span class="backpork-porked-pill hash-pill-local" title="${_bhs.hash_count} file(s) hashed">⧭ Hashed</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="backpork-game-row">
|
||||
${g.prospero_icon_url
|
||||
? `<img class="backpork-game-icon" src="${escHtml(g.prospero_icon_url)}" alt="" loading="lazy" data-image-error="hide"/>`
|
||||
: '<div class="backpork-game-icon"></div>'}
|
||||
<div class="backpork-game-info">
|
||||
<div class="backpork-game-title" title="${escHtml(title)}">${escHtml(title)}</div>
|
||||
<div class="backpork-game-id">${escHtml(g.game_id)}</div>
|
||||
</div>
|
||||
${porked ? `<span class="backpork-porked-pill">✓ Porked</span>` : ''}
|
||||
${hashPill}
|
||||
<div class="backpork-game-actions">
|
||||
<button class="btn btn-sm" data-bp-view="${escHtml(g.game_id)}">View</button>
|
||||
<button class="btn btn-sm btn-teal"
|
||||
data-bp-pork="${escHtml(g.game_id)}"
|
||||
data-bp-fw="${escHtml(f.name)}"
|
||||
data-bp-path="${escHtml(f.path)}"
|
||||
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
|
||||
Pork ▶
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">No games detected — click Rescan</div>';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="backpork-card-header">
|
||||
<div class="backpork-badge">${escHtml(f.name)}</div>
|
||||
<div class="backpork-info">
|
||||
<div class="backpork-path" title="${escHtml(f.path)}">${escHtml(f.path)}</div>
|
||||
<div class="backpork-count"><strong>${games.length}</strong> game(s) detected</div>
|
||||
</div>
|
||||
<div class="backpork-actions">
|
||||
<button class="btn btn-sm btn-teal" data-action="scan" data-id="${f.id}">↻ Rescan</button>
|
||||
<button class="btn btn-sm btn-teal"
|
||||
data-action="pork-all"
|
||||
data-id="${f.id}"
|
||||
data-fw="${escHtml(f.name)}"
|
||||
data-path="${escHtml(f.path)}"
|
||||
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}
|
||||
title="Queue all ${games.length} game(s) from ${escHtml(f.name)} for PS5 install">
|
||||
🔧 Apply All (${games.length})
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="remove" data-id="${f.id}">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backpork-games">${gamesHtml}</div>`;
|
||||
|
||||
list.appendChild(card);
|
||||
}
|
||||
|
||||
list.onclick = async e => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (btn) {
|
||||
const id = Number(btn.dataset.id);
|
||||
if (btn.dataset.action === 'scan') {
|
||||
btn.disabled = true; btn.textContent = 'Scanning…';
|
||||
try {
|
||||
const { count } = await window.pork.backporksScan(id);
|
||||
setStatus(`Rescan complete — ${count} game(s) found`, 'ok');
|
||||
await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
} catch (err) { setStatus(err.message, 'error'); }
|
||||
}
|
||||
if (btn.dataset.action === 'remove') {
|
||||
const fwName = btn.closest('.backpork-card')?.querySelector('.backpork-badge')?.textContent || String(id);
|
||||
const confirmation = await showExactConfirmation(`Remove firmware folder "${fwName}" from Chimera?`, 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
await window.pork.backporksRemove(id, confirmation);
|
||||
setStatus('Folder removed', 'ok');
|
||||
await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
}
|
||||
|
||||
if (btn.dataset.action === 'pork-all') {
|
||||
const folderName = btn.dataset.fw;
|
||||
const folderPath = btn.dataset.path;
|
||||
const folderEntry = gamesByFolder.find(({ folder }) => folder.name === folderName);
|
||||
const folderGames = folderEntry?.games || [];
|
||||
|
||||
if (!folderGames.length) { setStatus('No games in this firmware folder', 'warn'); return; }
|
||||
if (!await showConfirm(`Queue all ${folderGames.length} game(s) from ${folderName} for PS5 install?\n\nProgress will be shown below in real-time.`)) return;
|
||||
|
||||
// ── Progress panel helpers ────────────────────────────────────────────
|
||||
const panel = $('pork-all-progress-panel');
|
||||
const queueFill = $('pork-all-queue-fill');
|
||||
const queueLbl = $('pork-all-queue-label');
|
||||
const queueCnt = $('pork-all-queue-count');
|
||||
const gameLbl = $('pork-all-current-game');
|
||||
const fileFill = $('pork-all-file-fill');
|
||||
const fileLbl = $('pork-all-file-label');
|
||||
const logEl = $('pork-all-log');
|
||||
|
||||
panel.style.display = '';
|
||||
logEl.textContent = '';
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
||||
const ts = () => new Date().toLocaleTimeString();
|
||||
function appendLog(line, color) {
|
||||
const span = document.createElement('span');
|
||||
if (color) span.style.color = color;
|
||||
span.textContent = `[${ts()}] ${line}\n`;
|
||||
logEl.appendChild(span);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' GB';
|
||||
if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB';
|
||||
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
|
||||
return b + ' B';
|
||||
}
|
||||
function fmtSpeed(bps) {
|
||||
return fmtBytes(bps) + '/s';
|
||||
}
|
||||
|
||||
// ── Phase 1: Resolve FTP paths (before registering listener) ────────────
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Resolving paths…';
|
||||
appendLog(`Starting bulk apply — ${folderGames.length} game(s) from ${folderName}`);
|
||||
queueLbl.textContent = `Resolving FTP paths… (0 / ${folderGames.length})`;
|
||||
|
||||
const readyGames = []; // { game, ftpDest }
|
||||
let skipped = 0, failed = 0;
|
||||
let pathsDone = 0;
|
||||
|
||||
for (const game of folderGames) {
|
||||
try {
|
||||
const ftpDest = await ensureGameFtpPath(game.game_id);
|
||||
pathsDone++;
|
||||
if (!ftpDest) {
|
||||
skipped++;
|
||||
appendLog(` SKIP ${game.game_id} — no FTP path (cancelled)`, '#f59e0b');
|
||||
} else {
|
||||
appendLog(` PATH ${game.game_id} → ${ftpDest}`);
|
||||
readyGames.push({ game, ftpDest });
|
||||
}
|
||||
queueLbl.textContent = `Resolving FTP paths… (${pathsDone} / ${folderGames.length})`;
|
||||
} catch (err) {
|
||||
pathsDone++;
|
||||
failed++;
|
||||
appendLog(` ERROR ${game.game_id}: ${err.message}`, '#f87171');
|
||||
console.error('[pork-all]', game.game_id, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!readyGames.length) {
|
||||
const parts = [`Nothing queued from ${folderName}`];
|
||||
if (skipped) parts.push(`${skipped} skipped`);
|
||||
if (failed) parts.push(`${failed} failed`);
|
||||
appendLog(`\n${parts.join(' — ')}`, '#f59e0b');
|
||||
setStatus(parts.join(' — '), 'warn');
|
||||
panel.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Phase 2: Register listener BEFORE queuing so we catch all events ──
|
||||
const batchIds = new Set();
|
||||
const gameById = {};
|
||||
const lastState = {};
|
||||
let doneCount = 0;
|
||||
let lastLoggedFile = {};
|
||||
let batchDone = false;
|
||||
|
||||
const updateProgress = (transferState) => {
|
||||
if (batchDone) return;
|
||||
const batchJobs2 = (transferState.jobs || []).filter(j => batchIds.has(j.id));
|
||||
|
||||
let nowDone = 0;
|
||||
let activeJob = null;
|
||||
|
||||
for (const j of batchJobs2) {
|
||||
const game = gameById[j.id];
|
||||
const prev = lastState[j.id];
|
||||
|
||||
if (j.status === 'active') activeJob = j;
|
||||
|
||||
if (j.status !== prev) {
|
||||
lastState[j.id] = j.status;
|
||||
if (j.status === 'active') {
|
||||
appendLog(`\n▶ START #${j.id} ${game?.game_id || j.gameId} (${j.label})`);
|
||||
} else if (j.status === 'done') {
|
||||
nowDone++;
|
||||
appendLog(`✓ DONE #${j.id} ${game?.game_id || j.gameId}`, '#4ade80');
|
||||
} else if (j.status === 'error') {
|
||||
nowDone++;
|
||||
appendLog(`✗ ERROR #${j.id} ${game?.game_id || j.gameId}: ${j.error || 'unknown error'}`, '#f87171');
|
||||
} else if (j.status === 'cancelled') {
|
||||
nowDone++;
|
||||
appendLog(`⊘ CANCEL #${j.id} ${game?.game_id || j.gameId}`, '#f59e0b');
|
||||
}
|
||||
} else if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') {
|
||||
nowDone++;
|
||||
}
|
||||
|
||||
if (j.status === 'active' && j.progress?.file && j.progress.file !== lastLoggedFile[j.id]) {
|
||||
lastLoggedFile[j.id] = j.progress.file;
|
||||
const fileShort = j.progress.file.replace(/\\/g, '/').split('/').pop();
|
||||
const fileInfo = j.progress.filesTotal
|
||||
? ` FILE [${j.progress.filesDone + 1}/${j.progress.filesTotal}] ${fileShort}`
|
||||
: ` FILE ${fileShort}`;
|
||||
appendLog(fileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
doneCount = nowDone;
|
||||
|
||||
// Overall queue bar — use batchIds.size (grows as jobs are queued)
|
||||
const batchTotal = batchIds.size;
|
||||
const qPct = batchTotal > 0 ? Math.round((doneCount / batchTotal) * 100) : 0;
|
||||
if (!batchDone) {
|
||||
// Only update bar/label while we're still monitoring
|
||||
queueFill.style.width = qPct + '%';
|
||||
queueLbl.textContent = `Applying backporks to PS5 — ${doneCount} / ${batchTotal} complete`;
|
||||
queueCnt.textContent = `${qPct}%`;
|
||||
btn.textContent = `Applying… (${doneCount}/${batchTotal})`;
|
||||
}
|
||||
|
||||
if (activeJob) {
|
||||
const g = gameById[activeJob.id];
|
||||
const p = activeJob.progress || {};
|
||||
const pct = p.percent || 0;
|
||||
fileFill.style.width = pct + '%';
|
||||
|
||||
const fileShort = (p.file || '').replace(/\\/g, '/').split('/').pop();
|
||||
const speedStr = p.speedBps ? ` @ ${fmtSpeed(p.speedBps)}` : '';
|
||||
const sizeStr = p.total ? ` — ${fmtBytes(p.transferred)} / ${fmtBytes(p.total)}` : '';
|
||||
const filesStr = p.filesTotal ? ` [file ${p.filesDone + 1}/${p.filesTotal}]` : '';
|
||||
|
||||
gameLbl.textContent = `${g?.game_id || activeJob.gameId}${filesStr}: ${fileShort || 'preparing…'}`;
|
||||
fileLbl.textContent = `${pct.toFixed(1)}%${sizeStr}${speedStr}`;
|
||||
} else if (doneCount < batchTotal) {
|
||||
gameLbl.textContent = 'Waiting for next job to start…';
|
||||
fileLbl.textContent = '';
|
||||
fileFill.style.width = '0%';
|
||||
}
|
||||
|
||||
// All batch jobs finished (or no more in queue)
|
||||
if (batchIds.size > 0 && doneCount >= batchIds.size) {
|
||||
batchDone = true;
|
||||
const errCount = batchJobs2.filter(j => j.status === 'error').length;
|
||||
const cancelCount = batchJobs2.filter(j => j.status === 'cancelled').length;
|
||||
const parts = [`Applied ${batchIds.size - errCount - cancelCount} of ${batchIds.size}`];
|
||||
if (skipped) parts.push(`${skipped} skipped`);
|
||||
if (failed) parts.push(`${failed} path errors`);
|
||||
if (errCount) parts.push(`${errCount} transfer errors`);
|
||||
appendLog(`\n━━━ All done — ${parts.join(', ')} ━━━`, '#4ade80');
|
||||
queueFill.style.width = '100%';
|
||||
queueLbl.textContent = `Bulk apply complete — ${parts.join(', ')}`;
|
||||
queueCnt.textContent = '100%';
|
||||
gameLbl.textContent = '';
|
||||
fileLbl.textContent = '';
|
||||
fileFill.style.width = '0%';
|
||||
btn.disabled = false;
|
||||
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
|
||||
setStatus(parts.join(' — '), errCount || failed ? 'warn' : 'ok');
|
||||
loadBackporks().catch(() => {});
|
||||
_bpApplyUpdater = null; // stop forwarding transfer:update to this run
|
||||
}
|
||||
};
|
||||
|
||||
_bpApplyUpdater = updateProgress;
|
||||
|
||||
// ── Phase 3: Queue jobs now that listener is active ───────────────────
|
||||
appendLog(`\nQueuing ${readyGames.length} job(s)…`);
|
||||
queueLbl.textContent = `Queuing ${readyGames.length} job(s)…`;
|
||||
btn.textContent = `Queuing… (0/${readyGames.length})`;
|
||||
let queuedCount = 0;
|
||||
|
||||
for (const { game } of readyGames) {
|
||||
try {
|
||||
const result = await window.pork.backporksPork(game.game_id, folderName, folderPath);
|
||||
batchIds.add(result.jobId);
|
||||
gameById[result.jobId] = game;
|
||||
queuedCount++;
|
||||
appendLog(` QUEUE #${result.jobId} ${game.game_id}`);
|
||||
btn.textContent = `Queuing… (${queuedCount}/${readyGames.length})`;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
appendLog(` QUEUE-ERR ${game.game_id}: ${err.message}`, '#f87171');
|
||||
console.error('[pork-all queue]', game.game_id, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!batchIds.size) {
|
||||
batchDone = true;
|
||||
_bpApplyUpdater = null; // nothing queued — stop forwarding
|
||||
appendLog('\nNothing was queued — all queue attempts failed', '#f59e0b');
|
||||
setStatus('Queue failed — check Transfers tab', 'error');
|
||||
panel.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
|
||||
return;
|
||||
}
|
||||
|
||||
appendLog(`\nAll ${batchIds.size} job(s) queued — monitoring transfers…\n`);
|
||||
queueLbl.textContent = `Applying backporks to PS5 — 0 / ${batchIds.size} complete`;
|
||||
queueFill.style.width = '0%';
|
||||
btn.textContent = `Applying… (0/${batchIds.size})`;
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const viewBtn = e.target.closest('[data-bp-view]');
|
||||
if (viewBtn) { openModal(viewBtn.dataset.bpView); return; }
|
||||
|
||||
const porkBtn = e.target.closest('[data-bp-pork]');
|
||||
if (porkBtn && !porkBtn.disabled) {
|
||||
const gameId = porkBtn.dataset.bpPork;
|
||||
const folderName = porkBtn.dataset.bpFw;
|
||||
const folderPath = porkBtn.dataset.bpPath;
|
||||
|
||||
const ftpDest = await ensureGameFtpPath(gameId);
|
||||
if (!ftpDest) return; // user cancelled picker
|
||||
|
||||
porkBtn.disabled = true;
|
||||
porkBtn.textContent = 'Queuing…';
|
||||
setStatus(`Queuing ${gameId} (${folderName}) for install…`);
|
||||
try {
|
||||
await window.pork.backporksPork(gameId, folderName, folderPath);
|
||||
setStatus(`${gameId} queued — see Transfers tab for progress`, 'ok');
|
||||
await loadBackporks();
|
||||
} catch (err) {
|
||||
setStatus(`Pork failed: ${err.message}`, 'error');
|
||||
porkBtn.disabled = false;
|
||||
porkBtn.textContent = 'Pork ▶';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.pork.on('backporks:scan:complete', async ({ folders, count, uniqueGames }) => {
|
||||
if (count > 0) {
|
||||
const folderLabel = `${folders} firmware folder${folders !== 1 ? 's' : ''}`;
|
||||
const buildLabel = `${count} build${count !== 1 ? 's' : ''}`;
|
||||
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
|
||||
? ` across ${uniqueGames} unique game${uniqueGames !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
showToast(`Backporks: ${buildLabel} in ${folderLabel}${uniqueLabel}`, 'info');
|
||||
}
|
||||
if (document.querySelector('.page.active')?.id === 'page-backporks') await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
});
|
||||
|
||||
$('btn-create-fw-folder').addEventListener('click', () => {
|
||||
$('new-fw-form').hidden = !$('new-fw-form').hidden;
|
||||
if (!$('new-fw-form').hidden) $('new-fw-name').focus();
|
||||
});
|
||||
$('btn-new-fw-cancel').addEventListener('click', () => {
|
||||
$('new-fw-form').hidden = true;
|
||||
$('new-fw-name').value = '';
|
||||
$('new-fw-parent').value = '';
|
||||
});
|
||||
$('btn-new-fw-browse').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (folder) $('new-fw-parent').value = folder;
|
||||
});
|
||||
$('btn-new-fw-create').addEventListener('click', async () => {
|
||||
const name = $('new-fw-name').value.trim();
|
||||
const parentPath = $('new-fw-parent').value.trim();
|
||||
if (!name) { setStatus('Enter a firmware version (e.g. 11.50.0)', 'error'); return; }
|
||||
if (!parentPath) { setStatus('Select a parent folder', 'error'); return; }
|
||||
if (!/^\d+\.\d+\.\d+$/.test(name)) { setStatus('Version must follow x.x.x format (e.g. 11.50.0)', 'error'); return; }
|
||||
try {
|
||||
$('btn-new-fw-create').disabled = true;
|
||||
const { path: created } = await window.pork.backporksCreateFolder(name, parentPath);
|
||||
showToast(`Created firmware folder ${name}`, 'ok');
|
||||
$('new-fw-form').hidden = true;
|
||||
$('new-fw-name').value = '';
|
||||
$('new-fw-parent').value = '';
|
||||
await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
} finally {
|
||||
$('btn-new-fw-create').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-add-backpork').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (!folder) return;
|
||||
|
||||
$('btn-add-backpork').disabled = true;
|
||||
setStatus('Scanning firmware folders…');
|
||||
try {
|
||||
const { folders, count } = await window.pork.backporksAddRoot(folder);
|
||||
setStatus(`Found ${folders} firmware folder(s) — ${count} game(s) detected`, 'ok');
|
||||
showToast(`Registered ${folders} firmware folder(s)`, 'ok');
|
||||
await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
} finally {
|
||||
$('btn-add-backpork').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Backporks card browse actions are wired here as a fallback because this
|
||||
// page module is known-good: the main "Select Backpork Folder" button on the
|
||||
// same page already uses the same picker bridge successfully.
|
||||
function applyBpgenAutoConfigFromPairs(pairs) {
|
||||
if (!Array.isArray(pairs) || !pairs.length) return;
|
||||
const ready = pairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
|
||||
if (!ready.length) return;
|
||||
|
||||
const pair = ready[0].pair;
|
||||
const sdkSelect = $('bpgen-sdk-pair');
|
||||
const fwInput = $('bpgen-fw-label');
|
||||
const fakelibInput = $('bpgen-fakelib-dir');
|
||||
|
||||
if (sdkSelect && [...sdkSelect.options].some(o => Number(o.value) === pair)) {
|
||||
sdkSelect.value = String(pair);
|
||||
}
|
||||
if (fwInput) fwInput.value = `${pair}.xx`;
|
||||
if (fakelibInput && !fakelibInput.value.trim()) {
|
||||
fakelibInput.value = ready[0].dir || '';
|
||||
}
|
||||
window.pork.setSettings({
|
||||
bpgenSdkPair: String(pair),
|
||||
bpgenFirmwareLabel: `${pair}.xx`,
|
||||
bpgenFakelibDir: fakelibInput?.value.trim() || '',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (!_bpgenPageInitStarted) {
|
||||
_bpgenPageInitStarted = true;
|
||||
|
||||
$('bpgen-sdk-pair')?.addEventListener('change', async () => {
|
||||
const pair = $('bpgen-sdk-pair').value;
|
||||
const baseDir = $('bpgen-fl-basedir').value.trim().replace(/[\\\/]+$/, '');
|
||||
if (pair === 'all') {
|
||||
$('bpgen-fw-label').value = 'All';
|
||||
if (baseDir) $('bpgen-fakelib-dir').value = '';
|
||||
} else {
|
||||
$('bpgen-fw-label').value = pair ? `${pair}.xx` : '';
|
||||
if (baseDir && pair) $('bpgen-fakelib-dir').value = `${baseDir}\\${pair}`;
|
||||
}
|
||||
await window.pork.setSettings({
|
||||
bpgenSdkPair: pair,
|
||||
bpgenFirmwareLabel: $('bpgen-fw-label').value.trim(),
|
||||
bpgenFakelibDir: $('bpgen-fakelib-dir').value.trim(),
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
if (!$('bpgen-btn-output')?.dataset.boundInBackporks) {
|
||||
$('bpgen-btn-output').dataset.boundInBackporks = '1';
|
||||
$('bpgen-btn-output').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (folder) {
|
||||
$('bpgen-output-dir').value = folder;
|
||||
await window.pork.setSettings({ bpgenOutputDir: folder });
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`Output folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$('bpgen-btn-fakelib')?.dataset.boundInBackporks) {
|
||||
$('bpgen-btn-fakelib').dataset.boundInBackporks = '1';
|
||||
$('bpgen-btn-fakelib').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (folder) {
|
||||
$('bpgen-fakelib-dir').value = folder;
|
||||
const save = { bpgenFakelibDir: folder };
|
||||
const pairMatch = String(folder).match(/[\\\/](\d+)\s*$/);
|
||||
if (pairMatch) {
|
||||
const pair = Number(pairMatch[1]);
|
||||
if (Number.isFinite(pair)) {
|
||||
if ([...$('bpgen-sdk-pair').options].some(o => Number(o.value) === pair)) {
|
||||
$('bpgen-sdk-pair').value = String(pair);
|
||||
}
|
||||
$('bpgen-fw-label').value = `${pair}.xx`;
|
||||
save.bpgenSdkPair = String(pair);
|
||||
save.bpgenFirmwareLabel = `${pair}.xx`;
|
||||
}
|
||||
}
|
||||
await window.pork.setSettings(save);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$('bpgen-fl-btn-base')?.dataset.boundInBackporks) {
|
||||
$('bpgen-fl-btn-base').dataset.boundInBackporks = '1';
|
||||
$('bpgen-fl-btn-base').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (!folder) return;
|
||||
$('bpgen-fl-basedir').value = folder;
|
||||
await window.pork.setSettings({ bpgenFakelibBaseDir: folder });
|
||||
if (window.pork.bpgenFakelibSetBasedir) {
|
||||
const result = await window.pork.bpgenFakelibSetBasedir(folder);
|
||||
applyBpgenAutoConfigFromPairs(result?.pairs || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`Fakelib base dir browse failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// ── Backups page ──────────────────────────────────────────────────────────────
|
||||
function protectionText(key, fallback) {
|
||||
if (typeof window.t !== 'function') return fallback;
|
||||
const translated = window.t(key);
|
||||
return translated && translated !== key ? translated : fallback;
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const gameContext = consumeGameWorkspaceContext('backups');
|
||||
if (gameContext && $('backups-search')) $('backups-search').value = gameContext;
|
||||
const titles = [];
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
do {
|
||||
const packet = await window.pork.listLibraryTitles({ limit: 200, offset });
|
||||
const page = window.chimeraLibraryModel.items(packet);
|
||||
titles.push(...page);
|
||||
total = Number(packet && packet.total || page.length);
|
||||
offset += page.length;
|
||||
if (!page.length) break;
|
||||
} while (offset < total);
|
||||
state.backups = window.chimeraProtectionModel.flatten(titles);
|
||||
populateBackupsRegionFilter();
|
||||
renderBackups();
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function populateBackupsRegionFilter() {
|
||||
const sel = $('backups-region-filter');
|
||||
const cur = sel.value;
|
||||
const regions = [...new Set(
|
||||
state.backups.map(copy => copy.region || '').filter(Boolean).sort()
|
||||
)];
|
||||
sel.innerHTML = '<option value="">All Regions</option>'
|
||||
+ regions.map(r => `<option value="${escHtml(r)}"${r === cur ? ' selected' : ''}>${escHtml(r)}</option>`).join('');
|
||||
}
|
||||
|
||||
function renderBackups() {
|
||||
const search = ($('backups-search')?.value || '').toLowerCase();
|
||||
const region = $('backups-region-filter')?.value || '';
|
||||
const list = $('backups-list');
|
||||
const empty = $('backups-empty');
|
||||
|
||||
const filtered = state.backups.filter(copy => {
|
||||
if (region && copy.region !== region) return false;
|
||||
if (search) {
|
||||
const haystack = [copy.titleName, copy.titleId, copy.deviceName, copy.path, copy.hash].filter(Boolean).join(' ').toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
empty.hidden = filtered.length > 0;
|
||||
list.innerHTML = filtered.map(copy => {
|
||||
const verified = copy.lastVerifiedAt ? new Date(copy.lastVerifiedAt).toLocaleString(window.i18n?.current || 'en') : protectionText('protection.copy.never', 'Never');
|
||||
const hash = copy.hash ? `${copy.hashAlgorithm.toUpperCase()} ${copy.hash}` : `${protectionText('protection.copy.notHashed', 'Not hashed')} (${copy.hashState})`;
|
||||
const integrity = copy.missing
|
||||
? protectionText('protection.copy.missing', 'Missing copy')
|
||||
: copy.integrityFailed
|
||||
? `${protectionText('protection.copy.failed', 'Failed')} (${copy.integrityState})`
|
||||
: copy.integrityState === 'unknown' ? protectionText('protection.copy.notVerified', 'Not verified') : copy.integrityState;
|
||||
const policyClass = copy.policy.healthy ? 'is-healthy' : copy.policy.status === 'needs-diversity' ? 'needs-diversity' : 'is-degraded';
|
||||
const policyLabel = copy.policy.healthy
|
||||
? `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.devicesProtected', 'devices protected')}`
|
||||
: `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.distinctDevices', 'distinct devices')}`;
|
||||
return `<article class="backup-card protection-copy-card${copy.missing ? ' is-missing' : ''}${copy.integrityFailed ? ' has-integrity-failure' : ''}" data-copy-id="${escHtml(copy.copyId)}">
|
||||
<div class="protection-copy-title">
|
||||
<div class="backup-card-icon backup-card-icon-empty"></div>
|
||||
<div class="protection-copy-title-copy"><strong>${escHtml(copy.titleName)}</strong><code>${escHtml(copy.titleId)} · copy ${escHtml(copy.copyId)}</code></div>
|
||||
</div>
|
||||
<div class="protection-copy-facts">
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.device', 'Device'))}</span><button class="protection-device-label" type="button" data-label-device-id="${escHtml(copy.deviceId || '')}" data-device-label="${escHtml(copy.deviceName)}" title="${escHtml(protectionText('protection.copy.renameDevice', 'Rename this drive'))}">${escHtml(copy.deviceName)}</button></div>
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.path', 'Path'))}</span><code title="${escHtml(copy.path)}">${escHtml(copy.path)}</code></div>
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.hash', 'Hash'))}</span><code title="${escHtml(hash)}">${escHtml(hash)}</code></div>
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.lastVerified', 'Last verified'))}</span><strong>${escHtml(verified)}</strong></div>
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.integrity', 'Integrity'))}</span><strong>${escHtml(integrity)}</strong></div>
|
||||
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.size', 'Size'))}</span><strong>${escHtml(copy.sizeBytes ? fmt(copy.sizeBytes) : protectionText('protection.copy.unknown', 'Unknown'))}</strong></div>
|
||||
</div>
|
||||
<div class="protection-copy-actions">
|
||||
<span class="protection-copy-policy ${policyClass}">${escHtml(policyLabel)}</span>
|
||||
<button class="btn btn-sm btn-accent" type="button" data-restore-copy-id="${escHtml(copy.copyId)}"${copy.missing || copy.integrityFailed ? ' disabled' : ''}>${escHtml(protectionText('protection.copy.prepareRestore', 'Prepare restore'))}</button>
|
||||
<button class="btn btn-sm backup-card-view" type="button" data-view-game="${escHtml(copy.titleId)}">${escHtml(protectionText('protection.copy.titleDetails', 'Title details'))}</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
|
||||
list.onclick = async e => {
|
||||
const labelButton = e.target.closest('[data-label-device-id]');
|
||||
if (labelButton) {
|
||||
const label = await showPrompt(protectionText('protection.copy.renamePrompt', 'Friendly name for this drive:'), labelButton.dataset.deviceLabel || '');
|
||||
if (label == null) return;
|
||||
await window.pork.labelStorageDevice({ deviceId: Number(labelButton.dataset.labelDeviceId), label });
|
||||
await loadBackups();
|
||||
setStatus(protectionText('protection.copy.renamed', 'Drive label saved.'), 'ok');
|
||||
return;
|
||||
}
|
||||
const restore = e.target.closest('[data-restore-copy-id]');
|
||||
if (restore) {
|
||||
const copy = state.backups.find(item => item.copyId === restore.dataset.restoreCopyId);
|
||||
if (!copy) return;
|
||||
state.protectionRestoreTarget = copy.restoreTarget;
|
||||
list.querySelectorAll('[data-restore-copy-id]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button === restore));
|
||||
if (!button.disabled) button.textContent = button === restore
|
||||
? protectionText('protection.copy.restoreSelected', 'Restore source selected')
|
||||
: protectionText('protection.copy.prepareRestore', 'Prepare restore');
|
||||
});
|
||||
setStatus(`${protectionText('protection.copy.restoreSelected', 'Restore source selected')}: ${copy.copyId} · ${copy.deviceName}. ${protectionText('protection.copy.noMutation', 'No restore has started.')}`, 'ok');
|
||||
return;
|
||||
}
|
||||
const details = e.target.closest('[data-view-game]');
|
||||
if (details) openModal(details.dataset.viewGame);
|
||||
};
|
||||
}
|
||||
|
||||
$('backups-search')?.addEventListener('input', renderBackups);
|
||||
$('backups-region-filter')?.addEventListener('change', renderBackups);
|
||||
|
||||
$('btn-scan-local').addEventListener('click', async () => {
|
||||
$('btn-scan-local').disabled = true;
|
||||
setStatus('Scanning local game source folders…');
|
||||
try {
|
||||
const { total, added } = await window.pork.scanBackups();
|
||||
await loadBackups();
|
||||
await loadDashboard();
|
||||
const msg = added > 0
|
||||
? `Found ${total} game(s) — ${added} new, fetching metadata…`
|
||||
: `Scan complete: ${total} game(s) found, none new`;
|
||||
setStatus(msg, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
} finally {
|
||||
$('btn-scan-local').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-rescan-all-backporks').addEventListener('click', async () => {
|
||||
const btn = $('btn-rescan-all-backporks');
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Scanning…';
|
||||
setStatus('Rescanning all backpork folders…');
|
||||
try {
|
||||
const { folders, count, uniqueGames } = await window.pork.backporksScanAll();
|
||||
await loadBackporks();
|
||||
await refreshFirmwareFilters();
|
||||
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
|
||||
? ` across ${uniqueGames} unique game(s)`
|
||||
: '';
|
||||
setStatus(`Rescan complete — ${count} build(s) in ${folders} firmware folder(s)${uniqueLabel}`, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// BDJB Genny
|
||||
|
||||
const bdjbPayloads = [];
|
||||
const bdjbEntries = [];
|
||||
|
||||
function renderBdjbEntries() {
|
||||
const list = $('bdjbgen-list');
|
||||
if (!bdjbEntries.length) { list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then adjust their autoload order and delays.</p>'; return; }
|
||||
list.innerHTML = bdjbEntries.map((entry, index) => `<div class="autoloader-row"><span>${entry.type === 'delay' ? `Delay: ${entry.value} ms` : `Payload: ${escHtml(entry.value)}`}</span><span><button class="btn btn-sm" data-bdjb-up="${index}" ${index === 0 ? 'disabled' : ''}>Up</button><button class="btn btn-sm" data-bdjb-down="${index}" ${index === bdjbEntries.length - 1 ? 'disabled' : ''}>Down</button><button class="btn btn-sm btn-red" data-bdjb-remove="${index}">Remove</button></span></div>`).join('');
|
||||
}
|
||||
|
||||
function bdjbLog(message) {
|
||||
const log = $('bdjbgen-log');
|
||||
log.textContent += `${message}\n`;
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
$('btn-bdjbgen-add-payloads').addEventListener('click', async () => {
|
||||
const added = await window.pork.bdjbGenSelectPayloads();
|
||||
for (const payload of added) {
|
||||
if (bdjbPayloads.some((existing) => existing.name === payload.name)) { bdjbLog(`Skipped duplicate filename: ${payload.name}`); continue; }
|
||||
bdjbPayloads.push(payload); bdjbEntries.push({ type: 'payload', value: payload.name });
|
||||
}
|
||||
renderBdjbEntries();
|
||||
});
|
||||
|
||||
$('btn-bdjbgen-add-delay').addEventListener('click', async () => {
|
||||
const value = await showPrompt('Delay in milliseconds:', '1000');
|
||||
if (value === null || value === '') return;
|
||||
bdjbEntries.push({ type: 'delay', value: String(value).trim() }); renderBdjbEntries();
|
||||
});
|
||||
|
||||
$('bdjbgen-list').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button'); if (!button) return;
|
||||
const remove = button.dataset.bdjbRemove, up = button.dataset.bdjbUp, down = button.dataset.bdjbDown;
|
||||
if (remove !== undefined) bdjbEntries.splice(Number(remove), 1);
|
||||
else if (up !== undefined) { const i = Number(up); [bdjbEntries[i - 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i - 1]]; }
|
||||
else if (down !== undefined) { const i = Number(down); [bdjbEntries[i + 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i + 1]]; }
|
||||
renderBdjbEntries();
|
||||
});
|
||||
|
||||
window.pork.on('bdjbgen:progress', ({ stage, message, percent }) => {
|
||||
$('bdjbgen-progress').value = percent || 0;
|
||||
$('bdjbgen-status').textContent = `${stage}: ${message}`;
|
||||
bdjbLog(`[${stage}] ${message}`);
|
||||
});
|
||||
|
||||
$('btn-bdjbgen-build').addEventListener('click', async () => {
|
||||
const button = $('btn-bdjbgen-build');
|
||||
button.disabled = true; $('bdjbgen-log').textContent = ''; $('bdjbgen-progress').value = 0;
|
||||
try {
|
||||
const result = await window.pork.bdjbGenBuild({ entries: bdjbEntries, payloads: bdjbPayloads, discTitle: $('bdjbgen-title').value });
|
||||
if (result.canceled) { $('bdjbgen-status').textContent = 'Build canceled.'; return; }
|
||||
$('bdjbgen-status').textContent = `ISO ready: ${result.outputIso} (${result.bytes} bytes)`;
|
||||
bdjbLog(`Validated ISO with ${result.payloadCount} payload(s).`);
|
||||
} catch (error) {
|
||||
$('bdjbgen-status').textContent = `Build failed: ${error.message}`;
|
||||
bdjbLog(`ERROR: ${error.message}`);
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
// ── Cheats page ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _cheatsDownloading = false;
|
||||
|
||||
function fmtEta(sec) {
|
||||
if (!sec || sec <= 0) return '—';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
let _cheatsSearchTimer = null;
|
||||
|
||||
// Map of cusa_id → prospero_icon_url, built once per loadCheats() call
|
||||
let _cheatIconMap = new Map();
|
||||
const _cheatIconAttempted = new Set();
|
||||
|
||||
async function refreshCheatStats() {
|
||||
try {
|
||||
const stats = await window.pork.cheatsStats();
|
||||
$('cheats-stat-files').textContent = `${stats.files ?? 0} files cached`;
|
||||
$('cheats-stat-games').textContent = `covering ${stats.games ?? 0} games`;
|
||||
const badge = $('nav-cheats-badge');
|
||||
if (stats.files > 0) {
|
||||
badge.textContent = stats.files;
|
||||
badge.hidden = false;
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadCheats({ background = false } = {}) {
|
||||
// Hide the progress panel unless a download is actively running
|
||||
if (!_cheatsDownloading) {
|
||||
$('cheats-dl-panel').hidden = true;
|
||||
$('btn-cheats-download-all').disabled = false;
|
||||
}
|
||||
|
||||
await refreshCheatStats();
|
||||
// Background warming keeps the badge current without building thousands of
|
||||
// hidden cards or starting artwork requests for a page the user did not open.
|
||||
if (background) return;
|
||||
|
||||
// Build icon map from games — reuse state.games if already loaded, otherwise fetch
|
||||
try {
|
||||
const games = state.games.length ? state.games : await window.pork.listGames({});
|
||||
_cheatIconMap = new Map(games.map(g => [g.game_id, g.prospero_icon_url || '']));
|
||||
} catch (_) {}
|
||||
|
||||
// Render with current search
|
||||
const q = $('cheats-search').value.trim();
|
||||
await renderCheatResults(q);
|
||||
|
||||
// Unmatched section (only shown when not searching)
|
||||
if (!q) await renderUnmatchedCheats();
|
||||
else $('cheats-unmatched-section').hidden = true;
|
||||
}
|
||||
|
||||
// ── GarlicSaves ──────────────────────────────────────────────────────────────
|
||||
let _garlicBusy = false;
|
||||
let _garlicHooked = false;
|
||||
|
||||
async function loadGarlic() {
|
||||
if (!_garlicHooked) {
|
||||
_garlicHooked = true;
|
||||
|
||||
$('btn-garlic-site').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
window.pork.openShell('https://garlicsaves.com');
|
||||
});
|
||||
$('garlic-contribute-link').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
window.pork.openShell('https://garlicsaves.com/contribute');
|
||||
});
|
||||
|
||||
// Start Working — show confirm modal
|
||||
$('btn-garlic-start').addEventListener('click', () => {
|
||||
if (_garlicBusy) return;
|
||||
openAccessibleDialog($('garlic-confirm-overlay'), $('btn-garlic-confirm-cancel'));
|
||||
});
|
||||
|
||||
// Modal: Cancel
|
||||
$('btn-garlic-confirm-cancel').addEventListener('click', () => {
|
||||
closeAccessibleDialog($('garlic-confirm-overlay'));
|
||||
});
|
||||
|
||||
// Modal: Confirm — run the worker
|
||||
$('btn-garlic-confirm-ok').addEventListener('click', async () => {
|
||||
closeAccessibleDialog($('garlic-confirm-overlay'));
|
||||
if (_garlicBusy) return;
|
||||
_garlicBusy = true;
|
||||
$('btn-garlic-start').disabled = true;
|
||||
$('garlic-progress-area').hidden = false;
|
||||
$('garlic-progress-text').textContent = 'Starting…';
|
||||
$('garlic-progress-bar').style.width = '0%';
|
||||
try {
|
||||
await window.pork.garlicStart();
|
||||
$('garlic-status-badge').hidden = false;
|
||||
showToast('GarlicSaves worker started! Restart PS5 to stop.', 'success');
|
||||
} catch (e) {
|
||||
$('garlic-progress-text').textContent = 'Error: ' + e.message;
|
||||
showToast('Failed to start worker: ' + e.message, 'error');
|
||||
} finally {
|
||||
_garlicBusy = false;
|
||||
$('btn-garlic-start').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Live progress events
|
||||
window.pork.on('garlic:progress', ({ msg, percent }) => {
|
||||
const txt = $('garlic-progress-text');
|
||||
const bar = $('garlic-progress-bar');
|
||||
if (txt) txt.textContent = msg;
|
||||
if (bar && percent != null) bar.style.width = Math.min(100, percent) + '%';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _garlicShowProgress(show) {
|
||||
$('garlic-progress-area').hidden = !show;
|
||||
if (show) {
|
||||
$('garlic-progress-text').textContent = 'Starting…';
|
||||
$('garlic-progress-bar').style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let _lazyIconCancel = false;
|
||||
|
||||
// Fetch icons from prosperopatches.com for cheat entries not already in the icon map.
|
||||
// Throttled at one request per 500 ms, capped at 100 entries, cancellable.
|
||||
async function lazyFetchCheatIcons(cusaIds) {
|
||||
_lazyIconCancel = true; // cancel any prior run
|
||||
await new Promise(r => setTimeout(r, 20)); // let prior loop see the flag
|
||||
_lazyIconCancel = false;
|
||||
|
||||
const toFetch = [...new Set(cusaIds)]
|
||||
.filter(id => /^(?:CUSA|PPSA)\d{5}$/i.test(String(id || '')) && !_cheatIconAttempted.has(id))
|
||||
.slice(0, 40);
|
||||
for (let i = 0; i < toFetch.length; i++) {
|
||||
if (_lazyIconCancel) break;
|
||||
const id = toFetch[i];
|
||||
_cheatIconAttempted.add(id);
|
||||
try {
|
||||
const { iconUrl } = await window.pork.prosperoFetchIcon(id);
|
||||
if (_lazyIconCancel) break;
|
||||
if (iconUrl) {
|
||||
_cheatIconMap.set(id, iconUrl);
|
||||
// Update every visible card for this game
|
||||
document.querySelectorAll(`.cheat-card[data-cusa="${id}"]`).forEach(card => {
|
||||
const empty = card.querySelector('.cheat-card-icon-empty');
|
||||
if (!empty) return;
|
||||
const img = document.createElement('img');
|
||||
img.className = 'cheat-card-icon';
|
||||
img.alt = '';
|
||||
img.src = iconUrl;
|
||||
img.onerror = () => { img.style.display = 'none'; };
|
||||
empty.replaceWith(img);
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!_lazyIconCancel && i < toFetch.length - 1) await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCheatResults(query) {
|
||||
const list = $('cheats-list');
|
||||
const empty = $('cheats-empty');
|
||||
list.innerHTML = '<div class="empty-msg">Loading…</div>';
|
||||
empty.hidden = true;
|
||||
|
||||
try {
|
||||
// Both cheatsAll() and cheatsSearch() return flat rows: { cusa_id, title, file_count }
|
||||
const rows = query
|
||||
? await window.pork.cheatsSearch(query)
|
||||
: await window.pork.cheatsAll();
|
||||
|
||||
list.innerHTML = '';
|
||||
if (!rows || rows.length === 0) {
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cheat-card';
|
||||
card.dataset.cusa = row.cusa_id;
|
||||
const fc = row.file_count ?? 1;
|
||||
const iconUrl = _cheatIconMap.get(row.cusa_id) || '';
|
||||
const iconHtml = iconUrl
|
||||
? `<img src="${escHtml(iconUrl)}" class="cheat-card-icon" alt="" loading="lazy" data-image-error="hide">`
|
||||
: `<div class="cheat-card-icon cheat-card-icon-empty"></div>`;
|
||||
card.innerHTML = `
|
||||
<div class="cheat-card-header">
|
||||
${iconHtml}
|
||||
<div class="cheat-card-info">
|
||||
<div class="cheat-card-title-row">
|
||||
<span class="cheat-card-id">${escHtml(row.cusa_id)}</span>
|
||||
<span class="cheat-card-title">${escHtml(row.title || row.cusa_id)}</span>
|
||||
</div>
|
||||
<span class="cheat-card-meta">${fc} version${fc !== 1 ? 's' : ''} cached</span>
|
||||
</div>
|
||||
</div>`;
|
||||
list.appendChild(card);
|
||||
}
|
||||
|
||||
// Background-fetch icons for entries that have no icon in the map
|
||||
const missing = rows.filter(r => r.cusa_id && !_cheatIconMap.get(r.cusa_id)).map(r => r.cusa_id);
|
||||
if (missing.length > 0) lazyFetchCheatIcons(missing);
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Renders per-version file block inside modal cheat panel (has full data field)
|
||||
function renderCheatFileBlock(f) {
|
||||
let parsed;
|
||||
try { parsed = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; } catch (_) { parsed = null; }
|
||||
const mods = parsed?.mods ?? [];
|
||||
const modsHtml = mods.map(m => `
|
||||
<div class="cheat-mod-row">
|
||||
<span class="cheat-mod-name">${escHtml(m.name || '?')}</span>
|
||||
<span class="cheat-mod-type">${escHtml(m.type || '')}</span>
|
||||
</div>`).join('');
|
||||
|
||||
return `
|
||||
<div class="cheat-file-block">
|
||||
<div class="cheat-file-ver">v${escHtml(f.version || '?')} <span style="opacity:.55;font-size:.8em">${escHtml(f.filename || '')}</span></div>
|
||||
<div class="cheat-mod-list">${modsHtml || '<span style="opacity:.45">No mods parsed</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function renderUnmatchedCheats() {
|
||||
const section = $('cheats-unmatched-section');
|
||||
const list = $('cheats-unmatched-list');
|
||||
const badge = $('cheats-unmatched-count');
|
||||
|
||||
try {
|
||||
const rows = await window.pork.cheatsUnmatched();
|
||||
if (!rows || rows.length === 0) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
section.hidden = false;
|
||||
badge.textContent = rows.length;
|
||||
|
||||
list.innerHTML = rows.map(r => `
|
||||
<div class="cheat-unmatched-row" data-filename="${escHtml(r.filename)}">
|
||||
<div class="cheat-unmatched-info">
|
||||
<span class="cheat-unmatched-title">${escHtml(r.title || r.filename)}</span>
|
||||
<span class="cheat-unmatched-file">${escHtml(r.filename)}</span>
|
||||
</div>
|
||||
<div class="cheat-unmatched-assign">
|
||||
<input type="text" class="unmatched-cusa-input" placeholder="CUSA00000" maxlength="9" spellcheck="false" aria-label="Assign game ID"/>
|
||||
<button class="btn btn-sm btn-accent btn-assign-cusa">Assign</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
section.hidden = false;
|
||||
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Assign CUSA ID delegation
|
||||
$('cheats-unmatched-list').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.btn-assign-cusa');
|
||||
if (!btn) return;
|
||||
const row = btn.closest('.cheat-unmatched-row');
|
||||
const filename = row?.dataset.filename;
|
||||
const input = row?.querySelector('.unmatched-cusa-input');
|
||||
const cusaId = input?.value.trim().toUpperCase();
|
||||
|
||||
if (!filename || !cusaId || !/^[A-Z]{4}\d{5}$/.test(cusaId)) {
|
||||
input?.focus();
|
||||
input?.classList.add('input-error');
|
||||
setTimeout(() => input?.classList.remove('input-error'), 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
await window.pork.cheatsAssign(filename, cusaId);
|
||||
row.style.opacity = '0';
|
||||
row.style.transition = 'opacity .3s';
|
||||
setTimeout(() => renderUnmatchedCheats(), 320);
|
||||
// Refresh stats / matched list
|
||||
loadCheats();
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Assign';
|
||||
setStatus(`Assign failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Search input (debounced)
|
||||
$('cheats-search').addEventListener('input', () => {
|
||||
clearTimeout(_cheatsSearchTimer);
|
||||
_cheatsSearchTimer = setTimeout(() => {
|
||||
const q = $('cheats-search').value.trim();
|
||||
renderCheatResults(q);
|
||||
if (!q) renderUnmatchedCheats();
|
||||
else $('cheats-unmatched-section').hidden = true;
|
||||
}, 350);
|
||||
});
|
||||
|
||||
// Download-all
|
||||
$('btn-cheats-download-all').addEventListener('click', async () => {
|
||||
_cheatsDownloading = true;
|
||||
$('cheats-dl-panel').hidden = false;
|
||||
$('cheats-dl-title').textContent = 'Fetching cheat index…';
|
||||
$('cheats-dl-bar').style.width = '0%';
|
||||
$('cheats-dl-count').textContent = '0 / 0';
|
||||
$('cheats-dl-pct').textContent = '0%';
|
||||
$('cheats-dl-saved').textContent = 'Saved: 0';
|
||||
$('cheats-dl-failed').textContent = 'Failed: 0';
|
||||
$('cheats-dl-eta').textContent = 'ETA: —';
|
||||
$('cheats-dl-current').textContent = '';
|
||||
$('btn-cheats-download-all').disabled = true;
|
||||
try {
|
||||
await window.pork.cheatsDownloadAll();
|
||||
} catch (e) {
|
||||
_cheatsDownloading = false;
|
||||
$('cheats-dl-title').textContent = `Error: ${e.message}`;
|
||||
$('btn-cheats-download-all').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel download
|
||||
$('btn-cheats-dl-cancel').addEventListener('click', async () => {
|
||||
await window.pork.cheatsCancel();
|
||||
$('cheats-dl-title').textContent = 'Cancelling…';
|
||||
});
|
||||
|
||||
// Progress IPC events
|
||||
window.pork.on('cheats:dl:start', ({ total }) => {
|
||||
$('cheats-dl-title').textContent = `Downloading cheat database…`;
|
||||
$('cheats-dl-count').textContent = `0 / ${total}`;
|
||||
$('cheats-dl-bar').style.width = '0%';
|
||||
});
|
||||
|
||||
window.pork.on('cheats:dl:progress', ({ done, total, saved, failed, etaSec, current, pct }) => {
|
||||
$('cheats-dl-bar').style.width = `${pct}%`;
|
||||
$('cheats-dl-count').textContent = `${done} / ${total}`;
|
||||
$('cheats-dl-pct').textContent = `${pct}%`;
|
||||
$('cheats-dl-saved').textContent = `Saved: ${saved}`;
|
||||
$('cheats-dl-failed').textContent = `Failed: ${failed}`;
|
||||
$('cheats-dl-eta').textContent = `ETA: ${fmtEta(etaSec)}`;
|
||||
$('cheats-dl-current').textContent = current || '';
|
||||
});
|
||||
|
||||
window.pork.on('cheats:dl:done', ({ saved, failed, cancelled }) => {
|
||||
_cheatsDownloading = false;
|
||||
$('cheats-dl-bar').style.width = cancelled ? $('cheats-dl-bar').style.width : '100%';
|
||||
$('cheats-dl-title').textContent = cancelled
|
||||
? `Download cancelled — ${saved} saved, ${failed} failed`
|
||||
: `Done — ${saved} saved, ${failed} failed`;
|
||||
$('cheats-dl-pct').textContent = cancelled ? '' : '100%';
|
||||
$('cheats-dl-eta').textContent = '';
|
||||
$('cheats-dl-current').textContent = '';
|
||||
$('btn-cheats-download-all').disabled = false;
|
||||
// Scheduled server syncs also emit this event. Keep hidden-page work cheap;
|
||||
// the full list and its artwork hydrate only when Cheats is actually open.
|
||||
const active = $('page-cheats').classList.contains('active');
|
||||
loadCheats({ background: !active });
|
||||
});
|
||||
|
||||
// ── Cheats FTP install ─────────────────────────────────────────────────────────
|
||||
|
||||
$('btn-cheats-ftp-install').addEventListener('click', async () => {
|
||||
// Reset panel to instruction state
|
||||
$('cheats-ftp-progress-wrap').hidden = true;
|
||||
$('cheats-ftp-bar').style.width = '0%';
|
||||
$('cheats-ftp-phase').textContent = 'Preparing…';
|
||||
$('cheats-ftp-count').textContent = '';
|
||||
$('cheats-ftp-pct').textContent = '0%';
|
||||
$('cheats-ftp-current').textContent = '';
|
||||
$('cheats-ftp-title').textContent = 'Install Cheats via FTP';
|
||||
$('btn-cheats-ftp-go').disabled = false;
|
||||
$('btn-cheats-ftp-go').textContent = '↑ Install Now';
|
||||
// Populate path from settings
|
||||
const s = await window.pork.getSettings();
|
||||
const configuredPath = (s.cheatsRemotePath || '').trim();
|
||||
$('cheats-ftp-path').value = configuredPath || '/data/etaHEN/cheats/';
|
||||
$('cheats-ftp-no-path-warn').hidden = !!configuredPath;
|
||||
$('cheats-ftp-panel').hidden = false;
|
||||
});
|
||||
|
||||
$('btn-cheats-ftp-browse').addEventListener('click', async () => {
|
||||
const cur = $('cheats-ftp-path').value.trim() || '/';
|
||||
const p = await openFtpBrowser(cur);
|
||||
if (p) $('cheats-ftp-path').value = p;
|
||||
});
|
||||
|
||||
$('btn-cheats-ftp-close').addEventListener('click', () => {
|
||||
$('cheats-ftp-panel').hidden = true;
|
||||
});
|
||||
|
||||
$('btn-cheats-ftp-go').addEventListener('click', async () => {
|
||||
$('btn-cheats-ftp-go').disabled = true;
|
||||
$('btn-cheats-ftp-go').textContent = 'Installing…';
|
||||
$('cheats-ftp-progress-wrap').hidden = false;
|
||||
try {
|
||||
const remotePath = $('cheats-ftp-path').value.trim() || '/data/etaHEN/cheats/';
|
||||
await window.pork.cheatsFtpInstall(remotePath);
|
||||
} catch (e) {
|
||||
$('cheats-ftp-title').textContent = `Error: ${e.message}`;
|
||||
$('btn-cheats-ftp-go').disabled = false;
|
||||
$('btn-cheats-ftp-go').textContent = '↑ Retry';
|
||||
}
|
||||
});
|
||||
|
||||
window.pork.on('cheats:ftp:start', ({ total }) => {
|
||||
$('cheats-ftp-count').textContent = `0 / ${total}`;
|
||||
});
|
||||
|
||||
window.pork.on('cheats:ftp:progress', ({ phase, done, total, pct, current }) => {
|
||||
$('cheats-ftp-bar').style.width = `${pct}%`;
|
||||
$('cheats-ftp-pct').textContent = `${pct}%`;
|
||||
$('cheats-ftp-count').textContent = `${done} / ${total}`;
|
||||
$('cheats-ftp-phase').textContent = phase === 'write' ? 'Writing files…' : 'Uploading to PS5…';
|
||||
$('cheats-ftp-current').textContent = current || '';
|
||||
});
|
||||
|
||||
window.pork.on('cheats:ftp:done', ({ count }) => {
|
||||
$('cheats-ftp-bar').style.width = '100%';
|
||||
$('cheats-ftp-pct').textContent = '100%';
|
||||
$('cheats-ftp-phase').textContent = `Done — ${count} cheats installed`;
|
||||
$('cheats-ftp-current').textContent = '';
|
||||
$('cheats-ftp-title').textContent = 'Install complete ✓';
|
||||
$('btn-cheats-ftp-go').disabled = false;
|
||||
$('btn-cheats-ftp-go').textContent = '↑ Install Again';
|
||||
});
|
||||
|
||||
// ── Cheats modal panel ─────────────────────────────────────────────────────────
|
||||
|
||||
async function renderCheatsPanel(game) {
|
||||
const body = $('modal-cheats-body');
|
||||
const badge = $('mtab-cheats-count');
|
||||
body.innerHTML = '<div class="prospero-placeholder">Loading cheats…</div>';
|
||||
badge.textContent = '';
|
||||
|
||||
try {
|
||||
const cusaId = game.game_id;
|
||||
const files = await window.pork.cheatsForGame(cusaId);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
// Nothing cached — offer to fetch on-demand
|
||||
body.innerHTML = `
|
||||
<div class="modal-cheat-fetch">
|
||||
<p>No cached cheats found for <code>${escHtml(cusaId)}</code>.</p>
|
||||
<button id="btn-modal-fetch-cheats" class="btn btn-accent">Fetch from Repository</button>
|
||||
</div>`;
|
||||
$('btn-modal-fetch-cheats').addEventListener('click', async () => {
|
||||
const fetchBtn = $('btn-modal-fetch-cheats');
|
||||
fetchBtn.disabled = true;
|
||||
fetchBtn.textContent = 'Fetching…';
|
||||
try {
|
||||
const index = await window.pork.cheatsFetchIndex();
|
||||
const matching = index.filter(e => e.cusaId === cusaId);
|
||||
if (matching.length === 0) {
|
||||
body.innerHTML = `<div class="prospero-placeholder">No cheats found in repository for ${escHtml(cusaId)}.</div>`;
|
||||
return;
|
||||
}
|
||||
// Download the cheat database (cached, with progress on the Cheats page),
|
||||
// then re-render this game's cheats from the freshly populated cache.
|
||||
fetchBtn.textContent = `Downloading ${matching.length} file(s)…`;
|
||||
await window.pork.cheatsDownloadAll();
|
||||
const cached = await window.pork.cheatsForGame(cusaId);
|
||||
if (cached && cached.length) {
|
||||
badge.textContent = cached.length;
|
||||
body.innerHTML = cached.map(f => renderCheatFileBlock(f)).join('');
|
||||
} else {
|
||||
body.innerHTML = `<div class="prospero-placeholder">Downloaded, but no cheats cached for ${escHtml(cusaId)}.</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">Fetch failed: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
badge.textContent = files.length;
|
||||
const modCount = files.reduce((n, f) => {
|
||||
try { const d = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; return n + (d?.mods?.length ?? 0); } catch (_) { return n; }
|
||||
}, 0);
|
||||
|
||||
body.innerHTML = files.map(f => renderCheatFileBlock(f)).join('');
|
||||
badge.textContent = `${modCount}`;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">${escHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
(function initCompatibilityGuidance(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.chimeraCompatibilityGuidance = api;
|
||||
})(typeof window !== 'undefined' ? window : null, function compatibilityGuidanceFactory() {
|
||||
'use strict';
|
||||
const STATES = new Set(['compatible', 'incompatible', 'workaround', 'conflict', 'unknown']);
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
||||
const tr = (key, fallback) => typeof window !== 'undefined' && window.t?.(key) && window.t(key) !== key ? window.t(key) : fallback;
|
||||
function normalize(value = {}) { const state = STATES.has(value.state) ? value.state : 'unknown'; return { state, blocking: value.blocking === true, rationale: Array.isArray(value.rationale) ? value.rationale : [], workarounds: Array.isArray(value.workarounds) ? value.workarounds : [] }; }
|
||||
function markup(value) {
|
||||
const guidance = normalize(value);
|
||||
const labels = { compatible: 'Compatible', incompatible: 'Incompatible', workaround: 'Workaround available', conflict: 'Conflicting evidence', unknown: 'Compatibility unknown' };
|
||||
const rationale = guidance.rationale.map(item => `<li><strong>${esc(item.explanation)}</strong><span>${esc(item.providerId)} / ${esc(item.revision)} / ${esc(item.confidence)} confidence</span><a href="${esc(item.sourceUrl)}" target="_blank" rel="noreferrer">${esc(tr('compatibilityGuidance.inspectSource', 'Inspect source'))}</a></li>`).join('');
|
||||
const workarounds = guidance.workarounds.map(item => `<li>${esc(item.explanation)} <small>${esc(item.providerId)} / ${esc(item.revision)}</small></li>`).join('');
|
||||
const detail = guidance.state === 'unknown' ? tr('compatibilityGuidance.unknownDetail', 'No applicable evidence is cached for this exact context.') : guidance.state === 'conflict' ? tr('compatibilityGuidance.conflictDetail', 'Sources disagree. Chimera does not resolve this automatically.') : guidance.blocking ? tr('compatibilityGuidance.blockingDetail', 'Blocked by critical, high-confidence, non-conflicting evidence.') : tr('compatibilityGuidance.informationalDetail', 'Guidance is informational unless the evidence meets the blocking policy.');
|
||||
return `<article class="compatibility-guidance state-${guidance.state}" data-compatibility-state="${guidance.state}" role="status"><header><strong>${esc(tr(`compatibilityGuidance.state.${guidance.state}`, labels[guidance.state]))}</strong>${guidance.blocking ? `<span>${esc(tr('compatibilityGuidance.blocked', 'Blocked'))}</span>` : ''}</header><p>${esc(detail)}</p>${rationale ? `<ul class="compatibility-rationale">${rationale}</ul>` : ''}${workarounds ? `<details><summary>${esc(tr('compatibilityGuidance.workarounds', 'Safe workaround guidance'))}</summary><ul>${workarounds}</ul></details>` : ''}</article>`;
|
||||
}
|
||||
async function load(host, context) {
|
||||
if (!host || typeof window === 'undefined') return null;
|
||||
let guidance;
|
||||
try { guidance = await window.pork.compatibilityGuidance(context); } catch (_) { guidance = { state: 'unknown', unknownReason: 'unavailable' }; }
|
||||
host.querySelector('.compatibility-guidance')?.remove();
|
||||
host.insertAdjacentHTML('beforeend', markup(guidance));
|
||||
return guidance;
|
||||
}
|
||||
return Object.freeze({ normalize, markup, load });
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// ── Connection state ──────────────────────────────────────────────────────────
|
||||
function setConnected(on) {
|
||||
state.connected = on;
|
||||
const dot = $('conn-dot');
|
||||
const label = $('conn-label');
|
||||
const badge = $('dash-conn-badge');
|
||||
const shellHealth = $('chimera-shell-health');
|
||||
const shellHealthLabel = $('chimera-shell-health-label');
|
||||
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
|
||||
|
||||
dot.className = `dot ${on ? 'green' : 'red'}`;
|
||||
label.textContent = on ? tr('connection.connected', 'Connected') : tr('connection.disconnected', 'Disconnected');
|
||||
badge.textContent = on ? tr('connection.online', 'Online') : tr('connection.offline', 'Offline');
|
||||
badge.className = `badge ${on ? 'badge-green' : 'badge-red'}`;
|
||||
if (shellHealth) {
|
||||
shellHealth.classList.toggle('is-online', on);
|
||||
shellHealth.classList.toggle('is-offline', !on);
|
||||
}
|
||||
if (shellHealthLabel) {
|
||||
shellHealthLabel.textContent = on
|
||||
? tr('connection.ready', 'Ready')
|
||||
: tr('connection.disconnected', 'Disconnected');
|
||||
shellHealthLabel.dataset.i18n = on ? 'connection.ready' : 'connection.disconnected';
|
||||
}
|
||||
|
||||
$('btn-connect').disabled = on;
|
||||
$('btn-disconnect').disabled = !on;
|
||||
$('btn-scan').disabled = !on;
|
||||
|
||||
// Re-evaluate PS5 action buttons — only enabled when connected AND a location is stored
|
||||
const deleteBtn = $('btn-manage-delete-ps5');
|
||||
const backupBtn = $('btn-manage-backup-ps5');
|
||||
if (deleteBtn) deleteBtn.disabled = !on || !state.currentGame?.ftp_path;
|
||||
if (backupBtn) backupBtn.disabled = !on || !state.currentGame?.ftp_path;
|
||||
}
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
let lastHomeReadiness = null;
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [settings, stats, catalog, ftpStatus] = await Promise.all([
|
||||
window.pork.getSettings(),
|
||||
window.pork.getStats(),
|
||||
window.pork.gameCatalogHealth().catch(() => ({})),
|
||||
window.pork.ftpStatus().catch(() => ({ connected: state.connected })),
|
||||
]);
|
||||
$('s-total').textContent = stats.totalGames;
|
||||
$('s-installed').textContent = stats.installed;
|
||||
$('s-backedup').textContent = stats.backedUp;
|
||||
$('s-backups').textContent = stats.totalBackups;
|
||||
if ($('d-host') && document.activeElement !== $('d-host') && !$('d-host').value) {
|
||||
$('d-host').value = settings.ftpHost || '';
|
||||
}
|
||||
if ($('d-ports') && document.activeElement !== $('d-ports') && !$('d-ports').value) {
|
||||
$('d-ports').value = (settings.ftpPorts && settings.ftpPorts.length) ? settings.ftpPorts.join(', ') : '1337, 2121, 21';
|
||||
}
|
||||
renderHomeReadiness({ settings, stats, catalog, connected: Boolean(ftpStatus.connected) });
|
||||
} catch (_) {}
|
||||
renderPinnedTiles();
|
||||
}
|
||||
|
||||
function renderHomeReadiness({ settings = {}, stats, catalog, connected }) {
|
||||
lastHomeReadiness = { settings, stats, catalog, connected };
|
||||
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
|
||||
const setIndicator = (id, stateName) => {
|
||||
const indicator = $(id);
|
||||
if (indicator) indicator.className = `chimera-readiness-dot ${stateName}`;
|
||||
};
|
||||
const complete = Number(catalog.complete || 0);
|
||||
const missing = Number(catalog.missing || catalog.incomplete || 0);
|
||||
const total = Number(stats.totalGames || 0);
|
||||
const backedUp = Number(stats.backedUp || 0);
|
||||
const host = String(settings.ftpHost || '').trim();
|
||||
const hostConfigured = Boolean(host);
|
||||
const consoleStatus = $('home-console-status');
|
||||
const libraryStatus = $('home-library-status');
|
||||
const backupStatus = $('home-backup-status');
|
||||
const inspectorState = $('home-inspector-state');
|
||||
const inspectorReason = $('home-inspector-reason');
|
||||
const inspectorTarget = $('home-inspector-target');
|
||||
const libraryTruth = $('home-truth-library');
|
||||
const protectionTruth = $('home-truth-protection');
|
||||
if (consoleStatus) consoleStatus.textContent = connected
|
||||
? tr('home.consoleReady', 'Connected · manual operations')
|
||||
: hostConfigured
|
||||
? tr('home.targetConfiguredOffline', 'Configured · offline')
|
||||
: tr('navigation.unavailable', 'Not configured');
|
||||
if (libraryStatus) libraryStatus.textContent = total
|
||||
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}${missing ? ` · ${missing} ${tr('home.toComplete', 'to complete')}` : ''}`
|
||||
: tr('home.noGames', 'No games detected yet');
|
||||
if (backupStatus) backupStatus.textContent = total
|
||||
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
|
||||
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
|
||||
if (inspectorState) {
|
||||
inspectorState.textContent = connected
|
||||
? tr('connection.ready', 'Ready')
|
||||
: hostConfigured
|
||||
? tr('home.targetOffline', 'Offline')
|
||||
: tr('navigation.unavailable', 'Not configured');
|
||||
inspectorState.className = `chimera-node-state ${connected ? 'ok' : hostConfigured ? 'attention' : 'unknown'}`;
|
||||
}
|
||||
if (inspectorReason) inspectorReason.textContent = connected
|
||||
? tr('home.targetReasonConnected', 'A bounded FTP session with the PS5 is active.')
|
||||
: hostConfigured
|
||||
? tr('home.targetReasonOffline', 'The PS5 target is saved, but there is no active FTP session.')
|
||||
: tr('home.targetReasonMissing', 'Chimera needs an explicit PS5 target before console features can connect.');
|
||||
if (inspectorTarget) inspectorTarget.textContent = hostConfigured ? host : tr('navigation.unavailable', 'Not configured');
|
||||
if (libraryTruth) libraryTruth.textContent = total
|
||||
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}`
|
||||
: tr('home.noGames', 'No games detected yet');
|
||||
if (protectionTruth) protectionTruth.textContent = total
|
||||
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
|
||||
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
|
||||
setIndicator('home-console-indicator', connected ? 'ok' : hostConfigured ? 'attention' : 'unknown');
|
||||
setIndicator('home-library-indicator', total && missing === 0 ? 'ok' : total ? 'attention' : 'unknown');
|
||||
setIndicator('home-backup-indicator', total && backedUp >= total ? 'ok' : backedUp ? 'attention' : 'unknown');
|
||||
|
||||
const title = $('home-next-title');
|
||||
const detail = $('home-next-detail');
|
||||
const action = $('home-next-action');
|
||||
if (!title || !detail || !action) return;
|
||||
if (!hostConfigured) {
|
||||
title.textContent = tr('home.next.configureTitle', 'Configure the PS5 target');
|
||||
detail.textContent = tr('home.next.configureDetail', 'Choose the console target yourself. Chimera will not guess an address or start a connection automatically.');
|
||||
action.textContent = tr('home.next.configureAction', 'Configure PS5 target');
|
||||
action.dataset.route = 'settings/general';
|
||||
action.dataset.focusTarget = 's-host';
|
||||
} else if (!connected) {
|
||||
title.textContent = tr('home.next.connectTitle', 'Connect safely to the PS5 first');
|
||||
detail.textContent = tr('home.next.connectDetail', 'The target is configured. Review it and start the bounded FTP connection manually.');
|
||||
action.textContent = tr('home.next.connectAction', 'Review connection');
|
||||
action.dataset.route = 'home';
|
||||
action.dataset.focusTarget = 'd-host';
|
||||
} else if (!total) {
|
||||
title.textContent = tr('home.next.scanTitle', 'Detect your game library');
|
||||
detail.textContent = tr('home.next.scanDetail', 'The PS5 is reachable, but the Chimera catalog does not contain any games yet.');
|
||||
action.textContent = tr('home.next.scanAction', 'Scan games');
|
||||
action.dataset.route = 'library/games';
|
||||
delete action.dataset.focusTarget;
|
||||
} else if (missing > 0) {
|
||||
title.textContent = `${tr('home.next.completeMetadata', 'Complete metadata for')} ${missing} ${missing === 1 ? tr('home.game', 'game') : tr('home.games', 'games')}`;
|
||||
detail.textContent = tr('home.next.metadataDetail', 'Titles, cover art and version information are enriched automatically; review only persistent exceptions.');
|
||||
action.textContent = tr('home.next.openLibrary', 'Open library');
|
||||
action.dataset.route = 'library/games';
|
||||
delete action.dataset.focusTarget;
|
||||
} else if (backedUp < total) {
|
||||
const remaining = total - backedUp;
|
||||
title.textContent = `${remaining} ${remaining === 1 ? tr('home.game', 'game') : tr('home.games', 'games')} ${tr('home.next.notProtected', 'not protected yet')}`;
|
||||
detail.textContent = tr('home.next.backupDetail', 'Review backups and create a new copy only from a confirmed source.');
|
||||
action.textContent = tr('home.next.checkBackups', 'Check backups');
|
||||
action.dataset.route = 'protection/backups';
|
||||
delete action.dataset.focusTarget;
|
||||
} else {
|
||||
title.textContent = tr('home.next.readyTitle', 'Chimera is ready');
|
||||
detail.textContent = tr('home.next.readyDetail', 'Console, metadata and library protection are in order. Updates remain explicit and reviewable.');
|
||||
action.textContent = tr('home.next.reviewPayloads', 'Review payload updates');
|
||||
action.dataset.route = 'payloads/updates';
|
||||
delete action.dataset.focusTarget;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('chimera:language-changed', () => {
|
||||
if (lastHomeReadiness) renderHomeReadiness(lastHomeReadiness);
|
||||
});
|
||||
|
||||
async function renderPinnedTiles() {
|
||||
const grid = $('dash-pinned-grid');
|
||||
const empty = $('dash-pinned-empty');
|
||||
if (!grid) return;
|
||||
try {
|
||||
const s = await window.pork.getSettings();
|
||||
const pinned = new Set(s.pinnedCards || []);
|
||||
const cards = PINNABLE_CARDS.filter(c => pinned.has(c.id));
|
||||
|
||||
grid.innerHTML = '';
|
||||
if (!cards.length) {
|
||||
empty.hidden = false;
|
||||
} else {
|
||||
empty.hidden = true;
|
||||
for (const c of cards) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'dash-pinned-tile';
|
||||
tile.dataset.navPage = c.page;
|
||||
if (c.type === 'widget') {
|
||||
tile.classList.add('dash-pinned-tile--widget');
|
||||
tile.removeAttribute('data-nav-page');
|
||||
tile.innerHTML = _renderWidgetTile(c);
|
||||
grid.appendChild(tile);
|
||||
_initWidgetTile(c, tile);
|
||||
continue; // already appended
|
||||
} else {
|
||||
tile.innerHTML = `
|
||||
<div class="dash-pinned-tile-title">${escHtml(c.label)}</div>
|
||||
<div class="dash-pinned-tile-page">${escHtml(_PIN_PAGE_LABELS[c.page] || c.page)}</div>
|
||||
<div class="dash-pinned-tile-hint">${escHtml(c.hint)}</div>
|
||||
<button class="btn btn-sm dash-pinned-tile-open">Open →</button>`;
|
||||
}
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all pin button visual states across the whole page
|
||||
document.querySelectorAll('.btn-pin').forEach(btn => {
|
||||
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId));
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── FTP Connect ───────────────────────────────────────────────────────────────
|
||||
$('btn-connect').addEventListener('click', async () => {
|
||||
const host = $('d-host').value.trim();
|
||||
const ports = $('d-ports').value.split(',').map(p => p.trim()).filter(Boolean);
|
||||
if (!ports.length) ports.push('21');
|
||||
if (!host) { setStatus('Enter a host address', 'error'); return; }
|
||||
|
||||
// Credential fields start blank. Fall back to stored settings when blank
|
||||
// so the user can click Connect without re-typing every session.
|
||||
const saved = await window.pork.getSettings();
|
||||
const user = $('d-user').value.trim() || saved.ftpUser || '';
|
||||
const password = $('d-pass').value || saved.ftpPass || '';
|
||||
|
||||
$('btn-connect').disabled = true;
|
||||
setStatus('Connecting…');
|
||||
try {
|
||||
const result = await window.pork.ftpConnect({ host, ports, user, password });
|
||||
setConnected(true);
|
||||
setStatus('Connected', 'ok');
|
||||
$('sb-sync').textContent = `Connected on :${result?.port || ports[0]} at ${new Date().toLocaleTimeString()}`;
|
||||
} catch (e) {
|
||||
setConnected(false);
|
||||
setStatus(`Connection failed: ${e.message}`, 'error');
|
||||
$('btn-connect').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-disconnect').addEventListener('click', async () => {
|
||||
await window.pork.ftpDisconnect();
|
||||
setConnected(false);
|
||||
setStatus('Disconnected');
|
||||
});
|
||||
|
||||
// ── FTP Scan ──────────────────────────────────────────────────────────────────
|
||||
$('btn-scan').addEventListener('click', async () => {
|
||||
const prog = $('scan-progress');
|
||||
prog.hidden = false;
|
||||
prog.textContent = 'Scanning PS5 game folders…';
|
||||
$('btn-scan').disabled = true;
|
||||
|
||||
window.pork.on('ftp:progress', ({ current, game }) => {
|
||||
prog.textContent = `[${current}] Found: ${game.title || game.game_id}`;
|
||||
});
|
||||
|
||||
try {
|
||||
const { count } = await window.pork.ftpScan();
|
||||
prog.textContent = `Scan complete — ${count} game(s) found.`;
|
||||
$('sb-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`;
|
||||
setStatus(`Scanned ${count} games`, 'ok');
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
prog.textContent = `Error: ${e.message}`;
|
||||
setStatus(e.message, 'error');
|
||||
} finally {
|
||||
window.pork.off('ftp:progress');
|
||||
$('btn-scan').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
'use strict';
|
||||
|
||||
// ── Dashboard Interactive Mini-Widgets ────────────────────────────────────────
|
||||
// Renders and drives interactive tool tiles pinned to the dashboard.
|
||||
// Called from connection.js renderPinnedTiles() for cards with type:'widget'.
|
||||
|
||||
// Off-screen canvas used by the XAvatar widget for pixel conversion
|
||||
const _dwXaCanvas = document.createElement('canvas');
|
||||
let _dwXaFilename = 'avatar';
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────────
|
||||
// Returns an innerHTML string for a given pinnable card.
|
||||
function _renderWidgetTile(card) {
|
||||
switch (card.id) {
|
||||
case 'psnotify':
|
||||
return `
|
||||
<div class="dash-widget-hdr">📢 Send Notification</div>
|
||||
<input class="dash-widget-input" data-dw-key="psnotify-msg" placeholder="Message…" maxlength="120">
|
||||
<input class="dash-widget-input" data-dw-key="psnotify-sub" placeholder="Sub-message (optional)…" maxlength="120">
|
||||
<div class="dash-widget-row">
|
||||
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="psnotify-send">📢 Send</button>
|
||||
<span class="dash-widget-status" data-dw-status="psnotify"></span>
|
||||
</div>
|
||||
<div class="dash-widget-row" style="margin-top:4px">
|
||||
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="psnotify">Open →</button>
|
||||
</div>`;
|
||||
|
||||
case 'payload-mgr':
|
||||
return `
|
||||
<div class="dash-widget-hdr">⇨ Quick Payload Send</div>
|
||||
<select class="dash-widget-select" data-dw-key="payload-file">
|
||||
<option value="">Loading payloads…</option>
|
||||
</select>
|
||||
<div class="dash-widget-row">
|
||||
<span class="dash-widget-port-lbl">Port</span>
|
||||
<input type="number" class="dash-widget-port-input" data-dw-key="payload-port" value="9021" min="1" max="65535">
|
||||
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="payload-send">⇨ Send</button>
|
||||
<span class="dash-widget-status" data-dw-status="payload"></span>
|
||||
</div>
|
||||
<div class="dash-widget-row" style="margin-top:4px">
|
||||
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="jailbreak">Open →</button>
|
||||
</div>`;
|
||||
|
||||
case 'xavatar-convert':
|
||||
return `
|
||||
<div class="dash-widget-hdr">👤 XAvatar Quick Send</div>
|
||||
<div class="dash-widget-drop" data-dw-drop="xavatar">Drop image or click to browse</div>
|
||||
<div class="dash-widget-row">
|
||||
<button class="btn btn-sm dash-widget-btn" data-dw-action="xavatar-browse">Browse…</button>
|
||||
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="xavatar-send" disabled>↗ Send to PS5</button>
|
||||
<span class="dash-widget-status" data-dw-status="xavatar"></span>
|
||||
</div>
|
||||
<div class="dash-widget-row" style="margin-top:4px">
|
||||
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="xavatar">Open →</button>
|
||||
</div>`;
|
||||
|
||||
case 'dash-savemgr':
|
||||
return `
|
||||
<div class="dash-widget-hdr">💾 Save Manager</div>
|
||||
<div class="dash-widget-savelist" data-dw-list="savemgr">Click Reload to fetch saves.</div>
|
||||
<div class="dash-widget-row">
|
||||
<button class="btn btn-sm dash-widget-btn" data-dw-action="savemgr-reload">↻ Reload</button>
|
||||
<span class="dash-widget-status" data-dw-status="savemgr"></span>
|
||||
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="savemgr">Open →</button>
|
||||
</div>`;
|
||||
|
||||
default:
|
||||
// Fallback: plain nav tile for unknown widget IDs
|
||||
return `
|
||||
<div class="dash-pinned-tile-title">${escHtml(card.label)}</div>
|
||||
<div class="dash-pinned-tile-hint">${escHtml(card.hint)}</div>
|
||||
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="${escHtml(card.page)}">Open →</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post-render init ───────────────────────────────────────────────────────────
|
||||
// Called immediately after the tile is appended to the DOM.
|
||||
function _initWidgetTile(card, tileEl) {
|
||||
switch (card.id) {
|
||||
case 'payload-mgr':
|
||||
_dwPopulatePayloadSelect(tileEl);
|
||||
break;
|
||||
case 'xavatar-convert':
|
||||
_dwWireXavatarDrop(tileEl);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payload select population ──────────────────────────────────────────────────
|
||||
async function _dwPopulatePayloadSelect(tileEl) {
|
||||
const sel = tileEl.querySelector('[data-dw-key="payload-file"]');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const files = await window.pork.payloadListLocal();
|
||||
if (!files.length) {
|
||||
sel.innerHTML = '<option value="">No local payloads</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = files.map(f =>
|
||||
`<option value="${escHtml(f.local_path)}|${escHtml(f.name)}">${escHtml(f.name)}</option>`
|
||||
).join('');
|
||||
} catch (_) {
|
||||
sel.innerHTML = '<option value="">Error loading payloads</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── XAvatar drop-zone wiring ───────────────────────────────────────────────────
|
||||
function _dwWireXavatarDrop(tileEl) {
|
||||
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
|
||||
if (!drop) return;
|
||||
|
||||
drop.addEventListener('click', () => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
inp.accept = 'image/*';
|
||||
inp.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
if (file) _dwLoadXavatarFile(file, tileEl);
|
||||
};
|
||||
inp.click();
|
||||
});
|
||||
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dw-drag-over'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('dw-drag-over'));
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
drop.classList.remove('dw-drag-over');
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/')) _dwLoadXavatarFile(file, tileEl);
|
||||
});
|
||||
}
|
||||
|
||||
async function _dwLoadXavatarFile(file, tileEl) {
|
||||
const status = tileEl.querySelector('[data-dw-status="xavatar"]');
|
||||
const sendBtn = tileEl.querySelector('[data-dw-action="xavatar-send"]');
|
||||
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
|
||||
if (status) status.textContent = 'Loading…';
|
||||
try {
|
||||
const bmp = await createImageBitmap(file);
|
||||
_dwXaCanvas.width = bmp.width;
|
||||
_dwXaCanvas.height = bmp.height;
|
||||
_dwXaCanvas.getContext('2d').drawImage(bmp, 0, 0);
|
||||
_dwXaFilename = file.name.replace(/\.[^.]+$/, '');
|
||||
if (drop) drop.textContent = file.name;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
if (status) status.textContent = 'Ready';
|
||||
} catch (err) {
|
||||
if (status) status.textContent = 'Load failed';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Manager ───────────────────────────────────────────────────────────────
|
||||
async function _dwSavemgrLoad(btn) {
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const listEl = tile?.querySelector('[data-dw-list="savemgr"]');
|
||||
const status = tile?.querySelector('[data-dw-status="savemgr"]');
|
||||
if (!listEl) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading…';
|
||||
if (status) status.textContent = '';
|
||||
try {
|
||||
const r = await window.pork.savemgrRequest('/api/saves');
|
||||
const saves = typeof r === 'string' ? JSON.parse(r) : r;
|
||||
if (!saves || !saves.length) {
|
||||
listEl.textContent = 'No saves found.';
|
||||
if (status) status.textContent = '0 saves';
|
||||
} else {
|
||||
listEl.innerHTML = saves.slice(0, 8).map((s, i) =>
|
||||
`<div class="dash-dw-save-row">
|
||||
<span class="dash-dw-save-name" title="${escHtml(s.titleId || '')}">${escHtml(s.name || s.title || s.titleId || `Save ${i}`)}</span>
|
||||
<button class="btn btn-xs dash-widget-btn" data-dw-action="savemgr-dl" data-sm-idx="${i}">⬇ ZIP</button>
|
||||
</div>`
|
||||
).join('');
|
||||
if (status) status.textContent = `${saves.length} save${saves.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
} catch (err) {
|
||||
listEl.textContent = 'Error: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '↻ Reload';
|
||||
}
|
||||
}
|
||||
|
||||
async function _dwSavemgrDl(btn) {
|
||||
const idx = btn.dataset.smIdx;
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const status = tile?.querySelector('[data-dw-status="savemgr"]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
if (status) status.textContent = 'Mounting…';
|
||||
try {
|
||||
await window.pork.savemgrRequest('/api/mount?idx=' + idx);
|
||||
if (status) status.textContent = 'Downloading…';
|
||||
await window.pork.savemgrDownload('/api/download', `save_${idx}.zip`);
|
||||
if (status) status.textContent = '✓ Saved';
|
||||
setTimeout(() => { if (status) status.textContent = ''; }, 4000);
|
||||
} catch (err) {
|
||||
if (status) status.textContent = 'Error: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '⬇ ZIP';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event delegation ───────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', async e => {
|
||||
// "Open →" buttons inside widget tiles — navigate to the full page
|
||||
const navBtn = e.target.closest('.dash-pinned-tile-open[data-nav-page]');
|
||||
if (navBtn && navBtn.closest('.dash-pinned-tile--widget')) {
|
||||
e.stopPropagation();
|
||||
navigate(navBtn.dataset.navPage);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest('.dash-widget-btn[data-dw-action]');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
|
||||
switch (btn.dataset.dwAction) {
|
||||
case 'psnotify-send': await _dwPsnotifySend(btn); break;
|
||||
case 'payload-send': await _dwPayloadSend(btn); break;
|
||||
case 'xavatar-browse': _dwXavatarBrowseClick(btn); break;
|
||||
case 'xavatar-send': await _dwXavatarSend(btn); break;
|
||||
case 'savemgr-reload': await _dwSavemgrLoad(btn); break;
|
||||
case 'savemgr-dl': await _dwSavemgrDl(btn); break;
|
||||
}
|
||||
});
|
||||
|
||||
// ── PSNotify action ────────────────────────────────────────────────────────────
|
||||
async function _dwPsnotifySend(btn) {
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const msg = tile?.querySelector('[data-dw-key="psnotify-msg"]')?.value?.trim() || '';
|
||||
const sub = tile?.querySelector('[data-dw-key="psnotify-sub"]')?.value?.trim() || '';
|
||||
const status = tile?.querySelector('[data-dw-status="psnotify"]');
|
||||
if (!msg) { if (status) { status.textContent = 'Message required'; status.style.color = 'var(--red)'; } return; }
|
||||
btn.disabled = true;
|
||||
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
|
||||
try {
|
||||
await window.pork.psnotifySend(msg, sub);
|
||||
if (status) { status.textContent = '✓ Sent'; status.style.color = 'var(--green)'; }
|
||||
if (tile) {
|
||||
const msgEl = tile.querySelector('[data-dw-key="psnotify-msg"]');
|
||||
const subEl = tile.querySelector('[data-dw-key="psnotify-sub"]');
|
||||
if (msgEl) msgEl.value = '';
|
||||
if (subEl) subEl.value = '';
|
||||
}
|
||||
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 3000);
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payload action ─────────────────────────────────────────────────────────────
|
||||
async function _dwPayloadSend(btn) {
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const sel = tile?.querySelector('[data-dw-key="payload-file"]');
|
||||
const portEl = tile?.querySelector('[data-dw-key="payload-port"]');
|
||||
const status = tile?.querySelector('[data-dw-status="payload"]');
|
||||
const val = sel?.value || '';
|
||||
if (!val) { if (status) { status.textContent = 'Pick a payload'; status.style.color = 'var(--red)'; } return; }
|
||||
const [localPath, filename] = val.split('|');
|
||||
const port = parseInt(portEl?.value || '9021', 10);
|
||||
btn.disabled = true;
|
||||
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
|
||||
try {
|
||||
await window.pork.payloadTcpSend({ localPath, port, filename });
|
||||
if (status) { status.textContent = `✓ ${filename} sent`; status.style.color = 'var(--green)'; }
|
||||
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 4000);
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── XAvatar actions ────────────────────────────────────────────────────────────
|
||||
function _dwXavatarBrowseClick(btn) {
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
inp.accept = 'image/*';
|
||||
inp.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
if (file) _dwLoadXavatarFile(file, tile);
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
async function _dwXavatarSend(btn) {
|
||||
const tile = btn.closest('.dash-pinned-tile');
|
||||
const status = tile?.querySelector('[data-dw-status="xavatar"]');
|
||||
if (_dwXaCanvas.width === 0) return;
|
||||
btn.disabled = true;
|
||||
if (status) { status.textContent = 'Converting…'; status.style.color = ''; }
|
||||
try {
|
||||
const ctx = _dwXaCanvas.getContext('2d');
|
||||
const imgData = ctx.getImageData(0, 0, _dwXaCanvas.width, _dwXaCanvas.height);
|
||||
const rgba = new Uint8Array(imgData.data.buffer);
|
||||
const res = await window.pork.xavatarConvertCanvas(rgba, _dwXaCanvas.width, _dwXaCanvas.height, _dwXaFilename);
|
||||
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
|
||||
if (status) status.textContent = 'Uploading…';
|
||||
const pad = n => String(n).padStart(5, '0');
|
||||
const rnd = pad(Math.floor(Math.random() * 100000));
|
||||
const name = `chimera${rnd}.xavatar`;
|
||||
await window.pork.xavatarUploadToPs5(res.buffer?.buffer || res.buffer, name);
|
||||
if (status) { status.textContent = `✓ Queued: ${name}`; status.style.color = 'var(--green)'; }
|
||||
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 5000);
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// ── Database page ─────────────────────────────────────────────────────────────
|
||||
const DB_PAGE_SIZE = 100;
|
||||
const _dbPages = {
|
||||
games: { offset: 0, total: 0 },
|
||||
backups: { offset: 0, total: 0 },
|
||||
hashes: { offset: 0, total: 0 },
|
||||
};
|
||||
|
||||
async function loadDatabase() {
|
||||
await Promise.all([loadDbTable('games'), loadDbTable('backups'), loadHashRowsTable()]);
|
||||
}
|
||||
|
||||
async function loadHashRowsTable() {
|
||||
try {
|
||||
const [rows, countRows] = await Promise.all([
|
||||
window.pork.dbQuery(`SELECT id, game_id, hash_type, firmware_label, file_size,
|
||||
SUBSTR(file_hash,1,16) || '…' AS file_hash_short,
|
||||
backup_path, verified, hashed_at
|
||||
FROM game_hashes ORDER BY hashed_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages.hashes.offset}`),
|
||||
window.pork.dbQuery('SELECT COUNT(*) AS total FROM game_hashes'),
|
||||
]);
|
||||
_dbPages.hashes.total = Number(countRows[0]?.total || 0);
|
||||
if (_dbPages.hashes.offset >= _dbPages.hashes.total && _dbPages.hashes.offset > 0) {
|
||||
_dbPages.hashes.offset = Math.max(0, Math.floor(Math.max(0, _dbPages.hashes.total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
|
||||
return loadHashRowsTable();
|
||||
}
|
||||
const countEl = $('hash-rows-count');
|
||||
if (countEl) countEl.textContent = dbPageLabel('hashes');
|
||||
updateDbPager('hashes');
|
||||
renderDbTableEl('db-hash-rows-table', rows, 'hashes');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbTable(name) {
|
||||
try {
|
||||
const sql = name === 'games'
|
||||
? `SELECT id, title, game_id, version, size, installed, backed_up, last_scanned FROM games ORDER BY title LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`
|
||||
: `SELECT id, game_id, backup_type, size, backup_path, created_at FROM backups ORDER BY created_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`;
|
||||
const [rows, countRows] = await Promise.all([window.pork.dbQuery(sql), window.pork.dbQuery(`SELECT COUNT(*) AS total FROM ${name}`)]);
|
||||
_dbPages[name].total = Number(countRows[0]?.total || 0);
|
||||
if (_dbPages[name].offset >= _dbPages[name].total && _dbPages[name].offset > 0) {
|
||||
_dbPages[name].offset = Math.max(0, Math.floor(Math.max(0, _dbPages[name].total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
|
||||
return loadDbTable(name);
|
||||
}
|
||||
const info = $(`db-${name}-page-info`);
|
||||
if (info) info.textContent = dbPageLabel(name);
|
||||
updateDbPager(name);
|
||||
renderDbTableEl(`db-${name}-table`, rows, name);
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function dbPageLabel(name) {
|
||||
const page = _dbPages[name];
|
||||
const first = page.total ? page.offset + 1 : 0;
|
||||
const last = Math.min(page.total, page.offset + DB_PAGE_SIZE);
|
||||
const label = name === 'games' ? 'games' : name === 'backups' ? 'back-ups' : 'hashes';
|
||||
const separator = window.i18n?.current === 'nl' ? 'van' : 'of';
|
||||
return `${first}–${last} ${separator} ${page.total} ${label}`;
|
||||
}
|
||||
|
||||
function dbColumnLabel(column) {
|
||||
return String(column || '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, letter => letter.toUpperCase())
|
||||
.replace(/\bId\b/g, 'ID')
|
||||
.replace(/\bSha 256\b/gi, 'SHA-256');
|
||||
}
|
||||
|
||||
function dbFormatValue(column, value) {
|
||||
if (value == null || value === '') return '—';
|
||||
if (['size', 'file_size'].includes(column) && Number.isFinite(Number(value))) return _fmtBytes(Number(value));
|
||||
if (['installed', 'backed_up', 'verified'].includes(column)) return Number(value) ? 'Yes' : 'No';
|
||||
if (/(?:_at|last_scanned)$/.test(column)) {
|
||||
const numeric = Number(value);
|
||||
const timestamp = Number.isFinite(numeric) && numeric > 0 && numeric < 1e12 ? numeric * 1000 : value;
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime())) return date.toLocaleString(window.i18n?.current || 'en');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function updateDbPager(name) {
|
||||
document.querySelectorAll(`.db-page-btn[data-table="${name}"]`).forEach(button => {
|
||||
button.disabled = button.dataset.direction === 'previous'
|
||||
? _dbPages[name].offset === 0
|
||||
: _dbPages[name].offset + DB_PAGE_SIZE >= _dbPages[name].total;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.db-page-btn').forEach(button => button.addEventListener('click', async () => {
|
||||
const name = button.dataset.table;
|
||||
const direction = button.dataset.direction;
|
||||
if (!_dbPages[name]) return;
|
||||
_dbPages[name].offset = direction === 'previous'
|
||||
? Math.max(0, _dbPages[name].offset - DB_PAGE_SIZE)
|
||||
: Math.min(Math.max(0, _dbPages[name].total - 1), _dbPages[name].offset + DB_PAGE_SIZE);
|
||||
if (name === 'hashes') await loadHashRowsTable();
|
||||
else await loadDbTable(name);
|
||||
}));
|
||||
|
||||
function renderDbTableEl(tableId, rows, tableName) {
|
||||
const table = $(tableId);
|
||||
if (!table) return;
|
||||
// Hide toolbar when table reloads
|
||||
const toolbar = $(`toolbar-${tableName}`);
|
||||
if (toolbar) toolbar.hidden = true;
|
||||
|
||||
if (!rows.length) {
|
||||
table.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const cols = Object.keys(rows[0]);
|
||||
const hasId = cols.includes('id');
|
||||
table.innerHTML = `
|
||||
<thead><tr>${hasId ? '<th class="db-row-cb"><input type="checkbox" class="db-select-all" data-table="' + tableName + '"></th>' : ''}${cols.map(c => `<th>${escHtml(dbColumnLabel(c))}</th>`).join('')}</tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => {
|
||||
const rowId = hasId ? r.id : '';
|
||||
return `<tr data-row-id="${rowId}">${hasId ? `<td class="db-row-cb"><input type="checkbox" class="db-row-check" data-table="${tableName}" value="${rowId}"></td>` : ''}${cols.map(c => `<td>${escHtml(dbFormatValue(c, r[c]))}</td>`).join('')}</tr>`;
|
||||
}).join('')}
|
||||
</tbody>`;
|
||||
|
||||
if (!hasId) return;
|
||||
|
||||
// Wire up "select all" header checkbox
|
||||
const selectAllCb = table.querySelector('.db-select-all');
|
||||
if (selectAllCb) {
|
||||
selectAllCb.addEventListener('change', () => {
|
||||
table.querySelectorAll('.db-row-check').forEach(cb => {
|
||||
cb.checked = selectAllCb.checked;
|
||||
cb.closest('tr').classList.toggle('db-row-selected', selectAllCb.checked);
|
||||
});
|
||||
_updateRowToolbar(tableName);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up individual row checkboxes
|
||||
table.querySelectorAll('.db-row-check').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
cb.closest('tr').classList.toggle('db-row-selected', cb.checked);
|
||||
_updateRowToolbar(tableName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getCheckedIds(tableName) {
|
||||
const tableEl = tableName === 'games' ? $('db-games-table')
|
||||
: tableName === 'backups' ? $('db-backups-table')
|
||||
: $('db-hash-rows-table');
|
||||
if (!tableEl) return [];
|
||||
return [...tableEl.querySelectorAll('.db-row-check:checked')].map(cb => Number(cb.value));
|
||||
}
|
||||
|
||||
function _updateRowToolbar(tableName) {
|
||||
const ids = _getCheckedIds(tableName);
|
||||
const toolbar = $(`toolbar-${tableName}`);
|
||||
if (!toolbar) return;
|
||||
toolbar.hidden = ids.length === 0;
|
||||
const countEl = $(`toolbar-${tableName}-count`);
|
||||
if (countEl) countEl.textContent = `${ids.length} selected`;
|
||||
}
|
||||
|
||||
// DB Tabs
|
||||
document.querySelectorAll('.db-tab').forEach(tab =>
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.db-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.db-panel').forEach(p => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
$(`dbtab-${tab.dataset.dbtab}`).classList.add('active');
|
||||
if (tab.dataset.dbtab === 'hash-rows') loadHashRowsTable();
|
||||
if (tab.dataset.dbtab === 'hashes') loadHashDbContent();
|
||||
})
|
||||
);
|
||||
|
||||
// Query runner
|
||||
$('btn-run-query').addEventListener('click', async () => {
|
||||
const sql = $('query-input').value.trim();
|
||||
if (!sql) return;
|
||||
const out = $('query-results');
|
||||
try {
|
||||
const rows = await window.pork.dbQuery(sql);
|
||||
if (!rows.length) { out.innerHTML = '<div class="empty-msg">Query returned no rows.</div>'; return; }
|
||||
const cols = Object.keys(rows[0]);
|
||||
out.innerHTML = `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>${cols.map(c => `<th>${escHtml(c)}</th>`).join('')}</tr></thead>
|
||||
<tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${escHtml(r[c] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Exports
|
||||
$('btn-export-sql').addEventListener('click', async () => {
|
||||
const p = await window.pork.exportSql();
|
||||
if (p) setStatus(`Exported to ${p}`, 'ok');
|
||||
});
|
||||
$('btn-export-games-csv').addEventListener('click', async () => {
|
||||
const p = await window.pork.exportCsv('games');
|
||||
if (p) setStatus(`Exported to ${p}`, 'ok');
|
||||
});
|
||||
$('btn-export-backups-csv').addEventListener('click', async () => {
|
||||
const p = await window.pork.exportCsv('backups');
|
||||
if (p) setStatus(`Exported to ${p}`, 'ok');
|
||||
});
|
||||
$('btn-export-hashes').addEventListener('click', async () => {
|
||||
const p = await window.pork.hashExport();
|
||||
if (p) setStatus(`Hash DB exported to ${p}`, 'ok');
|
||||
});
|
||||
|
||||
// ── Hash Database tab ───────────────────────────────────────────────────────────────────────
|
||||
async function loadHashDbContent() {
|
||||
try {
|
||||
const info = await window.pork.hashCommunityInfo();
|
||||
const countEl = $('hdb-community-count');
|
||||
const verEl = $('hdb-community-ver');
|
||||
const srcEl = $('hdb-community-source');
|
||||
const localEl = $('hdb-local-count');
|
||||
if (!countEl) return; // tab panel not in DOM
|
||||
countEl.textContent = `${info.count} hash${info.count !== 1 ? 'es' : ''}`;
|
||||
countEl.className = info.count > 0 ? 'hash-badge hash-community' : 'hash-badge hash-none';
|
||||
verEl.textContent = info.updated ? `v${info.version || 1} · ${info.updated}` : '';
|
||||
srcEl.textContent = info.source === 'imported' ? '(user-imported)' : '(bundled)';
|
||||
localEl.textContent = `${info.localCount} hash${info.localCount !== 1 ? 'es' : ''}`;
|
||||
localEl.className = info.localCount > 0 ? 'hash-badge hash-local' : 'hash-badge hash-none';
|
||||
} catch (_) {}
|
||||
try {
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
const list = $('hdb-game-list');
|
||||
if (!list) return;
|
||||
if (!summaries.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:16px;color:var(--text-dim)">No hashes recorded yet. Open any game, go to Hash Verification, and click \'Hash All Files\'. Or use the Hash All Games / Backporks buttons on those pages.</div>';
|
||||
return;
|
||||
}
|
||||
summaries.sort((a, b) => {
|
||||
if (a.community_mismatch !== b.community_mismatch) return a.community_mismatch ? -1 : 1;
|
||||
if (a.community_matches !== b.community_matches) return b.community_matches - a.community_matches;
|
||||
const typeOrder = { game: 0, backpork: 1 };
|
||||
const tDiff = (typeOrder[a.hash_type] ?? 0) - (typeOrder[b.hash_type] ?? 0);
|
||||
if (tDiff !== 0) return tDiff;
|
||||
return a.game_id.localeCompare(b.game_id);
|
||||
});
|
||||
list.innerHTML = `<div class="table-wrap" style="margin-top:14px">
|
||||
<table style="width:100%">
|
||||
<thead><tr><th>Game ID</th><th>Type</th><th>Firmware</th><th>Files Hashed</th><th>Community Matches</th><th>Status</th></tr></thead>
|
||||
<tbody>${summaries.map(s => {
|
||||
const badge = s.community_mismatch
|
||||
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch</span>`
|
||||
: s.community_matches > 0
|
||||
? `<span class="hash-badge hash-community">✓ Verified (${s.community_matches}/${s.hash_count})</span>`
|
||||
: `<span class="hash-badge hash-local">⧭ Hashed (${s.hash_count})</span>`;
|
||||
const typeLabel = s.hash_type === 'backpork'
|
||||
? `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Backpork</span>`
|
||||
: `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Game</span>`;
|
||||
const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—';
|
||||
return `<tr><td><code>${escHtml(s.game_id)}</code></td><td>${typeLabel}</td><td>${fwLabel}</td><td>${s.hash_count}</td><td>${s.community_matches || '—'}</td><td>${badge}</td></tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const _fmtBytes = b => b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : b < 1073741824 ? `${(b/1048576).toFixed(1)} MB` : `${(b/1073741824).toFixed(2)} GB`;
|
||||
|
||||
let _hashBulkRunning = false;
|
||||
|
||||
function _setBulkProgress({ show = true, pct = 0, status = '', fileStatus = '' } = {}) {
|
||||
const wrap = $('bulk-hash-progress');
|
||||
const bar = $('bulk-hash-bar');
|
||||
const pctEl = $('bulk-hash-pct');
|
||||
const statEl = $('hash-all-games-status');
|
||||
const fileEl = $('bulk-hash-file-status');
|
||||
if (!wrap) return;
|
||||
wrap.style.display = show ? 'flex' : 'none';
|
||||
if (bar) bar.style.width = `${pct}%`;
|
||||
if (pctEl) pctEl.textContent = `${pct}%`;
|
||||
if (statEl) statEl.textContent = status;
|
||||
if (fileEl) fileEl.textContent = fileStatus;
|
||||
}
|
||||
|
||||
async function _runBulkHash(source) {
|
||||
if (_hashBulkRunning) { showToast('A hash operation is already running.', 'info'); return; }
|
||||
_hashBulkRunning = true;
|
||||
|
||||
const isGames = source === 'local';
|
||||
const btnEl = $(isGames ? 'btn-hash-all-games' : 'btn-hash-all-backporks');
|
||||
const backporksStat = $('hash-all-backporks-status');
|
||||
|
||||
btnEl.disabled = true;
|
||||
if (isGames) {
|
||||
_setBulkProgress({ show: true, pct: 0, status: 'Preparing…', fileStatus: '' });
|
||||
} else if (backporksStat) {
|
||||
backporksStat.textContent = 'Preparing…';
|
||||
}
|
||||
|
||||
try {
|
||||
const { results, total, totalFilesHashed, totalBytesHashed } = await window.pork.hashBulk(source);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const fail = results.filter(r => !r.ok).length;
|
||||
const doneMsg = `Done ✓ — ${ok} game${ok !== 1 ? 's' : ''}, ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} (${_fmtBytes(totalBytesHashed || 0)})${fail ? `, ${fail} skipped` : ''}`;
|
||||
if (isGames) {
|
||||
_setBulkProgress({ show: true, pct: 100, status: doneMsg, fileStatus: '' });
|
||||
// Fade the progress block away after a few seconds
|
||||
setTimeout(() => _setBulkProgress({ show: false }), 6000);
|
||||
} else if (backporksStat) {
|
||||
backporksStat.textContent = doneMsg;
|
||||
}
|
||||
// Refresh hash summaries and re-render badges everywhere
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
showToast(`Hash complete — ${totalFilesHashed} files (${_fmtBytes(totalBytesHashed || 0)})`, 'ok');
|
||||
} catch (e) {
|
||||
const errMsg = 'Error: ' + e.message;
|
||||
if (isGames) {
|
||||
_setBulkProgress({ show: true, pct: 0, status: errMsg, fileStatus: '' });
|
||||
} else if (backporksStat) {
|
||||
backporksStat.textContent = errMsg;
|
||||
}
|
||||
showToast('Hash failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
_hashBulkRunning = false;
|
||||
btnEl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.pork.on('hash:bulk:progress', ({ phase, done, total, percent, gameId, currentFile, fileSize, filePct, totalFilesHashed, totalBytesHashed }) => {
|
||||
const backporksStat = $('hash-all-backporks-status');
|
||||
const isGamesPage = !!document.querySelector('#page-games.active');
|
||||
|
||||
const overallStatus = `Game ${done}/${total} (${percent}%) — ${gameId || ''}`
|
||||
+ (totalFilesHashed ? ` | ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} hashed` : '')
|
||||
+ (totalBytesHashed ? ` | ${_fmtBytes(totalBytesHashed)}` : '');
|
||||
|
||||
let fileStatus = '';
|
||||
if (currentFile) {
|
||||
const sizeSuffix = fileSize ? ` (${_fmtBytes(fileSize)})` : '';
|
||||
const pctSuffix = (phase === 'file-progress' && filePct != null) ? ` — ${filePct}%` : '';
|
||||
fileStatus = `⧭ ${currentFile}${sizeSuffix}${pctSuffix}`;
|
||||
} else if (phase === 'backup-done') {
|
||||
fileStatus = `✓ ${gameId} complete`;
|
||||
}
|
||||
|
||||
// done = completed backups so far (0 during first game's files).
|
||||
// Interpolate within the current slot: done/total gets us to the start of this slot,
|
||||
// then add filePct spread across one slot width.
|
||||
const barPct = (phase === 'file-progress' && filePct != null)
|
||||
? Math.min(100, Math.round(done / (total || 1) * 100 + filePct / (total || 1)))
|
||||
: percent;
|
||||
|
||||
_setBulkProgress({ show: true, pct: barPct, status: overallStatus, fileStatus });
|
||||
|
||||
// Mirror to backporks status if the games progress block isn't visible
|
||||
if (backporksStat && !isGamesPage) {
|
||||
backporksStat.textContent = overallStatus;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-hash-all-games').addEventListener('click', async () => {
|
||||
const ok = await showConfirm('Hash All Game Backups?\n\nThis will SHA-256 every file in every local game backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
|
||||
if (ok) _runBulkHash('local');
|
||||
});
|
||||
$('btn-hash-all-backporks').addEventListener('click', async () => {
|
||||
const ok = await showConfirm('Hash All Backpork Backups?\n\nThis will SHA-256 every file in every backpork backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
|
||||
if (ok) _runBulkHash('backpork');
|
||||
});
|
||||
|
||||
$('btn-hdb-export').addEventListener('click', async () => {
|
||||
const p = await window.pork.hashExport();
|
||||
if (p) showToast(`Hash DB exported to ${p}`, 'ok');
|
||||
});
|
||||
|
||||
$('btn-import-community-hashes').addEventListener('click', async () => {
|
||||
const btn = $('btn-import-community-hashes');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await window.pork.hashImportCommunity();
|
||||
if (!result) { btn.disabled = false; return; }
|
||||
showToast(`Community hash list imported — ${result.count} hashes loaded`, 'ok');
|
||||
await loadHashDbContent();
|
||||
// Refresh game summaries so badges update immediately
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
} catch (e) {
|
||||
showToast('Import failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
// ── Selective Clear dialog ──────────────────────────────────────────────────────
|
||||
$('btn-clear-db').addEventListener('click', () => {
|
||||
// Reset all checkboxes
|
||||
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = false; });
|
||||
$('clear-select-ok').disabled = true;
|
||||
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input'));
|
||||
});
|
||||
|
||||
// Toggle "Clear Selected" button enable/disable based on any checked
|
||||
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const anyChecked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')].some(c => c.checked);
|
||||
$('clear-select-ok').disabled = !anyChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// Select All button
|
||||
$('clear-select-all').addEventListener('click', () => {
|
||||
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = true; });
|
||||
$('clear-select-ok').disabled = false;
|
||||
});
|
||||
|
||||
// Cancel
|
||||
$('clear-select-cancel').addEventListener('click', () => {
|
||||
closeAccessibleDialog($('clear-select-overlay'));
|
||||
});
|
||||
|
||||
// Clear Selected
|
||||
$('clear-select-ok').addEventListener('click', async () => {
|
||||
const checked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')]
|
||||
.filter(cb => cb.checked).map(cb => cb.value);
|
||||
if (!checked.length) return;
|
||||
|
||||
const labels = checked.map(v => v.charAt(0).toUpperCase() + v.slice(1));
|
||||
|
||||
// Hide the selection overlay so the confirm dialog is visible on top
|
||||
closeAccessibleDialog($('clear-select-overlay'));
|
||||
const confirmation = await showExactConfirmation(`Permanently delete: ${labels.join(', ')}?\n\nThis cannot be undone.`, 'WIS SELECTIE');
|
||||
if (!confirmation) {
|
||||
// User cancelled — re-show the selection overlay with their choices preserved
|
||||
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input:checked'));
|
||||
return;
|
||||
}
|
||||
|
||||
const opts = {};
|
||||
checked.forEach(k => { opts[k] = true; });
|
||||
await window.pork.dbClearSelective(opts, confirmation);
|
||||
|
||||
loadDatabase();
|
||||
loadHashDbContent();
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
setStatus(`Cleared: ${labels.join(', ')}`, 'ok');
|
||||
showToast(`Cleared: ${labels.join(', ')}`, 'ok');
|
||||
});
|
||||
|
||||
// ── Row-level delete handlers ────────────────────────────────────────────────────
|
||||
async function _deleteSelectedRows(tableName, dbTable) {
|
||||
const ids = _getCheckedIds(tableName);
|
||||
if (!ids.length) return;
|
||||
const confirmation = await showExactConfirmation(`Delete ${ids.length} ${tableName} row${ids.length !== 1 ? 's' : ''}?\n\nThis cannot be undone.`, 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
const { deleted } = await window.pork.dbDeleteRows(dbTable, ids, confirmation);
|
||||
showToast(`Deleted ${deleted} ${tableName} row${deleted !== 1 ? 's' : ''}.`, 'ok');
|
||||
// Reload the affected table
|
||||
if (tableName === 'games') await loadDbTable('games');
|
||||
if (tableName === 'backups') await loadDbTable('backups');
|
||||
if (tableName === 'hashes') await loadHashRowsTable();
|
||||
// Refresh game page data
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
}
|
||||
|
||||
function _selectAllRows(tableName) {
|
||||
const tableEl = tableName === 'games' ? $('db-games-table')
|
||||
: tableName === 'backups' ? $('db-backups-table')
|
||||
: $('db-hash-rows-table');
|
||||
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = true; cb.closest('tr').classList.add('db-row-selected'); });
|
||||
const selectAll = tableEl.querySelector('.db-select-all');
|
||||
if (selectAll) selectAll.checked = true;
|
||||
_updateRowToolbar(tableName);
|
||||
}
|
||||
|
||||
function _deselectAllRows(tableName) {
|
||||
const tableEl = tableName === 'games' ? $('db-games-table')
|
||||
: tableName === 'backups' ? $('db-backups-table')
|
||||
: $('db-hash-rows-table');
|
||||
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = false; cb.closest('tr').classList.remove('db-row-selected'); });
|
||||
const selectAll = tableEl.querySelector('.db-select-all');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
_updateRowToolbar(tableName);
|
||||
}
|
||||
|
||||
// Games toolbar
|
||||
$('btn-delete-games').addEventListener('click', () => _deleteSelectedRows('games', 'games'));
|
||||
$('btn-select-all-games').addEventListener('click', () => _selectAllRows('games'));
|
||||
$('btn-deselect-all-games').addEventListener('click', () => _deselectAllRows('games'));
|
||||
|
||||
// Backups toolbar
|
||||
$('btn-delete-backups').addEventListener('click', () => _deleteSelectedRows('backups', 'backups'));
|
||||
$('btn-select-all-backups').addEventListener('click', () => _selectAllRows('backups'));
|
||||
$('btn-deselect-all-backups').addEventListener('click', () => _deselectAllRows('backups'));
|
||||
|
||||
// Hashes toolbar
|
||||
$('btn-delete-hashes').addEventListener('click', () => _deleteSelectedRows('hashes', 'game_hashes'));
|
||||
$('btn-select-all-hashes').addEventListener('click', () => _selectAllRows('hashes'));
|
||||
$('btn-deselect-all-hashes').addEventListener('click', () => _deselectAllRows('hashes'));
|
||||
|
||||
async function _clearHashes() {
|
||||
const confirmation = await showExactConfirmation('Delete all stored hashes from the database? This cannot be undone.', 'WIS HASHES');
|
||||
if (!confirmation) return;
|
||||
await window.pork.dbClearHashes(confirmation);
|
||||
await loadHashRowsTable();
|
||||
await loadHashDbContent();
|
||||
const summaries = await window.pork.hashGameSummary();
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
showToast('All hashes cleared.', 'ok');
|
||||
}
|
||||
|
||||
$('btn-clear-hashes').addEventListener('click', _clearHashes);
|
||||
$('btn-hdb-clear-hashes').addEventListener('click', _clearHashes);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,213 @@
|
||||
// ELF Arsenal native Chimera client. The upstream WebUI remains available in a
|
||||
// separate browser tab, but is no longer embedded: its opaque iframe errors
|
||||
// cannot be diagnosed and its storage assumptions conflict with sandboxing.
|
||||
|
||||
let _eaPath = '/';
|
||||
let _eaConnected = false;
|
||||
|
||||
function eaFriendlyError(value) {
|
||||
const message = String(value || '').trim();
|
||||
if (/Configureer eerst een geldig PS5-adres/i.test(message)) return 'Configure a PS5 host in Settings first.';
|
||||
if (/ELF Arsenal-payload.*gestart/i.test(message)) return 'Start the ELF Arsenal payload explicitly, then retry.';
|
||||
return message || 'ELF Arsenal is unavailable on port 6969.';
|
||||
}
|
||||
|
||||
function eaBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function eaJoin(base, name) {
|
||||
const cleanName = String(name || '').replace(/^\/+|\/+$/g, '');
|
||||
return base === '/' ? `/${cleanName}` : `${String(base).replace(/\/+$/, '')}/${cleanName}`;
|
||||
}
|
||||
|
||||
function eaParent(value) {
|
||||
const parts = String(value || '/').split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? `/${parts.join('/')}` : '/';
|
||||
}
|
||||
|
||||
function eaSetStatus(message, type = '') {
|
||||
const status = document.getElementById('ea-embed-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.className = `ea-native-status ${type}`;
|
||||
}
|
||||
|
||||
function eaRenderList(packet) {
|
||||
const target = document.getElementById('ea-native-list');
|
||||
const pathLabel = document.getElementById('ea-native-path');
|
||||
if (!target || !pathLabel) return;
|
||||
pathLabel.innerHTML = `<button class="btn btn-xs ea-native-parent" ${_eaPath === '/' ? 'disabled' : ''} data-path="${escHtml(eaParent(_eaPath))}">← Up</button><code>${escHtml(_eaPath)}</code>`;
|
||||
const entries = (packet.entries || []).slice().sort((left, right) =>
|
||||
Number(Boolean(right.is_dir)) - Number(Boolean(left.is_dir)) || String(left.name).localeCompare(String(right.name)));
|
||||
target.innerHTML = entries.length ? entries.map(entry => {
|
||||
const remotePath = eaJoin(_eaPath, entry.name);
|
||||
const isElf = !entry.is_dir && /\.elf$/i.test(entry.name);
|
||||
return `<article class="ea-native-row" data-path="${escHtml(remotePath)}" data-name="${escHtml(entry.name)}" data-dir="${entry.is_dir ? 'true' : 'false'}">
|
||||
<span class="ea-native-icon" aria-hidden="true">${entry.is_dir ? '📁' : isElf ? '◆' : '•'}</span>
|
||||
<div><strong>${escHtml(entry.name)}</strong><span>${entry.is_dir ? 'Directory' : eaBytes(entry.size)}${entry.mode ? ` · ${escHtml(entry.mode)}` : ''}</span></div>
|
||||
<div class="ecosystem-inline-actions">
|
||||
${entry.is_dir ? '<button class="btn btn-sm ea-native-open">Open</button>' : `<button class="btn btn-sm ea-native-download">Download</button>${isElf ? '<button class="btn btn-sm btn-accent ea-native-launch">Launch ELF</button>' : ''}`}
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('') : '<div class="operations-empty"><strong>This directory is empty</strong><span>Choose another safe root or upload a file explicitly.</span></div>';
|
||||
const lock = document.getElementById('ea-native-lock');
|
||||
if (lock) {
|
||||
lock.textContent = packet.safety_lock ? 'Safety lock enabled' : 'Safety lock not reported';
|
||||
lock.className = `operations-status ${packet.safety_lock ? 'operations-status-compatible' : 'operations-status-unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function eaLoadDirectory(path = _eaPath) {
|
||||
_eaPath = String(path || '/');
|
||||
const target = document.getElementById('ea-native-list');
|
||||
if (target) target.innerHTML = '<p class="hint">Loading directory…</p>';
|
||||
try {
|
||||
const [packet, job] = await Promise.all([
|
||||
window.pork.eaList(_eaPath),
|
||||
window.pork.eaJobStatus().catch(() => ({ busy: false })),
|
||||
]);
|
||||
eaRenderList(packet);
|
||||
const jobTarget = document.getElementById('ea-native-job');
|
||||
if (jobTarget) jobTarget.textContent = job.busy
|
||||
? `File operation active${job.progress != null ? ` · ${job.progress}%` : ''}`
|
||||
: 'No active file operation';
|
||||
} catch (error) {
|
||||
if (target) target.innerHTML = `<div class="operations-empty"><strong>Directory unavailable</strong><span>${escHtml(error.message)}</span></div>`;
|
||||
eaSetStatus(`File API error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function eaReload() {
|
||||
eaSetStatus('Checking ELF Arsenal…');
|
||||
try {
|
||||
const result = await window.pork.eaEmbedUrl();
|
||||
const open = document.getElementById('ea-embed-open');
|
||||
_eaConnected = result.connected === true;
|
||||
if (!_eaConnected) {
|
||||
if (open) { open.disabled = true; delete open.dataset.url; }
|
||||
eaSetStatus(eaFriendlyError(result.error), 'error');
|
||||
const list = document.getElementById('ea-native-list');
|
||||
if (list) list.innerHTML = '<div class="operations-empty"><strong>ELF Arsenal is offline</strong><span>Start it explicitly through Payload Manager, then retry.</span></div>';
|
||||
return;
|
||||
}
|
||||
if (open) {
|
||||
open.disabled = false;
|
||||
open.dataset.url = result.directUrl || result.url;
|
||||
}
|
||||
eaSetStatus(result.version ? `Online · ${result.version}` : 'Online', 'ok');
|
||||
document.getElementById('ea-native-version').textContent = result.version || 'Unknown';
|
||||
await eaLoadDirectory(_eaPath);
|
||||
} catch (error) {
|
||||
_eaConnected = false;
|
||||
eaSetStatus(`Connection error: ${eaFriendlyError(error.message)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function buildElfArsenalEmbed() {
|
||||
const root = document.getElementById('page-elf-arsenal');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>ELF Arsenal</h1>
|
||||
<p class="page-subtitle">Native Chimera file browser and launcher for the ELF Arsenal service on your PS5.</p>
|
||||
</div>
|
||||
<div class="header-tools">
|
||||
<span id="ea-embed-status" class="ea-native-status">Checking connection…</span>
|
||||
<button id="ea-embed-start" class="btn btn-accent">Start via Payload Manager</button>
|
||||
<button id="ea-embed-open" class="btn" disabled>Open original WebUI</button>
|
||||
<button id="ea-embed-reload" class="btn">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ecosystem-workflow-strip ea-workflow" aria-label="ELF Arsenal workflow">
|
||||
<span><b>1</b><strong>Start service</strong><small>Payload Manager, explicit</small></span>
|
||||
<span><b>2</b><strong>Browse</strong><small>Choose a reviewed ELF</small></span>
|
||||
<span><b>3</b><strong>Inspect</strong><small>Path and operation</small></span>
|
||||
<span><b>4</b><strong>Launch</strong><small>Exact confirmation</small></span>
|
||||
</div>
|
||||
<div class="ea-native-summary">
|
||||
<div class="operations-metric"><strong id="ea-native-version">—</strong><span>Version</span></div>
|
||||
<div class="operations-metric"><strong id="ea-native-job">No active job</strong><span>File operation</span></div>
|
||||
<div class="operations-metric"><strong id="ea-native-lock" class="operations-status operations-status-unknown">Unknown</strong><span>Upstream safety lock</span></div>
|
||||
</div>
|
||||
<div class="card ea-native-card">
|
||||
<div class="card-header ecosystem-card-header">
|
||||
<div><h3>PS5 files</h3><p class="hint">Browse with Chimera's authenticated API. The original upstream WebUI opens separately and can no longer crash this page.</p></div>
|
||||
<div class="ecosystem-inline-actions">
|
||||
<button class="btn btn-sm ea-native-root" data-path="/">Root</button>
|
||||
<button class="btn btn-sm ea-native-root" data-path="/data">Data</button>
|
||||
<button class="btn btn-sm ea-native-root" data-path="/mnt">USB / mounts</button>
|
||||
<button id="ea-native-upload" class="btn btn-sm btn-accent">Upload here</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ea-native-path" class="ea-native-path"></div>
|
||||
<div id="ea-native-list" class="ea-native-list"><p class="hint">Waiting for ELF Arsenal…</p></div>
|
||||
</div>
|
||||
<p class="hint">FTP alone is not enough: ELF Arsenal must be running on port 6969. Launch and upload actions always require an explicit confirmation.</p>`;
|
||||
|
||||
document.getElementById('ea-embed-open').addEventListener('click', () => {
|
||||
const url = document.getElementById('ea-embed-open')?.dataset.url;
|
||||
if (url) window.pork.openShell(url);
|
||||
});
|
||||
document.getElementById('ea-embed-start').addEventListener('click', async (event) => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
eaSetStatus('Starting ELF Arsenal explicitly through Payload Manager…', 'warning');
|
||||
try {
|
||||
await window.pork.eaStart();
|
||||
showToast('ELF Arsenal started. Rechecking the service.', 'ok');
|
||||
await eaReload();
|
||||
} catch (error) {
|
||||
eaSetStatus(`Start failed: ${error.message}`, 'error');
|
||||
showToast(`ELF Arsenal start failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById('ea-embed-reload').addEventListener('click', eaReload);
|
||||
document.getElementById('ea-native-path').addEventListener('click', event => {
|
||||
const button = event.target.closest('.ea-native-parent');
|
||||
if (button) eaLoadDirectory(button.dataset.path);
|
||||
});
|
||||
root.querySelectorAll('.ea-native-root').forEach(button => button.addEventListener('click', () => eaLoadDirectory(button.dataset.path)));
|
||||
document.getElementById('ea-native-list').addEventListener('click', async event => {
|
||||
const row = event.target.closest('.ea-native-row');
|
||||
if (!row) return;
|
||||
const button = event.target.closest('button');
|
||||
try {
|
||||
if (button?.classList.contains('ea-native-open')) await eaLoadDirectory(row.dataset.path);
|
||||
if (button?.classList.contains('ea-native-download')) await window.pork.eaDownload(row.dataset.path, row.dataset.name);
|
||||
if (button?.classList.contains('ea-native-launch')) {
|
||||
const confirmation = await showExactConfirmation(`Launch ${row.dataset.path} through ELF Arsenal?`, 'LAUNCH ELF');
|
||||
if (!confirmation) return;
|
||||
await window.pork.eaLaunch(row.dataset.path, [], false);
|
||||
showToast(`${row.dataset.name} launch request sent.`, 'ok');
|
||||
}
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
document.getElementById('ea-native-upload').addEventListener('click', async () => {
|
||||
if (!_eaConnected) return showToast('Start ELF Arsenal first.', 'error');
|
||||
const confirmation = await showExactConfirmation(`Upload one selected file to ${_eaPath}?`, 'UPLOAD FILE');
|
||||
if (!confirmation) return;
|
||||
try {
|
||||
const uploadRoot = _eaPath === '/' ? '/' : `${_eaPath.replace(/\/+$/, '')}/`;
|
||||
const result = await window.pork.eaUpload(null, uploadRoot);
|
||||
if (result) {
|
||||
showToast(`Uploaded ${eaBytes(result.bytes)} to ${result.remotePath}.`, 'ok');
|
||||
await eaLoadDirectory(_eaPath);
|
||||
}
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
eaReload();
|
||||
}
|
||||
|
||||
async function loadElfArsenal() {
|
||||
if (!document.getElementById('ea-native-list')) buildElfArsenalEmbed();
|
||||
else await eaReload();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeEtahenLinkUx(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.ChimeraLinkUx = api;
|
||||
})(typeof window === 'object' ? window : null, () => {
|
||||
const STATES = new Set(['online', 'offline', 'unknown']);
|
||||
const JOB_STATES = new Set(['none', 'staged', 'approval_required', 'recovery_required']);
|
||||
const safeState = (value, allowed, fallback) => allowed.has(String(value || '')) ? String(value) : fallback;
|
||||
const text = (value, fallback = '') => String(value || fallback).slice(0, 160);
|
||||
|
||||
function createModel(packet = {}) {
|
||||
const runtime = packet.runtime || {};
|
||||
const last = packet.lastInventory || null;
|
||||
const etaHenState = safeState(runtime.state, STATES, 'unknown');
|
||||
const agentState = safeState(packet.agent?.state, STATES, etaHenState === 'online' && last ? 'online' : 'unknown');
|
||||
const chimeraState = last ? 'online' : 'unknown';
|
||||
const jobState = safeState(packet.job?.state, JOB_STATES, 'none');
|
||||
const stagedCount = Math.max(0, Number(packet.job?.stagedCount) || 0);
|
||||
return Object.freeze({
|
||||
etaHen: Object.freeze({ state: etaHenState, version: text(runtime.identity?.version, '—') }),
|
||||
agent: Object.freeze({ state: agentState, paired: packet.agent?.paired === true }),
|
||||
chimera: Object.freeze({ state: chimeraState, lastScanAt: last?.completedAt || null }),
|
||||
transfer: Object.freeze({ state: jobState, stagedCount, installedCount: 0 }),
|
||||
recoveryRequired: jobState === 'recovery_required',
|
||||
approvalRequired: jobState === 'approval_required',
|
||||
});
|
||||
}
|
||||
|
||||
function exactConfirmation({ action, target, runtimeEpoch, localIntent, recoveryAvailable }) {
|
||||
if (!/^[A-Z][A-Z0-9_]{2,31}$/.test(String(action || ''))) throw new Error('Invalid action.');
|
||||
if (!/^[A-Za-z0-9_-]{8,96}$/.test(String(target || '')) || !/^[A-Za-z0-9_-]{8,96}$/.test(String(runtimeEpoch || ''))) throw new Error('Invalid confirmation identity.');
|
||||
if (localIntent !== true) throw new Error('Local console intent is required.');
|
||||
if (recoveryAvailable !== true) throw new Error('Verified recovery is required.');
|
||||
return `CONFIRM ${action} ${target} ${runtimeEpoch}`;
|
||||
}
|
||||
|
||||
function mount({ root, loadStatus, translate = (_key, fallback) => fallback } = {}) {
|
||||
if (!root || typeof loadStatus !== 'function') return null;
|
||||
const t = translate;
|
||||
root.innerHTML = `<section class="card etahen-link-card" aria-labelledby="etahen-link-title">
|
||||
<div class="card-header"><div><span class="etahen-link-kicker">${t('etahenLink.kicker', 'Console companion')}</span><h3 id="etahen-link-title">${t('etahenLink.title', 'Chimera Link for etaHEN')}</h3><p class="hint">${t('etahenLink.description', 'Separate health for etaHEN, the paired agent and Chimera evidence.')}</p></div><button id="etahen-link-refresh" class="btn btn-sm" type="button">${t('common.refresh', 'Refresh')}</button></div>
|
||||
<div class="etahen-link-health" role="list" aria-label="${t('etahenLink.healthLabel', 'Chimera Link health')}"></div>
|
||||
<div class="etahen-link-alert" role="status" aria-live="polite"></div>
|
||||
</section>`;
|
||||
const health = root.querySelector('.etahen-link-health'); const alert = root.querySelector('.etahen-link-alert'); const button = root.querySelector('#etahen-link-refresh');
|
||||
const label = state => t(`etahenLink.state.${state}`, state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Unknown');
|
||||
const render = packet => {
|
||||
const model = createModel(packet);
|
||||
health.innerHTML = [['etaHEN', model.etaHen], [t('etahenLink.agent', 'Link agent'), model.agent], ['Chimera', model.chimera]].map(([name, item]) => `<div class="etahen-link-health-item state-${item.state}" role="listitem"><span>${name}</span><strong>${label(item.state)}</strong></div>`).join('');
|
||||
alert.className = `etahen-link-alert ${model.recoveryRequired ? 'is-critical' : ''}`;
|
||||
alert.textContent = model.recoveryRequired ? t('etahenLink.recoveryRequired', 'Recovery required before any new action.') : model.approvalRequired ? t('etahenLink.approvalRequired', 'Approval must be confirmed locally on the console.') : model.transfer.stagedCount ? t('etahenLink.stagedOnly', 'Artifact staged — not installed.') : t('etahenLink.noPendingWork', 'No pending Link work.');
|
||||
return model;
|
||||
};
|
||||
const refresh = async () => { button.disabled = true; try { return render(await loadStatus()); } catch (_) { return render({ runtime: { state: 'unknown' } }); } finally { button.disabled = false; } };
|
||||
button.addEventListener('click', refresh); root.addEventListener('keydown', event => { if (event.key === 'Enter' && event.target === root) refresh(); });
|
||||
root.tabIndex = 0; refresh();
|
||||
return Object.freeze({ refresh, render });
|
||||
}
|
||||
|
||||
return Object.freeze({ createModel, exactConfirmation, mount });
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// ── FTP Browser ───────────────────────────────────────────────────────────────
|
||||
|
||||
let ftpBrowserResolve = null;
|
||||
let ftpBrowserCwd = '/';
|
||||
|
||||
function openFtpBrowser(startPath = '/') {
|
||||
return new Promise(resolve => {
|
||||
ftpBrowserResolve = resolve;
|
||||
openAccessibleDialog($('ftp-browser-overlay'), $('ftp-browser-close'));
|
||||
ftpBrowserNavigate(startPath);
|
||||
});
|
||||
}
|
||||
|
||||
function closeFtpBrowser(result) {
|
||||
closeAccessibleDialog($('ftp-browser-overlay'));
|
||||
if (ftpBrowserResolve) { ftpBrowserResolve(result); ftpBrowserResolve = null; }
|
||||
}
|
||||
|
||||
async function ftpBrowserNavigate(path) {
|
||||
ftpBrowserCwd = path;
|
||||
$('ftp-browser-cwd').textContent = path;
|
||||
$('ftp-browser-up').disabled = (path === '/');
|
||||
const body = $('ftp-browser-body');
|
||||
body.innerHTML = '<div class="ftp-browser-loading">Loading…</div>';
|
||||
try {
|
||||
const entries = await window.pork.ftpListDir(path);
|
||||
if (!entries.length) {
|
||||
body.innerHTML = '<div class="ftp-browser-loading">Empty folder.</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = entries.map(e => `
|
||||
<div class="ftp-browser-entry ${e.isDir ? 'ftp-entry-dir' : 'ftp-entry-file'}"
|
||||
data-name="${escHtml(e.name)}" data-isdir="${e.isDir}">
|
||||
<span class="ftp-entry-icon">${e.isDir ? '📁' : '📄'}</span>
|
||||
<span class="ftp-entry-name">${escHtml(e.name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="ftp-browser-loading" style="color:var(--red)">Error: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$('ftp-browser-body').addEventListener('click', e => {
|
||||
const entry = e.target.closest('.ftp-entry-dir');
|
||||
if (!entry) return;
|
||||
const name = entry.dataset.name;
|
||||
const next = (ftpBrowserCwd.endsWith('/') ? ftpBrowserCwd : ftpBrowserCwd + '/') + name;
|
||||
ftpBrowserNavigate(next);
|
||||
});
|
||||
|
||||
$('ftp-browser-up').addEventListener('click', () => {
|
||||
const parts = ftpBrowserCwd.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
ftpBrowserNavigate('/' + parts.join('/') || '/');
|
||||
});
|
||||
|
||||
$('ftp-browser-select').addEventListener('click', () => closeFtpBrowser(ftpBrowserCwd));
|
||||
$('ftp-browser-cancel').addEventListener('click', () => closeFtpBrowser(null));
|
||||
$('ftp-browser-close').addEventListener('click', () => closeFtpBrowser(null));
|
||||
$('ftp-browser-overlay').addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeFtpBrowser(null);
|
||||
});
|
||||
|
||||
$('btn-browse-ftp').addEventListener('click', async () => {
|
||||
if (!state.connected) { setStatus('Connect to FTP first to browse remote paths', 'error'); return; }
|
||||
const selected = await openFtpBrowser(ftpBrowserCwd || '/');
|
||||
if (!selected) return;
|
||||
// Auto-add immediately — no separate "+ Add" click required
|
||||
if (remoteGamePaths.some(p => p.path === selected)) {
|
||||
setStatus('That path is already in the list', 'ok');
|
||||
return;
|
||||
}
|
||||
const label = $('new-remote-label').value.trim()
|
||||
|| selected.replace(/\/$/, '').split('/').filter(Boolean).pop()
|
||||
|| selected;
|
||||
remoteGamePaths = [...remoteGamePaths, { label, path: selected }];
|
||||
await saveRemoteGamePaths();
|
||||
renderRemoteGamePaths();
|
||||
$('new-remote-label').value = '';
|
||||
$('new-remote-path').value = '';
|
||||
});
|
||||
|
||||
// Add payload source
|
||||
$('btn-add-payload-source').addEventListener('click', async () => {
|
||||
const name = $('new-source-name').value.trim();
|
||||
const url = $('new-source-url').value.trim();
|
||||
if (!name || !url) { setStatus('Enter both a name and a source URL', 'error'); return; }
|
||||
try {
|
||||
await window.pork.payloadSourcesAdd(name, url);
|
||||
$('new-source-name').value = '';
|
||||
$('new-source-url').value = '';
|
||||
await renderSettingsPayloadSources();
|
||||
setStatus(`Source "${name}" added`, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`Failed to add source: ${e.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated clicks in settings payload source list
|
||||
$('settings-payload-sources').addEventListener('click', async e => {
|
||||
const row = e.target.closest('[data-source-id]');
|
||||
if (!row) return;
|
||||
const id = Number(row.dataset.sourceId);
|
||||
|
||||
if (e.target.closest('.btn-source-remove')) {
|
||||
const confirmation = await showExactConfirmation('Remove this payload source? Downloaded files will not be deleted.', 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
await window.pork.payloadSourcesRemove(id, confirmation);
|
||||
await renderSettingsPayloadSources();
|
||||
} else if (e.target.closest('.source-toggle')) {
|
||||
await window.pork.payloadSourcesToggle(id);
|
||||
await renderSettingsPayloadSources();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
$('btn-rescan-game-sources').addEventListener('click', async () => {
|
||||
const btn = $('btn-rescan-game-sources');
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Scanning…';
|
||||
setStatus('Scanning local game source folders…');
|
||||
try {
|
||||
const { total, added } = await window.pork.scanBackups();
|
||||
await loadGames();
|
||||
await loadDashboard();
|
||||
const msg = added > 0
|
||||
? `Found ${total} game(s) — ${added} new, fetching metadata…`
|
||||
: `Scan complete: ${total} game(s) found, none new`;
|
||||
setStatus(msg, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
// ── Games page ────────────────────────────────────────────────────────────────
|
||||
async function loadGames() {
|
||||
try {
|
||||
const library = await window.pork.listLibraryTitles({ limit: 1000 });
|
||||
state.games = window.chimeraLibraryModel.items(library);
|
||||
window.pork.shadowMountStatus?.().then(result => {
|
||||
const byGame = new Map();
|
||||
for (const entry of (result.model?.entries || [])) {
|
||||
if (!entry.titleId) continue;
|
||||
const current = byGame.get(entry.titleId) || [];
|
||||
current.push(entry);
|
||||
byGame.set(entry.titleId, current);
|
||||
}
|
||||
state.games = state.games.map(game => {
|
||||
const matches = byGame.get(game.game_id) || [];
|
||||
if (!matches.length) return game;
|
||||
return { ...game, shadow_mount: {
|
||||
state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state,
|
||||
sources: matches.length,
|
||||
experimental: matches.some(entry => entry.format?.support === 'experimental'),
|
||||
formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))],
|
||||
} };
|
||||
});
|
||||
renderGames();
|
||||
}).catch(() => {});
|
||||
// Refresh hash summaries (fire-and-forget so badges update when ready)
|
||||
window.pork.hashGameSummary().then(summaries => {
|
||||
_populateHashMaps(summaries);
|
||||
renderGames();
|
||||
}).catch(() => {});
|
||||
renderGames();
|
||||
await refreshFirmwareFilters();
|
||||
await refreshConvQueuePanel();
|
||||
const conversionQueue = $('conv-queue-panel');
|
||||
const gamesPage = $('page-games');
|
||||
if (conversionQueue && gamesPage) gamesPage.append(conversionQueue);
|
||||
await refreshGameCatalogHealth();
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGameCatalogHealth() {
|
||||
if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return;
|
||||
try {
|
||||
const health = await window.pork.gameCatalogHealth();
|
||||
$('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`;
|
||||
$('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0));
|
||||
$('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0));
|
||||
const scan = health.scan || {};
|
||||
$('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic');
|
||||
$('catalog-health-scan-detail').textContent = scan.lastError
|
||||
? String(scan.lastError)
|
||||
: (scan.lastCompleted
|
||||
? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') })
|
||||
: window.t('games.health.interval', 'source check every 15 min'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.pork.on('local:scan:started', refreshGameCatalogHealth);
|
||||
window.pork.on('local:scan:complete', () => refreshGameCatalogHealth());
|
||||
window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth());
|
||||
|
||||
function gamePlatform(game) {
|
||||
const explicit = String(game?.platform || '').toLowerCase();
|
||||
if (explicit === 'ps4' || explicit === 'ps5') return explicit;
|
||||
const identity = [
|
||||
game?.game_id,
|
||||
game?.content_id,
|
||||
game?.prospero_content_id,
|
||||
].filter(Boolean).join(' ').toUpperCase();
|
||||
if (/CUSA\d{5}/.test(identity)) return 'ps4';
|
||||
if (/PPSA\d{5}/.test(identity)) return 'ps5';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function versionedArtworkUrl(url, fetchedAt) {
|
||||
const source = String(url || '');
|
||||
if (!source.startsWith('/pork-cache/')) return source;
|
||||
const revision = Number(fetchedAt || 0);
|
||||
return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source;
|
||||
}
|
||||
|
||||
function disconnectedDriveLabels(game) {
|
||||
const labels = [];
|
||||
for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) {
|
||||
const device = copy.device || copy.storageDevice || copy.volume?.device || {};
|
||||
const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase();
|
||||
if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue;
|
||||
labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive');
|
||||
}
|
||||
return [...new Set(labels.map(String))];
|
||||
}
|
||||
|
||||
function renderGamePlatformFilters() {
|
||||
const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 };
|
||||
for (const game of state.games) counts[gamePlatform(game)] += 1;
|
||||
for (const platform of Object.keys(counts)) {
|
||||
const count = $(`games-platform-count-${platform}`);
|
||||
if (count) count.textContent = String(counts[platform]);
|
||||
}
|
||||
document.querySelectorAll('[data-game-platform]').forEach(button => {
|
||||
const platform = button.dataset.gamePlatform;
|
||||
const active = platform === state.platformFilter;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active;
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredGames() {
|
||||
return state.games.filter(g => {
|
||||
const q = state.search.toLowerCase();
|
||||
if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) &&
|
||||
!(g.prospero_name || '').toLowerCase().includes(q)) return false;
|
||||
if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false;
|
||||
// Status and firmware are independent filter dimensions — compute status as a
|
||||
// boolean so both can compose (previously the status branches returned early,
|
||||
// silently disabling the firmware filter).
|
||||
let statusOk = true;
|
||||
if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g);
|
||||
else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g);
|
||||
else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g);
|
||||
if (!statusOk) return false;
|
||||
if (state.firmwareFilter !== 'all') {
|
||||
const labels = (g.firmware_labels || '').split(',').filter(Boolean);
|
||||
if (!labels.includes(state.firmwareFilter)) return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const sortValue = game => state.sortCol === 'title'
|
||||
? (game.prospero_name || game.title || game.game_id || '')
|
||||
: (game[state.sortCol] ?? '');
|
||||
let av = sortValue(a), bv = sortValue(b);
|
||||
if (typeof av === 'string') av = av.toLowerCase();
|
||||
if (typeof bv === 'string') bv = bv.toLowerCase();
|
||||
if (av === bv) return 0;
|
||||
return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGames() {
|
||||
renderGamePlatformFilters();
|
||||
const allGames = getFilteredGames();
|
||||
const list = allGames.slice(0, state.gameRenderLimit);
|
||||
const tbody = $('games-tbody');
|
||||
const grid = $('games-grid');
|
||||
const tableWrap = $('games-table-wrap');
|
||||
const empty = $('games-empty');
|
||||
const emptyDetail = $('games-empty-detail');
|
||||
const emptyScan = $('btn-games-empty-scan');
|
||||
const emptyReset = $('btn-games-empty-reset');
|
||||
const loadMore = $('btn-games-load-more');
|
||||
const resultCount = $('games-result-count');
|
||||
const filtersActive = Boolean(
|
||||
state.games.length && (
|
||||
state.search ||
|
||||
state.filter !== 'all' ||
|
||||
state.platformFilter !== 'all' ||
|
||||
state.firmwareFilter !== 'all'
|
||||
)
|
||||
);
|
||||
|
||||
tbody.innerHTML = '';
|
||||
grid.innerHTML = '';
|
||||
empty.hidden = allGames.length !== 0;
|
||||
emptyDetail.textContent = filtersActive
|
||||
? window.t('games.empty.filtered', 'No games match the selected platform and filters.')
|
||||
: window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.');
|
||||
emptyScan.hidden = filtersActive;
|
||||
emptyReset.hidden = !filtersActive;
|
||||
grid.hidden = !allGames.length || state.gameView !== 'grid';
|
||||
tableWrap.hidden = !allGames.length || state.gameView !== 'table';
|
||||
loadMore.hidden = list.length >= allGames.length;
|
||||
resultCount.textContent = allGames.length
|
||||
? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length })
|
||||
: '';
|
||||
document.querySelectorAll('[data-game-view]').forEach(button => {
|
||||
const active = button.dataset.gameView === state.gameView;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
|
||||
for (const g of list) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'game-row';
|
||||
tr.dataset.id = g.game_id;
|
||||
// Compact status cell: readable tags instead of cryptic symbols
|
||||
const patchesOnly = g.backup_count === 0 && g.backpork_count > 0;
|
||||
const _hs = _gameHashSummary.get(g.game_id);
|
||||
const hashTag = _hs
|
||||
? _hs.community_mismatch
|
||||
? `<span class="s-tag s-hash-fail" title="⚠ Community hash check FAILED — files may be modified or contain brick code">⚠ Hash Fail</span>`
|
||||
: _hs.community_matches > 0
|
||||
? `<span class="s-tag s-hash-ok" title="Community verified: ${_hs.community_matches}/${_hs.hash_count} files match known-good hashes">✓ Verified</span>`
|
||||
: `<span class="s-tag s-hash-local" title="${_hs.hash_count} file(s) hashed — not yet in community list">⧭ Hashed</span>`
|
||||
: '';
|
||||
const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean);
|
||||
const patchedTag = fwLabels.length
|
||||
? `<span class="s-tag s-patched" title="Backporked for: ${escHtml(fwLabels.join(', '))}">🔧 ${escHtml(fwLabels.join(', '))}</span>`
|
||||
: '';
|
||||
const shadowTag = g.shadow_mount
|
||||
? `<span class="s-tag ${g.shadow_mount.state === 'installed' ? 's-backed' : 's-warn'}" title="ShadowMount: ${escHtml(g.shadow_mount.sources)} source(s) · ${escHtml((g.shadow_mount.formats || []).join(', ') || 'unknown format')}${g.shadow_mount.experimental ? ' · experimental format' : ''}">◉ Mount ${escHtml(g.shadow_mount.state)}</span>`
|
||||
: '';
|
||||
const metadataTag = `<span class="s-tag ${g.metadata?.source === 'manual' ? 's-patched' : 's-num'}" title="Metadata: ${escHtml(g.metadata?.source || 'unknown')} · ${escHtml(window.t('games.metadataConfidence', 'confidence'))} ${escHtml(g.metadata?.confidence || 'unknown')}">Meta ${escHtml(g.metadata?.source || '?')}</span>`;
|
||||
const variantTag = g.regional_variants?.length
|
||||
? `<span class="s-tag s-num" title="${escHtml(g.regional_variants.map(item => `${item.titleId}${item.region ? ` (${item.region})` : ''}`).join(' · '))}">${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}</span>`
|
||||
: '';
|
||||
const platform = gamePlatform(g);
|
||||
const platformLabel = platform === 'unknown'
|
||||
? window.t('games.platform.unknown', 'Unknown')
|
||||
: platform.toUpperCase();
|
||||
const platformTag = `<span class="s-tag s-platform-${platform}">${escHtml(platformLabel)}</span>`;
|
||||
const requiredDrives = disconnectedDriveLabels(g);
|
||||
const requiredDriveTag = requiredDrives.length
|
||||
? `<span class="s-tag s-warn s-required-drive" title="${escHtml(window.t('games.requiredDrive.title', 'Reconnect the named drive to access this copy'))}">${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}</span>`
|
||||
: '';
|
||||
const statusHtml = `<div class="game-status-col">
|
||||
${platformTag}
|
||||
${requiredDriveTag}
|
||||
${window.chimeraLibraryModel.isInstalled(g) ? `<span class="s-tag s-installed" title="${escHtml(window.t('games.status.installedTitle', 'Currently installed on the PS5'))}">${escHtml(window.t('games.status.installed', 'Installed'))}</span>` : ''}
|
||||
${g.backup_count > 0 ? `<span class="s-tag s-num" title="${g.backup_count} ${escHtml(window.t('games.status.localBackups', 'local backup(s)'))}">${g.backup_count} bkp</span>` : ''}
|
||||
${window.chimeraLibraryModel.hasLocalCopy(g) ? `<span class="s-tag s-backed" title="${escHtml(window.t('games.status.backedUpTitle', 'Local copy available'))}">✓</span>` : ''}
|
||||
${patchesOnly ? `<span class="s-tag s-warn" title="${escHtml(window.t('games.status.patchesOnlyTitle', 'Only Backpork patches were found; no local game backup exists'))}">${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}</span>` : ''}
|
||||
${patchedTag}
|
||||
${shadowTag}
|
||||
${hashTag}
|
||||
${metadataTag}
|
||||
${variantTag}
|
||||
</div>`;
|
||||
const displayTitle = g.prospero_name || g.title || g.game_id;
|
||||
const metadataLine = [
|
||||
g.prospero_publisher,
|
||||
g.prospero_region,
|
||||
g.content_id || g.prospero_content_id,
|
||||
].filter(Boolean).join(' · ');
|
||||
const coverUrl = g.prospero_cover_url || '';
|
||||
const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at);
|
||||
const iconFallbackAttribute = g.prospero_icon_url
|
||||
? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"`
|
||||
: '';
|
||||
const coverPlaceholder = `<div class="game-card-cover-fallback" hidden aria-hidden="true"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>`;
|
||||
const cover = renderedCoverUrl
|
||||
? `<div class="game-card-cover-case has-native-cover">
|
||||
<img src="${escHtml(renderedCoverUrl)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback"${iconFallbackAttribute}>
|
||||
${coverPlaceholder}
|
||||
</div>`
|
||||
: g.prospero_icon_url
|
||||
? `<img src="${escHtml(g.prospero_banner_url || g.prospero_icon_url)}" class="game-card-cover-backdrop" alt="" aria-hidden="true" loading="lazy" decoding="async">
|
||||
<div class="game-card-cover-case">
|
||||
<span class="game-card-cover-platform">${escHtml(platformLabel)}</span>
|
||||
<img src="${escHtml(g.prospero_icon_url)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback">
|
||||
${coverPlaceholder}
|
||||
<div class="game-card-cover-caption"><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>
|
||||
</div>`
|
||||
: `<div class="game-card-cover-fallback is-visible" aria-label="${escHtml(window.t?.('games.coverMissing', 'Cover unavailable') || 'Cover unavailable')}"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id || '?')}</small></div>`;
|
||||
const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url);
|
||||
const metadataState = metadataSynced
|
||||
? window.t('games.metadata.synced', 'Metadata: synced')
|
||||
: window.t('games.metadata.enriching', 'Metadata: enriching');
|
||||
const card = document.createElement('article');
|
||||
card.className = 'game-card';
|
||||
card.dataset.id = g.game_id;
|
||||
card.dataset.platform = platform;
|
||||
card.style.setProperty('--card-hue', String(titleHue(g.game_id)));
|
||||
card.tabIndex = 0;
|
||||
card.innerHTML = `
|
||||
<div class="game-card-art">${cover}
|
||||
${metadataSynced ? '' : `<span class="game-card-sync pending" title="${escHtml(metadataState)}">${escHtml(metadataState)}</span>`}
|
||||
<div class="game-card-actions">
|
||||
${patchesOnly ? '' : `<button class="btn btn-sm btn-accent" data-action="install" data-id="${escHtml(g.game_id)}" data-i18n="games.install">Plan install</button>`}
|
||||
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" data-i18n="games.metadata">Metadata</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="game-card-body">
|
||||
<div class="game-card-heading">
|
||||
<strong title="${escHtml(displayTitle)}">${escHtml(displayTitle)}</strong>
|
||||
</div>
|
||||
<span class="game-card-meta">${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))}</span>
|
||||
${statusHtml}
|
||||
</div>`;
|
||||
const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover');
|
||||
if (nativeCover) {
|
||||
const applyNaturalCoverRatio = () => {
|
||||
const width = Number(nativeCover.naturalWidth || 0);
|
||||
const height = Number(nativeCover.naturalHeight || 0);
|
||||
const ratio = height > 0 ? width / height : 0;
|
||||
if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) {
|
||||
card.style.setProperty('--game-cover-ratio', `${width} / ${height}`);
|
||||
}
|
||||
};
|
||||
if (nativeCover.complete) applyNaturalCoverRatio();
|
||||
else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true });
|
||||
}
|
||||
grid.appendChild(card);
|
||||
if (patchesOnly) tr.classList.add('game-row-patches-only');
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="game-title-cell">
|
||||
${renderedCoverUrl || g.prospero_icon_url
|
||||
? `<img src="${escHtml(renderedCoverUrl || g.prospero_icon_url)}" class="game-row-icon${renderedCoverUrl ? ' is-portrait' : ''}" alt="${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}" loading="lazy" decoding="async" data-image-error="fallback"${renderedCoverUrl && g.prospero_icon_url ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` : ''}>`
|
||||
: '<div class="game-row-icon game-row-icon-empty"></div>'}
|
||||
${renderedCoverUrl || g.prospero_icon_url ? '<div class="game-row-icon game-row-icon-empty" hidden></div>' : ''}
|
||||
<span class="game-title-copy">
|
||||
<strong>${escHtml(g.prospero_name || g.title || g.game_id)}</strong>
|
||||
<small>${escHtml([
|
||||
g.prospero_publisher,
|
||||
g.prospero_region,
|
||||
g.content_id || g.prospero_content_id,
|
||||
].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>${escHtml(g.game_id)}</code></td>
|
||||
<td>${escHtml(g.version || g.prospero_version || '—')}</td>
|
||||
<td>${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))}</td>
|
||||
<td>${renderFwPills(g.firmware_labels)}</td>
|
||||
<td>${statusHtml}</td>
|
||||
<td class="game-actions-cell">
|
||||
${patchesOnly
|
||||
? `<span class="patches-only-hint" title="${escHtml(window.t('games.noBackupDetail', 'No game backup was found; only patch folders exist. Create or download a base-game backup first.'))}">⚠ ${escHtml(window.t('games.noBackup', 'No game backup'))}</span>`
|
||||
: `<button class="btn btn-sm btn-teal" data-action="install" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.installQueueTitle', 'Add to the PS5 installation queue'))}">
|
||||
⬆ ${escHtml(window.t('games.install', 'Plan install'))}
|
||||
</button>
|
||||
<button class="btn btn-sm" data-action="convert" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.convertTitle', 'Convert the game and optionally upload it to the PS5'))}">
|
||||
⚙ ${escHtml(window.t('games.convert', 'Convert'))}
|
||||
</button>`
|
||||
}
|
||||
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.metadataEditTitle', 'Correct the title, artwork and description manually'))}">Metadata</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Click delegation: action buttons take priority; otherwise the card/row opens detail.
|
||||
const handleLibraryClick = async e => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (btn) {
|
||||
try {
|
||||
if (btn.dataset.action === 'install') {
|
||||
await openInstallModal(btn.dataset.id, 'pfs');
|
||||
} else if (btn.dataset.action === 'convert') {
|
||||
const s = await window.pork.getSettings();
|
||||
const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode)
|
||||
? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg')
|
||||
: s.gameConversionMode;
|
||||
await openInstallModal(btn.dataset.id, mode);
|
||||
} else if (btn.dataset.action === 'metadata') {
|
||||
await editGameMetadata(btn.dataset.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(error.message, 'error');
|
||||
showToast(error.message, 'error', 9000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest('[data-id].game-row, [data-id].game-card');
|
||||
if (item) openModal(item.dataset.id);
|
||||
};
|
||||
tbody.onclick = handleLibraryClick;
|
||||
grid.onclick = handleLibraryClick;
|
||||
grid.onkeydown = e => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) {
|
||||
e.preventDefault();
|
||||
const card = e.target.closest('.game-card[data-id]');
|
||||
if (card) openModal(card.dataset.id);
|
||||
}
|
||||
};
|
||||
window.i18n?.apply(grid);
|
||||
window.i18n?.apply(tbody);
|
||||
}
|
||||
|
||||
async function editGameMetadata(gameId) {
|
||||
const game = state.games.find(item => item.game_id === gameId);
|
||||
if (!game) return;
|
||||
const hasOverride = Boolean(game.metadata?.overriddenFields?.length);
|
||||
if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) {
|
||||
await window.pork.updateGameMetadata(gameId, {});
|
||||
await loadGames();
|
||||
setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok');
|
||||
return;
|
||||
}
|
||||
const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || ''));
|
||||
const title = await ask('Volledige titel', game.prospero_name || game.title);
|
||||
if (title == null) return;
|
||||
const publisher = await ask('Uitgever', game.prospero_publisher);
|
||||
if (publisher == null) return;
|
||||
const region = await ask('Regio', game.prospero_region);
|
||||
if (region == null) return;
|
||||
const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url);
|
||||
if (iconUrl == null) return;
|
||||
if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) {
|
||||
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
|
||||
}
|
||||
const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url);
|
||||
if (coverUrl == null) return;
|
||||
if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) {
|
||||
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
|
||||
}
|
||||
const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description);
|
||||
if (description == null) return;
|
||||
await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description });
|
||||
await loadGames();
|
||||
setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success');
|
||||
}
|
||||
|
||||
// Sorting
|
||||
document.querySelectorAll('#games-table th[data-col]').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; }
|
||||
localStorage.setItem('chimera.games.sortCol', state.sortCol);
|
||||
localStorage.setItem('chimera.games.sortDir', state.sortDir);
|
||||
renderGames();
|
||||
});
|
||||
});
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('.fbtn').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.filter = btn.dataset.filter;
|
||||
state.gameRenderLimit = 160;
|
||||
localStorage.setItem('chimera.games.filter', state.filter);
|
||||
renderGames();
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-game-platform]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform)
|
||||
? button.dataset.gamePlatform
|
||||
: 'all';
|
||||
state.gameRenderLimit = 160;
|
||||
localStorage.setItem('chimera.games.platform', state.platformFilter);
|
||||
renderGames();
|
||||
});
|
||||
});
|
||||
|
||||
// Search
|
||||
$('games-search').addEventListener('input', e => {
|
||||
state.search = e.target.value;
|
||||
state.gameRenderLimit = 160;
|
||||
renderGames();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-game-view]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid';
|
||||
localStorage.setItem('chimera.games.view', state.gameView);
|
||||
renderGames();
|
||||
});
|
||||
});
|
||||
|
||||
$('btn-games-load-more')?.addEventListener('click', () => {
|
||||
state.gameRenderLimit += 160;
|
||||
renderGames();
|
||||
});
|
||||
|
||||
$('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click());
|
||||
$('btn-games-empty-reset')?.addEventListener('click', () => {
|
||||
state.search = '';
|
||||
state.filter = 'all';
|
||||
state.platformFilter = 'all';
|
||||
state.firmwareFilter = 'all';
|
||||
state.gameRenderLimit = 160;
|
||||
$('games-search').value = '';
|
||||
localStorage.setItem('chimera.games.filter', 'all');
|
||||
localStorage.setItem('chimera.games.platform', 'all');
|
||||
document.querySelectorAll('.fwbtn').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.fw === 'all');
|
||||
});
|
||||
renderGames();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.fbtn').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.filter === state.filter);
|
||||
});
|
||||
|
||||
window.addEventListener('chimera:language-changed', () => renderGames());
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// ── Hash verification ──────────────────────────────────────────────────────────
|
||||
let hashingInProgress = false;
|
||||
|
||||
window.pork.on('hash:progress', ({ percent }) => {
|
||||
const fill = $('hash-progress-fill');
|
||||
const label = $('hash-progress-label');
|
||||
if (fill) fill.style.width = `${percent}%`;
|
||||
if (label) label.textContent = `Hashing… ${percent}%`;
|
||||
});
|
||||
|
||||
async function renderHashSection(game_id, backups) {
|
||||
const section = $('modal-hash-section');
|
||||
const badge = $('modal-hash-community-badge');
|
||||
badge.hidden = true;
|
||||
badge.className = 'hash-badge'; // reset class each render
|
||||
|
||||
// Include both local and backpork backups — all game files deserve verification.
|
||||
if (!backups.length) { section.hidden = true; return; }
|
||||
section.hidden = false;
|
||||
|
||||
const { localHashes, communityMatches, communityMismatch } = await window.pork.hashStatus(game_id);
|
||||
const communitySet = new Set(communityMatches.map(h => h.file_hash));
|
||||
|
||||
// Badge above hash list: mismatch warning takes priority over verified check.
|
||||
const allInCommunity = localHashes.length > 0 && communityMatches.length === localHashes.length;
|
||||
if (communityMismatch && localHashes.length > 0) {
|
||||
badge.hidden = false;
|
||||
badge.className = 'hash-badge hash-mismatch';
|
||||
badge.textContent = '⚠ Community Hash Mismatch — files may be modified or contain brick code';
|
||||
} else if (allInCommunity) {
|
||||
badge.hidden = false;
|
||||
badge.className = 'hash-badge hash-community';
|
||||
badge.textContent = 'Community Verified ✓';
|
||||
}
|
||||
|
||||
// Each backup is a folder — show aggregate hash status for the whole folder.
|
||||
$('modal-hash-list').innerHTML = backups.map(b => {
|
||||
const folderName = b.backup_path.replace(/\\/g, '/').split('/').pop();
|
||||
const hashedCount = localHashes.length;
|
||||
const communityCount = communityMatches.length;
|
||||
|
||||
if (hashedCount === 0) {
|
||||
return `
|
||||
<div class="hash-entry">
|
||||
<div class="hash-entry-info">
|
||||
<span class="hash-filename">${escHtml(folderName)}</span>
|
||||
<span class="hash-badge hash-none">Not hashed</span>
|
||||
</div>
|
||||
<div class="hash-entry-status">
|
||||
<button class="btn btn-sm btn-teal hash-btn-add"
|
||||
data-path="${escHtml(b.backup_path)}"
|
||||
data-game="${escHtml(game_id)}"
|
||||
data-source="${escHtml(b.source || 'local')}">Hash All Files</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const allCommunity = communityCount === hashedCount;
|
||||
const statusBadge = allCommunity
|
||||
? `<span class="hash-badge hash-community">${communityCount} file${communityCount !== 1 ? 's' : ''} community ✓</span>`
|
||||
: communityMismatch
|
||||
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch — ${hashedCount} local hash${hashedCount !== 1 ? 'es' : ''} don't match community list</span>`
|
||||
: `<span class="hash-badge hash-local">${hashedCount} file${hashedCount !== 1 ? 's' : ''} hashed</span>`
|
||||
+ (communityCount > 0 ? ` <span class="hash-badge hash-community">${communityCount} community ✓</span>` : '');
|
||||
|
||||
return `
|
||||
<div class="hash-entry">
|
||||
<div class="hash-entry-info">
|
||||
<span class="hash-filename">${escHtml(folderName)}</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="hash-entry-status">
|
||||
<button class="btn btn-sm hash-btn-reverify"
|
||||
data-path="${escHtml(b.backup_path)}"
|
||||
data-game="${escHtml(game_id)}"
|
||||
data-source="${escHtml(b.source || 'local')}">Re-verify All</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function runHash(backup_path, game_id, verified = 1, hash_type = 'game') {
|
||||
if (hashingInProgress) return;
|
||||
hashingInProgress = true;
|
||||
|
||||
// Disable all hash buttons while running
|
||||
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = true);
|
||||
|
||||
const prog = $('modal-hash-progress');
|
||||
const fill = $('hash-progress-fill');
|
||||
fill.style.width = '0%';
|
||||
prog.hidden = false;
|
||||
|
||||
try {
|
||||
const { hashes } = await window.pork.hashCompute(backup_path, game_id);
|
||||
let communityCount = 0;
|
||||
for (const h of hashes) {
|
||||
const { communityMatch } = await window.pork.hashAdd({
|
||||
game_id, file_hash: h.file_hash, file_size: h.file_size, backup_path: h.backup_path, verified, hash_type,
|
||||
});
|
||||
if (communityMatch) communityCount++;
|
||||
}
|
||||
|
||||
prog.hidden = true;
|
||||
if (communityCount > 0) {
|
||||
setStatus(`${hashes.length} file(s) hashed — ${communityCount} community match(es) ✓`, 'ok');
|
||||
} else {
|
||||
setStatus(`${hashes.length} file(s) hashed — will appear in the next community update`, 'ok');
|
||||
}
|
||||
|
||||
// Re-render the hash section with fresh data
|
||||
const backups = await window.pork.listBackups(game_id);
|
||||
await renderHashSection(game_id, backups);
|
||||
} catch (e) {
|
||||
prog.hidden = true;
|
||||
setStatus(`Hash failed: ${e.message}`, 'error');
|
||||
// Re-enable buttons on error
|
||||
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = false);
|
||||
} finally {
|
||||
hashingInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash button delegation — attached once on the hash list container
|
||||
$('modal-hash-list').addEventListener('click', async e => {
|
||||
if (hashingInProgress) return;
|
||||
|
||||
// ── Hash & Add (not yet in DB) ─────────────────────────────────────────────
|
||||
const addBtn = e.target.closest('.hash-btn-add');
|
||||
if (addBtn) {
|
||||
await runHash(addBtn.dataset.path, addBtn.dataset.game, 1, addBtn.dataset.source === 'backpork' ? 'backpork' : 'game');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Re-verify (already in DB) — show inline confirm first ─────────────────
|
||||
const reverifyBtn = e.target.closest('.hash-btn-reverify');
|
||||
if (reverifyBtn) {
|
||||
const statusEl = reverifyBtn.closest('.hash-entry-status');
|
||||
const path = reverifyBtn.dataset.path;
|
||||
const game = reverifyBtn.dataset.game;
|
||||
const source = reverifyBtn.dataset.source;
|
||||
|
||||
// Replace button with inline confirm
|
||||
reverifyBtn.style.display = 'none';
|
||||
const confirm = document.createElement('div');
|
||||
confirm.className = 'hash-confirm';
|
||||
confirm.innerHTML = `
|
||||
<span class="hash-confirm-msg">Game works?</span>
|
||||
<button class="btn btn-sm btn-teal hash-confirm-yes">Yes, verify</button>
|
||||
<button class="btn btn-sm hash-confirm-no">Cancel</button>`;
|
||||
statusEl.appendChild(confirm);
|
||||
|
||||
confirm.querySelector('.hash-confirm-yes').addEventListener('click', async () => {
|
||||
confirm.remove();
|
||||
await runHash(path, game, 1, source === 'backpork' ? 'backpork' : 'game');
|
||||
});
|
||||
confirm.querySelector('.hash-confirm-no').addEventListener('click', () => {
|
||||
confirm.remove();
|
||||
reverifyBtn.style.display = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// ── Accent color picker ────────────────────────────────────────────────────────
|
||||
$('s-accent-color').addEventListener('input', async e => {
|
||||
const hex = e.target.value;
|
||||
applyAccent(hex);
|
||||
$('s-accent-hex').value = hex.toUpperCase();
|
||||
await window.pork.setSettings({ accentColor: hex });
|
||||
});
|
||||
|
||||
// Hex text field — let user type a hex value directly
|
||||
$('s-accent-hex').addEventListener('input', e => {
|
||||
const raw = e.target.value.trim();
|
||||
const hex = raw.startsWith('#') ? raw : '#' + raw;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
applyAccent(hex);
|
||||
window.pork.setSettings({ accentColor: hex });
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-reset-accent').addEventListener('click', async () => {
|
||||
const def = '#CBBAFF';
|
||||
applyAccent(def);
|
||||
await window.pork.setSettings({ accentColor: def });
|
||||
setStatus('Accent color reset to default', 'ok');
|
||||
});
|
||||
|
||||
// Confirm dialog helper
|
||||
function showConfirm(message) {
|
||||
return new Promise(resolve => {
|
||||
$('confirm-message').textContent = message;
|
||||
openAccessibleDialog($('confirm-overlay'), $('confirm-ok'));
|
||||
|
||||
function cleanup(result) {
|
||||
closeAccessibleDialog($('confirm-overlay'));
|
||||
$('confirm-ok').removeEventListener('click', onOk);
|
||||
$('confirm-cancel').removeEventListener('click', onCancel);
|
||||
$('confirm-overlay').removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
const onOk = () => cleanup(true);
|
||||
const onCancel = () => cleanup(false);
|
||||
const onKey = event => { if (event.key === 'Escape') onCancel(); };
|
||||
$('confirm-ok').addEventListener('click', onOk);
|
||||
$('confirm-cancel').addEventListener('click', onCancel);
|
||||
$('confirm-overlay').addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
// Prompt dialog helper
|
||||
function showPrompt(message, defaultValue = '', options = {}) {
|
||||
return new Promise(resolve => {
|
||||
$('prompt-message').textContent = message;
|
||||
$('prompt-input').value = defaultValue;
|
||||
$('prompt-dialog').classList.toggle('prompt-dialog-danger', options.tone === 'danger');
|
||||
$('prompt-ok').className = `btn ${options.tone === 'danger' ? 'btn-danger' : 'btn-accent'}`;
|
||||
$('prompt-ok').textContent = options.confirmLabel || (typeof window.t === 'function' ? window.t('common.ok', 'OK') : 'OK');
|
||||
$('prompt-cancel').textContent = typeof window.t === 'function' ? window.t('common.cancel', 'Cancel') : 'Cancel';
|
||||
openAccessibleDialog($('prompt-overlay'), $('prompt-input'));
|
||||
|
||||
function cleanup(result) {
|
||||
closeAccessibleDialog($('prompt-overlay'));
|
||||
$('prompt-ok').removeEventListener('click', onOk);
|
||||
$('prompt-cancel').removeEventListener('click', onCancel);
|
||||
$('prompt-input').removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup($('prompt-input').value); }
|
||||
function onCancel() { cleanup(null); }
|
||||
function onKey(e) { if (e.key === 'Enter') onOk(); else if (e.key === 'Escape') onCancel(); }
|
||||
$('prompt-ok').addEventListener('click', onOk);
|
||||
$('prompt-cancel').addEventListener('click', onCancel);
|
||||
$('prompt-input').addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
async function showExactConfirmation(message, phrase) {
|
||||
const isDutch = window.i18n?.current === 'nl';
|
||||
const englishPhrases = { VERWIJDER: 'DELETE', 'VERWIJDER VAN PS5': 'DELETE FROM PS5', UITSCHAKELEN: 'DISABLE', INTREKKEN: 'REVOKE', HERSTEL: 'RESTORE', 'PAS ETAHEN TOE': 'APPLY ETAHEN', 'WEK PS5': 'WAKE PS5' }; // i18n: server confirmation tokens
|
||||
const visiblePhrase = isDutch ? phrase : (englishPhrases[phrase] || phrase);
|
||||
const instruction = isDutch ? `Typ exact ${visiblePhrase} om door te gaan:` : `Type ${visiblePhrase} exactly to continue:`;
|
||||
const entered = await showPrompt(`${message}\n\n${instruction}`, '', {
|
||||
tone: 'danger',
|
||||
confirmLabel: isDutch ? 'Bevestigen' : 'Confirm',
|
||||
});
|
||||
if (entered === visiblePhrase) return phrase;
|
||||
if (entered !== null) showToast(isDutch ? 'Actie geannuleerd: de bevestiging kwam niet exact overeen.' : 'Action cancelled: the confirmation phrase did not match exactly.', 'info');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Destination picker for upload and conversion
|
||||
|
||||
function showDestinationPicker(paths) {
|
||||
return new Promise(resolve => {
|
||||
const list = $('dest-picker-list');
|
||||
list.innerHTML = paths.map((p, i) => `
|
||||
<button class="dest-picker-option" data-idx="${i}">
|
||||
<span class="dest-opt-label">${escHtml(p.label)}</span>
|
||||
<span class="dest-opt-path">${escHtml(p.path)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
openAccessibleDialog($('dest-picker-overlay'), list.querySelector('.dest-picker-option'));
|
||||
|
||||
let resolved = false;
|
||||
function cleanup(result) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
closeAccessibleDialog($('dest-picker-overlay'));
|
||||
// #dest-picker-cancel is a persistent element; remove our handler so it
|
||||
// doesn't accumulate across opens when the user picks an option instead.
|
||||
$('dest-picker-cancel').removeEventListener('click', onCancel);
|
||||
$('dest-picker-overlay').removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
const onCancel = () => cleanup(null);
|
||||
const onKey = event => { if (event.key === 'Escape') onCancel(); };
|
||||
// Option buttons live in list.innerHTML, which is re-rendered each open, so
|
||||
// their listeners die with the old DOM — no cleanup needed for those.
|
||||
list.querySelectorAll('.dest-picker-option').forEach((btn, i) => {
|
||||
btn.addEventListener('click', () => cleanup(paths[i]), { once: true });
|
||||
});
|
||||
$('dest-picker-cancel').addEventListener('click', onCancel);
|
||||
$('dest-picker-overlay').addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure a game has an ftp_path set (prompting if needed). Returns the path or null if cancelled.
|
||||
async function ensureGameFtpPath(gameId) {
|
||||
const g = state.currentGame?.game_id === gameId ? state.currentGame
|
||||
: (await window.pork.listGames({})).find(x => x.game_id === gameId);
|
||||
|
||||
if (g?.ftp_path) return g.ftp_path;
|
||||
|
||||
// No stored path — pick one
|
||||
let basePath = null;
|
||||
if (remoteGamePaths.length === 0) {
|
||||
// No locations configured — use default, but don't store (user hasn't set up locations)
|
||||
return `/mnt/sandbox/pfsmnt/${gameId}-app0`;
|
||||
} else if (remoteGamePaths.length === 1) {
|
||||
basePath = remoteGamePaths[0].path;
|
||||
} else {
|
||||
const chosen = await showDestinationPicker(remoteGamePaths);
|
||||
if (!chosen) return null;
|
||||
basePath = chosen.path;
|
||||
}
|
||||
|
||||
const ftp_path = basePath.replace(/\/?$/, '/') + gameId + '-app0';
|
||||
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path } });
|
||||
if (state.currentGame?.game_id === gameId) {
|
||||
state.currentGame = { ...state.currentGame, ftp_path };
|
||||
renderManageInstallLocation(state.currentGame);
|
||||
}
|
||||
return ftp_path;
|
||||
}
|
||||
|
||||
$('btn-refresh-all-meta').addEventListener('click', async () => {
|
||||
const ok = await showConfirm(
|
||||
'This will clear all cached Prospero metadata and re-fetch every game from scratch. ' +
|
||||
'Background fetches will queue automatically. Continue?'
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
$('btn-refresh-all-meta').disabled = true;
|
||||
setStatus('Queueing metadata refresh for all games…');
|
||||
try {
|
||||
const { queued } = await window.pork.prosperoRefreshAll();
|
||||
setStatus(`Refreshing metadata for ${queued} game(s) in background…`, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`Refresh failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
$('btn-refresh-all-meta').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Firmware helpers
|
||||
function renderFwPills(firmware_labels) {
|
||||
if (!firmware_labels) return '<span style="color:var(--text-dim)">—</span>';
|
||||
return firmware_labels.split(',').filter(Boolean)
|
||||
.map(l => `<span class="fw-pill fw-pill-patched" title="Backporked for firmware ${escHtml(l)}">🔧 ${escHtml(l)}</span>`).join('');
|
||||
}
|
||||
|
||||
async function refreshFirmwareFilters() {
|
||||
const labels = await window.pork.firmwareLabels();
|
||||
const row = $('firmware-filter-row');
|
||||
const cont = $('firmware-filter-btns');
|
||||
|
||||
if (!labels.length) { row.hidden = true; return; }
|
||||
row.hidden = false;
|
||||
|
||||
cont.innerHTML = `<button class="fwbtn${state.firmwareFilter === 'all' ? ' active' : ''}" data-fw="all">All</button>` +
|
||||
labels.map(l => `<button class="fwbtn${state.firmwareFilter === l ? ' active' : ''}" data-fw="${escHtml(l)}">${escHtml(l)}</button>`).join('');
|
||||
|
||||
cont.onclick = e => {
|
||||
const btn = e.target.closest('.fwbtn');
|
||||
if (!btn) return;
|
||||
state.firmwareFilter = btn.dataset.fw;
|
||||
cont.querySelectorAll('.fwbtn').forEach(b => b.classList.toggle('active', b.dataset.fw === state.firmwareFilter));
|
||||
renderGames();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// ── System View hotkeys ────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
// Escape always exits in-app fullscreen, regardless of active page
|
||||
if (e.key === 'Escape' && $('sv-viewer-wrap')?.classList.contains('sv-viewer-wrap--fullscreen')) {
|
||||
e.preventDefault();
|
||||
svToggleFullscreen();
|
||||
return;
|
||||
}
|
||||
if (!$('page-system-view')?.classList.contains('active')) return;
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === _svHotkeys.mute) { e.preventDefault(); $('btn-sv-mute').click(); }
|
||||
else if (k === _svHotkeys.gif) { e.preventDefault(); $('btn-sv-record').click(); }
|
||||
else if (k === _svHotkeys.video) { e.preventDefault(); $('btn-sv-vid-rec').click(); }
|
||||
else if (k === _svHotkeys.fullscreen) { e.preventDefault(); svToggleFullscreen(); }
|
||||
else if (k === _svHotkeys.popout) { e.preventDefault(); $('btn-sv-popout').click(); }
|
||||
});
|
||||
|
||||
// Auto-scan toast on launch
|
||||
window.pork.on('local:scan:complete', async ({ total, added }) => {
|
||||
if (added > 0) {
|
||||
showToast(`Auto-scan: found ${added} new game${added !== 1 ? 's' : ''} (${total} total) — fetching metadata…`, 'ok');
|
||||
} else if (total > 0) {
|
||||
showToast(`Auto-scan: ${total} game${total !== 1 ? 's' : ''} already tracked`, 'info');
|
||||
}
|
||||
// Always refresh the active page so pruned/stale game rows clear from the UI
|
||||
// even when 0 games were found this scan.
|
||||
const activePage = document.querySelector('.page.active')?.id;
|
||||
if (activePage === 'page-dashboard') loadDashboard();
|
||||
if (activePage === 'page-games') loadGames();
|
||||
if (activePage === 'page-backups') loadBackups();
|
||||
});
|
||||
|
||||
// Toast when Prospero background queue finishes
|
||||
window.pork.on('prospero:queue:done', ({ fetched, failed }) => {
|
||||
if (fetched > 0 && failed === 0) {
|
||||
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''}`, 'ok');
|
||||
} else if (fetched > 0) {
|
||||
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''} (${failed} failed)`, 'info');
|
||||
} else if (failed > 0) {
|
||||
showToast(`Metadata fetch failed for ${failed} game${failed !== 1 ? 's' : ''}`, 'error', 8000);
|
||||
}
|
||||
});
|
||||
|
||||
// Live-refresh modal when background fetch completes
|
||||
window.pork.on('prospero:updated', async ({ game_id, data }) => {
|
||||
// Reload games list in background so future opens have fresh data
|
||||
state.games = await window.pork.listGames({});
|
||||
renderGames(); // Redraw table so icon/name/metadata show without requiring a page nav
|
||||
|
||||
// Update cheat icon map and any visible cheat card for this game
|
||||
if (data.iconUrl) {
|
||||
_cheatIconMap.set(game_id, data.iconUrl);
|
||||
document.querySelectorAll(`.cheat-card[data-cusa="${game_id}"]`).forEach(card => {
|
||||
const empty = card.querySelector('.cheat-card-icon-empty');
|
||||
if (!empty) return;
|
||||
const img = document.createElement('img');
|
||||
img.className = 'cheat-card-icon';
|
||||
img.alt = '';
|
||||
img.src = data.iconUrl;
|
||||
img.onerror = () => { img.style.display = 'none'; };
|
||||
empty.replaceWith(img);
|
||||
});
|
||||
}
|
||||
|
||||
// If the modal is open for this game, refresh it
|
||||
if (state.currentGame?.game_id === game_id) {
|
||||
const updated = state.games.find(g => g.game_id === game_id);
|
||||
if (updated) {
|
||||
state.currentGame = updated;
|
||||
$('modal-title').textContent = updated.prospero_name || updated.title || game_id;
|
||||
$('modal-subtitle').textContent = `${updated.game_id} · ${updated.prospero_region || (updated.version ? 'v' + updated.version : '')}`;
|
||||
|
||||
// Refresh info table with new metadata
|
||||
const patches = tryParseJson(updated.prospero_patches, []);
|
||||
const fwVersions = [...new Set(patches.map(p => p.requiredFirmware).filter(Boolean))];
|
||||
const dlc = tryParseJson(updated.prospero_dlc, []);
|
||||
const infoRows = [
|
||||
['Game ID', updated.game_id],
|
||||
['Content ID', updated.content_id || updated.prospero_content_id || '—'],
|
||||
['Version', updated.version || updated.prospero_version || '—'],
|
||||
['Size', updated.backup_size ? fmt(updated.backup_size) : (updated.size ? fmt(updated.size) : (updated.prospero_size || '—'))],
|
||||
['Installed', updated.installed ? 'Yes' : 'No'],
|
||||
['Backed Up', updated.backed_up ? 'Yes' : 'No'],
|
||||
['Prepared for firmware', updated.porked_firmware ? `${updated.porked_firmware}${updated.porked_at ? ' (' + new Date(updated.porked_at).toLocaleDateString() + ')' : ''}` : '—'],
|
||||
['Publisher', updated.prospero_publisher || '—'],
|
||||
['Publisher ID', updated.prospero_publisher_id || '—'],
|
||||
['Region', updated.prospero_region || '—'],
|
||||
['Last Updated', updated.prospero_last_updated || '—'],
|
||||
['Required Firmware', fwVersions.length ? fwVersions.join(', ') : '—'],
|
||||
['DLC Available', dlc.length ? `${dlc.length} item(s)` : 'None'],
|
||||
['FTP Path', updated.ftp_path || '—'],
|
||||
['Last Scanned', updated.last_scanned ? new Date(updated.last_scanned).toLocaleString() : '—'],
|
||||
['Metadata Age', updated.prospero_fetched_at ? fmtAge(Date.now() - updated.prospero_fetched_at) : 'Never fetched'],
|
||||
];
|
||||
$('modal-info').innerHTML = infoRows.map(([k, v]) => `<tr><th>${escHtml(k)}</th><td>${escHtml(v)}</td></tr>`).join('');
|
||||
|
||||
renderProsperoData(updated);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// ── Chimera i18n engine ───────────────────────────────────────────────────────
|
||||
// Loaded first so window.t() is available to all modules.
|
||||
// Call window.i18n.load(code) to fetch a lang file and re-apply all translations.
|
||||
|
||||
window.chimeraI18nReady = new Promise(resolve => {
|
||||
window.chimeraResolveI18nReady = resolve;
|
||||
});
|
||||
|
||||
window.i18n = {
|
||||
current: 'en',
|
||||
strings: {},
|
||||
_observer: null,
|
||||
|
||||
/** Translate a key. Falls back to `fallback` if provided, then the key itself. */
|
||||
t(key, fallback) {
|
||||
const v = this.strings[key];
|
||||
if (v !== undefined) return v;
|
||||
return fallback !== undefined ? fallback : key;
|
||||
},
|
||||
|
||||
/** Walk every [data-i18n] element and set its textContent to the translated string.
|
||||
* If no translation exists for a key the original text is left unchanged. */
|
||||
apply(root = document) {
|
||||
document.documentElement.lang = this.current || 'en';
|
||||
const select = selector => [
|
||||
...(root.matches?.(selector) ? [root] : []),
|
||||
...(root.querySelectorAll?.(selector) || []),
|
||||
];
|
||||
select('[data-i18n]').forEach(el => {
|
||||
const val = this.strings[el.dataset.i18n];
|
||||
if (val !== undefined) el.textContent = val;
|
||||
});
|
||||
select('[data-i18n-placeholder]').forEach(el => {
|
||||
const val = this.strings[el.dataset.i18nPlaceholder];
|
||||
if (val !== undefined) el.placeholder = val;
|
||||
});
|
||||
select('[data-i18n-title]').forEach(el => {
|
||||
const val = this.strings[el.dataset.i18nTitle];
|
||||
if (val !== undefined) el.title = val;
|
||||
});
|
||||
select('[data-i18n-aria-label]').forEach(el => {
|
||||
const val = this.strings[el.dataset.i18nAriaLabel];
|
||||
if (val !== undefined) el.setAttribute('aria-label', val);
|
||||
});
|
||||
if (!this._observer && document.body) {
|
||||
this._observer = new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) this.apply(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
this._observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
},
|
||||
|
||||
/** Load a language file by code (e.g. "en", "fr", "de") via IPC, then apply. */
|
||||
async load(code) {
|
||||
if (!code) code = 'en';
|
||||
try {
|
||||
const data = await window.pork.langLoad(code);
|
||||
if (data && typeof data.strings === 'object') {
|
||||
this.strings = data.strings;
|
||||
this.current = data.meta?.code || code;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Could not load language "' + code + '":', e.message || e);
|
||||
// Fall back to empty (original HTML text remains visible)
|
||||
this.strings = {};
|
||||
this.current = 'en';
|
||||
}
|
||||
this.apply();
|
||||
window.dispatchEvent(new CustomEvent('chimera:language-changed', {
|
||||
detail: { code: this.current },
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
/** Shorthand global translate function */
|
||||
window.t = (key, fallback) => window.i18n.t(key, fallback);
|
||||
window.tf = (key, fallback, values = {}) => Object.entries(values).reduce(
|
||||
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
||||
window.i18n.t(key, fallback),
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
"use strict";!function(t,e){const a=e();"object"==typeof module&&module.exports&&(module.exports=a),t?.document&&t.addEventListener("DOMContentLoaded",()=>a.mount(t))}("undefined"!=typeof window?window:null,function(){const t=["upload","inspect","recipe","destination","finalize"];function e(t){return Array.from(t||[]).map(t=>{return{file:t,relativePath:(e=t.webkitRelativePath||t.name,String(e||"").replace(/\\/g,"/").replace(/^\/+/,"").split("/").filter(t=>t&&"."!==t&&".."!==t).join("/")),size:Number(t.size)};var e}).filter(t=>t.relativePath&&Number.isSafeInteger(t.size)&&t.size>0)}function a(t,e,a){return e?(t+(a?-1:1)+e)%e:-1}function i(t){const e=Array.isArray(t?.files)?t.files:[],a=e.reduce((t,e)=>t+Number(e.size||0),0),i=e.reduce((t,e)=>t+Number(e.offset??e.bytesReceived??0),0);return{resumable:["created","uploading"].includes(t?.state),expected:a,received:i,percent:a?Math.min(100,Math.round(i/a*100)):0}}function r(t){return 0!==t.step||t.selection?.length||t.session?t.step>=2&&"classified"!==t.classification?.status?"importWizard.reviewRequired":t.step>=3&&!t.destinationId?"importWizard.error.destination":4!==t.step||t.confirmed?null:"importWizard.error.confirm":"importWizard.error.selection"}const o=t=>String(t??"").replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[t])),s=t=>{const e=Number(t);if(!Number.isFinite(e))return"unknown";const a=["B","KiB","MiB","GiB","TiB"];let i=0,r=e;for(;r>=1024&&i<a.length-1;)r/=1024,i+=1;return`${r.toFixed(i?1:0)} ${a[i]}`};async function n(t){const e=await crypto.subtle.digest("SHA-256",await t.arrayBuffer());return Array.from(new Uint8Array(e),t=>t.toString(16).padStart(2,"0")).join("")}return Object.freeze({deriveResumeState:i,mount:function(d){const l=d.pork;if(d.document.getElementById("chimera-import-open"))return;const c=(t,e)=>d.i18n?.t?.(t,e)||e,p={step:0,selection:[],session:null,job:null,classification:null,order:[],patchIds:new Set,artworkIds:new Set,installNotes:"",destinations:[],destinationId:"",preview:null,uploads:[],jobs:[],busy:!1,confirmed:!1,status:"",error:""};let m=null;const u=d.document.createElement("button");u.id="chimera-import-open",u.type="button",u.className="btn btn-accent chimera-import-launcher",u.style.cssText="position:fixed;right:22px;bottom:22px;z-index:1150",u.dataset.i18n="importWizard.open",u.textContent=c("importWizard.open","Managed import"),d.document.body.appendChild(u);const f=d.document.createElement("div");f.id="import-wizard-overlay",f.className="import-wizard-overlay",f.hidden=!0,f.innerHTML='<section class="import-wizard" role="dialog" aria-modal="true" aria-labelledby="import-wizard-title" aria-describedby="import-wizard-subtitle"><header class="import-wizard-head"><div><span class="import-wizard-kicker">QUARANTINE / VERIFY / COMMIT</span><h2 id="import-wizard-title" data-i18n="importWizard.title">Managed Import</h2><p id="import-wizard-subtitle" data-i18n="importWizard.subtitle">Upload, inspect and finalize without executing package content.</p></div><button type="button" class="import-wizard-close" data-wizard-close aria-label="Close import wizard" data-i18n-aria-label="importWizard.close">x</button></header><div class="import-wizard-steps" role="list"></div><main class="import-wizard-body"></main><footer class="import-wizard-footer"><div class="import-wizard-status" role="status" aria-live="polite"></div><div class="import-wizard-footer-actions"><button type="button" class="btn" data-wizard-previous data-i18n="importWizard.previous">Previous</button><button type="button" class="btn btn-accent" data-wizard-next data-i18n="importWizard.next">Next</button></div></footer><input id="import-wizard-files" type="file" multiple hidden><input id="import-wizard-folder" type="file" webkitdirectory multiple hidden></section>',d.document.body.appendChild(f);const b=f.querySelector(".import-wizard-body"),v=f.querySelector(".import-wizard-status"),h=()=>({installNotes:p.installNotes,componentOrder:p.order,patchComponentIds:[...p.patchIds],artworkComponentIds:[...p.artworkIds]}),w=()=>p.classification?.components?.reduce((t,e)=>t+Number(e.size||0),0)||p.selection.reduce((t,e)=>t+e.size,0);function z(){const t=p.session?i(p.session):{percent:0,received:0,expected:w()};b.innerHTML=`<div class="import-wizard-drop"><p data-i18n="importWizard.choose">Choose multiple files or a complete folder. Files stay quarantined until every validation succeeds.</p><button class="import-wizard-choice" type="button" data-choose-files data-i18n="importWizard.files">Files</button><button class="import-wizard-choice" type="button" data-choose-folder data-i18n="importWizard.folder">Folder</button></div>${p.selection.length?`<div class="import-wizard-progress" aria-label="${o(c("importWizard.progress","Upload progress"))}"><span style="width:${t.percent}%"></span></div><p>${p.selection.length} files - ${s(w())}</p><button class="btn btn-accent" type="button" data-start-upload data-i18n="importWizard.startUpload">Start re
Line truncated
|
||||
@@ -0,0 +1,114 @@
|
||||
// ── External links — open in default browser via shell ────────────────────────
|
||||
document.addEventListener('click', e => {
|
||||
const link = e.target.closest('a.external-link[data-url]');
|
||||
if (link) { e.preventDefault(); window.pork.openShell(link.dataset.url); }
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const button = e.target.closest('[data-payload-target]');
|
||||
if (!button) return;
|
||||
const target = document.getElementById(button.dataset.payloadTarget);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
target.classList.add('payload-workspace-highlight');
|
||||
window.setTimeout(() => target.classList.remove('payload-workspace-highlight'), 1200);
|
||||
});
|
||||
|
||||
// Pin buttons
|
||||
document.addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.btn-pin');
|
||||
if (!btn) return;
|
||||
e.stopPropagation(); // prevent tile nav from firing if btn is inside a tile
|
||||
const pinId = btn.dataset.pinId;
|
||||
if (!pinId) return;
|
||||
const s = await window.pork.getSettings();
|
||||
const pinned = new Set(s.pinnedCards || []);
|
||||
if (pinned.has(pinId)) pinned.delete(pinId);
|
||||
else pinned.add(pinId);
|
||||
await window.pork.setSettings({ pinnedCards: [...pinned] });
|
||||
// Update pin button visuals everywhere
|
||||
document.querySelectorAll(`.btn-pin[data-pin-id="${pinId}"]`).forEach(b =>
|
||||
b.classList.toggle('pinned', pinned.has(pinId))
|
||||
);
|
||||
// If on dashboard, refresh pinned grid immediately
|
||||
if (document.querySelector('#page-dashboard.active')) renderPinnedTiles();
|
||||
});
|
||||
|
||||
// Dashboard tiles
|
||||
document.addEventListener('click', e => {
|
||||
const tile = e.target.closest('.dash-pinned-tile[data-nav-page]');
|
||||
if (tile) navigate(tile.dataset.navPage);
|
||||
});
|
||||
|
||||
// Title bar
|
||||
$('btn-min').addEventListener('click', () => window.pork.minimize());
|
||||
$('btn-max').addEventListener('click', () => window.pork.maximize());
|
||||
$('btn-cls').addEventListener('click', () => window.pork.close());
|
||||
$('sidebar-donate').addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
if (typeof window.chimeraOpenAbout === 'function') window.chimeraOpenAbout();
|
||||
else window.pork.openDonate();
|
||||
});
|
||||
$('titlebar-x-link').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
showToast('Chimera · private PS4/PS5 operations hub', 'info');
|
||||
});
|
||||
|
||||
// Conversion jobs
|
||||
window.pork.on('conv:job-update', (job) => {
|
||||
if (document.querySelector('#page-games.active')) {
|
||||
patchConvJobCard(job);
|
||||
}
|
||||
});
|
||||
window.pork.on('conv:queue:paused-change', () => {
|
||||
if (document.querySelector('#page-games.active')) {
|
||||
refreshConvQueuePanel().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
(async function init() {
|
||||
// Load just the startup essentials. Heavy page-specific setup is deferred
|
||||
// until the user actually opens those pages.
|
||||
const s = await window.pork.getSettings().catch(() => ({}));
|
||||
applyDensity(s.interfaceDensity);
|
||||
try {
|
||||
await window.i18n.load(s.language || 'en');
|
||||
} finally {
|
||||
window.chimeraResolveI18nReady?.();
|
||||
delete window.chimeraResolveI18nReady;
|
||||
}
|
||||
await loadDashboard();
|
||||
|
||||
// Restore FTP connection state immediately so the UI isn't blank while devices enumerate.
|
||||
let connected = false;
|
||||
try {
|
||||
({ connected } = await window.pork.ftpStatus());
|
||||
setConnected(connected);
|
||||
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
|
||||
} catch (_) {}
|
||||
|
||||
if (!connected && s.autoConnect && s.ftpHost) {
|
||||
setStatus('Auto-connecting…');
|
||||
try {
|
||||
const autoPorts = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts : ['1337', '2121', '21'];
|
||||
await window.pork.ftpConnect({
|
||||
host: s.ftpHost,
|
||||
ports: autoPorts,
|
||||
user: s.ftpUser || '',
|
||||
password: s.ftpPass || '',
|
||||
});
|
||||
setConnected(true);
|
||||
setStatus('Connected', 'ok');
|
||||
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
|
||||
} catch (_) {
|
||||
setConnected(false);
|
||||
setStatus('Auto-connect failed — enter credentials and connect manually', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleBackgroundWarm(1200);
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
(function installDestinationSelector() {
|
||||
'use strict';
|
||||
|
||||
let options = [];
|
||||
let requiredBytes = 0;
|
||||
|
||||
const esc = value => String(value == null ? '' : value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const bytes = value => value == null ? 'unknown' : value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)} GB` : value >= 1024 ** 2 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${Math.max(0, value)} B`;
|
||||
const age = value => value == null ? 'unknown age' : value < 60_000 ? 'just now' : value < 3_600_000 ? `${Math.ceil(value / 60_000)} min old` : `${Math.ceil(value / 3_600_000)} h old`;
|
||||
const reason = code => ({
|
||||
'offline-volume': 'offline',
|
||||
'install-capability-disabled': 'install disabled by adapter',
|
||||
'missing-mountpoint': 'mountpoint unavailable',
|
||||
'capacity-unknown': 'capacity unknown',
|
||||
'insufficient-capacity': 'not enough free space',
|
||||
'stale-capacity-telemetry': 'capacity telemetry stale',
|
||||
'stale-capability-telemetry': 'capability telemetry stale',
|
||||
'compatibility-incompatible': 'blocked by applicable compatibility evidence',
|
||||
}[code] || code);
|
||||
|
||||
function selected() {
|
||||
const select = document.getElementById('install-destination-volume');
|
||||
return options.find(item => item.volumeId === select?.value) || null;
|
||||
}
|
||||
|
||||
function expertOpen() {
|
||||
return document.getElementById('install-expert-destination')?.open === true;
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const status = document.getElementById('install-destination-status');
|
||||
const option = selected();
|
||||
if (!status) return;
|
||||
if (option) {
|
||||
status.className = `install-destination-status ${option.eligible ? 'is-eligible' : 'is-ineligible'}`;
|
||||
status.innerHTML = `<strong>${esc(option.eligible ? 'Eligible destination' : 'Destination unavailable')}</strong><span>Required ${esc(bytes(requiredBytes))} · free ${esc(bytes(option.freeBytes))} · projected ${esc(bytes(option.projectedFreeBytes))} · evidence ${esc(age(option.capacityAgeMs))}${option.reasons.length ? ` · ${esc(option.reasons.map(reason).join(', '))}` : ''}</span>`;
|
||||
const raw = document.getElementById('install-ftp-path');
|
||||
if (option.compatibility) status.insertAdjacentHTML('beforeend', window.chimeraCompatibilityGuidance?.markup?.(option.compatibility) || '');
|
||||
if (raw && option.suggestedRemotePath) raw.value = option.suggestedRemotePath;
|
||||
} else {
|
||||
status.className = 'install-destination-status is-ineligible';
|
||||
status.innerHTML = '<strong>No eligible semantic destination</strong><span>Refresh console storage evidence or use the Expert raw-path override.</span>';
|
||||
}
|
||||
if (typeof updateInstallConfirmState === 'function') updateInstallConfirmState('pfs');
|
||||
}
|
||||
|
||||
async function load({ titleId, required = 0, legacyPath = '' } = {}) {
|
||||
requiredBytes = Math.max(0, Number(required) || 0);
|
||||
const packet = await window.pork.installDestinations({ titleId, requiredBytes });
|
||||
options = Array.isArray(packet?.destinations) ? packet.destinations : [];
|
||||
const select = document.getElementById('install-destination-volume');
|
||||
const raw = document.getElementById('install-ftp-path');
|
||||
if (raw) raw.value = legacyPath || '';
|
||||
if (!select) return packet;
|
||||
select.innerHTML = '<option value="">Select a destination volume</option>' + options.map(option => {
|
||||
const suffix = option.eligible ? `${bytes(option.freeBytes)} free` : option.reasons.map(reason).join(', ');
|
||||
return `<option value="${esc(option.volumeId)}"${option.eligible ? '' : ' disabled'}>${esc(option.deviceName)} · ${esc(option.volumeKey)} · ${esc(suffix)}</option>`;
|
||||
}).join('');
|
||||
const preferred = options.find(option => option.eligible);
|
||||
select.value = preferred?.volumeId || '';
|
||||
renderStatus();
|
||||
return packet;
|
||||
}
|
||||
|
||||
function selection() {
|
||||
const option = selected();
|
||||
if (option?.eligible) return { destinationVolumeId: option.volumeId, remotePath: option.suggestedRemotePath, expertRawPath: false };
|
||||
const raw = document.getElementById('install-ftp-path')?.value?.trim() || '';
|
||||
return expertOpen() && raw ? { destinationVolumeId: null, remotePath: raw, expertRawPath: true } : null;
|
||||
}
|
||||
|
||||
document.getElementById('install-destination-volume')?.addEventListener('change', renderStatus);
|
||||
document.getElementById('install-expert-destination')?.addEventListener('toggle', renderStatus);
|
||||
document.getElementById('install-ftp-path')?.addEventListener('input', renderStatus);
|
||||
|
||||
window.chimeraInstallDestination = Object.freeze({ load, selection, isReady: () => Boolean(selection()) });
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeLibraryModel(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.chimeraLibraryModel = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : window, function createLibraryModel() {
|
||||
function items(response) {
|
||||
if (Array.isArray(response)) return response;
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
function isInstalled(title) { return Number(title?.availability?.confirmedInstallCount || 0) > 0; }
|
||||
function hasLocalCopy(title) { return Number(title?.protection?.localCopyCount || title?.availability?.localCopyCount || 0) > 0; }
|
||||
function needsProtection(title) { return isInstalled(title) && !hasLocalCopy(title); }
|
||||
return Object.freeze({ items, isInstalled, hasLocalCopy, needsProtection });
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
// ── Manage panel ──────────────────────────────────────────────────────────────
|
||||
let uploadInProgress = false;
|
||||
let uploadLocalPath = null;
|
||||
|
||||
window.pork.on('ftp:upload:progress', info => {
|
||||
const log = $('manage-upload-log');
|
||||
if (!log) return;
|
||||
// Directory upload shape has filesTotal; single-file shape has bytes/size
|
||||
if (info.filesTotal != null) {
|
||||
const pct = info.filesTotal > 0 ? Math.round((info.filesDone / info.filesTotal) * 100) : 0;
|
||||
log.textContent = `Uploading… File ${info.filesDone}/${info.filesTotal} (${pct}%)${info.file ? ' ' + info.file : ''}`;
|
||||
} else {
|
||||
const done = info.bytes || 0;
|
||||
const total = info.size || 0;
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
log.textContent = `Uploading… ${pct}% (${fmt(done)} / ${fmt(total)})`;
|
||||
}
|
||||
});
|
||||
|
||||
async function renderManagePanel(g, backups) {
|
||||
// ── Backup file list (local only — backpork entries shown in Backpork Versions) ─
|
||||
const list = $('manage-backup-list');
|
||||
backups = backups.filter(b => b.source !== 'backpork');
|
||||
$('mtab-manage-count').textContent = backups.length || '';
|
||||
|
||||
if (!backups.length) {
|
||||
list.innerHTML = '<div class="prospero-placeholder">No backup files recorded.</div>';
|
||||
} else {
|
||||
list.innerHTML = backups.map(b => {
|
||||
const filename = b.backup_path.replace(/\\/g, '/').split('/').pop();
|
||||
return `
|
||||
<div class="manage-backup-item" data-backup-id="${b.id}" data-game="${escHtml(g.game_id)}"
|
||||
data-local="${escHtml(b.backup_path)}" data-folder="${escHtml(b.backup_path)}">
|
||||
<div class="manage-backup-info">
|
||||
<span class="manage-filename" title="${escHtml(b.backup_path)}">${escHtml(filename)}</span>
|
||||
<span class="manage-size">${fmt(b.size)}</span>
|
||||
<span class="pill pill-num">${escHtml(b.backup_type || 'dump')}</span>
|
||||
</div>
|
||||
<div class="manage-backup-actions">
|
||||
<button class="btn btn-sm manage-btn-folder">Open Folder</button>
|
||||
<button class="btn btn-sm btn-teal manage-btn-upload">Upload to PS5</button>
|
||||
<button class="btn btn-sm btn-danger manage-btn-remove">Remove Record</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Upload section state ────────────────────────────────────────────────────
|
||||
uploadLocalPath = null;
|
||||
$('manage-remote-path').value = '';
|
||||
$('manage-remote-path').readOnly = true;
|
||||
$('btn-manage-upload').disabled = true;
|
||||
$('manage-upload-log').hidden = true;
|
||||
|
||||
// ── Backed-up / Installed status buttons ─────────────────────────────────────
|
||||
const toggleBackedBtn = $('btn-manage-toggle-backed');
|
||||
if (g.backed_up) {
|
||||
toggleBackedBtn.textContent = 'Clear Backed Up Status';
|
||||
toggleBackedBtn.className = 'btn btn-danger';
|
||||
} else {
|
||||
toggleBackedBtn.textContent = 'Mark as Backed Up';
|
||||
toggleBackedBtn.className = 'btn btn-teal';
|
||||
}
|
||||
const toggleInstBtn = $('btn-manage-toggle-installed');
|
||||
if (g.installed) {
|
||||
toggleInstBtn.textContent = 'Mark as Not Installed';
|
||||
toggleInstBtn.className = 'btn btn-danger';
|
||||
} else {
|
||||
toggleInstBtn.textContent = 'Mark as Installed';
|
||||
toggleInstBtn.className = 'btn';
|
||||
}
|
||||
|
||||
// ── Install location display ──────────────────────────────────────────────────
|
||||
renderManageInstallLocation(g);
|
||||
|
||||
// ── Backpork versions section ────────────────────────────────────────────────
|
||||
await renderBackporkSection(g);
|
||||
}
|
||||
|
||||
// ── Manage: install location display ─────────────────────────────────────────
|
||||
|
||||
function renderManageInstallLocation(g) {
|
||||
const locRow = $('manage-install-location');
|
||||
const noLocRow = $('manage-no-location');
|
||||
const locPath = $('manage-loc-path');
|
||||
const deleteBtn = $('btn-manage-delete-ps5');
|
||||
const backupBtn = $('btn-manage-backup-ps5');
|
||||
|
||||
if (g?.ftp_path) {
|
||||
locRow.hidden = false;
|
||||
noLocRow.hidden = true;
|
||||
locPath.textContent = g.ftp_path;
|
||||
deleteBtn.disabled = !state.connected;
|
||||
backupBtn.disabled = !state.connected;
|
||||
} else {
|
||||
locRow.hidden = true;
|
||||
noLocRow.hidden = false;
|
||||
locPath.textContent = '';
|
||||
deleteBtn.disabled = true;
|
||||
backupBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-manage-forget-loc').addEventListener('click', async () => {
|
||||
if (!state.currentGame) return;
|
||||
const gameId = state.currentGame.game_id;
|
||||
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path: '' } });
|
||||
state.currentGame = { ...state.currentGame, ftp_path: '' };
|
||||
renderManageInstallLocation(state.currentGame);
|
||||
setStatus('PS5 install location forgotten', 'ok');
|
||||
});
|
||||
|
||||
$('btn-manage-backup-ps5').addEventListener('click', async () => {
|
||||
if (!state.currentGame || !state.connected) return;
|
||||
const { game_id, ftp_path } = state.currentGame;
|
||||
if (!ftp_path) { setStatus('No PS5 location stored — run a scan first', 'error'); return; }
|
||||
|
||||
const btn = $('btn-manage-backup-ps5');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await window.pork.ftpDownloadFolder(game_id);
|
||||
setStatus('Backup queued — see Transfers for progress', 'ok');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-manage-delete-ps5').addEventListener('click', async () => {
|
||||
if (!state.currentGame) return;
|
||||
const g = state.currentGame;
|
||||
if (!g.ftp_path) { setStatus('No PS5 location stored — cannot delete', 'error'); return; }
|
||||
|
||||
const confirmation = await showExactConfirmation(
|
||||
`Delete "${g.title || g.game_id}" from PS5?\n\nFolder: ${g.ftp_path}\n\nThis cannot be undone.`,
|
||||
'VERWIJDER VAN PS5'
|
||||
);
|
||||
if (!confirmation) return;
|
||||
|
||||
const btn = $('btn-manage-delete-ps5');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting…';
|
||||
|
||||
try {
|
||||
await window.pork.ftpDeleteGame(g.game_id, confirmation);
|
||||
state.currentGame = { ...g, availability: { ...(g.availability || {}), state: 'absent', confirmedInstallCount: 0 }, ftp_path: '' };
|
||||
state.games = await window.pork.listGames({});
|
||||
renderManageInstallLocation(state.currentGame);
|
||||
const toggleInstBtn = $('btn-manage-toggle-installed');
|
||||
toggleInstBtn.textContent = 'Mark as Installed';
|
||||
toggleInstBtn.className = 'btn';
|
||||
setStatus(`${g.title || g.game_id} deleted from PS5`, 'ok');
|
||||
} catch (err) {
|
||||
setStatus(`Delete failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.textContent = 'Delete from PS5';
|
||||
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Backpork section in Manage tab ────────────────────────────────────────────
|
||||
let porkInProgress = false;
|
||||
|
||||
window.pork.on('backporks:pork:progress', ({ file, filesDone, filesTotal, bytesOverall }) => {
|
||||
const fill = $('pork-progress-fill');
|
||||
const label = $('pork-progress-label');
|
||||
const pct = filesTotal > 0 ? Math.round((filesDone / filesTotal) * 100) : 0;
|
||||
if (fill) fill.style.width = `${pct}%`;
|
||||
if (label) label.textContent = `Uploading ${filesDone + 1}/${filesTotal}: ${escHtml(file || '')}`;
|
||||
});
|
||||
|
||||
async function renderBackporkSection(g) {
|
||||
const section = $('manage-backpork-section');
|
||||
const entries = await window.pork.backporksEntries(g.game_id);
|
||||
|
||||
if (!entries.length) { section.hidden = true; return; }
|
||||
section.hidden = false;
|
||||
|
||||
// Porked-to status
|
||||
const statusEl = $('manage-porked-status');
|
||||
if (g.porked_firmware) {
|
||||
const when = g.porked_at ? ` · ${new Date(g.porked_at).toLocaleDateString()}` : '';
|
||||
statusEl.innerHTML = `
|
||||
<div class="manage-porked-status">
|
||||
✓ Porked to firmware <strong>${escHtml(g.porked_firmware)}</strong>${escHtml(when)}
|
||||
<button class="clear-pork btn btn-sm" data-game="${escHtml(g.game_id)}">Clear</button>
|
||||
</div>`;
|
||||
} else {
|
||||
statusEl.innerHTML = '';
|
||||
}
|
||||
|
||||
// Entry list
|
||||
const listEl = $('manage-backpork-list');
|
||||
listEl.innerHTML = entries.map(e => {
|
||||
const gamePath = e.game_path || e.folder_path;
|
||||
const displayPath = gamePath.replace(/\\/g, '/').split('/').pop();
|
||||
return `
|
||||
<div class="manage-backpork-entry"
|
||||
data-fw="${escHtml(e.folder_name)}"
|
||||
data-path="${escHtml(e.folder_path)}"
|
||||
data-game="${escHtml(g.game_id)}">
|
||||
<span class="manage-backpork-fw">${escHtml(e.folder_name)}</span>
|
||||
<span class="manage-backpork-path" title="${escHtml(gamePath)}">${escHtml(displayPath)}</span>
|
||||
<button class="btn btn-sm manage-btn-view-backpork"
|
||||
data-open="${escHtml(gamePath)}">View Backpork</button>
|
||||
<button class="btn btn-sm btn-teal pork-btn"
|
||||
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
|
||||
Pork to PS5
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Reset progress bar
|
||||
$('manage-pork-progress').hidden = true;
|
||||
$('pork-progress-fill').style.width = '0%';
|
||||
}
|
||||
|
||||
// Clear porked status
|
||||
$('manage-porked-status').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.clear-pork');
|
||||
if (!btn) return;
|
||||
const gameId = btn.dataset.game;
|
||||
await window.pork.updateGame({ game_id: gameId, fields: { porked_firmware: null, porked_at: null } });
|
||||
state.games = await window.pork.listGames({});
|
||||
const updated = state.games.find(x => x.game_id === gameId) || state.currentGame;
|
||||
state.currentGame = { ...state.currentGame, porked_firmware: null, porked_at: null };
|
||||
await renderBackporkSection(state.currentGame);
|
||||
setStatus('Porked status cleared', 'ok');
|
||||
});
|
||||
|
||||
// Backpork list click delegation
|
||||
$('manage-backpork-list').addEventListener('click', async e => {
|
||||
// ── View Backpork folder ─────────────────────────────────────────────────────
|
||||
const viewBtn = e.target.closest('.manage-btn-view-backpork');
|
||||
if (viewBtn) {
|
||||
window.pork.openShell(viewBtn.dataset.open);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pork to PS5
|
||||
const btn = e.target.closest('.pork-btn');
|
||||
if (!btn || porkInProgress) return;
|
||||
const entry = btn.closest('.manage-backpork-entry');
|
||||
if (!entry) return;
|
||||
|
||||
const gameId = entry.dataset.game;
|
||||
const folderName = entry.dataset.fw;
|
||||
const folderPath = entry.dataset.path;
|
||||
|
||||
// Ensure a destination is remembered before porking
|
||||
const ftpDest = await ensureGameFtpPath(gameId);
|
||||
if (!ftpDest) return; // user cancelled picker
|
||||
|
||||
porkInProgress = true;
|
||||
btn.disabled = true;
|
||||
$('manage-pork-progress').hidden = false;
|
||||
$('pork-progress-fill').style.width = '0%';
|
||||
$('pork-progress-label').textContent = 'Starting…';
|
||||
setStatus(`Porking ${gameId} (${folderName})…`);
|
||||
|
||||
try {
|
||||
const { remotePath } = await window.pork.backporksPork(gameId, folderName, folderPath);
|
||||
$('manage-pork-progress').hidden = true;
|
||||
setStatus(`Porked to PS5 at ${remotePath}`, 'ok');
|
||||
|
||||
// Refresh game state
|
||||
state.games = await window.pork.listGames({});
|
||||
const updated = state.games.find(x => x.game_id === gameId);
|
||||
if (updated) {
|
||||
state.currentGame = updated;
|
||||
await renderBackporkSection(updated);
|
||||
// Update installed toggle
|
||||
const toggleInstBtn = $('btn-manage-toggle-installed');
|
||||
toggleInstBtn.textContent = 'Mark as Not Installed';
|
||||
toggleInstBtn.className = 'btn btn-danger';
|
||||
}
|
||||
} catch (err) {
|
||||
$('manage-pork-progress').hidden = true;
|
||||
setStatus(`Pork failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
porkInProgress = false;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle installed status
|
||||
$('btn-manage-toggle-installed').addEventListener('click', async () => {
|
||||
setStatus('Install status is derived from complete console scan evidence.', 'info');
|
||||
});
|
||||
|
||||
// ── Delegation for all manage-panel interactions (attached once, never re-bound)
|
||||
$('manage-backup-list').addEventListener('click', async e => {
|
||||
const item = e.target.closest('.manage-backup-item');
|
||||
if (!item) return;
|
||||
|
||||
const backupId = Number(item.dataset.backupId);
|
||||
const gameId = item.dataset.game;
|
||||
const local = item.dataset.local;
|
||||
const folder = item.dataset.folder;
|
||||
|
||||
// ── Open Folder ─────────────────────────────────────────────────────────────
|
||||
if (e.target.closest('.manage-btn-folder')) {
|
||||
window.pork.openShell(folder);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Upload to PS5 — inline expand ───────────────────────────────────────────
|
||||
if (e.target.closest('.manage-btn-upload')) {
|
||||
if (uploadInProgress) return;
|
||||
await openInstallModal(gameId, 'pfs');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Remove backup record ─────────────────────────────────────────────────────
|
||||
if (e.target.closest('.manage-btn-remove')) {
|
||||
const btn = e.target.closest('.manage-btn-remove');
|
||||
const confirmation = await showExactConfirmation('Remove this backup record from Chimera?', 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await window.pork.deleteBackup(backupId, confirmation);
|
||||
setStatus('Backup record removed', 'ok');
|
||||
|
||||
// Refresh the modal's backup-dependent sections
|
||||
const backups = await window.pork.listBackups(gameId);
|
||||
const localBackups = backups.filter(b => b.source !== 'backpork');
|
||||
state.games = await window.pork.listGames({});
|
||||
const g = state.games.find(x => x.game_id === gameId) || state.currentGame;
|
||||
state.currentGame = g;
|
||||
|
||||
await renderManagePanel(g, backups);
|
||||
await renderHashSection(gameId, backups);
|
||||
$('modal-backup-list').innerHTML = localBackups.length
|
||||
? localBackups.map(b => `<li><span>${escHtml(b.backup_path)}</span><span>${fmt(b.size)}</span></li>`).join('')
|
||||
: '<li style="justify-content:center">No backups recorded</li>';
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-manage-upload').addEventListener('click', async () => {
|
||||
if (uploadInProgress || !uploadLocalPath) return;
|
||||
const remotePath = $('manage-remote-path').value.trim();
|
||||
if (!remotePath) { setStatus('Enter a remote path on the PS5', 'error'); return; }
|
||||
|
||||
uploadInProgress = true;
|
||||
$('btn-manage-upload').disabled = true;
|
||||
const log = $('manage-upload-log');
|
||||
log.hidden = false;
|
||||
log.textContent = 'Starting upload…';
|
||||
|
||||
try {
|
||||
await window.pork.ftpUpload({ localPath: uploadLocalPath, remotePath });
|
||||
log.textContent = 'Upload complete ✓';
|
||||
setStatus('Uploaded to PS5 successfully', 'ok');
|
||||
uploadLocalPath = null;
|
||||
$('manage-remote-path').value = '';
|
||||
$('manage-remote-path').readOnly = true;
|
||||
document.querySelectorAll('.manage-item-selected').forEach(el => el.classList.remove('manage-item-selected'));
|
||||
} catch (e) {
|
||||
log.textContent = `Upload failed: ${e.message}`;
|
||||
setStatus(e.message, 'error');
|
||||
$('btn-manage-upload').disabled = false;
|
||||
} finally {
|
||||
uploadInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-manage-toggle-backed').addEventListener('click', async () => {
|
||||
setStatus('Protection status is derived from registered local-copy evidence.', 'info');
|
||||
});
|
||||
|
||||
$('btn-manage-refresh-meta').addEventListener('click', async () => {
|
||||
if (!state.currentGame) return;
|
||||
const { game_id } = state.currentGame;
|
||||
$('btn-manage-refresh-meta').disabled = true;
|
||||
setStatus('Refreshing metadata…');
|
||||
try {
|
||||
await window.pork.prosperoFetch(game_id);
|
||||
setStatus('Metadata refreshed', 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`Refresh failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
$('btn-manage-refresh-meta').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
// ── Transfer Manager controls ─────────────────────────────────────────────────
|
||||
|
||||
// ── Media page ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Format a Unix ms timestamp into a short human-readable string.
|
||||
// Respects _timeFormat ('12h' | '24h').
|
||||
let _timeFormat = '12h'; // default; overwritten by loadSettings
|
||||
function _fmtDate(ms) {
|
||||
if (!ms) return '';
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
let time;
|
||||
if (_timeFormat === '24h') {
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} else {
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h % 12 || 12;
|
||||
time = `${h12}:${String(m).padStart(2, '0')} ${ampm}`;
|
||||
}
|
||||
const isSameDay = d.toDateString() === now.toDateString();
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||||
const isYesterday = d.toDateString() === yesterday.toDateString();
|
||||
if (isSameDay) return `Today ${time}`;
|
||||
if (isYesterday) return `Yesterday ${time}`;
|
||||
const mo = d.toLocaleString('default', { month: 'short' });
|
||||
return `${mo} ${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
let _mediaItems = []; // all items returned by last scan
|
||||
let _mediaFilter = 'all';
|
||||
let _mediaSort = 'date-desc';
|
||||
let _mediaSearch = '';
|
||||
let _mediaSearchTimer = null;
|
||||
const _thumbCache = new Map(); // remotePath → dataUrl (in-memory, cleared on new scan)
|
||||
const _thumbFetching = new Set(); // remotePaths currently being fetched
|
||||
let _thumbQueued = 0; // total thumb loads enqueued in current render cycle
|
||||
let _thumbDone = 0; // completed (success + failure)
|
||||
let _mediaCacheStatus = { entries: [], metrics: {} };
|
||||
|
||||
function _mediaText(key, fallback) {
|
||||
return window.t ? window.t(key, fallback) : fallback;
|
||||
}
|
||||
|
||||
function _renderMediaCacheStatus() {
|
||||
const metrics = _mediaCacheStatus.metrics || {};
|
||||
const target = $('media-cache-metrics');
|
||||
if (!target) return;
|
||||
target.textContent = metrics.managedCount
|
||||
? `${metrics.managedCount} ${_mediaText('media.cache.assets', 'assets')} · ${fmt(metrics.managedBytes || 0)} · ${metrics.pinnedCount || 0} ${_mediaText('media.cache.pinned', 'pinned')}`
|
||||
: _mediaText('media.cache.empty', 'No managed cache assets.');
|
||||
}
|
||||
|
||||
async function _refreshMediaCacheStatus({ rerender = false } = {}) {
|
||||
if (!window.pork.mediaCacheStatus) return;
|
||||
_mediaCacheStatus = await window.pork.mediaCacheStatus();
|
||||
_renderMediaCacheStatus();
|
||||
if (rerender) renderMediaGrid();
|
||||
}
|
||||
|
||||
async function _mediaSha256(blob) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function _renderImportedMedia(packet) {
|
||||
const target = $('media-imported-list');
|
||||
if (!target) return;
|
||||
const items = packet?.items || [];
|
||||
target.innerHTML = items.map(item => `<article class="media-imported-card">
|
||||
${item.mime.startsWith('image/') ? `<img src="${escHtml(item.url)}" alt="">` : `<video src="${escHtml(item.url)}" controls preload="metadata"></video>`}
|
||||
<div><strong>${escHtml(item.titleId)} · ${escHtml(item.kind)}</strong><span>${escHtml(item.originalName)} · ${fmt(item.bytes)} · ${escHtml(item.provenance.provider)}</span>${item.kind === 'cover' ? `<button class="btn btn-sm" type="button" data-preferred-cover="${escHtml(item.id)}" data-title-id="${escHtml(item.titleId)}">${item.preferred ? _mediaText('media.import.preferredActive', 'Preferred') : _mediaText('media.import.makePreferred', 'Make preferred')}</button>` : ''}</div>
|
||||
</article>`).join('');
|
||||
}
|
||||
|
||||
async function _loadImportedMedia() {
|
||||
if (!window.pork.mediaImportsList) return;
|
||||
_renderImportedMedia(await window.pork.mediaImportsList());
|
||||
}
|
||||
|
||||
function _updateThumbStatus() {
|
||||
const el = $('media-thumb-status');
|
||||
if (!el) return;
|
||||
const pending = _thumbQueued - _thumbDone;
|
||||
if (pending <= 0 || _thumbQueued === 0) { el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
el.textContent = `Loading thumbnails: ${_thumbDone} / ${_thumbQueued}`;
|
||||
}
|
||||
|
||||
/** Show or update the thumb-status bar with an arbitrary message (for clip-batch). */
|
||||
function _setThumbStatusMsg(msg) {
|
||||
const el = $('media-thumb-status');
|
||||
if (!el) return;
|
||||
if (msg == null) { el.hidden = true; return; }
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function _setCardLabel(remotePath, text) {
|
||||
const lbl = document.querySelector(`.thumb-label[data-remote="${CSS.escape(remotePath)}"]`);
|
||||
if (lbl) lbl.textContent = text;
|
||||
}
|
||||
|
||||
// ── Video frame extractor removed ───────────────────────────────────────────
|
||||
// Webm thumbnails are not generated — the card shows a WEBM type badge instead.
|
||||
// generateVideoThumb intentionally omitted.
|
||||
|
||||
// ── IntersectionObserver for lazy thumbnail loading ───────────────────────────
|
||||
const _thumbObserver = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const imgEl = entry.target;
|
||||
const remote = imgEl.dataset.remote;
|
||||
if (!remote) continue;
|
||||
_thumbObserver.unobserve(imgEl);
|
||||
|
||||
if (_thumbCache.has(remote)) {
|
||||
_applyThumb(imgEl, _thumbCache.get(remote));
|
||||
continue;
|
||||
}
|
||||
if (_thumbFetching.has(remote)) continue;
|
||||
_thumbFetching.add(remote);
|
||||
_thumbQueued++;
|
||||
_updateThumbStatus();
|
||||
|
||||
// Videos: screenshots only now (clips use static badge)
|
||||
const thumbPromise = window.pork.mediaFetchThumb(remote);
|
||||
|
||||
thumbPromise
|
||||
.then(thumbUrl => {
|
||||
_thumbCache.set(remote, thumbUrl);
|
||||
document.querySelectorAll(`.media-thumb-img[data-remote="${CSS.escape(remote)}"]`).forEach(el => {
|
||||
_applyThumb(el, thumbUrl);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
const card = imgEl.closest('.media-card');
|
||||
const wrap = imgEl.closest('.media-card-thumb');
|
||||
wrap?.classList.add('thumb-error');
|
||||
// Show a retry button so the user isn't left with a silent `!`
|
||||
if (card && !card.querySelector('.btn-thumb-retry')) {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'btn btn-sm btn-thumb-retry';
|
||||
retryBtn.title = err?.message || 'Thumbnail failed';
|
||||
retryBtn.textContent = '\u21BB Retry';
|
||||
retryBtn.addEventListener('click', () => {
|
||||
_thumbFetching.delete(remote);
|
||||
_thumbCache.delete(remote);
|
||||
_thumbQueued = Math.max(0, _thumbQueued - 1);
|
||||
_thumbDone = Math.max(0, _thumbDone - 1);
|
||||
retryBtn.remove();
|
||||
wrap?.classList.remove('thumb-error');
|
||||
// Re-add the spinner and img, then re-observe
|
||||
const newSpin = document.createElement('div');
|
||||
newSpin.className = 'thumb-spin';
|
||||
const newLbl = document.createElement('div');
|
||||
newLbl.className = 'thumb-label'; newLbl.dataset.remote = remote;
|
||||
newLbl.textContent = 'Waiting…';
|
||||
wrap?.prepend(newLbl);
|
||||
wrap?.prepend(newSpin);
|
||||
imgEl.src = ''; imgEl.classList.remove('thumb-loaded'); imgEl.classList.add('thumb-loading');
|
||||
_thumbObserver.observe(imgEl);
|
||||
});
|
||||
card.querySelector('.media-card-body')?.prepend(retryBtn);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
_thumbFetching.delete(remote);
|
||||
_thumbDone++;
|
||||
_updateThumbStatus();
|
||||
});
|
||||
}
|
||||
}, { rootMargin: '200px', threshold: 0 });
|
||||
|
||||
function _applyThumb(imgEl, dataUrl) {
|
||||
imgEl.src = dataUrl;
|
||||
imgEl.classList.remove('thumb-loading');
|
||||
imgEl.classList.add('thumb-loaded');
|
||||
const spin = imgEl.parentElement?.querySelector('.thumb-spin');
|
||||
if (spin) spin.remove();
|
||||
}
|
||||
|
||||
async function loadMedia() {
|
||||
const gameContext = consumeGameWorkspaceContext('media');
|
||||
if (gameContext) {
|
||||
_mediaSearch = gameContext;
|
||||
if ($('media-search')) $('media-search').value = gameContext;
|
||||
}
|
||||
try {
|
||||
const s = await window.pork.getSettings();
|
||||
if (s.mediaLocalPath) $('media-save-path').value = s.mediaLocalPath;
|
||||
} catch (_) {}
|
||||
if (window.pork.mediaCatalog) {
|
||||
try {
|
||||
const catalog = await window.pork.mediaCatalog();
|
||||
if (Array.isArray(catalog?.items)) {
|
||||
_mediaItems = catalog.items;
|
||||
const status = $('media-scan-status');
|
||||
if (catalog.scannedAt) {
|
||||
status.textContent = `Catalogus: ${catalog.stats?.total || catalog.items.length} items · ${_fmtDate(catalog.scannedAt)}${catalog.stale ? ' · nieuwe scan aanbevolen' : ''}`;
|
||||
status.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
try { await _refreshMediaCacheStatus(); } catch (_) {}
|
||||
try { await _loadImportedMedia(); } catch (_) {}
|
||||
renderMediaGrid();
|
||||
}
|
||||
|
||||
function getFilteredMedia() {
|
||||
const filtered = _mediaItems.filter(item => {
|
||||
if (_mediaFilter !== 'all' && item.type !== _mediaFilter) return false;
|
||||
if (_mediaSearch) {
|
||||
const q = _mediaSearch.toLowerCase();
|
||||
return item.game_id.toLowerCase().includes(q) || item.filename.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
switch (_mediaSort) {
|
||||
case 'date-asc': return (a.capturedAt || 0) - (b.capturedAt || 0);
|
||||
case 'size-desc': return b.size - a.size;
|
||||
case 'name-asc': return a.filename.localeCompare(b.filename);
|
||||
default: return (b.capturedAt || 0) - (a.capturedAt || 0); // date-desc
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function renderMediaGrid() {
|
||||
const grid = $('media-grid');
|
||||
const empty = $('media-empty');
|
||||
const all = _mediaItems;
|
||||
const shots = all.filter(i => i.type === 'screenshot');
|
||||
const clips = all.filter(i => i.type === 'clip');
|
||||
|
||||
$('media-count-all').textContent = all.length || '';
|
||||
$('media-count-screenshots').textContent = shots.length || '';
|
||||
$('media-count-clips').textContent = clips.length || '';
|
||||
|
||||
// Show the "Fetch Clip Thumbnails" button whenever there are clips loaded
|
||||
const clipThumbBtn = $('btn-clip-thumbs');
|
||||
if (clipThumbBtn) {
|
||||
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
|
||||
clipThumbBtn.style.display = clips.length ? '' : 'none';
|
||||
clipThumbBtn.title = missing
|
||||
? `Generate thumbnails for ${missing} clip${missing !== 1 ? 's' : ''} (${clips.length - missing} already cached)`
|
||||
: 'All clip thumbnails are already cached';
|
||||
}
|
||||
|
||||
const badge = $('nav-media-badge');
|
||||
// The Chimera v2 navigation replaces Porkfolio's legacy media badge. The
|
||||
// background warmer may render Media after that replacement, so the badge is
|
||||
// deliberately optional.
|
||||
if (badge) {
|
||||
if (all.length) { badge.textContent = all.length; badge.hidden = false; }
|
||||
else badge.hidden = true;
|
||||
}
|
||||
|
||||
const visible = getFilteredMedia();
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (!visible.length) { empty.hidden = false; return; }
|
||||
empty.hidden = true;
|
||||
|
||||
const iconMap = new Map((state.games || []).map(g => [g.game_id, g.prospero_icon_url || '']));
|
||||
|
||||
for (const item of visible) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'media-card';
|
||||
card.dataset.remote = item.remotePath;
|
||||
card.dataset.type = item.type;
|
||||
|
||||
const icon = iconMap.get(item.game_id) || '';
|
||||
const iconHtml = icon
|
||||
? `<img src="${escHtml(icon)}" class="media-card-game-icon" alt="" loading="lazy" data-image-error="hide">`
|
||||
: `<div class="media-card-game-icon-empty"></div>`;
|
||||
|
||||
// Thumbnail area — real <img> for screenshots (lazy-loaded), styled placeholder for clips
|
||||
let thumbHtml;
|
||||
if (item.type === 'screenshot') {
|
||||
const cached = _thumbCache.get(item.remotePath);
|
||||
if (cached) {
|
||||
thumbHtml = `
|
||||
<div class="media-card-thumb type-screenshot media-thumb-wrap">
|
||||
<img src="${escHtml(cached)}" class="media-thumb-img thumb-loaded"
|
||||
data-remote="${escHtml(item.remotePath)}" alt=""/>
|
||||
</div>`;
|
||||
} else {
|
||||
thumbHtml = `
|
||||
<div class="media-card-thumb type-screenshot media-thumb-wrap">
|
||||
<div class="thumb-spin"></div>
|
||||
<img src="" class="media-thumb-img thumb-loading"
|
||||
data-remote="${escHtml(item.remotePath)}" alt=""/>
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
// Video clip — static WEBM badge + play overlay; no thumbnail download.
|
||||
const cachedClip = _thumbCache.get(item.remotePath);
|
||||
const clipImg = cachedClip
|
||||
? `<img src="${escHtml(cachedClip)}" class="media-thumb-img thumb-loaded" data-remote="${escHtml(item.remotePath)}" alt=""/>`
|
||||
: `<div class="clip-type-badge">WEBM</div>`;
|
||||
thumbHtml = `
|
||||
<div class="media-card-thumb type-clip media-thumb-wrap">
|
||||
${clipImg}
|
||||
<div class="clip-play-overlay">
|
||||
<svg viewBox="0 0 40 40" width="36" height="36" fill="none">
|
||||
<circle cx="20" cy="20" r="18" fill="rgba(0,0,0,.45)" stroke="rgba(255,255,255,.25)" stroke-width="1"/>
|
||||
<polygon points="16,13 30,20 16,27" fill="white" opacity=".9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const cachedAsset = item.cache || (_mediaCacheStatus.entries || []).find(entry => entry.origin?.locator === item.remotePath && entry.kind === 'cached');
|
||||
const provenance = item.provenance || { type: 'remote', provider: 'ps5-ftp' };
|
||||
const originLabel = provenance.type === 'remote'
|
||||
? _mediaText('media.cache.remote', 'Remote PS5')
|
||||
: provenance.type === 'imported' ? _mediaText('media.cache.imported', 'Imported original') : _mediaText('media.cache.generated', 'Generated');
|
||||
card.innerHTML = `
|
||||
${thumbHtml}
|
||||
<div class="media-card-body">
|
||||
<div class="media-card-game">
|
||||
${iconHtml}
|
||||
<span class="media-card-game-id">${escHtml(item.game_id)}</span>
|
||||
</div>
|
||||
<div class="media-card-filename" title="${escHtml(item.filename)}">${escHtml(item.filename)}</div>
|
||||
<div class="media-card-meta">
|
||||
<span class="media-card-size">${fmt(item.size)}</span>
|
||||
${item.capturedAt ? `<span class="media-card-date" title="${new Date(item.capturedAt).toLocaleString()}">${_fmtDate(item.capturedAt)}</span>` : ''}
|
||||
</div>
|
||||
<div class="media-card-provenance"><span>${escHtml(originLabel)}</span><span>${escHtml(provenance.provider || '')}</span>${cachedAsset ? `<button class="btn-cache-pin" type="button" data-cache-pin="${escHtml(cachedAsset.id)}" data-pinned="${cachedAsset.pinned ? 'true' : 'false'}" aria-pressed="${cachedAsset.pinned ? 'true' : 'false'}">${cachedAsset.pinned ? _mediaText('media.cache.unpin', 'Unpin') : _mediaText('media.cache.pin', 'Pin cache')}</button>` : ''}</div>
|
||||
<div class="media-card-actions">
|
||||
${item.type === 'clip'
|
||||
? `<button class="btn btn-sm btn-accent btn-media-play"
|
||||
data-remote="${escHtml(item.remotePath)}"
|
||||
data-file="${escHtml(item.filename)}">▶ Play</button>`
|
||||
: ''}
|
||||
<button class="btn btn-sm btn-media-dl"
|
||||
data-remote="${escHtml(item.remotePath)}"
|
||||
data-game="${escHtml(item.game_id)}"
|
||||
data-file="${escHtml(item.filename)}">⇓ Download</button>
|
||||
<button class="btn btn-sm btn-media-share"
|
||||
data-remote="${escHtml(item.remotePath)}"
|
||||
data-game="${escHtml(item.game_id)}"
|
||||
data-file="${escHtml(item.filename)}"
|
||||
data-type="${escHtml(item.type)}">🔗 Discord</button>
|
||||
</div>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
|
||||
// Register lazy loading — screenshots only; clips show a static WEBM badge.
|
||||
if (item.type === 'screenshot' && !_thumbCache.has(item.remotePath)) {
|
||||
const imgEl = card.querySelector('.media-thumb-img');
|
||||
if (imgEl) _thumbObserver.observe(imgEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$('media-grid').addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-cache-pin]');
|
||||
if (!button || !window.pork.mediaCachePin) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await window.pork.mediaCachePin(button.dataset.cachePin, button.dataset.pinned !== 'true');
|
||||
await _refreshMediaCacheStatus({ rerender: true });
|
||||
} catch (error) {
|
||||
setStatus(error.message, 'error');
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function _clearMediaCache(options) {
|
||||
if (!window.pork.mediaCacheClear) return;
|
||||
const result = await window.pork.mediaCacheClear(options);
|
||||
_thumbCache.clear();
|
||||
await _refreshMediaCacheStatus({ rerender: true });
|
||||
setStatus(`${result.removed || 0} ${_mediaText('media.cache.cleared', 'cache assets cleared')}.`, 'ok');
|
||||
}
|
||||
|
||||
$('btn-media-cache-expired').addEventListener('click', () => _clearMediaCache({ expiredOnly: true }).catch(error => setStatus(error.message, 'error')));
|
||||
$('btn-media-cache-unpinned').addEventListener('click', async () => {
|
||||
const ok = await showConfirm(_mediaText('media.cache.clearConfirm', 'Clear all unpinned generated and cached media? Imported originals are never deleted.'));
|
||||
if (ok) await _clearMediaCache({});
|
||||
});
|
||||
|
||||
$('btn-media-import').addEventListener('click', async () => {
|
||||
const button = $('btn-media-import');
|
||||
const status = $('media-import-status');
|
||||
const file = $('media-import-file').files?.[0];
|
||||
const titleId = $('media-import-title-id').value.trim().toUpperCase();
|
||||
const kind = $('media-import-kind').value;
|
||||
if (!file || !/^(CUSA|PPSA)\d{5}$/.test(titleId)) return setStatus(_mediaText('media.import.invalid', 'Select a file and valid title ID.'), 'error');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const session = await window.pork.importUploadCreate([{ relativePath: file.name, size: file.size }]);
|
||||
const staged = session.files[0];
|
||||
let offset = 0;
|
||||
while (offset < file.size) {
|
||||
const chunk = file.slice(offset, Math.min(file.size, offset + 4 * 1024 * 1024));
|
||||
const progress = await window.pork.importUploadChunk(session.id, staged.id, offset, chunk, await _mediaSha256(chunk));
|
||||
offset = Number(progress.files[0].offset);
|
||||
status.textContent = `${Math.round(offset / file.size * 100)}%`;
|
||||
}
|
||||
const crop = $('media-import-crop').value === 'center' ? { x: 0.125, y: 0, width: 0.75, height: 1 } : { x: 0, y: 0, width: 1, height: 1 };
|
||||
await window.pork.mediaImportFinalize({ sessionId: session.id, fileId: staged.id, titleId, kind, crop: kind === 'cover' ? crop : null, preferred: kind === 'cover' && $('media-import-preferred').checked });
|
||||
status.textContent = _mediaText('media.import.done', 'Import complete.');
|
||||
$('media-import-file').value = '';
|
||||
await _loadImportedMedia();
|
||||
} catch (error) { status.textContent = error.message; setStatus(error.message, 'error'); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$('media-imported-list').addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-preferred-cover]');
|
||||
if (!button || !window.pork.mediaImportPreferred) return;
|
||||
await window.pork.mediaImportPreferred(button.dataset.titleId, button.dataset.preferredCover);
|
||||
await _loadImportedMedia();
|
||||
});
|
||||
|
||||
// Clip thumbnail batch progress — update the status bar with live count
|
||||
window.pork.on('media:clip-thumb:progress', ({ done, total, remote, state }) => {
|
||||
const label = remote ? remote.split('/').pop() : '';
|
||||
if (state === 'downloading') {
|
||||
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — downloading ${label}…`);
|
||||
} else if (state === 'extracting') {
|
||||
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — extracting frame…`);
|
||||
} else {
|
||||
// 'ok' or 'failed'
|
||||
if (done >= total) {
|
||||
const clips = _mediaItems.filter(i => i.type === 'clip');
|
||||
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
|
||||
_setThumbStatusMsg(`Done — fetched ${done} clip thumbnail${done !== 1 ? 's' : ''}${missing ? `, ${missing} still missing (partial download may not have contained a keyframe)` : ''}`);
|
||||
setTimeout(() => _setThumbStatusMsg(null), 8000);
|
||||
// Refresh button tooltip
|
||||
const clipThumbBtn = $('btn-clip-thumbs');
|
||||
if (clipThumbBtn) {
|
||||
clipThumbBtn.disabled = false;
|
||||
clipThumbBtn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
|
||||
}
|
||||
renderMediaGrid();
|
||||
} else {
|
||||
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total}${state === 'failed' ? ` (last failed)` : ''}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Fetch Clip Thumbnails button ──────────────────────────────────────────────
|
||||
$('btn-clip-thumbs').addEventListener('click', async () => {
|
||||
const clips = _mediaItems.filter(i => i.type === 'clip');
|
||||
const missing = clips.filter(c => !_thumbCache.has(c.remotePath));
|
||||
|
||||
if (missing.length === 0) {
|
||||
setStatus('All clip thumbnails are already cached.', 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if it will take a while — each clip needs a partial FTP download + ffmpeg
|
||||
const warnThreshold = 5;
|
||||
if (missing.length > warnThreshold) {
|
||||
const ok = await showConfirm(
|
||||
`Fetching thumbnails for ${missing.length} video clip${missing.length !== 1 ? 's' : ''}.\n\n` +
|
||||
`This will download the first ~10 MB of each clip from your PS5 and extract a frame.\n` +
|
||||
`With many files this can take several minutes.\n\nContinue?`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const btn = $('btn-clip-thumbs');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Fetching…';
|
||||
_setThumbStatusMsg(`Starting thumbnail fetch for ${missing.length} clip${missing.length !== 1 ? 's' : ''}…`);
|
||||
|
||||
try {
|
||||
await window.pork.mediaFetchClipThumbs(missing.map(c => c.remotePath));
|
||||
} catch (err) {
|
||||
setStatus(`Clip thumbnail fetch failed: ${err.message}`, 'error');
|
||||
_setThumbStatusMsg(null);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scan buttons ──────────────────────────────────────────────────────────────
|
||||
async function runMediaScan(force = false) {
|
||||
const btn = $('btn-media-scan');
|
||||
const forceBtn = $('btn-media-force-scan');
|
||||
const cancelBtn = $('btn-media-cancel-scan');
|
||||
const status = $('media-scan-status');
|
||||
btn.disabled = true;
|
||||
forceBtn.disabled = true;
|
||||
cancelBtn.hidden = !window.pork.mediaScanCancel;
|
||||
btn.textContent = force ? 'Volledige scan…' : 'Scanning…';
|
||||
status.textContent = 'Connecting to PS5…';
|
||||
status.hidden = false;
|
||||
|
||||
// Clear old data + caches
|
||||
_mediaItems = [];
|
||||
_thumbCache.clear();
|
||||
_thumbFetching.clear();
|
||||
_thumbQueued = 0;
|
||||
_thumbDone = 0;
|
||||
const tsEl = $('media-thumb-status');
|
||||
if (tsEl) tsEl.hidden = true;
|
||||
renderMediaGrid();
|
||||
|
||||
try {
|
||||
const result = await window.pork.mediaScan({ force });
|
||||
const packet = Array.isArray(result) ? { items: result, stats: { total: result.length }, scan: { mode: 'full' }, delta: {} } : result;
|
||||
_mediaItems = Array.isArray(packet?.items) ? packet.items : [];
|
||||
const mode = packet.cached ? 'cache' : packet.scan?.mode === 'incremental' ? 'incrementeel' : 'volledig';
|
||||
const delta = packet.delta || {};
|
||||
status.textContent = `${_mediaItems.length} items · ${mode} · +${delta.added || 0} / -${delta.removed || 0} / changed ${delta.changed || 0}${packet.scan?.reusedDirectories ? ` · ${packet.scan.reusedDirectories} folders reused` : ''}${packet.scan?.errors?.length ? ` · ${packet.scan.errors.length} FTP error(s), previous contents preserved` : ''}`;
|
||||
status.style.color = '';
|
||||
// Restore persisted clip thumbnails so they show immediately after scan
|
||||
try {
|
||||
const diskCache = await window.pork.mediaLoadClipThumbs();
|
||||
for (const [rp, du] of Object.entries(diskCache || {})) _thumbCache.set(rp, du);
|
||||
} catch (_) {}
|
||||
renderMediaGrid();
|
||||
} catch (e) {
|
||||
status.textContent = `Scan failed: ${e.message}`;
|
||||
status.style.cssText += ';color:var(--red)';
|
||||
status.style.animationName = 'none';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
forceBtn.disabled = false;
|
||||
cancelBtn.hidden = true;
|
||||
btn.textContent = '↺ Scan via FTP';
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-media-scan').addEventListener('click', () => runMediaScan(false));
|
||||
$('btn-media-force-scan').addEventListener('click', () => runMediaScan(true));
|
||||
$('btn-media-cancel-scan').addEventListener('click', async event => {
|
||||
event.currentTarget.disabled = true;
|
||||
try { await window.pork.mediaScanCancel(); $('media-scan-status').textContent = 'Scan annuleren…'; }
|
||||
catch (error) { setStatus(error.message, 'error'); }
|
||||
finally { event.currentTarget.disabled = false; }
|
||||
});
|
||||
|
||||
// Per-card thumb state updates from main process
|
||||
window.pork.on('media:thumb:progress', ({ remotePath, state }) => {
|
||||
const labels = { downloading: 'Downloading…', decoding: 'Processing…' };
|
||||
if (labels[state]) _setCardLabel(remotePath, labels[state]);
|
||||
});
|
||||
|
||||
// Live scan progress
|
||||
window.pork.on('media:scan:progress', ({ game_id, type, found, scannedDirectories, reusedDirectories }) => {
|
||||
const status = $('media-scan-status');
|
||||
if (!status.hidden) {
|
||||
status.textContent = `Scanning ${type === 'screenshot' ? 'screenshots' : 'video clips'}… ${found} found${game_id ? ` (${game_id})` : ''}${scannedDirectories ? ` · ${scannedDirectories} folders read` : ''}${reusedDirectories ? ` · ${reusedDirectories} reused` : ''}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Discord share progress — update the share button live for every pipeline stage
|
||||
// Track remote paths whose discord share has fully resolved so late progress
|
||||
// events (which can race against the IPC reply) don't override the final state.
|
||||
const _discordCompleted = new Set();
|
||||
|
||||
window.pork.on('media:discord:progress', ({ remotePath, state, pct, attempt, max }) => {
|
||||
if (_discordCompleted.has(remotePath)) return;
|
||||
const btn = document.querySelector(`.btn-media-share[data-remote="${CSS.escape(remotePath)}"]`);
|
||||
if (!btn) return;
|
||||
|
||||
if (state === 'downloading') {
|
||||
btn.classList.remove('uploading');
|
||||
btn.textContent = pct != null ? `Downloading ${pct}%` : 'Downloading…';
|
||||
} else if (state === 'converting') {
|
||||
btn.classList.remove('uploading');
|
||||
btn.textContent = pct != null ? `Converting ${pct}%` : 'Converting…';
|
||||
} else if (state === 'uploading-video') {
|
||||
btn.classList.add('uploading');
|
||||
btn.style.setProperty('--upload-pct', `${pct ?? 0}%`);
|
||||
btn.textContent = pct != null ? `Uploading ${pct}%` : 'Uploading…';
|
||||
} else if (state === 'retrying') {
|
||||
btn.classList.remove('uploading');
|
||||
btn.style.removeProperty('--upload-pct');
|
||||
btn.textContent = `Retrying… (${attempt}/${max})`;
|
||||
} else {
|
||||
btn.classList.remove('uploading');
|
||||
btn.style.removeProperty('--upload-pct');
|
||||
const labels = { sending: 'Sending…', uploading: 'Uploading…' };
|
||||
if (labels[state]) btn.textContent = labels[state];
|
||||
}
|
||||
});
|
||||
|
||||
// Discord share thumbnail — ffmpeg extracted a frame from the local mp4; cache it
|
||||
// and patch the clip card immediately so the user sees the thumbnail right away.
|
||||
window.pork.on('media:discord:thumb', ({ remotePath, dataUrl }) => {
|
||||
if (!remotePath || !dataUrl) return;
|
||||
_thumbCache.set(remotePath, dataUrl);
|
||||
// Find every clip card for this path and swap the background → actual thumb
|
||||
document.querySelectorAll(`.media-card[data-remote="${CSS.escape(remotePath)}"]`).forEach(card => {
|
||||
const wrap = card.querySelector('.media-card-thumb');
|
||||
if (!wrap) return;
|
||||
// Replace the WEBM badge placeholder with a real thumbnail image
|
||||
const badge = wrap.querySelector('.clip-type-badge');
|
||||
if (badge) badge.remove();
|
||||
let img = wrap.querySelector('.media-thumb-img');
|
||||
if (!img) {
|
||||
img = document.createElement('img');
|
||||
img.className = 'media-thumb-img';
|
||||
img.alt = '';
|
||||
img.dataset.remote = remotePath;
|
||||
wrap.insertBefore(img, wrap.firstChild);
|
||||
}
|
||||
img.src = dataUrl;
|
||||
img.classList.add('thumb-loaded');
|
||||
img.classList.remove('thumb-loading');
|
||||
});
|
||||
});
|
||||
|
||||
// Save path browse
|
||||
$('btn-media-save-browse').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (!folder) return;
|
||||
$('media-save-path').value = folder;
|
||||
await window.pork.setSettings({ mediaLocalPath: folder });
|
||||
});
|
||||
|
||||
// Open media folder in Explorer
|
||||
$('btn-media-open-folder').addEventListener('click', () => {
|
||||
const folder = $('media-save-path').value;
|
||||
if (folder) window.pork.openShell(folder);
|
||||
else setStatus('No media folder configured — select one first.', 'error');
|
||||
});
|
||||
|
||||
// Live update when main process falls back to a default path (e.g. configured drive missing)
|
||||
window.pork.on('settings:updated', (patch) => {
|
||||
if (patch?.mediaLocalPath) {
|
||||
const el = $('media-save-path');
|
||||
if (el) el.value = patch.mediaLocalPath;
|
||||
setStatus(`Media save folder updated to "${patch.mediaLocalPath}" (previous path was inaccessible).`, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
// Sort dropdown
|
||||
$('media-sort').addEventListener('change', e => {
|
||||
_mediaSort = e.target.value;
|
||||
renderMediaGrid();
|
||||
});
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('[data-mfilter]').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-mfilter]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_mediaFilter = btn.dataset.mfilter;
|
||||
renderMediaGrid();
|
||||
})
|
||||
);
|
||||
|
||||
// Search
|
||||
$('media-search').addEventListener('input', () => {
|
||||
clearTimeout(_mediaSearchTimer);
|
||||
_mediaSearchTimer = setTimeout(() => {
|
||||
_mediaSearch = $('media-search').value.trim();
|
||||
renderMediaGrid();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||
let _lightboxItem = null; // current { remotePath, game_id, filename }
|
||||
|
||||
function openLightbox(item) {
|
||||
_lightboxItem = item;
|
||||
const box = $('media-lightbox');
|
||||
const img = $('lightbox-img');
|
||||
const spin = $('lightbox-spinner');
|
||||
const meta = $('lightbox-meta');
|
||||
|
||||
img.src = '';
|
||||
img.hidden = true;
|
||||
spin.hidden = false;
|
||||
meta.textContent = `${item.game_id} — ${item.filename}`;
|
||||
box.hidden = false;
|
||||
document.body.classList.add('lightbox-open');
|
||||
|
||||
const cached = _thumbCache.get(item.remotePath);
|
||||
if (cached) {
|
||||
img.src = cached;
|
||||
img.hidden = false;
|
||||
spin.hidden = true;
|
||||
} else {
|
||||
window.pork.mediaFetchThumb(item.remotePath)
|
||||
.then(dataUrl => {
|
||||
_thumbCache.set(item.remotePath, dataUrl);
|
||||
if (_lightboxItem?.remotePath === item.remotePath) {
|
||||
img.src = dataUrl;
|
||||
img.hidden = false;
|
||||
spin.hidden = true;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
spin.hidden = true;
|
||||
meta.textContent += ` — Load failed: ${err.message}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
$('media-lightbox').hidden = true;
|
||||
document.body.classList.remove('lightbox-open');
|
||||
$('lightbox-img').src = '';
|
||||
_lightboxItem = null;
|
||||
}
|
||||
|
||||
$('btn-lightbox-close').addEventListener('click', closeLightbox);
|
||||
$('media-lightbox').addEventListener('click', e => {
|
||||
if (e.target === $('media-lightbox') || e.target.classList.contains('media-lightbox-backdrop')) closeLightbox();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !$('media-lightbox').hidden) closeLightbox();
|
||||
});
|
||||
|
||||
$('btn-lightbox-dl').addEventListener('click', async () => {
|
||||
if (!_lightboxItem) return;
|
||||
const btn = $('btn-lightbox-dl');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Queued';
|
||||
try {
|
||||
await window.pork.mediaDownload({
|
||||
remotePath: _lightboxItem.remotePath,
|
||||
game_id: _lightboxItem.game_id,
|
||||
filename: _lightboxItem.filename,
|
||||
});
|
||||
setStatus(`Queued: ${_lightboxItem.filename} — see Transfers`, 'ok');
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
} finally {
|
||||
setTimeout(() => { btn.disabled = false; btn.textContent = '↓ Save to PC'; }, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Grid click delegation ─────────────────────────────────────────────────────
|
||||
$('media-grid').addEventListener('click', async e => {
|
||||
// Share to Discord button
|
||||
const shareBtn = e.target.closest('.btn-media-share');
|
||||
if (shareBtn) {
|
||||
shareBtn.disabled = true;
|
||||
shareBtn.textContent = 'Preparing…';
|
||||
try {
|
||||
const res = await window.pork.mediaDiscordSend({
|
||||
remotePath: shareBtn.dataset.remote,
|
||||
game_id: shareBtn.dataset.game,
|
||||
filename: shareBtn.dataset.file,
|
||||
type: shareBtn.dataset.type,
|
||||
});
|
||||
_discordCompleted.add(shareBtn.dataset.remote);
|
||||
shareBtn.classList.remove('uploading');
|
||||
shareBtn.style.removeProperty('--upload-pct');
|
||||
shareBtn.textContent = '\u2713 Sent!';
|
||||
const detail = res?.url ? ` • ${res.url}` : '';
|
||||
setStatus(`Shared to Discord: ${shareBtn.dataset.file}${detail}`, 'ok');
|
||||
setTimeout(() => {
|
||||
shareBtn.disabled = false;
|
||||
shareBtn.textContent = '\uD83D\uDD17 Discord';
|
||||
_discordCompleted.delete(shareBtn.dataset.remote);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
_discordCompleted.add(shareBtn.dataset.remote);
|
||||
shareBtn.classList.remove('uploading');
|
||||
shareBtn.style.removeProperty('--upload-pct');
|
||||
shareBtn.disabled = false;
|
||||
shareBtn.textContent = '\uD83D\uDD17 Discord';
|
||||
_discordCompleted.delete(shareBtn.dataset.remote);
|
||||
setStatus(`Discord share failed: ${err.message}`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Download button
|
||||
const dlBtn = e.target.closest('.btn-media-dl');
|
||||
if (dlBtn) {
|
||||
dlBtn.disabled = true;
|
||||
dlBtn.textContent = 'Queued';
|
||||
try {
|
||||
await window.pork.mediaDownload({
|
||||
remotePath: dlBtn.dataset.remote,
|
||||
game_id: dlBtn.dataset.game,
|
||||
filename: dlBtn.dataset.file,
|
||||
});
|
||||
setStatus(`Queued: ${dlBtn.dataset.file} — see Transfers`, 'ok');
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
}
|
||||
dlBtn.disabled = false;
|
||||
dlBtn.textContent = '\u21D3 Download';
|
||||
return;
|
||||
}
|
||||
|
||||
// Play button (video clips)
|
||||
const playBtn = e.target.closest('.btn-media-play');
|
||||
if (playBtn) {
|
||||
playBtn.disabled = true;
|
||||
playBtn.textContent = 'Opening\u2026';
|
||||
try {
|
||||
await window.pork.mediaOpen(playBtn.dataset.remote);
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
} finally {
|
||||
setTimeout(() => { playBtn.disabled = false; playBtn.textContent = '\u25B6 Play'; }, 1500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on a screenshot thumbnail → open lightbox
|
||||
const card = e.target.closest('.media-card[data-type="screenshot"]');
|
||||
if (card && !e.target.closest('button')) {
|
||||
const remote = card.dataset.remote;
|
||||
const mediaItem = _mediaItems.find(i => i.remotePath === remote);
|
||||
if (mediaItem) openLightbox(mediaItem);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeNavigationContract(root, factory) {
|
||||
const contract = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = contract;
|
||||
else root.ChimeraNavigationContract = contract;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildNavigationContract() {
|
||||
const groups = [
|
||||
{
|
||||
id: 'home', label: 'Home', icon: 'home', defaultRoute: 'home',
|
||||
routes: [
|
||||
{ id: 'home', label: 'Overview', description: 'Library, connection, activity and quick actions.', page: 'dashboard', keywords: ['dashboard', 'start'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'library', label: 'Library', icon: 'library', defaultRoute: 'library/games',
|
||||
routes: [
|
||||
{ id: 'library/games', label: 'Games', description: 'Complete game library with metadata and cover art.', page: 'games', keywords: ['titles', 'roms', 'covers'] },
|
||||
{ id: 'library/updates', label: 'Game updates', description: 'Available patches and updates for each title.', page: 'ecosystem', tab: 'game-updates', keywords: ['patches'] },
|
||||
{ id: 'library/cheats', label: 'Cheats & patches', description: 'Search, review and manage cheats and patches.', page: 'ecosystem', tab: 'cheats', keywords: ['goldhen', 'patches'] },
|
||||
{ id: 'library/media', label: 'Media', description: 'Screenshots and video clips from the console.', page: 'media', keywords: ['screenshots', 'video'] },
|
||||
{ id: 'library/mounts', label: 'Mounts', description: 'ShadowMount configuration with restore-point protection.', page: 'operations', panel: 'shadowmount', keywords: ['shadowmount', 'folders'], expert: true },
|
||||
{ id: 'library/packages', label: 'Packages', description: 'Browse package sets, components, versions, hashes and recipes.', page: 'packages', keywords: ['pkg', 'sets', 'components', 'recipes'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'console', label: 'Console', icon: 'console', defaultRoute: 'console/status',
|
||||
routes: [
|
||||
{ id: 'console/status', label: 'Status', description: 'Configured PS5 endpoint, connection evidence and service health.', page: 'ecosystem', tab: 'overview', keywords: ['health', 'overview'] },
|
||||
{ id: 'console/live', label: 'Live session', description: 'Live console information and the active session.', page: 'ecosystem', tab: 'live', keywords: ['runtime', 'temperature'], expert: true },
|
||||
{ id: 'console/remote-play', label: 'Remote Play', description: 'Discover and wake the PS5, then start a local chiaki-ng session.', page: 'ecosystem', tab: 'remote-play', keywords: ['chiaki', 'stream', 'controller', 'wake'] },
|
||||
{ id: 'console/system-view', label: 'System View', description: 'Capture-card video, audio and recording.', page: 'system-view', keywords: ['capture', 'stream'] },
|
||||
{ id: 'console/files', label: 'Files', description: 'Safely browse files on the PS5.', page: 'ecosystem', tab: 'files', keywords: ['ftp', 'browser'], expert: true },
|
||||
{ id: 'console/profile', label: 'Profile & compatibility', description: 'Firmware, HEN and loader profile with validation.', page: 'operations', panel: 'compatibility', keywords: ['firmware', 'etahen', 'kstuff'] },
|
||||
{ id: 'console/etahen', label: 'etaHEN', description: 'etaHEN configuration and integration status.', page: 'ecosystem', tab: 'etahen', keywords: ['hen'] },
|
||||
{ id: 'console/notifications', label: 'Notifications', description: 'Test the connection and send console notifications.', page: 'psnotify', keywords: ['notify', 'toast'] },
|
||||
{ id: 'console/avatar', label: 'XAvatar', description: 'Convert, transfer and manage avatars.', page: 'xavatar', keywords: ['profile', 'image'] },
|
||||
{ id: 'console/power', label: 'System actions', description: 'Confirmed power, rest-mode and disc actions.', page: 'jailbreak', focus: 'power', keywords: ['shutdown', 'reboot', 'eject'], risk: 'confirm', expert: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'payloads', label: 'Payloads & Homebrew', icon: 'payload', defaultRoute: 'payloads/installed',
|
||||
routes: [
|
||||
{ id: 'payloads/installed', label: 'Payload Manager', description: 'PS5 manager, local payloads, catalog and sender.', page: 'jailbreak', focus: 'payloads', keywords: ['sender', 'elf', 'bin'] },
|
||||
{ id: 'payloads/updates', label: 'Updates', description: 'Safely update payloads and components with version checks.', page: 'ecosystem', tab: 'updates', keywords: ['upgrade', 'watchdog'] },
|
||||
{ id: 'payloads/catalog', label: 'Component catalog', description: 'Tracked PS5 projects, releases and compatibility.', page: 'ecosystem', tab: 'components', keywords: ['projects', 'releases'] },
|
||||
{ id: 'payloads/homebrew', label: 'Homebrew', description: 'Homebrew library and available releases.', page: 'ecosystem', tab: 'homebrew', keywords: ['apps'] },
|
||||
{ id: 'payloads/plugins', label: 'Plugins', description: 'Plugin library and source information.', page: 'ecosystem', tab: 'plugins', keywords: ['extensions'] },
|
||||
{ id: 'payloads/boot', label: 'Boot & autoload', description: 'Autoload order, snapshots and USB/disc builders.', page: 'jailbreak', focus: 'boot', keywords: ['y2jb', 'bdjb', 'autoload'] },
|
||||
{ id: 'payloads/elf-arsenal', label: 'ELF Arsenal', description: 'Manage ELF files and send them to the console.', page: 'elf-arsenal', keywords: ['payload', 'sender'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'protection', label: 'Protection', icon: 'shield', defaultRoute: 'protection/backups',
|
||||
routes: [
|
||||
{ id: 'protection/backups', label: 'Backups', description: 'View and manage game backups.', page: 'backups', keywords: ['backup'] },
|
||||
{ id: 'protection/trophies', label: 'Trophy evidence', description: 'Compare read-only PSN proof with the selected local profile.', page: 'trophies', keywords: ['trophy', 'profile', 'evidence', 'reconstruct'] },
|
||||
{ id: 'protection/images', label: 'Backpork images', description: 'Manage, build and verify firmware images.', page: 'backporks', keywords: ['image', 'firmware'] },
|
||||
{ id: 'protection/saves', label: 'Save Vault', description: 'Save snapshots, comparison and safe recovery.', page: 'ecosystem', tab: 'saves', keywords: ['saves', 'restore'] },
|
||||
{ id: 'protection/save-service', label: 'GarlicSaves worker', description: 'Install and manage the optional GarlicSaves community worker.', page: 'garlic', keywords: ['garlic', 'community', 'worker'], expert: true },
|
||||
{ id: 'protection/save-browser', label: 'Save browser', description: 'Advanced local save browser.', page: 'savemgr', keywords: ['local save manager'], expert: true },
|
||||
{ id: 'protection/recovery', label: 'Recovery Vault', description: 'Create, export and restore recovery points.', page: 'operations', panel: 'recovery', keywords: ['snapshot', 'restore'], risk: 'confirm' },
|
||||
{ id: 'protection/integrity', label: 'Integrity audit', description: 'Audit mounts, registrations and orphaned files read-only.', page: 'operations', panel: 'storage', keywords: ['storage', 'audit'] },
|
||||
{ id: 'protection/audit', label: 'Audit Center', description: 'Search mutating actions, results and safety classifications.', page: 'operations', panel: 'audit', keywords: ['log', 'history', 'security'], expert: true },
|
||||
{ id: 'protection/replication', label: 'Replication', description: 'Use a second backup target with SHA-256 verification.', page: 'operations', panel: 'reliability', focus: 'replication', keywords: ['3-2-1', 'copy'], expert: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools', label: 'Tools', icon: 'tools', defaultRoute: 'tools/transfers',
|
||||
routes: [
|
||||
{ id: 'tools/transfers', label: 'Transfers', description: 'Queue, progress and recent FTP transfers.', page: 'transfers', keywords: ['upload', 'download'], badge: 'transfer' },
|
||||
{ id: 'tools/pfs', label: 'PFS Ripper', description: 'PFS workflow and extraction tools.', page: 'pfs-ripper', keywords: ['extract'], expert: true },
|
||||
{ id: 'tools/database', label: 'Database', description: 'Manage the Chimera database and hash information.', page: 'database', keywords: ['sqlite', 'hash'], expert: true },
|
||||
{ id: 'tools/itemzflow', label: 'Itemzflow', description: 'Itemzflow status and integration.', page: 'ecosystem', tab: 'itemzflow', keywords: ['itemz'], expert: true },
|
||||
{ id: 'tools/timeline', label: 'Timeline', description: 'Recent platform and console activity.', page: 'ecosystem', tab: 'timeline', keywords: ['events', 'log'], expert: true },
|
||||
{ id: 'tools/diagnostics', label: 'Diagnostics', description: 'Read-only probes and a sanitized support bundle.', page: 'operations', panel: 'diagnostics', keywords: ['support', 'logs'], expert: true },
|
||||
{ id: 'tools/package-services', label: 'Package Services', description: 'Expert DPI, Void and raw package service diagnostics.', page: 'operations', panel: 'packages', keywords: ['dpi', 'voidpkg', 'handoff'], expert: true },
|
||||
{ id: 'tools/research', label: 'Research', description: 'Evaluated projects, sources and expert information.', page: 'ecosystem', tab: 'research', keywords: ['linux', 'sources'], expert: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings', label: 'Settings', icon: 'settings', defaultRoute: 'settings/general',
|
||||
routes: [
|
||||
{ id: 'settings/general', label: 'General', description: 'Language, paths, console connection and platform preferences.', page: 'settings', keywords: ['configuration', 'ftp'] },
|
||||
{ id: 'settings/automation', label: 'Automation', description: 'Safe maintenance tasks and execution history.', page: 'operations', panel: 'automation', keywords: ['scheduler', 'tasks'] },
|
||||
{ id: 'settings/sources', label: 'Sources & trust', description: 'Upstream sources and supply-chain policy.', page: 'ecosystem', tab: 'sources', keywords: ['https', 'checksum'] },
|
||||
{ id: 'settings/security', label: 'Security', description: 'Active sessions and password management.', page: 'operations', panel: 'reliability', focus: 'security', keywords: ['sessions', 'password'] },
|
||||
{ id: 'settings/monitoring', label: 'Monitoring', description: 'Health metrics and Prometheus token.', page: 'operations', panel: 'reliability', focus: 'monitoring', keywords: ['metrics', 'prometheus'], expert: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const legacyAliases = {
|
||||
dashboard: 'home', games: 'library/games', media: 'library/media', cheats: 'library/cheats',
|
||||
backporks: 'protection/images', backups: 'protection/backups', trophies: 'protection/trophies', garlic: 'protection/save-service',
|
||||
savemgr: 'protection/save-browser', jailbreak: 'payloads/installed', psnotify: 'console/notifications',
|
||||
xavatar: 'console/avatar', 'system-view': 'console/system-view',
|
||||
transfers: 'tools/transfers', database: 'tools/database', 'elf-arsenal': 'payloads/elf-arsenal',
|
||||
'pfs-ripper': 'tools/pfs', ecosystem: 'console/status', operations: 'settings/automation', settings: 'settings/general',
|
||||
};
|
||||
|
||||
const routes = groups.flatMap(group => group.routes.map(route => Object.freeze({ ...route, group: group.id })));
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
defaultRoute: 'home',
|
||||
groups: Object.freeze(groups.map(group => Object.freeze({ ...group, routes: Object.freeze(group.routes) }))),
|
||||
routes: Object.freeze(routes),
|
||||
legacyAliases: Object.freeze(legacyAliases),
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,700 @@
|
||||
'use strict';
|
||||
|
||||
let _operationsLoaded = false;
|
||||
let _operationsStatus = null;
|
||||
let _operationsBusy = new Set();
|
||||
let _operationsStorageReport = null;
|
||||
let _operationsStorageTopology = null;
|
||||
|
||||
function operationsDate(value) {
|
||||
if (!value) return '—';
|
||||
try { return new Date(Number(value) || value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return '—'; }
|
||||
}
|
||||
|
||||
function operationsDuration(value) {
|
||||
const ms = Number(value || 0);
|
||||
if (!ms) return '—';
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function operationsBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let amount = bytes;
|
||||
let unit = 0;
|
||||
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
|
||||
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function operationsRunSummary(run) {
|
||||
const raw = String(run.error || run.summary || '').trim();
|
||||
if (!raw) return run.status === 'completed' ? 'Completed' : '';
|
||||
if (run.error) return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
|
||||
let result;
|
||||
try { result = JSON.parse(raw); } catch (_) { return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; }
|
||||
if (result == null) return 'Completed';
|
||||
if (typeof result === 'string' || typeof result === 'number') return String(result);
|
||||
if (run.task_id === 'library-scan') {
|
||||
const total = Number(result.total ?? result.found?.length ?? result.found ?? 0);
|
||||
const added = Number(result.added || 0);
|
||||
return `${total} game${total === 1 ? '' : 's'} found · ${added} new`;
|
||||
}
|
||||
if (run.task_id === 'metadata-refresh') {
|
||||
const queued = Number(result.queued ?? (typeof result === 'number' ? result : 0));
|
||||
return `${queued} metadata item${queued === 1 ? '' : 's'} queued`;
|
||||
}
|
||||
if (run.task_id === 'payload-sync' && Array.isArray(result)) {
|
||||
const failed = result.filter(item => item?.error).length;
|
||||
return `${result.length} source${result.length === 1 ? '' : 's'} checked${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
|
||||
}
|
||||
if (run.task_id === 'cheat-sync') {
|
||||
const indexed = Number(result.ecosystem?.total || 0);
|
||||
const cached = Number(result.legacy?.total || 0);
|
||||
const failed = Number(result.legacy?.failed || 0) + Number(result.ecosystem?.errors?.length || 0);
|
||||
return `${indexed} modern cheats or patches indexed · ${cached} legacy items cached${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
|
||||
}
|
||||
if (run.task_id === 'storage-audit') {
|
||||
const summary = result.summary || {};
|
||||
const issues = Number(summary.missingBackups || 0) + Number(summary.partialFiles || 0) + Number(summary.orphanGameFolders || 0);
|
||||
return `${Number(summary.games || 0)} games · ${Number(summary.backups || 0)} backups · ${issues} attention item${issues === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (run.task_id === 'recovery-snapshot') {
|
||||
const localFiles = Array.isArray(result.files) ? result.files.length : 0;
|
||||
const remoteFiles = Array.isArray(result.remote) ? result.remote.filter(item => item.included).length : 0;
|
||||
return `${localFiles} local and ${remoteFiles} PS5 files secured`;
|
||||
}
|
||||
const values = Object.entries(result).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 3);
|
||||
return values.length ? values.map(([key, value]) => `${key}: ${value}`).join(' · ') : 'Completed';
|
||||
}
|
||||
|
||||
function operationsStatusBadge(status) {
|
||||
const labels = { completed: 'Succeeded', failed: 'Failed', running: 'Running', compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' };
|
||||
return `<span class="operations-status operations-status-${escHtml(status || 'unknown')}">${escHtml(labels[status] || status || 'Unknown')}</span>`;
|
||||
}
|
||||
|
||||
async function operationsAction(key, button, task) {
|
||||
if (_operationsBusy.has(key)) return null;
|
||||
_operationsBusy.add(key);
|
||||
const prior = button?.textContent;
|
||||
if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); }
|
||||
try { return await task(); }
|
||||
finally {
|
||||
_operationsBusy.delete(key);
|
||||
if (button) { button.disabled = false; button.textContent = prior; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderOperationsAutomation(data) {
|
||||
_operationsStatus = data;
|
||||
const scheduler = $('operations-scheduler-state');
|
||||
scheduler.textContent = data.running ? 'Active' : 'Stopped';
|
||||
scheduler.className = `badge ${data.running ? 'badge-green' : 'badge-red'}`;
|
||||
const tasks = data.tasks || [];
|
||||
$('operations-task-list').innerHTML = tasks.map(task => `
|
||||
<div class="operations-task" data-task-id="${escHtml(task.id)}">
|
||||
<div class="operations-task-main">
|
||||
<span class="operations-task-indicator ${task.running ? 'running' : task.enabled ? 'enabled' : ''}"></span>
|
||||
<div><strong>${escHtml(task.label)}</strong><span>${task.running ? 'Running now' : task.enabled ? `Next: ${operationsDate(task.nextRunAt)}` : 'Disabled'}</span></div>
|
||||
</div>
|
||||
<label class="operations-switch"><input class="operations-task-enabled" type="checkbox" ${task.enabled ? 'checked' : ''}/><span>Active</span></label>
|
||||
<label class="operations-interval"><input class="operations-task-interval" type="number" min="5" max="10080" value="${Number(task.intervalMinutes)}"/><span>min.</span></label>
|
||||
<button class="btn btn-sm operations-task-save">Save</button>
|
||||
<button class="btn btn-sm btn-accent operations-task-run" ${task.running ? 'disabled' : ''}>Run now</button>
|
||||
</div>`).join('') || '<p class="hint">No tasks configured.</p>';
|
||||
|
||||
const runs = (data.recentRuns || []).slice(0, 25);
|
||||
$('operations-run-list').innerHTML = runs.map(run => {
|
||||
const summary = operationsRunSummary(run);
|
||||
return `
|
||||
<div class="operations-run">
|
||||
<div><strong>${escHtml(tasks.find(task => task.id === run.task_id)?.label || run.task_id)}</strong><span>${escHtml(run.trigger || 'manual')} · ${operationsDate(run.started_at)}</span></div>
|
||||
${operationsStatusBadge(run.status)}
|
||||
<span>${operationsDuration(run.duration_ms)}</span>
|
||||
<span class="operations-run-message" title="${escHtml(run.error || run.summary || '')}">${escHtml(summary)}</span>
|
||||
</div>`;
|
||||
}).join('') || '<p class="hint">No runs yet.</p>';
|
||||
|
||||
const failed = runs.filter(run => run.status === 'failed').length;
|
||||
const health = $('operations-health');
|
||||
health.textContent = failed
|
||||
? window.tf('operations.status.failures', '{count} recent failure(s)', { count: failed })
|
||||
: window.t('operations.status.healthy', 'Automation healthy');
|
||||
health.className = `operations-health ${failed ? 'warning' : 'healthy'}`;
|
||||
const badge = $('nav-operations-badge');
|
||||
if (badge) { badge.hidden = failed === 0; badge.textContent = failed || ''; }
|
||||
}
|
||||
|
||||
function renderOperationsStorage(report) {
|
||||
_operationsStorageReport = report;
|
||||
if (!report) {
|
||||
$('operations-storage-summary').innerHTML = '';
|
||||
$('operations-storage-details').innerHTML = '<p class="hint">No storage report available yet.</p>';
|
||||
return;
|
||||
}
|
||||
const summary = report.summary || {};
|
||||
const metrics = [
|
||||
[window.t('operations.storage.metrics.mounts', 'Mounts'), summary.roots],
|
||||
[window.t('operations.storage.metrics.games', 'Games'), summary.games],
|
||||
[window.t('operations.storage.metrics.backups', 'Backups'), summary.backups],
|
||||
[window.t('operations.storage.metrics.missing', 'Missing'), summary.missingBackups],
|
||||
[window.t('operations.storage.metrics.duplicates', 'Duplicate games'), summary.duplicateGames],
|
||||
[window.t('operations.storage.metrics.partial', 'Partial files'), summary.partialFiles],
|
||||
[window.t('operations.storage.metrics.orphaned', 'Orphaned folders'), summary.orphanGameFolders],
|
||||
];
|
||||
$('operations-storage-summary').innerHTML = metrics.map(([label, value]) => `<div class="operations-metric"><strong>${Number(value || 0)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
const sections = [];
|
||||
if ((report.roots || []).length) sections.push(`<div class="operations-audit-section"><h4>${escHtml(window.t('operations.storage.volumes', 'Volumes'))}</h4>${report.roots.map(root => `<div class="operations-audit-row"><code>${escHtml(root.path)}</code><span>${root.error ? escHtml(root.error) : `${operationsBytes(root.availableBytes)} ${escHtml(window.t('operations.storage.freeOf', 'free of'))} ${operationsBytes(root.totalBytes)}`}</span></div>`).join('')}</div>`);
|
||||
const issue = (title, items, render) => {
|
||||
if (!items?.length) return;
|
||||
sections.push(`<details class="operations-audit-section"><summary>${escHtml(title)} <span>${items.length}</span></summary>${items.slice(0, 100).map(render).join('')}${items.length > 100 ? `<p class="hint">${escHtml(window.t('operations.storage.first100', 'Only the first 100 items are shown.'))}</p>` : ''}</details>`);
|
||||
};
|
||||
issue(window.t('operations.storage.issues.missing', 'Missing backups'), report.missingBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
|
||||
issue(window.t('operations.storage.issues.duplicates', 'Duplicate backup registrations'), report.duplicateBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)} (${item.count})</strong><span>${item.paths.map(escHtml).join('<br>')}</span></div>`);
|
||||
issue(window.t('operations.storage.issues.partial', 'Partial files'), report.partialFiles, item => `<div class="operations-audit-row"><code>${escHtml(item)}</code></div>`);
|
||||
issue(window.t('operations.storage.issues.orphaned', 'Orphaned game folders'), report.orphanGameFolders, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
|
||||
if (!report.missingBackups?.length && !report.partialFiles?.length && !report.orphanGameFolders?.length) sections.push(`<div class="operations-empty-ok">✓ ${escHtml(window.t('operations.storage.healthy', 'No missing backups, partial files or orphaned game folders found.'))}</div>`);
|
||||
$('operations-storage-details').innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.reportAt', 'Report from'))} ${operationsDate(report.generatedAt)}</p>${sections.join('')}`;
|
||||
}
|
||||
|
||||
function renderStorageTopology(packet) {
|
||||
const target = $('operations-storage-topology');
|
||||
if (!target || !packet) return;
|
||||
_operationsStorageTopology = packet;
|
||||
target.innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.consoleSource', 'Console source'))}: ${escHtml(packet.provenance?.console || 'unknown')} · ${escHtml(window.t('operations.storage.serverSource', 'server source'))}: ${escHtml(packet.provenance?.server || 'unknown')}</p><div class="ecosystem-update-list">${(packet.volumes || []).map(volume => `<div class="ecosystem-service-row"><div><strong>${escHtml(volume.scope === 'console' ? `PS5 ${volume.kind}` : volume.mountpoint)}</strong><span>${escHtml(volume.mountpoint || 'unknown')} · ${escHtml(volume.filesystem || 'unknown')} · ${volume.freeBytes == null ? escHtml(window.t('operations.storage.freeUnknown', 'free space unknown')) : `${operationsBytes(volume.freeBytes)} ${escHtml(window.t('operations.storage.free', 'free'))}`} · ${volume.totalBytes == null ? escHtml(window.t('operations.storage.totalUnknown', 'total unknown')) : operationsBytes(volume.totalBytes)}</span></div>${operationsStatusBadge(volume.status === 'unknown' || volume.source === 'unavailable' ? 'unknown' : volume.readOnly === true ? 'warning' : 'compatible')}</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function loadStorageTopology() {
|
||||
if (!window.pork.consoleStorageTopology) return;
|
||||
renderStorageTopology(await window.pork.consoleStorageTopology());
|
||||
}
|
||||
|
||||
function renderOperationsRecovery(snapshots) {
|
||||
$('operations-recovery-list').innerHTML = (snapshots || []).map(snapshot => {
|
||||
const included = (snapshot.remote || []).filter(item => item.included).length;
|
||||
return `<div class="operations-recovery-item" data-snapshot-id="${escHtml(snapshot.id)}">
|
||||
<div class="operations-recovery-icon">↺</div>
|
||||
<div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Restore point')}</strong><span>${operationsDate(snapshot.createdAt)} · Chimera ${escHtml(snapshot.chimeraVersion || '?')} · ${included} remote files · ${snapshot.integrity === 'sha256' ? 'SHA-256' : 'legacy verification'}</span><code>${escHtml(snapshot.id)}</code></div>
|
||||
<div class="operations-inline-actions"><button class="btn btn-sm operations-recovery-verify">Verify</button><button class="btn btn-sm operations-recovery-export">Download</button><button class="btn btn-sm operations-recovery-restore">Restore</button><button class="btn btn-sm btn-danger operations-recovery-delete">Delete</button></div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="operations-empty"><strong>No restore points yet</strong><span>Create a restore point before making major changes.</span></div>';
|
||||
}
|
||||
|
||||
function operationsAuditFilters() {
|
||||
const since = $('operations-audit-since').value;
|
||||
const until = $('operations-audit-until').value;
|
||||
return {
|
||||
level: $('operations-audit-level').value,
|
||||
classification: $('operations-audit-classification').value,
|
||||
result: $('operations-audit-result').value,
|
||||
search: $('operations-audit-search').value.trim(),
|
||||
since: since ? new Date(since).getTime() : null,
|
||||
until: until ? new Date(until).getTime() : null,
|
||||
limit: 200,
|
||||
};
|
||||
}
|
||||
|
||||
function renderOperationsAudit(result, health) {
|
||||
const summary = result.summary || {};
|
||||
$('operations-audit-summary').innerHTML = [
|
||||
['Matched', result.totalMatched || 0], ['Succeeded', summary.success || 0], ['Failed', summary.failed || 0],
|
||||
['Sensitive', summary.sensitive || 0], ['Destructive', summary.destructive || 0], ['Errors', summary.errors || 0],
|
||||
].map(([label, value]) => `<div class="operations-metric"><strong>${Number(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
const files = health?.files || [];
|
||||
$('operations-audit-health').textContent = `${files.length} log file(s) · ${operationsBytes(health?.totalBytes)} · rotation ${operationsBytes(health?.retention?.maxBytes)} × ${Number(health?.retention?.maxFiles || 0)} · secrets redacted at rest`;
|
||||
$('operations-audit-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
|
||||
<div><strong>${escHtml(entry.command || entry.event || entry.level)}</strong><span>${operationsDate(entry.time)}${entry.requestIp ? ` · ${escHtml(entry.requestIp)}` : ''}</span></div>
|
||||
${operationsStatusBadge(entry.ok === true ? 'completed' : entry.ok === false ? 'failed' : 'unknown')}
|
||||
<span>${escHtml(entry.classification || entry.level)}</span>
|
||||
<span class="operations-run-message" title="${escHtml(entry.error || '')}">${escHtml(entry.error || (entry.durationMs == null ? entry.event : `${entry.event} · ${operationsDuration(entry.durationMs)}`))}</span>
|
||||
</div>`).join('') || '<div class="operations-empty"><strong>No audit records</strong><span>No records match these filters.</span></div>';
|
||||
}
|
||||
|
||||
async function loadOperationsAudit() {
|
||||
const [result, health] = await Promise.all([window.pork.auditQuery(operationsAuditFilters()), window.pork.auditHealth()]);
|
||||
renderOperationsAudit(result, health);
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderOperationsCompatibility(data) {
|
||||
const profile = data?.profile || {};
|
||||
$('operations-profile-firmware').value = profile.firmware || '';
|
||||
$('operations-profile-hen').value = profile.hen || '';
|
||||
$('operations-profile-hen-version').value = profile.henVersion || '';
|
||||
$('operations-profile-kstuff').value = profile.kstuffVersion || '';
|
||||
$('operations-profile-loader').value = profile.loader || '';
|
||||
$('operations-profile-notes').value = profile.notes || '';
|
||||
$('operations-compatibility-checks').innerHTML = (data?.checks || []).map(check => `<div class="operations-check"><div><strong>${escHtml(check.label)}</strong><span>${escHtml(check.detail)}</span></div>${operationsStatusBadge(check.status)}</div>`).join('') || '<p class="hint">No checks available.</p>';
|
||||
}
|
||||
|
||||
function renderReplicationStatus(status) {
|
||||
const targetState = status.available && status.writable ? 'Available and writable' : status.error || 'Unavailable';
|
||||
const free = status.storage ? `${operationsBytes(status.storage.availableBytes)} free` : 'free space unknown';
|
||||
const protection = status.protection || {};
|
||||
const rpo = protection.effectiveRpoSeconds == null ? 'not established' : protection.effectiveRpoSeconds < 3600
|
||||
? `${Math.ceil(protection.effectiveRpoSeconds / 60)} min`
|
||||
: `${Math.round(protection.effectiveRpoSeconds / 360) / 10} h`;
|
||||
const rto = protection.lastRestoreDrillDurationMs == null ? 'not measured' : `${Math.max(1, Math.round(protection.lastRestoreDrillDurationMs / 1000))} s drill`;
|
||||
$('operations-replication-status').innerHTML = `
|
||||
<div class="operations-audit-row"><strong>${escHtml(targetState)}</strong><code>${escHtml(status.target || '/replication')}</code><span>${escHtml(free)}${status.running ? ' · replication running' : ''}</span></div>
|
||||
<div class="ecosystem-metrics">
|
||||
<div class="operations-metric"><strong>${protection.threeTwoOneSatisfied ? 'Met' : 'Not met'}</strong><span>3-2-1 status</span></div>
|
||||
<div class="operations-metric"><strong>${escHtml(rpo)}</strong><span>Effective RPO</span></div>
|
||||
<div class="operations-metric"><strong>${escHtml(rto)}</strong><span>Observed restore drill</span></div>
|
||||
<div class="operations-metric"><strong>${protection.immutableObserved ? 'Yes' : 'No'}</strong><span>Immutable target</span></div>
|
||||
</div>
|
||||
<div class="${protection.threeTwoOneSatisfied ? 'operations-empty-ok' : 'operations-warning'}"><strong>Target contract: ${escHtml(status.targetContract?.type || 'unknown')}</strong><p>Atomic: ${status.targetContract?.atomicPublish ? 'yes' : 'unknown'} · hashes: ${status.targetContract?.hashVerified ? 'yes' : 'unknown'} · immutable: ${status.targetContract?.immutable ? 'yes' : 'no'} · off-host: ${escHtml(status.targetContract?.offHost || 'unknown')}</p>${protection.blockers?.length ? `<ul>${protection.blockers.map(item => `<li>${escHtml(item)}</li>`).join('')}</ul>` : ''}</div>`;
|
||||
$('operations-replication-retention-enabled').checked = status.retention?.enabled !== false;
|
||||
$('operations-replication-retention-count').value = status.retention?.maxSnapshots || 14;
|
||||
$('operations-replication-retention-days').value = status.retention?.maxAgeDays || 180;
|
||||
$('operations-replication-run').disabled = !status.available || !status.writable || status.running;
|
||||
$('operations-replication-list').innerHTML = (status.snapshots || []).map(snapshot => snapshot.invalid
|
||||
? `<div class="operations-recovery-item"><div class="operations-recovery-info"><strong>Invalid snapshot</strong><code>${escHtml(snapshot.id)}</code><span>${escHtml(snapshot.error)}</span></div></div>`
|
||||
: `<div class="operations-recovery-item" data-replication-id="${escHtml(snapshot.id)}"><div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Replication snapshot')}</strong><span>${operationsDate(snapshot.completedAt || snapshot.createdAt)} · ${Number(snapshot.files || 0)} files · ${operationsBytes(snapshot.bytes)}${snapshot.verifiedAt ? ` · verified ${operationsDate(snapshot.verifiedAt)}` : ''}${snapshot.lastRestoreDrillAt ? ` · restore drill ${operationsDate(snapshot.lastRestoreDrillAt)}` : ''}</span><code>${escHtml(snapshot.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm operations-replication-verify">Verify SHA-256</button><button class="btn btn-sm operations-replication-drill">Local restore drill</button></div></div>`).join('') || '<p class="hint">No replication snapshots yet.</p>';
|
||||
}
|
||||
|
||||
function renderMonitoring(value) {
|
||||
const metrics = [
|
||||
['Uptime', `${Math.round(Number(value.uptimeSeconds || 0) / 60)} min.`],
|
||||
['Memory', operationsBytes(value.memory?.rssBytes)],
|
||||
['Games', value.library?.totalGames || 0],
|
||||
['Backups', value.library?.totalBackups || 0],
|
||||
['FTP', value.ftpConnected ? 'Connected' : 'Offline'],
|
||||
['Task failures', value.automation?.recentFailures || 0],
|
||||
['Replications', value.replication?.snapshots || 0],
|
||||
['API p95', `${Number(value.api?.p95DurationMs || 0)} ms`],
|
||||
['Last scan', value.scans?.lastDurationMs == null ? 'not measured' : `${Number(value.scans.lastDurationMs)} ms`],
|
||||
['Transfer / 24h', operationsBytes(value.transfers?.bytes)],
|
||||
['Cache hit', `${Math.round(Number(value.transport?.staticCacheHitRatio || 0) * 100)}%`],
|
||||
['WebSocket disconnects', Number(value.transport?.wsDisconnects || 0)],
|
||||
];
|
||||
$('operations-monitoring-summary').innerHTML = metrics.map(([label, result]) => `<div class="operations-metric"><strong>${escHtml(result)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderSessions(result) {
|
||||
$('operations-session-list').innerHTML = (result.sessions || []).map(session => `<div class="operations-recovery-item" data-session-id="${escHtml(session.id)}"><div class="operations-recovery-info"><strong>${session.current ? 'This browser' : escHtml(session.userAgent || 'Unknown browser')}</strong><span>${escHtml(session.ip || 'unknown')} · last active ${operationsDate(session.lastSeenAt)} · expires ${operationsDate(session.expiresAt)}</span><code>${escHtml(session.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm btn-danger operations-session-revoke">${session.current ? 'Sign out' : 'Revoke'}</button></div></div>`).join('') || '<p class="hint">No active sessions.</p>';
|
||||
}
|
||||
|
||||
async function loadOperationsReliability({ focus = '' } = {}) {
|
||||
if (focus === 'security') {
|
||||
if (!window.pork.authSessions) {
|
||||
$('operations-session-list').innerHTML = '<p class="hint">Session management is available only in server mode.</p>';
|
||||
return;
|
||||
}
|
||||
renderSessions(await window.pork.authSessions());
|
||||
return;
|
||||
}
|
||||
if (!window.pork.backupReplicationStatus || !window.pork.authSessions) {
|
||||
$('operations-replication-status').innerHTML = '<p class="hint">Session and replication management are available only in server mode.</p>';
|
||||
return;
|
||||
}
|
||||
const [replication, monitoring, token, sessions] = await Promise.all([
|
||||
window.pork.backupReplicationStatus(), window.pork.monitoringSnapshot(), window.pork.monitoringTokenStatus(), window.pork.authSessions(),
|
||||
]);
|
||||
renderReplicationStatus(replication);
|
||||
renderMonitoring(monitoring);
|
||||
$('operations-monitoring-token-state').textContent = token.configured
|
||||
? window.t('operations.monitoring.tokenActive', 'Metrics token active · endpoint /metrics')
|
||||
: window.t('operations.monitoring.tokenDisabled', 'Metrics disabled until a token is created');
|
||||
renderSessions(sessions);
|
||||
}
|
||||
|
||||
async function loadOperations({ background = false } = {}) {
|
||||
const [status, storage, snapshots, compatibility, topology] = await Promise.all([
|
||||
window.pork.operationsStatus(),
|
||||
window.pork.storageStatus().catch(() => null),
|
||||
window.pork.recoveryList().catch(() => []),
|
||||
window.pork.compatibilityGet().catch(() => ({ profile: {}, checks: [] })),
|
||||
window.pork.consoleStorageTopology?.().catch(() => null) || Promise.resolve(null),
|
||||
]);
|
||||
renderOperationsAutomation(status);
|
||||
renderOperationsStorage(storage);
|
||||
renderOperationsRecovery(snapshots);
|
||||
renderOperationsCompatibility(compatibility);
|
||||
renderStorageTopology(topology);
|
||||
_operationsLoaded = true;
|
||||
if (!background) $('operations-health').title = window.tf('operations.status.updated', 'Updated {time}', {
|
||||
time: new Date().toLocaleTimeString(window.i18n?.current || 'en'),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOperationsShadowMount() {
|
||||
const result = await window.pork.shadowMountStatus();
|
||||
const config = result.files?.['shadowmount-config'] || {};
|
||||
const manual = result.files?.['shadowmount-manual'] || {};
|
||||
$('operations-shadow-config').value = config.text || '';
|
||||
$('operations-shadow-manual').value = manual.text || '';
|
||||
for (const [element, file] of [[$('operations-shadow-config-state'), config], [$('operations-shadow-manual-state'), manual]]) {
|
||||
element.textContent = file.ok ? 'Loaded' : 'Unavailable';
|
||||
element.className = `badge ${file.ok ? 'badge-green' : 'badge-red'}`;
|
||||
element.title = file.error || '';
|
||||
}
|
||||
renderOperationsShadowMountModel(result.model || { summary: {}, entries: [], unknownStatusLines: [] });
|
||||
renderOperationsShadowPreflight(result.preflight);
|
||||
}
|
||||
|
||||
function renderOperationsShadowPreflight(preflight) {
|
||||
const target = $('operations-shadow-preflight');
|
||||
if (!target) return;
|
||||
if (!preflight) {
|
||||
target.innerHTML = '<strong>Configuration preflight unavailable</strong><p>Load a reachable config.ini or run the check again while saving.</p>';
|
||||
return;
|
||||
}
|
||||
const counts = preflight.counts || {};
|
||||
const headline = !preflight.ok ? 'Save blocked' : preflight.requiresRiskConfirmation ? 'Explicit risk confirmation required' : 'Preflight passed';
|
||||
const findings = (preflight.findings || []).map(item => `<li><strong>${escHtml(item.severity)}</strong>${item.line ? ` · line ${Number(item.line)}` : ''}${item.key ? ` · ${escHtml(item.key)}` : ''}: ${escHtml(item.message)}</li>`).join('');
|
||||
target.innerHTML = `<strong>${escHtml(headline)}</strong><p>${Number(counts.invalid || 0)} invalid · ${Number(counts.critical || 0)} critical · ${Number(counts.warning || 0)} warning(s) · ${Number(counts.unknown || 0)} unknown future key(s)</p>${findings ? `<ul>${findings}</ul>` : '<p>No safety issues in the recognized configuration.</p>'}`;
|
||||
}
|
||||
|
||||
function renderOperationsShadowMountModel(model) {
|
||||
const summary = model.summary || {};
|
||||
const entries = model.entries || [];
|
||||
const statusBadge = $('operations-shadow-status-state');
|
||||
statusBadge.textContent = summary.invalid || summary.unknownStatusLines ? 'review needed' : `${summary.installed || 0} installed`;
|
||||
statusBadge.className = `badge ${summary.invalid || summary.unknownStatusLines ? 'badge-yellow' : 'badge-green'}`;
|
||||
$('operations-shadow-summary').innerHTML = [
|
||||
['Active sources', summary.active || 0],
|
||||
['Installed', summary.installed || 0],
|
||||
['Pending', summary.pending || 0],
|
||||
['Deleted', summary.deleted || 0],
|
||||
['Experimental', summary.experimental || 0],
|
||||
['Library matches', summary.libraryMatches || 0],
|
||||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
|
||||
const stateLabel = { installed: 'Installed', pending: 'Pending', deleted: 'Deleted', invalid: 'Invalid' };
|
||||
const supportLabel = { recommended: 'Recommended', compatibility: 'Compatibility', experimental: 'Experimental', normal: 'Folder', unknown: 'Unknown' };
|
||||
const rows = entries.map(entry => `<article class="operations-detail-row shadowmount-entry ${entry.warnings?.length ? 'has-warning' : ''}">
|
||||
<div><strong>${escHtml(entry.titleName || entry.titleId || entry.sourceName || 'Unknown source')}</strong><span>${escHtml(entry.titleId || 'no title ID')} · ${escHtml(entry.format?.label || 'Unknown')} · ${escHtml(supportLabel[entry.format?.support] || entry.format?.support || 'Unknown')}</span><code>${escHtml(entry.sourcePath)}</code>${entry.warnings?.length ? `<small>${entry.warnings.map(escHtml).join(' ')}</small>` : ''}</div>
|
||||
<span class="badge ${entry.state === 'installed' ? 'badge-green' : entry.state === 'deleted' || entry.state === 'invalid' ? 'badge-red' : 'badge-yellow'}">${escHtml(stateLabel[entry.state] || entry.state)}</span>
|
||||
</article>`);
|
||||
if (model.unknownStatusLines?.length) rows.push(`<article class="operations-detail-row has-warning"><div><strong>${model.unknownStatusLines.length} unknown status line(s)</strong><span>Preserved because the upstream format was not recognized.</span><code>${escHtml(model.unknownStatusLines.map(item => `line ${item.line}: ${item.text}`).join('\n'))}</code></div><span class="badge badge-yellow">Raw</span></article>`);
|
||||
$('operations-shadow-entries').innerHTML = rows.length ? rows.join('') : '<p class="hint">manual.lst and manual.status do not contain game sources yet.</p>';
|
||||
}
|
||||
|
||||
async function loadOperationsPackages() {
|
||||
const result = await window.pork.packagesStatus();
|
||||
$('operations-package-services').innerHTML = (result.services || []).map(service => `<div class="operations-service"><span class="operations-service-dot ${service.reachable ? 'online' : ''}"></span><div><strong>${escHtml(service.label)}</strong><span>Port ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}</span></div><b>${service.reachable ? 'Online' : 'Offline'}</b></div>`).join('');
|
||||
return result;
|
||||
}
|
||||
|
||||
function showOperationsJson(element, value) {
|
||||
element.hidden = false;
|
||||
element.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function klogFilters() {
|
||||
return { severity: $('operations-klog-severity').value, search: $('operations-klog-search').value.trim(), limit: 200 };
|
||||
}
|
||||
|
||||
function renderKlogStatus(status) {
|
||||
const badge = $('operations-klog-state');
|
||||
badge.textContent = status.running ? status.connected ? 'Capture active' : 'Connecting…' : 'Stopped';
|
||||
badge.className = `badge ${status.running ? status.connected ? 'badge-green' : 'badge-yellow' : ''}`;
|
||||
$('operations-klog-start').disabled = status.running || !status.available;
|
||||
$('operations-klog-stop').disabled = !status.running;
|
||||
$('operations-klog-summary').innerHTML = [
|
||||
['Port', status.port || 3232], ['Status', status.reason || 'unknown'], ['Lines', status.lines || 0],
|
||||
['Received', operationsBytes(status.bytes)], ['Write contract', status.zeroWriteContract ? '0 bytes' : 'unknown'], ['Payload started', status.payloadLaunchAttempted ? 'yes' : 'no'],
|
||||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderKlogEntries(result) {
|
||||
$('operations-klog-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
|
||||
<div><strong>${escHtml(entry.severity.toUpperCase())}</strong><span>${operationsDate(entry.time)}</span></div>
|
||||
${operationsStatusBadge(entry.severity === 'error' ? 'failed' : entry.severity === 'warn' ? 'warning' : 'completed')}
|
||||
<span>${escHtml(String(entry.sessionId || '').slice(0, 8))}</span>
|
||||
<span class="operations-run-message" title="${escHtml(entry.line)}">${escHtml(entry.line)}</span>
|
||||
</div>`).join('') || '<p class="hint">No kernel log records match these filters.</p>';
|
||||
}
|
||||
|
||||
async function loadKlog() {
|
||||
const [status, result] = await Promise.all([window.pork.klogStatus(), window.pork.klogQuery(klogFilters())]);
|
||||
renderKlogStatus(status);
|
||||
renderKlogEntries(result);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', async () => {
|
||||
const tab = button.dataset.operationsTab;
|
||||
document.querySelectorAll('.operations-tab').forEach(item => item.classList.toggle('active', item === button));
|
||||
document.querySelectorAll('.operations-panel').forEach(panel => {
|
||||
const active = panel.dataset.operationsPanel === tab;
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
try {
|
||||
if (tab === 'shadowmount') await loadOperationsShadowMount();
|
||||
if (tab === 'storage') await loadStorageTopology();
|
||||
if (tab === 'packages') await loadOperationsPackages();
|
||||
if (tab === 'reliability') await loadOperationsReliability();
|
||||
if (tab === 'audit') await loadOperationsAudit();
|
||||
if (tab === 'diagnostics') await loadKlog();
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
}));
|
||||
|
||||
$('operations-refresh')?.addEventListener('click', event => operationsAction('refresh', event.currentTarget, () => loadOperations()).catch(error => showToast(error.message, 'error')));
|
||||
|
||||
$('operations-task-list')?.addEventListener('click', async event => {
|
||||
const row = event.target.closest('.operations-task');
|
||||
if (!row) return;
|
||||
const taskId = row.dataset.taskId;
|
||||
try {
|
||||
if (event.target.closest('.operations-task-run')) {
|
||||
await operationsAction(`run-${taskId}`, event.target.closest('button'), () => window.pork.operationsRunTask(taskId));
|
||||
showToast('Task completed.', 'ok');
|
||||
await loadOperations();
|
||||
}
|
||||
if (event.target.closest('.operations-task-save')) {
|
||||
await operationsAction(`save-${taskId}`, event.target.closest('button'), () => window.pork.operationsConfigureTask(taskId, {
|
||||
enabled: row.querySelector('.operations-task-enabled').checked,
|
||||
intervalMinutes: Number(row.querySelector('.operations-task-interval').value),
|
||||
}));
|
||||
showToast('Schedule saved.', 'ok');
|
||||
await loadOperations();
|
||||
}
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-storage-run')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const report = await operationsAction('storage', event.currentTarget, () => window.pork.storageAudit());
|
||||
renderOperationsStorage(report);
|
||||
showToast('Storage audit completed. Nothing was deleted.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-storage-topology-refresh')?.addEventListener('click', event => operationsAction('storage-topology', event.currentTarget, loadStorageTopology).catch(error => showToast(error.message, 'error')));
|
||||
|
||||
$('operations-recovery-create')?.addEventListener('click', async event => {
|
||||
const label = await showPrompt('Name for this restore point:', 'Manual restore point');
|
||||
if (label == null) return;
|
||||
try {
|
||||
await operationsAction('recovery-create', event.currentTarget, () => window.pork.recoveryCreate({ label, includeRemote: true }));
|
||||
renderOperationsRecovery(await window.pork.recoveryList());
|
||||
showToast('Restore point saved safely.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-recovery-import')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const result = await operationsAction('recovery-import', event.currentTarget, () => window.pork.recoveryImport());
|
||||
if (!result) return;
|
||||
renderOperationsRecovery(await window.pork.recoveryList());
|
||||
showToast(`Archive imported as ${result.id} and verified with SHA-256. Nothing was restored.`, 'ok', 9000);
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
|
||||
$('operations-recovery-list')?.addEventListener('click', async event => {
|
||||
const row = event.target.closest('.operations-recovery-item');
|
||||
if (!row) return;
|
||||
const id = row.dataset.snapshotId;
|
||||
try {
|
||||
if (event.target.closest('.operations-recovery-verify')) {
|
||||
const verified = await operationsAction(`verify-${id}`, event.target.closest('button'), () => window.pork.recoveryVerify(id));
|
||||
showToast(`${verified.files.length} files verified (${verified.integrity}). Nothing was restored.`, 'ok');
|
||||
}
|
||||
if (event.target.closest('.operations-recovery-export')) await operationsAction(`export-${id}`, event.target.closest('button'), () => window.pork.recoveryExport(id));
|
||||
if (event.target.closest('.operations-recovery-restore')) {
|
||||
if (!await showExactConfirmation('This restores the database, settings and available PS5 configuration. A new safety recovery point is created first.', 'RESTORE')) return;
|
||||
await operationsAction(`restore-${id}`, event.target.closest('button'), () => window.pork.recoveryRestore(id, true));
|
||||
showToast('Restore completed. Refresh the page if settings are not immediately visible.', 'ok', 8000);
|
||||
await loadOperations();
|
||||
}
|
||||
if (event.target.closest('.operations-recovery-delete')) {
|
||||
if (!await showExactConfirmation('Permanently delete this recovery point?', 'DELETE')) return;
|
||||
await operationsAction(`delete-${id}`, event.target.closest('button'), () => window.pork.recoveryDelete(id));
|
||||
renderOperationsRecovery(await window.pork.recoveryList());
|
||||
}
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-replication-refresh')?.addEventListener('click', event => operationsAction('replication-refresh', event.currentTarget, loadOperationsReliability).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-replication-run')?.addEventListener('click', async event => {
|
||||
const includeGameBackups = $('operations-replication-games').checked;
|
||||
const warning = includeGameBackups ? '\n\nFull game backups are selected and may use a large amount of storage.' : '';
|
||||
if (!await showConfirm(`Create a new hash-verified snapshot on the secondary backup target? Nothing is removed from the source.${warning}`)) return;
|
||||
try {
|
||||
const label = `Manual replication ${new Date().toLocaleString(window.i18n?.current || 'en')}`;
|
||||
await operationsAction('replication-run', event.currentTarget, () => window.pork.backupReplicationRun({ includeGameBackups, label }));
|
||||
await loadOperationsReliability();
|
||||
showToast('Replication completed and every copied file was verified.', 'ok', 8000);
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
$('operations-replication-list')?.addEventListener('click', async event => {
|
||||
const row = event.target.closest('[data-replication-id]');
|
||||
if (!row) return;
|
||||
try {
|
||||
const button = event.target.closest('button');
|
||||
if (button?.classList.contains('operations-replication-verify')) {
|
||||
await operationsAction(`replication-verify-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationVerify(row.dataset.replicationId));
|
||||
showToast('Snapshot fully verified with SHA-256.', 'ok');
|
||||
} else if (button?.classList.contains('operations-replication-drill')) {
|
||||
if (!await showConfirm('Copy every snapshot file to a temporary local destination, verify its hash, then remove the temporary copies? The PS5 is not modified.')) return;
|
||||
const result = await operationsAction(`replication-drill-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationRestoreDrill(row.dataset.replicationId));
|
||||
showToast(`Restore drill succeeded: ${result.files} files, PS5 unchanged.`, 'ok', 8000);
|
||||
} else return;
|
||||
await loadOperationsReliability();
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
$('operations-replication-retention')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const preview = await operationsAction('replication-retention', event.submitter, () => window.pork.backupReplicationRetentionSave({ enabled: $('operations-replication-retention-enabled').checked, maxSnapshots: Number($('operations-replication-retention-count').value), maxAgeDays: Number($('operations-replication-retention-days').value) }));
|
||||
showToast(`Retention saved. ${preview.removable?.length || 0} snapshot(s) will be eligible after the next successful replication.`, 'ok', 9000);
|
||||
await loadOperationsReliability();
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-monitoring-refresh')?.addEventListener('click', event => operationsAction('monitoring-refresh', event.currentTarget, async () => renderMonitoring(await window.pork.monitoringSnapshot())).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-monitoring-token')?.addEventListener('click', async event => {
|
||||
if (!await showConfirm('Create a new metrics token? An existing token becomes invalid immediately.')) return;
|
||||
try {
|
||||
const result = await operationsAction('metrics-token', event.currentTarget, () => window.pork.monitoringTokenRotate());
|
||||
$('operations-monitoring-token-state').textContent = 'New token active. Save it now; Chimera will not show it again.';
|
||||
showOperationsJson($('operations-monitoring-token-output'), `Endpoint: ${location.origin}${result.endpoint}\nAuthorization: Bearer ${result.token}\n\nThis token is shown only once.`);
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-sessions-refresh')?.addEventListener('click', event => operationsAction('sessions-refresh', event.currentTarget, async () => renderSessions(await window.pork.authSessions())).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-session-list')?.addEventListener('click', async event => {
|
||||
const row = event.target.closest('[data-session-id]');
|
||||
if (!row || !event.target.closest('.operations-session-revoke') || !await showExactConfirmation('Revoke this session immediately?', 'REVOKE')) return;
|
||||
try {
|
||||
const result = await window.pork.authSessionRevoke(row.dataset.sessionId);
|
||||
if (result.current) { location.replace('/server-login.html'); return; }
|
||||
renderSessions(await window.pork.authSessions());
|
||||
showToast('Session revoked.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-password-form')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const currentPassword = $('operations-password-current').value;
|
||||
const newPassword = $('operations-password-new').value;
|
||||
if (newPassword !== $('operations-password-repeat').value) { showToast('The new passwords do not match.', 'error'); return; }
|
||||
if (!await showConfirm('Change the password and revoke all other active sessions?')) return;
|
||||
try {
|
||||
const result = await operationsAction('password-change', event.submitter, () => window.pork.authPasswordChange(currentPassword, newPassword));
|
||||
event.currentTarget.reset();
|
||||
showToast(`Password changed; ${result.otherSessionsRevoked} other session(s) revoked.`, 'ok', 8000);
|
||||
renderSessions(await window.pork.authSessions());
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-profile-save')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const result = await operationsAction('profile', event.currentTarget, () => window.pork.compatibilitySet({
|
||||
firmware: $('operations-profile-firmware').value,
|
||||
hen: $('operations-profile-hen').value,
|
||||
henVersion: $('operations-profile-hen-version').value,
|
||||
kstuffVersion: $('operations-profile-kstuff').value,
|
||||
loader: $('operations-profile-loader').value,
|
||||
notes: $('operations-profile-notes').value,
|
||||
}));
|
||||
renderOperationsCompatibility(result);
|
||||
showToast('PS5 profile saved.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-shadow-reload')?.addEventListener('click', event => operationsAction('shadow-load', event.currentTarget, loadOperationsShadowMount).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-shadow-propose')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const proposal = await operationsAction('shadow-proposal', event.currentTarget, () => window.pork.shadowMountProposal());
|
||||
$('operations-shadow-manual').value = proposal.proposedManual;
|
||||
$('operations-shadow-preflight').innerHTML = `<strong>Library proposal · ${proposal.additions.length} addition(s)</strong><p>${proposal.unchanged.length} unchanged · ${proposal.unresolved.length} unresolved. Nothing was written to the PS5.</p>${proposal.unresolved.length ? `<ul>${proposal.unresolved.slice(0, 20).map(item => `<li>${escHtml(item.gameId)} · ${escHtml(item.reason)}</li>`).join('')}</ul>` : ''}`;
|
||||
showToast(proposal.additions.length ? 'ShadowMount proposal loaded for review; save is still separate.' : 'No new PS5-visible paths were found.', proposal.unresolved.length ? 'info' : 'ok', 9000);
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
$('operations-shadow-save')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const config = $('operations-shadow-config').value;
|
||||
const manual = $('operations-shadow-manual').value;
|
||||
const preflight = await operationsAction('shadow-preflight', event.currentTarget, () => window.pork.shadowMountPreflight(config));
|
||||
renderOperationsShadowPreflight(preflight);
|
||||
if (!preflight.ok) throw new Error('The configuration contains invalid values. Resolve the preflight findings before saving.');
|
||||
let confirmation = null;
|
||||
if (preflight.requiresRiskConfirmation) {
|
||||
confirmation = await showExactConfirmation('This configuration activates critical ShadowMount options. Review the preflight carefully.', preflight.confirmationPhrase);
|
||||
if (!confirmation) return;
|
||||
} else if (!await showConfirm('Save the ShadowMount configuration? Chimera creates a recovery point first.')) return;
|
||||
const result = await operationsAction('shadow-save', event.currentTarget, () => window.pork.shadowMountSave({ config, manual, confirmation }));
|
||||
showToast(`ShadowMount saved. Safety restore point: ${result.snapshotId}`, 'ok', 8000);
|
||||
await loadOperationsShadowMount();
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-shadow-logs')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const result = await operationsAction('shadow-logs', event.currentTarget, () => window.pork.shadowMountLogs());
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
showOperationsJson($('operations-shadow-output'), result.text || 'debug.log is empty.');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-package-probe')?.addEventListener('click', event => operationsAction('package-probe', event.currentTarget, loadOperationsPackages).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-package-list')?.addEventListener('click', async event => {
|
||||
try { showOperationsJson($('operations-package-output'), await operationsAction('package-list', event.currentTarget, () => window.pork.packagesList())); }
|
||||
catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-package-progress')?.addEventListener('click', async event => {
|
||||
try { showOperationsJson($('operations-package-output'), await operationsAction('package-progress', event.currentTarget, () => window.pork.packagesProgress())); }
|
||||
catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-package-install')?.addEventListener('click', async event => {
|
||||
const url = $('operations-package-url').value.trim();
|
||||
if (!url || !await showConfirm(`Send this HTTPS URL to etaHEN DPI?\n\n${url}`)) return;
|
||||
try {
|
||||
const result = await operationsAction('package-install', event.currentTarget, () => window.pork.packagesInstallUrl(url));
|
||||
showOperationsJson($('operations-package-output'), result);
|
||||
showToast('Package URL sent to etaHEN DPI.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('operations-diagnostics-run')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const result = await operationsAction('diagnostics', event.currentTarget, () => window.pork.diagnosticsSummary());
|
||||
const services = result.packages?.services || [];
|
||||
const build = result.chimera?.build || {};
|
||||
$('operations-diagnostics-summary').innerHTML = [
|
||||
['Chimera', result.chimera?.version || '—'],
|
||||
['Build', build.id || 'development'],
|
||||
['Revision', String(build.revision || 'development').slice(0, 12)],
|
||||
['Built', build.builtAt ? operationsDate(build.builtAt) : '—'],
|
||||
['Uptime', `${Math.round(Number(result.chimera?.uptimeSeconds || 0) / 60)} min.`],
|
||||
['FTP', result.ps5?.ftpConnected ? 'Connected' : result.ps5?.ftpReconnectAvailable ? 'Reconnect available' : 'Not connected'],
|
||||
['PS5 services', `${services.filter(item => item.reachable).length}/${services.length} online`],
|
||||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||||
showOperationsJson($('operations-diagnostics-log'), result.recentLogs || []);
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-diagnostics-export')?.addEventListener('click', event => operationsAction('diagnostics-export', event.currentTarget, () => window.pork.diagnosticsExport()).then(() => showToast('Geschoonde supportbundel aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||||
|
||||
$('operations-audit-filter')?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
operationsAction('audit-filter', event.submitter, loadOperationsAudit).catch(error => showToast(error.message, 'error'));
|
||||
});
|
||||
$('operations-audit-refresh')?.addEventListener('click', event => operationsAction('audit-refresh', event.currentTarget, loadOperationsAudit).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-audit-export')?.addEventListener('click', event => operationsAction('audit-export', event.currentTarget, () => window.pork.auditExport(operationsAuditFilters())).then(() => showToast('Gefilterde auditexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||||
|
||||
$('operations-klog-start')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const status = await operationsAction('klog-start', event.currentTarget, () => window.pork.klogStart({ durationSeconds: Number($('operations-klog-duration').value) }));
|
||||
renderKlogStatus(status);
|
||||
showToast('Passive capture started; Chimera writes nothing to the PS5.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-klog-stop')?.addEventListener('click', async event => {
|
||||
try {
|
||||
renderKlogStatus(await operationsAction('klog-stop', event.currentTarget, () => window.pork.klogStop()));
|
||||
await loadKlog();
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
$('operations-klog-refresh')?.addEventListener('click', event => operationsAction('klog-refresh', event.currentTarget, loadKlog).catch(error => showToast(error.message, 'error')));
|
||||
$('operations-klog-export')?.addEventListener('click', event => operationsAction('klog-export', event.currentTarget, () => window.pork.klogExport(klogFilters())).then(() => showToast('Geschoonde kernellogexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||||
|
||||
window.pork?.on?.('operations:task-update', () => {
|
||||
if (document.querySelector('#page-operations.active')) loadOperations().catch(() => {});
|
||||
});
|
||||
window.pork?.on?.('klog:status', status => renderKlogStatus(status));
|
||||
window.addEventListener('chimera:language-changed', () => {
|
||||
if (!document.querySelector('#page-operations.active [data-operations-panel="storage"]:not([hidden])')) return;
|
||||
renderOperationsStorage(_operationsStorageReport);
|
||||
if (_operationsStorageTopology) renderStorageTopology(_operationsStorageTopology);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
(function () {
|
||||
let catalog = { sets: [], stats: {} };
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
||||
const tr = (key, fallback) => window.t?.(key, fallback) || fallback;
|
||||
if (!document.querySelector('link[data-package-library-style]')) {
|
||||
const link = document.createElement('link');
|
||||
Object.assign(link, { rel: 'stylesheet', href: 'styles/package-library.css' });
|
||||
link.dataset.packageLibraryStyle = 'true';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const componentHtml = component => `<li><div><strong>${esc(component.type)}</strong><span>${esc(component.componentId)} ${tr(`packageLibrary.${component.required ? 'required' : 'optional'}`, component.required ? 'required' : 'optional')}</span></div><div><span>v${esc(component.version || '?')} ${esc(component.region || '')}</span><code title="SHA-256 ${esc(component.sha256)}">${esc(component.sha256)}</code></div></li>`;
|
||||
const recipeHtml = item => `<li><strong>${esc(item.label || item.recipeKey)}</strong><span>v${esc(item.version)} ${esc(item.state)} ${esc(item.recipeSha256.slice(0, 12))}...</span></li>`;
|
||||
function setHtml(set) {
|
||||
const recipes = set.recipes.length ? set.recipes.map(recipeHtml).join('') : `<li class="package-library-empty">${esc(tr('packageLibrary.noRecipes', 'No recipes yet'))}</li>`;
|
||||
return `<article class="package-library-set"><header><div><span>${esc(set.title.externalTitleId)}</span><h3>${esc(set.title.name)}</h3></div><div><strong>${esc(set.label || set.setKey)}</strong><small>${esc(set.platform)} ${esc(set.region || '')} ${esc(set.confidence)} ${esc(set.source)}</small></div></header><div class="package-library-columns"><section><h4>${esc(tr('packageLibrary.components', 'Components'))} (${set.components.length})</h4><ul class="package-component-list">${set.components.map(componentHtml).join('')}</ul></section><section><h4>${esc(tr('packageLibrary.recipes', 'Recipes'))} (${set.recipes.length})</h4><ul class="package-recipe-list">${recipes}</ul></section></div></article>`;
|
||||
}
|
||||
function render() {
|
||||
const host = document.getElementById('package-library-content');
|
||||
if (!host) return;
|
||||
const query = document.getElementById('package-library-search')?.value.trim().toLowerCase();
|
||||
const sets = catalog.sets.filter(set => !query || JSON.stringify(set).toLowerCase().includes(query));
|
||||
host.innerHTML = sets.length ? `<div class="package-library-list">${sets.map(setHtml).join('')}</div>` : `<div class="operations-empty"><strong>${esc(tr('packageLibrary.empty', 'No package sets match'))}</strong><span>${esc(tr('packageLibrary.emptyDetail', 'Import or adjust the search. Package inventory never implies installation.'))}</span></div>`;
|
||||
const stats = document.getElementById('package-library-stats');
|
||||
if (stats) stats.textContent = [['titles', 'titles'], ['sets', 'sets'], ['components', 'componentsLower'], ['recipes', 'recipesLower']].map(([field, key]) => `${catalog.stats[field] || 0} ${tr(`packageLibrary.${key}`, field)}`).join(' / ');
|
||||
}
|
||||
window.loadPackages = async () => { catalog = await window.pork.packageCatalog(); render(); };
|
||||
document.getElementById('package-library-search')?.addEventListener('input', render);
|
||||
})();
|
||||
@@ -0,0 +1,636 @@
|
||||
// ── Payload Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
function renderPs5PayloadManagerObservability(snapshot) {
|
||||
const root = $('pldmgr-observability');
|
||||
if (!root || !snapshot) return;
|
||||
const autoload = snapshot.autoload || {};
|
||||
const processes = snapshot.processes || {};
|
||||
const logs = snapshot.logs || {};
|
||||
const stateLabels = {
|
||||
idle: window.t('payload.state.idle', 'idle'),
|
||||
countdown: window.t('payload.state.countdown', 'countdown'),
|
||||
running: window.t('payload.state.running', 'running'),
|
||||
done: window.t('payload.state.done', 'done'),
|
||||
unknown: window.t('common.unknown', 'unknown'),
|
||||
};
|
||||
const stateClasses = { done: 'badge-green', running: 'badge-blue', countdown: 'badge-yellow', idle: '', unknown: 'badge-red' };
|
||||
const state = autoload.supported ? (autoload.state || 'unknown') : 'unknown';
|
||||
const daemonItems = (processes.items || []).filter(item => item.daemon).slice(0, 40);
|
||||
|
||||
root.hidden = false;
|
||||
$('pldmgr-autoload-metric').textContent = autoload.supported ? `${autoload.done || 0}/${autoload.total || autoload.list?.length || 0}` : 'n/b';
|
||||
$('pldmgr-process-metric').textContent = processes.supported ? String(processes.total || 0) : 'n/b';
|
||||
$('pldmgr-memory-metric').textContent = processes.supported ? `${Number(processes.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB` : 'n/a';
|
||||
$('pldmgr-log-metric').textContent = logs.supported ? String(logs.total || 0) : 'n/b';
|
||||
$('pldmgr-autoload-state').textContent = stateLabels[state] || state;
|
||||
$('pldmgr-autoload-state').className = `badge ${stateClasses[state] || ''}`.trim();
|
||||
|
||||
const detail = [];
|
||||
if (autoload.supported) {
|
||||
if (autoload.current) detail.push(window.tf('payload.current', 'Current: {value}', { value: autoload.current }));
|
||||
if (autoload.remaining > 0) detail.push(window.tf('payload.startsIn', 'starts in {seconds}s', { seconds: autoload.remaining }));
|
||||
if (autoload.list?.length) detail.push(window.tf('payload.itemsConfigured', '{count} item(s) configured', { count: autoload.list.length }));
|
||||
if (autoload.scanUsbPayloads) detail.push(window.t('payload.usbScanActive', 'USB scan active'));
|
||||
} else detail.push(autoload.unavailable || window.t('payload.endpointUnsupported', 'This endpoint is not supported by this Payload Manager version.'));
|
||||
$('pldmgr-autoload-detail').textContent = detail.join(' · ');
|
||||
|
||||
$('pldmgr-process-list').innerHTML = processes.supported
|
||||
? (daemonItems.length ? daemonItems.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name)}</strong><span>PID ${item.pid}</span></div><b>${Number(item.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB</b></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noDaemons', 'No separate payload daemons detected.'))}</p>`)
|
||||
: `<p class="hint">${escHtml(processes.unavailable || window.t('payload.processesUnsupported', 'Processes are unavailable in this Payload Manager version.'))}</p>`;
|
||||
$('pldmgr-log-output').textContent = logs.supported && logs.lines?.length
|
||||
? `${logs.truncated ? `[${window.t('payload.latestLogsOnly', 'latest lines only')}]\n` : ''}${logs.lines.join('\n')}`
|
||||
: (logs.unavailable || window.t('payload.noLogs', 'No log lines yet.'));
|
||||
$('pldmgr-observability-note').textContent = window.tf(
|
||||
'payload.snapshotNote',
|
||||
'Read-only snapshot from {time}. Chimera never terminates a process automatically.',
|
||||
{ time: new Date(snapshot.checkedAt || Date.now()).toLocaleTimeString(window.i18n?.current || 'en') },
|
||||
);
|
||||
}
|
||||
|
||||
function renderPs5PayloadManagerInventory(inventory) {
|
||||
const list = $('pldmgr-installed-list');
|
||||
if (!list) return;
|
||||
const payloads = (inventory?.payloads || []).slice(0, 200);
|
||||
list.innerHTML = payloads.length ? payloads.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name || item.filename)}</strong><span>${escHtml(item.version || window.t('payload.unknownVersion', 'unknown version'))}${item.checksumVerified ? ` · ${escHtml(window.t('payload.checksumKnown', 'checksum known'))}` : ''}</span></div><button class="btn btn-sm pldmgr-safe-launch" data-payload-path="${escHtml(item.path)}">${escHtml(window.t('payload.preflightStart', 'Preflight & start'))}</button></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noneInstalled', 'No installed payloads reported.'))}</p>`;
|
||||
}
|
||||
|
||||
async function refreshPs5PayloadManagerObservability({ quiet = false } = {}) {
|
||||
if (!window.pork.pldmgrObservability) return null;
|
||||
try {
|
||||
const result = await window.pork.pldmgrObservability();
|
||||
renderPs5PayloadManagerObservability(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!quiet) showToast(error.message, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectPs5PayloadManager({ quiet = false, persistPort = false } = {}) {
|
||||
const status = $('pldmgr-status');
|
||||
const version = $('pldmgr-version');
|
||||
const frame = $('pldmgr-frame');
|
||||
if (!window.pork.pldmgrProbe || !status || !frame) return;
|
||||
if (!quiet) status.textContent = window.t('payload.connectingPs5', 'Connecting to PS5…');
|
||||
try {
|
||||
const port = Number($('pldmgr-port')?.value || 8084);
|
||||
if (persistPort) await window.pork.setSettings({ payloadManagerPort: port });
|
||||
const settings = await window.pork.getSettings();
|
||||
if (!settings.ftpHost) throw new Error(window.t('payload.configureIp', 'Configure the PS5 IP address first.'));
|
||||
const [result] = await Promise.all([
|
||||
window.pork.pldmgrProbe(),
|
||||
refreshPs5PayloadManagerObservability({ quiet: true }),
|
||||
]);
|
||||
const versionValue = typeof result.version === 'string'
|
||||
? result.version
|
||||
: (result.version?.version || result.version?.menu_version || 'online');
|
||||
version.textContent = String(versionValue).replace(/^"|"$/g, '') || 'online';
|
||||
const payloadCount = Number(result.inventory?.total || 0);
|
||||
renderPs5PayloadManagerInventory(result.inventory);
|
||||
const sourceCount = Number(result.sources?.total || 0);
|
||||
const repositoryCount = Number(result.repository?.total || 0);
|
||||
const autoloadRaw = result.autoload?.list || result.autoload?.AUTOLOAD_LIST || '';
|
||||
const autoloadCount = Array.isArray(autoloadRaw)
|
||||
? autoloadRaw.length
|
||||
: String(autoloadRaw || '').split(',').filter(Boolean).length;
|
||||
status.textContent = window.tf(
|
||||
'payload.connectedSummary',
|
||||
'Connected · {payloads} installed · {autoload} autoload · {sources} sources · {available} available',
|
||||
{ payloads: payloadCount, autoload: autoloadCount, sources: sourceCount, available: repositoryCount },
|
||||
);
|
||||
const embedUrl = await window.pork.pldmgrEmbedUrl();
|
||||
frame.dataset.url = embedUrl;
|
||||
if (!quiet || frame.dataset.userOpened === 'true') {
|
||||
frame.src = embedUrl;
|
||||
frame.hidden = false;
|
||||
} else {
|
||||
frame.hidden = true;
|
||||
}
|
||||
} catch (error) {
|
||||
version.textContent = 'offline';
|
||||
frame.hidden = true;
|
||||
if ($('pldmgr-observability')) $('pldmgr-observability').hidden = true;
|
||||
status.textContent = `${window.t('payload.unreachable', 'Unavailable')}: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-pldmgr-refresh')?.addEventListener('click', () => connectPs5PayloadManager());
|
||||
$('btn-pldmgr-logs-refresh')?.addEventListener('click', () => refreshPs5PayloadManagerObservability());
|
||||
$('pldmgr-installed-list')?.addEventListener('click', async event => {
|
||||
const button = event.target.closest('.pldmgr-safe-launch');
|
||||
if (!button) return;
|
||||
const payloadPath = button.dataset.payloadPath;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const preflight = await window.pork.pldmgrLaunchPreflight(payloadPath);
|
||||
let confirmConflict = false;
|
||||
if (preflight.conflict) {
|
||||
const conflictConfirmation = await showExactConfirmation(
|
||||
`${preflight.warning}\n\nActive: ${preflight.active.map(item => `${item.name} (PID ${item.pid})`).join(', ')}\n\nA duplicate instance can make the console unstable.`,
|
||||
'START PAYLOAD',
|
||||
);
|
||||
if (!conflictConfirmation) return;
|
||||
confirmConflict = true;
|
||||
} else if (!await showExactConfirmation(`Start ${preflight.selected} on the PS5?`, 'START PAYLOAD')) return;
|
||||
await window.pork.pldmgrLaunch(payloadPath, confirmConflict, 'START PAYLOAD');
|
||||
showToast(`${preflight.selected} was sent to Payload Manager.`, 'ok');
|
||||
setTimeout(() => refreshPs5PayloadManagerObservability({ quiet: true }), 1_000);
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
$('pldmgr-port')?.addEventListener('change', () => connectPs5PayloadManager({ persistPort: true }));
|
||||
$('btn-pldmgr-open')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const frame = $('pldmgr-frame');
|
||||
const url = frame?.dataset.url || await window.pork.pldmgrEmbedUrl();
|
||||
if (window.pork.isWebMode && String(url).startsWith('/')) {
|
||||
frame.dataset.userOpened = 'true';
|
||||
frame.src = url;
|
||||
frame.hidden = false;
|
||||
frame.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else await window.pork.openShell(url);
|
||||
} catch (error) { setStatus(error.message, 'error'); }
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const page = $('page-jailbreak');
|
||||
if (page?.classList.contains('active') && $('pldmgr-version')?.textContent !== 'offline') {
|
||||
refreshPs5PayloadManagerObservability({ quiet: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
async function loadPs5ServiceMatrix() {
|
||||
const grid = $('service-matrix-grid');
|
||||
const summary = $('service-matrix-summary');
|
||||
const button = $('btn-service-matrix-refresh');
|
||||
if (!grid || !window.pork.ps5ServiceMatrix) return;
|
||||
if (button) button.disabled = true;
|
||||
summary.textContent = 'controleren…';
|
||||
try {
|
||||
const result = await window.pork.ps5ServiceMatrix();
|
||||
summary.textContent = `${result.online}/${result.total} online`;
|
||||
grid.innerHTML = result.services.map(service => `
|
||||
<div class="service-matrix-item ${service.online ? 'online' : 'offline'}">
|
||||
<div class="service-matrix-head">
|
||||
<span class="service-matrix-dot"></span>
|
||||
<span class="service-matrix-name">${escHtml(service.name)}</span>
|
||||
<span class="service-matrix-port">:${service.port}${service.online && service.latencyMs != null ? ` · ${service.latencyMs} ms` : ''}</span>
|
||||
</div>
|
||||
<p>${service.online ? `${escHtml(service.section)} is reachable.` : escHtml(service.hint)}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
summary.textContent = 'check failed';
|
||||
grid.innerHTML = `<p class="hint">${escHtml(error.message)}</p>`;
|
||||
} finally { if (button) button.disabled = false; }
|
||||
}
|
||||
|
||||
$('btn-service-matrix-refresh')?.addEventListener('click', loadPs5ServiceMatrix);
|
||||
|
||||
async function renderSettingsPayloadSources() {
|
||||
const sources = await window.pork.payloadSourcesList();
|
||||
const cont = $('settings-payload-sources');
|
||||
if (!sources.length) {
|
||||
cont.innerHTML = '<p class="hint" style="margin:6px 0">No sources yet. Add one below.</p>';
|
||||
return;
|
||||
}
|
||||
const expertMode = document.body.classList.contains('chimera-expert-mode');
|
||||
const visibleSources = sources.filter(source =>
|
||||
expertMode || payloadCatalogGroup({ source, files: [] }) !== 'expert'
|
||||
);
|
||||
const hiddenCount = sources.length - visibleSources.length;
|
||||
const hiddenNotice = hiddenCount > 0
|
||||
? `<p class="hint payload-expert-hidden">${escHtml(window.tf(
|
||||
'payload.settings.expertHidden',
|
||||
'{count} expert source(s) hidden. Enable Expert tools to manage them.',
|
||||
{ count: hiddenCount },
|
||||
))}</p>`
|
||||
: '';
|
||||
cont.innerHTML = hiddenNotice + visibleSources.map(s => `
|
||||
<div class="payload-settings-source-row" data-source-id="${s.id}">
|
||||
<input type="checkbox" class="source-toggle" ${s.enabled ? 'checked' : ''}/>
|
||||
<span class="payload-settings-source-name">${escHtml(s.name)}</span>
|
||||
<span class="payload-settings-source-url" title="${escHtml(s.github_url)}">${escHtml(s.github_url)}</span>
|
||||
${s.latest_tag ? `<span class="payload-tag-chip">${escHtml(s.latest_tag)}</span>` : ''}
|
||||
${s.built_in ? `<span class="payload-trust-chip payload-trust-official">${escHtml(window.t('payload.builtIn', 'built-in'))}</span>` : '<button class="btn btn-sm btn-source-remove" style="margin-left:auto">✕</button>'}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
window.addEventListener('chimera:expert-mode-changed', () => {
|
||||
if ($('page-settings')?.classList.contains('active')) renderSettingsPayloadSources().catch(error => showToast(error.message, 'error'));
|
||||
});
|
||||
|
||||
function payloadStatusBadge(file) {
|
||||
if (file.local_hash && file.local_version && file.version && file.local_version !== file.version) {
|
||||
return `<span class="badge badge-yellow">↓ ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
|
||||
}
|
||||
if (file.local_hash && file.remote_hash) {
|
||||
if (file.local_hash === file.remote_hash) return `<span class="badge badge-green">✓ ${escHtml(window.t('payload.status.current', 'Up to date'))}</span>`;
|
||||
return `<span class="badge badge-yellow">↓ ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
|
||||
}
|
||||
if (file.local_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.downloadedUnverified', 'Downloaded (no checksum)'))}</span>`;
|
||||
if (file.remote_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.notDownloaded', 'Not downloaded'))}</span>`;
|
||||
return '<span class="badge badge-dim">—</span>';
|
||||
}
|
||||
|
||||
function payloadActionBtn(file, source_id) {
|
||||
const upToDate = !!(file.local_hash && (
|
||||
(file.remote_hash && file.local_hash === file.remote_hash) ||
|
||||
(!file.remote_hash && file.local_version && file.version && file.local_version === file.version)
|
||||
));
|
||||
const hasUpdate = !!(file.local_hash && (
|
||||
(file.remote_hash && file.local_hash !== file.remote_hash) ||
|
||||
(file.local_version && file.version && file.local_version !== file.version)
|
||||
));
|
||||
|
||||
const dlLabel = hasUpdate
|
||||
? `↓ ${escHtml(window.t('payload.action.update', 'Update'))}`
|
||||
: `↓ ${escHtml(window.t('payload.action.download', 'Download'))}`;
|
||||
const dlCls = hasUpdate ? 'btn btn-sm btn-teal btn-payload-download' : 'btn btn-sm btn-payload-download';
|
||||
const dlBtn = !upToDate
|
||||
? `<button class="${dlCls}"
|
||||
data-source-id="${source_id}"
|
||||
data-asset-name="${escHtml(file.asset_name)}"
|
||||
data-asset-url="${escHtml(file.asset_url || '')}"
|
||||
data-version="${escHtml(file.version || '')}">${dlLabel}</button>`
|
||||
: '';
|
||||
|
||||
const pushBtn = file.local_path
|
||||
? `<button class="btn btn-sm btn-payload-push-remote"
|
||||
data-local-path="${escHtml(file.local_path)}"
|
||||
data-filename="${escHtml(file.asset_name)}"
|
||||
title="Upload to PS5 via FTP transfer queue">↑ PS5</button>`
|
||||
: '';
|
||||
|
||||
return (dlBtn || pushBtn)
|
||||
? `<span style="display:inline-flex;gap:4px">${dlBtn}${pushBtn}</span>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function payloadHasUpdate(file) {
|
||||
return Boolean(file.local_hash && (
|
||||
(file.remote_hash && file.local_hash !== file.remote_hash) ||
|
||||
(file.local_version && file.version && file.local_version !== file.version)
|
||||
));
|
||||
}
|
||||
|
||||
function payloadCatalogGroup({ source, files }) {
|
||||
const fingerprint = `${source.name || ''} ${source.category || ''} ${source.description || ''} ${(files || []).map(file => file.asset_name).join(' ')}`.toLowerCase();
|
||||
const expertCategory = ['kernel', 'debug', 'linux'].includes(String(source.category || '').toLowerCase());
|
||||
if (expertCategory || /(kernel|kstuff|kpatch|exploit|debugger|gdb|elf\s*(?:ldr|loader)|binloader|shell(?:code)?|jailbreak|linux\s+loader)/.test(fingerprint)) return 'expert';
|
||||
if ((files || []).some(payloadHasUpdate)) return 'updates';
|
||||
if (source.trust === 'custom' || /(experimental|prototype|research|legacy)/.test(fingerprint)) return 'experimental';
|
||||
if (source.trust === 'official' && (files || []).some(file => file.remote_hash)) return 'recommended';
|
||||
return 'compatible';
|
||||
}
|
||||
|
||||
const PAYLOAD_GROUPS = [
|
||||
{ id: 'updates', label: 'Updates', detail: 'Downloaded payloads with a newer or changed upstream asset.' },
|
||||
{ id: 'recommended', label: 'Recommended', detail: 'Reviewed official sources with a published checksum.' },
|
||||
{ id: 'compatible', label: 'Compatible', detail: 'Normal payloads that still require a manual compatibility check.' },
|
||||
{ id: 'experimental', label: 'Experimental', detail: 'Custom, research or incomplete sources. Review before downloading.' },
|
||||
{ id: 'expert', label: 'Expert', detail: 'Low-level payloads that can affect console stability.' },
|
||||
];
|
||||
|
||||
function payloadSourceMarkup({ source, files, error }, forceOpen = false) {
|
||||
const usableFiles = (files || []).filter(file =>
|
||||
/\.(bin|elf|js)$/i.test(file.asset_name) &&
|
||||
!/sha256|checksums|sha2|sums/i.test(file.asset_name)
|
||||
);
|
||||
const tag = source.latest_tag ? `<span class="payload-tag-chip">${escHtml(source.latest_tag)}</span>` : '';
|
||||
const trust = escHtml(source.trust || 'custom');
|
||||
const checked = source.last_checked
|
||||
? `<span class="hint payload-checked-at">${escHtml(window.tf('payload.checkedAt', 'checked {date}', { date: new Date(source.last_checked).toLocaleString() }))}</span>`
|
||||
: '';
|
||||
const errRow = error ? `<p class="hint payload-source-error">${escHtml(error)}</p>` : '';
|
||||
const rows = usableFiles.map(file => `
|
||||
<tr>
|
||||
<td class="payload-asset-name">${escHtml(file.asset_name)}</td>
|
||||
<td>${payloadStatusBadge(file)}</td>
|
||||
<td class="payload-hash-snippet">${file.remote_hash ? file.remote_hash.slice(0, 12) + '…' : '—'}</td>
|
||||
<td class="payload-asset-meta">${escHtml(file.version || '—')} · ${file.asset_size ? fmt(file.asset_size) : escHtml(window.t('payload.unknownSize', 'unknown size'))}</td>
|
||||
<td>${payloadActionBtn(file, source.id)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
return `
|
||||
<details class="payload-check-source" ${forceOpen ? 'open' : ''}>
|
||||
<summary class="payload-check-source-header">
|
||||
<span class="payload-check-source-name">${escHtml(source.name)}</span>
|
||||
<span class="payload-source-count">${usableFiles.length}</span>
|
||||
${tag} ${checked}
|
||||
<span class="payload-source-meta">
|
||||
<span class="payload-trust-chip payload-trust-${trust}">${trust}</span>
|
||||
<span class="payload-tag-chip">${escHtml(source.category || 'other')}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="payload-source-body">
|
||||
${source.description ? `<p class="payload-source-description">${escHtml(source.description)}</p>` : ''}
|
||||
${errRow}
|
||||
${rows ? `<div class="payload-asset-scroll"><table class="payload-asset-table"><tbody>${rows}</tbody></table></div>` : `<p class="hint">${escHtml(window.t('payload.noAssets', 'No assets found.'))}</p>`}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderPayloadSourceCheckList(results) {
|
||||
const cont = $('payload-sources-check-list');
|
||||
if (!results || !results.length) {
|
||||
cont.innerHTML = `<div class="library-empty"><span class="library-empty-icon">⌁</span><strong>${escHtml(window.t('payload.empty.title', 'No payload sources match'))}</strong><span>${escHtml(window.t('payload.empty.detail', 'Adjust the filters or add a source in Settings.'))}</span></div>`;
|
||||
return;
|
||||
}
|
||||
const queryActive = Boolean(String($('payload-catalog-search')?.value || '').trim());
|
||||
const grouped = Object.groupBy
|
||||
? Object.groupBy(results, payloadCatalogGroup)
|
||||
: results.reduce((groups, item) => {
|
||||
const id = payloadCatalogGroup(item);
|
||||
(groups[id] ||= []).push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
cont.innerHTML = PAYLOAD_GROUPS.map(group => {
|
||||
const items = grouped[group.id] || [];
|
||||
if (!items.length) return '';
|
||||
const label = window.t(`payload.groups.${group.id}.label`, group.label);
|
||||
const detail = window.t(`payload.groups.${group.id}.detail`, group.detail);
|
||||
const risk = group.id === 'expert' || group.id === 'experimental' ? group.id : 'standard';
|
||||
return `<details class="payload-catalog-group payload-catalog-group-${risk}" ${queryActive || group.id === 'updates' || group.id === 'recommended' ? 'open' : ''}>
|
||||
<summary><span><strong>${escHtml(label)}</strong><small>${escHtml(detail)}</small></span><b>${items.length}</b></summary>
|
||||
<div class="payload-catalog-group-body">${items.map(item => payloadSourceMarkup(item, queryActive)).join('')}</div>
|
||||
</details>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
let _payloadCatalog = null;
|
||||
|
||||
function catalogResults(catalog) {
|
||||
return (catalog.sources || []).map(source => ({
|
||||
source,
|
||||
files: (catalog.files || []).filter(file => file.source_id === source.id),
|
||||
error: source.last_error || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyPayloadCatalogFilters() {
|
||||
if (!_payloadCatalog) return;
|
||||
const query = String($('payload-catalog-search')?.value || '').trim().toLowerCase();
|
||||
const category = String($('payload-catalog-category')?.value || 'all');
|
||||
const showExpert = Boolean($('payload-show-expert')?.checked);
|
||||
const results = catalogResults(_payloadCatalog)
|
||||
.filter(({ source }) => category === 'all' || source.category === category)
|
||||
.map(item => ({
|
||||
...item,
|
||||
files: item.files.filter(file => !query || `${file.asset_name} ${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query)),
|
||||
}))
|
||||
.filter(item => !query || item.files.length || `${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query))
|
||||
.filter(item => showExpert || payloadCatalogGroup(item) !== 'expert');
|
||||
renderPayloadSourceCheckList(results);
|
||||
}
|
||||
|
||||
function renderPayloadCatalog(catalog) {
|
||||
_payloadCatalog = catalog;
|
||||
const stats = catalog.stats || {};
|
||||
$('payload-stat-assets').textContent = String(stats.available || 0);
|
||||
$('payload-stat-sources').textContent = String(stats.enabledSources || stats.sources || 0);
|
||||
$('payload-stat-downloaded').textContent = String(stats.downloaded || 0);
|
||||
$('payload-stat-updates').textContent = String(stats.updates || 0);
|
||||
$('payload-stat-verified').textContent = String(stats.verified || 0);
|
||||
const select = $('payload-catalog-category');
|
||||
const selected = select.value || 'all';
|
||||
const categories = [...new Set((catalog.sources || []).map(source => source.category || 'other'))].sort();
|
||||
select.innerHTML = `<option value="all">${escHtml(window.t('payload.allCategories', 'All categories'))}</option>` + categories.map(value => `<option value="${escHtml(value)}">${escHtml(value)}</option>`).join('');
|
||||
select.value = categories.includes(selected) ? selected : 'all';
|
||||
applyPayloadCatalogFilters();
|
||||
}
|
||||
|
||||
async function loadPayloadCatalog() {
|
||||
const catalog = await window.pork.payloadCatalog();
|
||||
renderPayloadCatalog(catalog);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
async function renderLocalPayloads() {
|
||||
let files;
|
||||
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
|
||||
const list = $('payload-local-list');
|
||||
const empty = $('payload-local-empty');
|
||||
if (!files.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
list.innerHTML = files.map(f => {
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||||
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
|
||||
return `<div class="al-file-card payload-local-card">
|
||||
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
|
||||
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
|
||||
<span class="payload-card-size">${fmt(f.size)}</span>
|
||||
${payloadStatusBadge(f)}
|
||||
<div class="payload-card-actions">
|
||||
<button class="btn btn-sm btn-teal btn-payload-push"
|
||||
data-local-path="${escHtml(f.local_path)}"
|
||||
data-filename="${escHtml(f.name)}" title="Push to PS5 via FTP transfer queue">↑ FTP</button>
|
||||
<button class="btn btn-sm btn-payload-delete-local"
|
||||
data-id="${f.db_id ?? ''}"
|
||||
data-local-path="${escHtml(f.local_path)}" title="Delete local file">🗑</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function renderPayloadSender() {
|
||||
let files;
|
||||
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
|
||||
const list = $('payload-sender-list');
|
||||
const empty = $('payload-sender-empty');
|
||||
if (!files.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
const status = $('payload-sender-status');
|
||||
if (status) status.textContent = '';
|
||||
list.innerHTML = files.map(f => {
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||||
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
|
||||
return `<div class="al-file-card payload-sender-card">
|
||||
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
|
||||
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
|
||||
<span class="payload-card-size">${fmt(f.size)}</span>
|
||||
<button class="btn btn-sm btn-teal btn-payload-tcp-send"
|
||||
data-local-path="${escHtml(f.local_path)}"
|
||||
data-filename="${escHtml(f.name)}">⇒ Send</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
$('btn-payload-check-all').addEventListener('click', async () => {
|
||||
const btn = $('btn-payload-check-all');
|
||||
const status = $('payload-check-status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Checking…';
|
||||
try {
|
||||
const results = await window.pork.payloadCheckUpdates();
|
||||
await loadPayloadCatalog();
|
||||
await renderLocalPayloads();
|
||||
status.textContent = `Done — ${results.length} source(s) checked`;
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
setStatus(`Payload check failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('payload-catalog-search')?.addEventListener('input', applyPayloadCatalogFilters);
|
||||
$('payload-catalog-category')?.addEventListener('change', applyPayloadCatalogFilters);
|
||||
$('payload-show-expert')?.addEventListener('change', event => {
|
||||
localStorage.setItem('chimera.payload.showExpert', event.target.checked ? '1' : '0');
|
||||
applyPayloadCatalogFilters();
|
||||
});
|
||||
$('btn-payload-update-downloaded')?.addEventListener('click', async () => {
|
||||
const btn = $('btn-payload-update-downloaded');
|
||||
const status = $('payload-check-status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Checking and updating local payloads…';
|
||||
try {
|
||||
const result = await window.pork.payloadUpdateDownloaded();
|
||||
await loadPayloadCatalog();
|
||||
await renderLocalPayloads();
|
||||
await renderPayloadSender();
|
||||
status.textContent = `${result.checked} checked locally · ${result.updated} updated${result.failed ? ` · ${result.failed} failed` : ''}`;
|
||||
} catch (error) {
|
||||
status.textContent = `Update failed: ${error.message}`;
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
$('payload-sources-check-list').addEventListener('click', async e => {
|
||||
const pushBtn = e.target.closest('.btn-payload-push-remote');
|
||||
if (pushBtn) {
|
||||
const local_path = pushBtn.dataset.localPath;
|
||||
const filename = pushBtn.dataset.filename;
|
||||
const origText = pushBtn.textContent;
|
||||
pushBtn.disabled = true;
|
||||
pushBtn.textContent = 'Queuing…';
|
||||
try {
|
||||
await window.pork.payloadPush({ local_path, filename });
|
||||
setStatus(`${filename} queued for FTP transfer to PS5`, 'ok');
|
||||
navigate('transfers');
|
||||
} catch (err) {
|
||||
setStatus(`FTP push failed: ${err.message}`, 'error');
|
||||
pushBtn.disabled = false;
|
||||
pushBtn.textContent = origText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dlBtn = e.target.closest('.btn-payload-download');
|
||||
if (!dlBtn) return;
|
||||
const source_id = Number(dlBtn.dataset.sourceId);
|
||||
const asset_name = dlBtn.dataset.assetName;
|
||||
const asset_url = dlBtn.dataset.assetUrl;
|
||||
const version = dlBtn.dataset.version;
|
||||
dlBtn.disabled = true;
|
||||
dlBtn.textContent = 'Downloading…';
|
||||
try {
|
||||
await window.pork.payloadDownload({ source_id, asset_name, asset_url, version });
|
||||
setStatus(`Downloaded ${asset_name}`, 'ok');
|
||||
await loadPayloadCatalog();
|
||||
await renderLocalPayloads();
|
||||
await renderPayloadSender();
|
||||
} catch (err) {
|
||||
setStatus(`Download failed: ${err.message}`, 'error');
|
||||
dlBtn.disabled = false;
|
||||
dlBtn.textContent = '↓ Retry';
|
||||
}
|
||||
});
|
||||
|
||||
$('payload-local-list').addEventListener('click', async e => {
|
||||
const pushBtn = e.target.closest('.btn-payload-push');
|
||||
if (pushBtn) {
|
||||
const local_path = pushBtn.dataset.localPath;
|
||||
const filename = pushBtn.dataset.filename;
|
||||
try {
|
||||
await window.pork.payloadPush({ local_path, filename });
|
||||
setStatus(`${filename} queued for transfer`, 'ok');
|
||||
navigate('transfers');
|
||||
} catch (e2) {
|
||||
setStatus(`Push failed: ${e2.message}`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delBtn = e.target.closest('.btn-payload-delete-local');
|
||||
if (delBtn) {
|
||||
const confirmation = await showExactConfirmation(`Delete local file "${delBtn.dataset.localPath}"?`, 'VERWIJDER');
|
||||
if (!confirmation) return;
|
||||
try {
|
||||
const id = delBtn.dataset.id ? Number(delBtn.dataset.id) : null;
|
||||
const local_path = delBtn.dataset.localPath;
|
||||
await window.pork.payloadDeleteLocal({ id, local_path, confirmation });
|
||||
await renderLocalPayloads();
|
||||
await renderPayloadSender();
|
||||
} catch (e2) {
|
||||
setStatus(`Delete failed: ${e2.message}`, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('payload-sender-list').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.btn-payload-tcp-send');
|
||||
if (!btn) return;
|
||||
const local_path = btn.dataset.localPath;
|
||||
const filename = btn.dataset.filename;
|
||||
const port = parseInt($('payload-sender-port')?.value || '9021', 10);
|
||||
const status = $('payload-sender-status');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending\u2026';
|
||||
if (status) { status.textContent = `Sending ${filename} on port ${port}\u2026`; status.style.color = ''; }
|
||||
try {
|
||||
await window.pork.payloadTcpSend({ localPath: local_path, port, filename });
|
||||
if (status) { status.textContent = `\u2713 ${filename} sent \u2014 PS5 notified`; status.style.color = 'var(--teal)'; }
|
||||
setStatus(`${filename} sent to PS5 on port ${port}`, 'ok');
|
||||
} catch (e2) {
|
||||
if (status) { status.textContent = `Error: ${e2.message}`; status.style.color = 'var(--red)'; }
|
||||
setStatus(`TCP send failed: ${e2.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '\u21D2 Send';
|
||||
}
|
||||
});
|
||||
|
||||
$('autoloader-file-list').addEventListener('click', async e => {
|
||||
const saveBtn = e.target.closest('.btn-al-save-local');
|
||||
if (saveBtn) {
|
||||
e.stopPropagation(); // don't also add to sequence
|
||||
const filename = saveBtn.dataset.filename;
|
||||
const orig = saveBtn.textContent;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '⋯';
|
||||
try {
|
||||
await window.pork.autoloaderSaveLocal({ filename });
|
||||
setStatus(`${filename} saved to local payloads`, 'ok');
|
||||
saveBtn.textContent = '✓';
|
||||
await renderLocalPayloads();
|
||||
await renderPayloadSender();
|
||||
setTimeout(() => { saveBtn.textContent = orig; saveBtn.disabled = false; }, 2000);
|
||||
} catch (e2) {
|
||||
setStatus(`Save failed: ${e2.message}`, 'error');
|
||||
saveBtn.textContent = orig;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const card = e.target.closest('[data-al-add]');
|
||||
if (!card) return;
|
||||
_alSequence.push({ type: 'payload', filename: card.dataset.alAdd });
|
||||
_alRenderSequence();
|
||||
$('btn-autoloader-save').disabled = false;
|
||||
});
|
||||
|
||||
window.pork.on('payload:download:progress', ({ asset_name, percent }) => {
|
||||
const btn = document.querySelector(`.btn-payload-download[data-asset-name="${CSS.escape(asset_name)}"]`);
|
||||
if (btn) btn.textContent = `${percent}%`;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// ── PFS Ripper — PS5 PFS/image library browser ───────────────────────────────
|
||||
// Native port of Deckerr97's "PFS Ripper": browse a library of PS5 image files,
|
||||
// show cover art (ProsperoPatches), search, view PPSA IDs + versions, export.
|
||||
|
||||
let _pfsInit = false;
|
||||
let _pfsItems = [];
|
||||
let _pfsSearch = '';
|
||||
const _pfsIconCache = new Map();
|
||||
|
||||
function _pfsEsc(s) { return (typeof escHtml === 'function') ? escHtml(s) : String(s ?? ''); }
|
||||
function _pfsToast(m, k) { if (typeof showToast === 'function') showToast(m, k); }
|
||||
function _pfsFmt(n) {
|
||||
if (!n) return '';
|
||||
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
|
||||
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
|
||||
return (n / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function _pfsBuildUI() {
|
||||
const root = document.getElementById('page-pfs-ripper');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="embed-toolbar">
|
||||
<div class="embed-controls">
|
||||
<span class="embed-page-title">PFS Ripper</span>
|
||||
<button id="pfs-add" class="btn btn-sm">+ Add Folder</button>
|
||||
<button id="pfs-rescan" class="btn btn-xs">⟳ Rescan</button>
|
||||
<span id="pfs-count" class="hint"></span>
|
||||
</div>
|
||||
<div class="embed-controls">
|
||||
<input id="pfs-search" type="text" placeholder="Search title or ID…" style="width:220px" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="pfs-folders" style="display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px"></div>
|
||||
<div id="pfs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:12px"></div>`;
|
||||
|
||||
document.getElementById('pfs-add').addEventListener('click', async () => {
|
||||
await window.pork.pfsFoldersAdd();
|
||||
await _pfsRenderFolders();
|
||||
await _pfsScan();
|
||||
});
|
||||
document.getElementById('pfs-rescan').addEventListener('click', () => _pfsScan());
|
||||
document.getElementById('pfs-search').addEventListener('input', e => {
|
||||
_pfsSearch = e.target.value.toLowerCase();
|
||||
_pfsRenderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
async function _pfsRenderFolders() {
|
||||
const el = document.getElementById('pfs-folders');
|
||||
if (!el) return;
|
||||
const folders = (await window.pork.pfsFoldersList().catch(() => [])) || [];
|
||||
if (!folders.length) {
|
||||
el.innerHTML = '<span class="hint">No library folders yet — click “Add Folder”.</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = folders.map(f =>
|
||||
`<span class="pill" style="display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border:1px solid var(--border);border-radius:12px;font-size:12px">
|
||||
<code style="font-size:11px">${_pfsEsc(f)}</code>
|
||||
<button class="btn btn-xs btn-red pfs-folder-rm" data-folder="${_pfsEsc(f)}" title="Remove">✕</button>
|
||||
</span>`).join('');
|
||||
el.querySelectorAll('.pfs-folder-rm').forEach(b =>
|
||||
b.addEventListener('click', async () => {
|
||||
await window.pork.pfsFoldersRemove(b.dataset.folder);
|
||||
await _pfsRenderFolders();
|
||||
await _pfsScan();
|
||||
}));
|
||||
}
|
||||
|
||||
async function _pfsScan() {
|
||||
const grid = document.getElementById('pfs-grid');
|
||||
if (grid) grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">Scanning…</div>';
|
||||
_pfsItems = (await window.pork.pfsScan().catch(() => [])) || [];
|
||||
_pfsRenderGrid();
|
||||
}
|
||||
|
||||
function _pfsFiltered() {
|
||||
if (!_pfsSearch) return _pfsItems;
|
||||
return _pfsItems.filter(it =>
|
||||
(it.gameId || '').toLowerCase().includes(_pfsSearch) ||
|
||||
(it.file || '').toLowerCase().includes(_pfsSearch));
|
||||
}
|
||||
|
||||
function _pfsRenderGrid() {
|
||||
const grid = document.getElementById('pfs-grid');
|
||||
const countEl = document.getElementById('pfs-count');
|
||||
if (!grid) return;
|
||||
const list = _pfsFiltered();
|
||||
if (countEl) countEl.textContent = `${list.length} image${list.length !== 1 ? 's' : ''}`;
|
||||
if (!list.length) {
|
||||
grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">No images found. Add a folder containing .ffpkg / .pkg / .exfat images.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map((it, i) => `
|
||||
<div class="pfs-card" style="border:1px solid var(--border);border-radius:8px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div class="pfs-art" data-gid="${_pfsEsc(it.gameId)}" style="aspect-ratio:1;background:rgba(var(--accent-rgb),.08);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text-dim)">
|
||||
${it.gameId ? _pfsEsc(it.gameId) : '—'}
|
||||
</div>
|
||||
<div style="padding:8px;display:flex;flex-direction:column;gap:2px;flex:1">
|
||||
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_pfsEsc(it.file)}">${_pfsEsc(it.gameId || it.file)}</div>
|
||||
<div class="hint" style="font-size:11px">
|
||||
<span class="pill-num" style="padding:0 4px">${_pfsEsc(it.ext)}</span>
|
||||
${it.version ? ' v' + _pfsEsc(it.version) : ''} · ${_pfsFmt(it.size)}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap">
|
||||
<button class="btn btn-xs pfs-export" data-i="${i}" title="Copy to a folder">Export</button>
|
||||
<button class="btn btn-xs pfs-ftp" data-i="${i}" title="Send to PS5 via FTP">→ PS5</button>
|
||||
<button class="btn btn-xs pfs-reveal" data-i="${i}" title="Show in Explorer">📂</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
// Lazy-load cover art via ProsperoPatches.
|
||||
grid.querySelectorAll('.pfs-art[data-gid]').forEach(el => {
|
||||
const gid = el.dataset.gid;
|
||||
if (!gid) return;
|
||||
_pfsLoadArt(gid, el);
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.pfs-export').forEach(b => b.addEventListener('click', () => _pfsExport(list[+b.dataset.i])));
|
||||
grid.querySelectorAll('.pfs-ftp').forEach(b => b.addEventListener('click', () => _pfsFtp(list[+b.dataset.i])));
|
||||
grid.querySelectorAll('.pfs-reveal').forEach(b => b.addEventListener('click', () => window.pork.pfsReveal(list[+b.dataset.i].path)));
|
||||
}
|
||||
|
||||
async function _pfsLoadArt(gid, el) {
|
||||
try {
|
||||
let url = _pfsIconCache.get(gid);
|
||||
if (url === undefined) {
|
||||
const result = await window.pork.prosperoFetchIcon(gid).catch(() => null);
|
||||
url = result?.iconUrl || '';
|
||||
_pfsIconCache.set(gid, url || null);
|
||||
}
|
||||
if (url && el.isConnected) {
|
||||
el.innerHTML = `<img src="${_pfsEsc(url)}" alt="" style="width:100%;height:100%;object-fit:cover" data-image-error="hide">`;
|
||||
}
|
||||
} catch (_) { /* leave the ID placeholder */ }
|
||||
}
|
||||
|
||||
async function _pfsExport(it) {
|
||||
if (!it) return;
|
||||
try {
|
||||
const r = await window.pork.pfsExportCopy(it.path);
|
||||
if (r && r.ok) _pfsToast('Exported to ' + r.dest, 'ok');
|
||||
} catch (e) { _pfsToast('Export failed: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function _pfsFtp(it) {
|
||||
if (!it) return;
|
||||
try {
|
||||
await window.pork.pfsExportFtp(it.path);
|
||||
_pfsToast('Queued upload to PS5 — see Transfers', 'ok');
|
||||
} catch (e) { _pfsToast('Send failed: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Entry point invoked by the page router (state.js pageLoaders).
|
||||
async function loadPfsRipper() {
|
||||
if (!_pfsInit) { _pfsInit = true; _pfsBuildUI(); }
|
||||
await _pfsRenderFolders();
|
||||
await _pfsScan();
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use strict';
|
||||
|
||||
(function mountPremiumProjectIllustrations() {
|
||||
const targets = Object.freeze({
|
||||
'games-empty': 'library',
|
||||
'backups-empty': 'protection',
|
||||
'transfers-empty': 'transfer',
|
||||
'media-empty': 'media',
|
||||
});
|
||||
const illustrations = Object.freeze({
|
||||
'library': `<div class="premium-project-illustration" data-variant="library" aria-hidden="true">
|
||||
<span class="premium-project-glow"></span>
|
||||
<svg viewBox="0 0 320 180" focusable="false">
|
||||
<path class="premium-project-path path-a" d="M31 124C76 50 122 57 159 88s75 37 130-23"/>
|
||||
<path class="premium-project-path path-b" d="M37 58c42 18 65 4 96 34s70 44 151 27"/>
|
||||
<g class="premium-project-source library-source"><rect x="24" y="82" width="38" height="54" rx="6"/><rect x="31" y="74" width="38" height="54" rx="6"/><path d="M39 91h22M39 100h16"/></g>
|
||||
<g class="premium-project-surface library-index"><rect x="119" y="39" width="83" height="104" rx="11"/><path d="M135 63h51M135 79h34M135 105h18M160 105h26M135 119h18M160 119h26"/></g>
|
||||
<g class="premium-project-node library-art"><rect x="260" y="48" width="38" height="48" rx="7"/><path d="m268 84 8-10 7 7 6-8"/><circle cx="286" cy="60" r="4"/></g>
|
||||
<g class="premium-project-node library-proof"><circle cx="276" cy="126" r="15"/><path d="m269 126 5 5 9-11"/></g>
|
||||
</svg>
|
||||
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
|
||||
</div>`,
|
||||
'protection': `<div class="premium-project-illustration" data-variant="protection" aria-hidden="true">
|
||||
<span class="premium-project-glow"></span>
|
||||
<svg viewBox="0 0 320 180" focusable="false">
|
||||
<path class="premium-project-path path-a" d="M35 95h79c21 0 27-30 49-30h27c24 0 29 31 49 31h47"/>
|
||||
<path class="premium-project-path path-b" d="M65 131h67c18 0 25-21 42-21h35c17 0 23 21 43 21h34"/>
|
||||
<g class="premium-project-source protection-copy"><rect x="27" y="61" width="46" height="68" rx="7"/><path d="M38 80h24M38 94h19M38 108h24"/></g>
|
||||
<g class="premium-project-node protection-hash"><rect x="129" y="49" width="68" height="55" rx="10"/><path d="M142 68h42M142 80h32"/><text x="144" y="94">SHA</text></g>
|
||||
<g class="premium-project-surface protection-vault"><path d="M238 55h55v77h-55z"/><circle cx="265" cy="93" r="14"/><path d="M265 84v18M256 93h18"/></g>
|
||||
<g class="premium-project-shield"><path d="M183 112c0 19-12 28-25 34-13-6-25-15-25-34l25-9z"/><path d="m149 121 7 7 12-16"/></g>
|
||||
</svg>
|
||||
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
|
||||
</div>`,
|
||||
'transfer': `<div class="premium-project-illustration" data-variant="transfer" aria-hidden="true">
|
||||
<span class="premium-project-glow"></span>
|
||||
<svg viewBox="0 0 320 180" focusable="false">
|
||||
<path class="premium-project-path path-a" d="M68 87h67c21 0 26-27 47-27h28c22 0 27 27 47 27h26"/>
|
||||
<path class="premium-project-path path-b" d="M68 119h63c22 0 31 25 53 25h30c21 0 29-25 47-25h22"/>
|
||||
<g class="premium-project-source transfer-console"><path d="M27 61h46l8 65H19z"/><path d="M37 78h27M42 111h17"/><circle cx="50" cy="93" r="5"/></g>
|
||||
<g class="premium-project-surface transfer-router"><rect x="133" y="42" width="69" height="78" rx="11"/><path d="M146 64h43M146 79h43M146 94h31"/><circle cx="186" cy="95" r="3"/></g>
|
||||
<g class="premium-project-node transfer-vault"><rect x="251" y="61" width="48" height="65" rx="7"/><path d="M260 78h30M260 91h30M260 104h21"/></g>
|
||||
<g class="premium-project-progress"><path d="M126 144h82"/><path d="M126 144h55"/></g>
|
||||
</svg>
|
||||
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
|
||||
</div>`,
|
||||
'media': `<div class="premium-project-illustration" data-variant="media" aria-hidden="true">
|
||||
<span class="premium-project-glow"></span>
|
||||
<svg viewBox="0 0 320 180" focusable="false">
|
||||
<path class="premium-project-path path-a" d="M85 90h58c21 0 24-28 46-28h23c21 0 25 28 44 28h35"/>
|
||||
<path class="premium-project-path path-b" d="M84 120h57c22 0 27 22 47 22h26c20 0 25-22 44-22h33"/>
|
||||
<g class="premium-project-source media-console"><rect x="22" y="48" width="70" height="84" rx="9"/><path d="m34 111 15-18 12 12 18-24"/><circle cx="71" cy="66" r="6"/></g>
|
||||
<g class="premium-project-wave"><path d="M119 92h8l5-17 9 35 8-25 8 13 7-6h8"/></g>
|
||||
<g class="premium-project-surface media-gallery"><rect x="225" y="43" width="67" height="47" rx="7"/><rect x="217" y="99" width="75" height="43" rx="7"/><path d="m235 80 10-11 8 8 11-14 17 17M227 132l11-12 9 8 11-13 22 17"/></g>
|
||||
<g class="premium-project-node media-play"><circle cx="184" cy="95" r="19"/><path d="m180 86 12 9-12 9z"/></g>
|
||||
</svg>
|
||||
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
|
||||
</div>`,
|
||||
'trophies': `<div class="premium-project-illustration" data-variant="trophies" aria-hidden="true">
|
||||
<span class="premium-project-glow"></span>
|
||||
<svg viewBox="0 0 320 180" focusable="false">
|
||||
<path class="premium-project-path path-a" d="M48 126c39-72 80-80 119-45s65 40 111-25"/>
|
||||
<path class="premium-project-path path-b" d="M46 66c43 5 66 23 91 59s80 32 139 4"/>
|
||||
<g class="premium-project-source trophy-game"><rect x="27" y="54" width="52" height="75" rx="7"/><path d="M38 75h30M38 88h20M38 108h30"/></g>
|
||||
<g class="premium-project-surface trophy-cup"><path d="M139 49h46v22c0 23-9 36-23 36s-23-13-23-36z"/><path d="M139 58h-14v8c0 13 7 20 19 21M185 58h14v8c0 13-7 20-19 21M162 107v17M145 126h34"/></g>
|
||||
<g class="premium-project-node trophy-locked"><circle cx="257" cy="62" r="19"/><path d="M250 62v-5a7 7 0 0 1 14 0v5M248 62h18v14h-18z"/></g>
|
||||
<g class="premium-project-node trophy-earned"><circle cx="268" cy="127" r="17"/><path d="m259 127 6 6 12-14"/></g>
|
||||
<g class="premium-project-progress trophy-progress"><circle cx="162" cy="84" r="56"/><path d="M162 28a56 56 0 0 1 51 79"/></g>
|
||||
</svg>
|
||||
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
|
||||
</div>`,
|
||||
});
|
||||
const reducedMotion = typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
: { matches: false, addListener() {}, removeListener() {} };
|
||||
const mounted = new Set();
|
||||
const visibilityObserver = 'IntersectionObserver' in window ? new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
entry.target.classList.toggle('is-active', entry.isIntersecting);
|
||||
syncMotion(entry.target);
|
||||
}
|
||||
}, { threshold: .16 }) : null;
|
||||
|
||||
function motionPaused() {
|
||||
return reducedMotion.matches || document.visibilityState !== 'visible';
|
||||
}
|
||||
|
||||
function syncMotion(element) {
|
||||
if (!element) return;
|
||||
element.classList.toggle('is-paused', motionPaused());
|
||||
}
|
||||
|
||||
function syncAllMotion() {
|
||||
for (const element of mounted) {
|
||||
if (!element.isConnected) {
|
||||
visibilityObserver?.unobserve(element);
|
||||
mounted.delete(element);
|
||||
continue;
|
||||
}
|
||||
syncMotion(element);
|
||||
}
|
||||
}
|
||||
|
||||
function bind(illustration) {
|
||||
if (!illustration || mounted.has(illustration)) return;
|
||||
syncAllMotion();
|
||||
mounted.add(illustration);
|
||||
visibilityObserver?.observe(illustration);
|
||||
if (!visibilityObserver) illustration.classList.add('is-active');
|
||||
syncMotion(illustration);
|
||||
let pointerFrame = 0;
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
illustration.addEventListener('pointermove', event => {
|
||||
if (motionPaused() || event.pointerType === 'touch') return;
|
||||
pointerX = event.clientX;
|
||||
pointerY = event.clientY;
|
||||
if (pointerFrame) return;
|
||||
pointerFrame = window.requestAnimationFrame(() => {
|
||||
pointerFrame = 0;
|
||||
const rect = illustration.getBoundingClientRect();
|
||||
illustration.style.setProperty('--illustration-x', `${((pointerX - rect.left) / rect.width - .5) * 7}deg`);
|
||||
illustration.style.setProperty('--illustration-y', `${((pointerY - rect.top) / rect.height - .5) * -5}deg`);
|
||||
});
|
||||
}, { passive: true });
|
||||
illustration.addEventListener('pointerleave', () => {
|
||||
if (pointerFrame) window.cancelAnimationFrame(pointerFrame);
|
||||
pointerFrame = 0;
|
||||
illustration.style.removeProperty('--illustration-x');
|
||||
illustration.style.removeProperty('--illustration-y');
|
||||
});
|
||||
}
|
||||
|
||||
function bindHomeTopology() {
|
||||
const home = document.getElementById('chimera-home-command-center');
|
||||
if (!home) return;
|
||||
home.classList.add('premium-topology-motion');
|
||||
bind(home);
|
||||
}
|
||||
|
||||
function dockImportLauncher() {
|
||||
const launcher = document.getElementById('chimera-import-open');
|
||||
const sidebarDonate = document.getElementById('sidebar-donate');
|
||||
if (!launcher || !sidebarDonate) return false;
|
||||
launcher.classList.add('chimera-import-launcher-docked');
|
||||
sidebarDonate.after(launcher);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureTrophyIllustration() {
|
||||
const target = document.querySelector('.trophy-library-empty');
|
||||
if (!target) return;
|
||||
let illustration = target.querySelector('.premium-project-illustration');
|
||||
if (!illustration) {
|
||||
target.insertAdjacentHTML('afterbegin', illustrations.trophies);
|
||||
illustration = target.querySelector('.premium-project-illustration');
|
||||
}
|
||||
bind(illustration);
|
||||
}
|
||||
|
||||
for (const [id, variant] of Object.entries(targets)) {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) continue;
|
||||
const ensure = () => {
|
||||
let illustration = target.querySelector('.premium-project-illustration');
|
||||
if (!illustration) {
|
||||
target.insertAdjacentHTML('afterbegin', illustrations[variant]);
|
||||
illustration = target.querySelector('.premium-project-illustration');
|
||||
}
|
||||
bind(illustration);
|
||||
};
|
||||
ensure();
|
||||
new MutationObserver(ensure).observe(target, { childList: true });
|
||||
}
|
||||
|
||||
// Trophy content is rendered by a route module and the page node may be
|
||||
// replaced while the feature is loading. Observe the stable content root so
|
||||
// both a late render and a replacement page receive the illustration.
|
||||
const trophyHost = document.getElementById('content') || document.body;
|
||||
const touchesTrophyLibrary = mutation => {
|
||||
const target = mutation.target?.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target?.parentElement;
|
||||
if (target?.matches?.('#page-trophies, #page-trophies *')) return true;
|
||||
return [...mutation.addedNodes].some(node => node.nodeType === Node.ELEMENT_NODE && (
|
||||
node.matches?.('#page-trophies, .trophy-library-empty')
|
||||
|| node.querySelector?.('#page-trophies, .trophy-library-empty')
|
||||
));
|
||||
};
|
||||
ensureTrophyIllustration();
|
||||
new MutationObserver(mutations => {
|
||||
if (mutations.some(touchesTrophyLibrary)) ensureTrophyIllustration();
|
||||
}).observe(trophyHost, { childList: true, subtree: true });
|
||||
bindHomeTopology();
|
||||
if (!dockImportLauncher()) {
|
||||
const importLauncherObserver = new MutationObserver(() => {
|
||||
if (!dockImportLauncher()) return;
|
||||
importLauncherObserver.disconnect();
|
||||
});
|
||||
importLauncherObserver.observe(document.body, { childList: true });
|
||||
}
|
||||
document.addEventListener('visibilitychange', syncAllMotion);
|
||||
if (typeof reducedMotion.addEventListener === 'function') reducedMotion.addEventListener('change', syncAllMotion);
|
||||
else reducedMotion.addListener(syncAllMotion);
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
(function initProtectionModel(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.chimeraProtectionModel = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createProtectionModel() {
|
||||
'use strict';
|
||||
|
||||
const list = value => Array.isArray(value) ? value : [];
|
||||
const value = input => input == null || input === '' ? null : input;
|
||||
const first = (...inputs) => inputs.map(value).find(item => item != null) ?? null;
|
||||
const lower = input => String(input || '').trim().toLowerCase();
|
||||
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted', 'absent']);
|
||||
const failedIntegrityStates = new Set(['failed', 'corrupt', 'mismatch', 'invalid']);
|
||||
|
||||
function normalizeCopy(title, copy, index) {
|
||||
const volume = copy.volume || {};
|
||||
const device = copy.device || volume.device || copy.storageDevice || {};
|
||||
const id = first(copy.id, copy.copyId, copy.copy_id);
|
||||
const volumeId = first(copy.volumeId, copy.volume_id, volume.id);
|
||||
const deviceId = first(copy.deviceId, copy.device_id, device.id, volume.deviceId, volume.device_id);
|
||||
const deviceFingerprint = first(copy.deviceFingerprint, copy.stableFingerprint, device.stableFingerprint, device.stable_fingerprint);
|
||||
const volumeKey = first(copy.stableVolumeKey, copy.stable_volume_key, volume.stableVolumeKey, volume.stable_volume_key);
|
||||
const deviceKey = deviceId != null ? `device:${deviceId}` : deviceFingerprint ? `fingerprint:${deviceFingerprint}` : 'device:unknown';
|
||||
const relativePath = first(copy.relativePath, copy.relative_path, copy.path, '');
|
||||
const mountPath = first(copy.mountPath, copy.mount_path, volume.mountPath, volume.mount_path, '');
|
||||
const fullPath = mountPath && relativePath
|
||||
? `${String(mountPath).replace(/[\\/]+$/, '')}/${String(relativePath).replace(/^[\\/]+/, '')}`
|
||||
: String(first(relativePath, mountPath, 'Unknown path'));
|
||||
const availabilityState = lower(first(copy.availabilityState, copy.availability_state, volume.availabilityState, volume.availability_state, device.availabilityState, device.availability_state, 'unknown'));
|
||||
const integrityState = lower(first(copy.integrityState, copy.integrity_state, 'unknown'));
|
||||
const hashState = lower(first(copy.hashState, copy.hash_state, copy.inventoryHash || copy.inventory_hash ? 'recorded' : 'missing'));
|
||||
const missing = missingStates.has(availabilityState);
|
||||
const integrityFailed = failedIntegrityStates.has(integrityState);
|
||||
|
||||
return Object.freeze({
|
||||
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
|
||||
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
|
||||
titleName: String(first(title.prospero_name, title.name, title.title, title.game_id, 'Unknown title')),
|
||||
region: String(first(title.prospero_region, title.region, '')),
|
||||
deviceId: deviceId == null ? null : String(deviceId),
|
||||
deviceKey,
|
||||
deviceName: String(first(copy.deviceName, copy.displayName, device.displayName, device.display_name, deviceFingerprint, 'Unknown device')),
|
||||
deviceFingerprint: deviceFingerprint == null ? null : String(deviceFingerprint),
|
||||
volumeId: volumeId == null ? null : String(volumeId),
|
||||
volumeKey: volumeKey == null ? null : String(volumeKey),
|
||||
path: fullPath,
|
||||
sizeBytes: Number(first(copy.sizeBytes, copy.size_bytes, 0)) || 0,
|
||||
hash: first(copy.inventoryHash, copy.inventory_hash),
|
||||
hashAlgorithm: String(first(copy.hashAlgorithm, copy.hash_algorithm, 'sha256')),
|
||||
hashState,
|
||||
integrityState,
|
||||
lastVerifiedAt: first(copy.lastVerifiedAt, copy.last_verified_at),
|
||||
lastSeenAt: first(copy.lastSeenAt, copy.last_seen_at),
|
||||
availabilityState,
|
||||
missing,
|
||||
integrityFailed,
|
||||
restoreTarget: Object.freeze({
|
||||
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
|
||||
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
|
||||
volumeId: volumeId == null ? null : String(volumeId),
|
||||
deviceId: deviceId == null ? null : String(deviceId),
|
||||
path: fullPath
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function evaluate(copies, requiredDistinctDevices = 2) {
|
||||
const concrete = list(copies);
|
||||
const healthyCopies = concrete.filter(copy => !copy.missing && !copy.integrityFailed);
|
||||
const knownDevices = new Set(healthyCopies.map(copy => copy.deviceKey).filter(key => key !== 'device:unknown'));
|
||||
const unknownDeviceCopies = healthyCopies.filter(copy => copy.deviceKey === 'device:unknown').length;
|
||||
const distinctDevices = knownDevices.size + (unknownDeviceCopies ? 1 : 0);
|
||||
const required = Math.max(1, Number(requiredDistinctDevices) || 2);
|
||||
const missingCopies = concrete.filter(copy => copy.missing).length;
|
||||
const failedCopies = concrete.filter(copy => copy.integrityFailed).length;
|
||||
const healthy = distinctDevices >= required;
|
||||
const sameDeviceExcess = Math.max(0, healthyCopies.length - distinctDevices);
|
||||
const status = healthy ? 'healthy' : healthyCopies.length ? 'needs-diversity' : concrete.length ? 'degraded' : 'unprotected';
|
||||
return Object.freeze({
|
||||
copyCount: concrete.length,
|
||||
healthyCopyCount: healthyCopies.length,
|
||||
distinctDeviceCount: distinctDevices,
|
||||
requiredDistinctDevices: required,
|
||||
missingCopyCount: missingCopies,
|
||||
failedCopyCount: failedCopies,
|
||||
sameDeviceExcess,
|
||||
healthy,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(titles, options = {}) {
|
||||
const result = [];
|
||||
for (const title of list(titles)) {
|
||||
const copies = list(title.localCopies).map((copy, index) => normalizeCopy(title, copy, index));
|
||||
const policy = evaluate(copies, options.requiredDistinctDevices);
|
||||
copies.forEach(copy => result.push(Object.freeze({ ...copy, policy })));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Object.freeze({ normalizeCopy, evaluate, flatten });
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// ── PS Notify page ────────────────────────────────────────────────────────────
|
||||
|
||||
let _psnotifyConfigured = false;
|
||||
|
||||
function applyPsNotifyAvailability(configured, detail = '') {
|
||||
_psnotifyConfigured = configured;
|
||||
['btn-psn-send', 'btn-psn-test', 'btn-psn-push-payload'].forEach(id => {
|
||||
const button = $(id);
|
||||
if (!button) return;
|
||||
button.disabled = !configured;
|
||||
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
|
||||
});
|
||||
document.querySelectorAll('#page-psnotify [id^="btn-psn-exp-"]').forEach(button => {
|
||||
button.disabled = !configured;
|
||||
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
|
||||
});
|
||||
document.querySelectorAll('.psnotify-compose-card, .psnotify-payload-card').forEach(card => {
|
||||
card.classList.toggle('is-unavailable', !configured);
|
||||
});
|
||||
const status = $('psn-status');
|
||||
if (!configured && status && !status.textContent) {
|
||||
status.textContent = detail || 'Configure a PS5 host in Settings to enable console actions.';
|
||||
}
|
||||
}
|
||||
|
||||
function enhancePsNotifyLayout() {
|
||||
const page = $('page-psnotify');
|
||||
if (!page || page.dataset.enhanced) return;
|
||||
page.dataset.enhanced = 'true';
|
||||
const header = page.querySelector(':scope > .page-header');
|
||||
header?.insertAdjacentHTML('beforeend', '<p class="page-subtitle psnotify-page-subtitle">Compose, test and review PS5 notifications from one focused workspace.</p><span id="psn-page-readiness" class="operations-status operations-status-unknown">Not checked</span>');
|
||||
const cards = [...page.querySelectorAll(':scope > .card')];
|
||||
const [compose, settings, payload, lab, history] = cards;
|
||||
if (!compose || !settings || !payload || !lab || !history) return;
|
||||
compose.classList.add('psnotify-compose-card');
|
||||
settings.classList.add('psnotify-settings-card');
|
||||
payload.classList.add('psnotify-payload-card');
|
||||
lab.classList.add('psnotify-lab', 'collapsed');
|
||||
history.classList.add('psnotify-history-card');
|
||||
compose.querySelector('.card-header h3').textContent = 'Compose notification';
|
||||
compose.querySelector('.hint').textContent = 'Sent to the PS5 host from your active console profile.';
|
||||
compose.querySelector('.form-grid')?.insertAdjacentHTML('beforebegin', `
|
||||
<div class="psnotify-templates" aria-label="Notification templates">
|
||||
<button class="btn btn-xs" data-psn-template="complete">Build complete</button>
|
||||
<button class="btn btn-xs" data-psn-template="backup">Backup complete</button>
|
||||
<button class="btn btn-xs" data-psn-template="update">Update ready</button>
|
||||
<button class="btn btn-xs" data-psn-template="warning">Attention needed</button>
|
||||
</div>`);
|
||||
compose.querySelector('.form-grid')?.insertAdjacentHTML('afterend', `
|
||||
<div class="psnotify-preview" aria-live="polite">
|
||||
<span>PS5 preview</span>
|
||||
<strong id="psn-preview-message">Your notification appears here</strong>
|
||||
<small id="psn-preview-submessage">Optional supporting detail</small>
|
||||
</div>`);
|
||||
$('psn-message')?.insertAdjacentHTML('afterend', '<span id="psn-message-count" class="psnotify-counter">0/120</span>');
|
||||
$('psn-submessage')?.insertAdjacentHTML('afterend', '<span id="psn-submessage-count" class="psnotify-counter">0/120</span>');
|
||||
settings.querySelector('.card-header h3').textContent = 'Service connection';
|
||||
payload.querySelector('.card-header h3').textContent = 'Notification payload';
|
||||
history.querySelector('.card-header h3').textContent = 'Notification history';
|
||||
lab.querySelector('.card-header')?.insertAdjacentHTML('beforeend', '<button id="psn-lab-toggle" class="btn btn-sm" type="button" aria-expanded="false">Show advanced lab</button>');
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'psnotify-grid';
|
||||
compose.insertAdjacentElement('beforebegin', grid);
|
||||
grid.append(compose);
|
||||
const side = document.createElement('div');
|
||||
side.className = 'psnotify-side';
|
||||
side.append(settings, payload);
|
||||
grid.append(side);
|
||||
history.insertAdjacentElement('afterend', lab);
|
||||
$('psn-lab-toggle')?.addEventListener('click', event => {
|
||||
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
|
||||
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
|
||||
event.currentTarget.textContent = expanded ? 'Show advanced lab' : 'Hide advanced lab';
|
||||
lab.classList.toggle('collapsed', expanded);
|
||||
});
|
||||
const templates = {
|
||||
complete: ['Build complete', 'The latest Chimera task finished successfully.'],
|
||||
backup: ['Backup complete', 'Your PS5 data has been verified and stored.'],
|
||||
update: ['Update ready', 'A reviewed update is ready in Chimera.'],
|
||||
warning: ['Attention needed', 'Open Chimera for details and the recommended next step.'],
|
||||
};
|
||||
compose.querySelector('.psnotify-templates')?.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-psn-template]');
|
||||
if (!button) return;
|
||||
[$('psn-message').value, $('psn-submessage').value] = templates[button.dataset.psnTemplate];
|
||||
updatePsNotifyPreview();
|
||||
$('psn-message').focus();
|
||||
});
|
||||
}
|
||||
|
||||
function updatePsNotifyPreview() {
|
||||
const message = $('psn-message')?.value || '';
|
||||
const submessage = $('psn-submessage')?.value || '';
|
||||
if ($('psn-message-count')) $('psn-message-count').textContent = `${message.length}/120`;
|
||||
if ($('psn-submessage-count')) $('psn-submessage-count').textContent = `${submessage.length}/120`;
|
||||
if ($('psn-preview-message')) $('psn-preview-message').textContent = message || 'Your notification appears here';
|
||||
if ($('psn-preview-submessage')) $('psn-preview-submessage').textContent = submessage || 'Optional supporting detail';
|
||||
}
|
||||
|
||||
async function loadPsNotify() {
|
||||
enhancePsNotifyLayout();
|
||||
// Populate settings fields from store
|
||||
try {
|
||||
const s = await window.pork.getSettings();
|
||||
$('psn-enabled').checked = s.psnotifyEnabled !== false;
|
||||
$('psn-port').value = s.psnotifyPort || 6969;
|
||||
const readiness = $('psn-page-readiness');
|
||||
if (readiness) {
|
||||
readiness.textContent = s.ftpHost ? `Configured · ${s.ftpHost}:${s.psnotifyPort || 6969}` : 'PS5 host not configured';
|
||||
readiness.className = `operations-status ${s.ftpHost ? 'operations-status-compatible' : 'operations-status-warning'}`;
|
||||
}
|
||||
applyPsNotifyAvailability(Boolean(s.ftpHost));
|
||||
} catch (_) {
|
||||
applyPsNotifyAvailability(false, 'Console configuration could not be loaded.');
|
||||
}
|
||||
updatePsNotifyPreview();
|
||||
await renderPsnHistory();
|
||||
}
|
||||
|
||||
$('psn-message').addEventListener('input', updatePsNotifyPreview);
|
||||
$('psn-submessage').addEventListener('input', updatePsNotifyPreview);
|
||||
$('psn-message').addEventListener('keydown', event => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') $('btn-psn-send').click();
|
||||
});
|
||||
|
||||
async function renderPsnHistory() {
|
||||
const hist = await window.pork.psnotifyHistory();
|
||||
const list = $('psn-history');
|
||||
const empty = $('psn-history-empty');
|
||||
if (!hist.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
list.innerHTML = hist.slice().reverse().map(e => {
|
||||
const cls = e.ok !== false ? 'psn-ok' : 'psn-fail';
|
||||
const ts = new Date(e.ts).toLocaleTimeString();
|
||||
const sub = e.subMessage ? `<span class="psn-entry-sub">\u2014 ${escHtml(e.subMessage)}</span>` : '';
|
||||
return `<div class="psn-entry ${cls}">
|
||||
<span class="psn-entry-ts">${ts}</span>
|
||||
<span class="psn-entry-msg">${escHtml(e.message)}</span>${sub}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
$('btn-psn-send').addEventListener('click', async () => {
|
||||
if (!_psnotifyConfigured) return;
|
||||
const msg = $('psn-message').value.trim();
|
||||
const sub = $('psn-submessage').value.trim();
|
||||
if (!msg) { $('psn-status').textContent = 'Message is required.'; return; }
|
||||
const btn = $('btn-psn-send');
|
||||
btn.disabled = true;
|
||||
$('psn-status').textContent = 'Sending\u2026';
|
||||
try {
|
||||
await window.pork.psnotifySend(msg, sub);
|
||||
$('psn-status').textContent = 'Sent \u2713';
|
||||
$('psn-message').value = '';
|
||||
$('psn-submessage').value = '';
|
||||
updatePsNotifyPreview();
|
||||
await renderPsnHistory();
|
||||
} catch (e) {
|
||||
$('psn-status').textContent = 'Error: ' + e.message;
|
||||
}
|
||||
btn.disabled = !_psnotifyConfigured;
|
||||
});
|
||||
|
||||
$('btn-psn-test').addEventListener('click', async () => {
|
||||
if (!_psnotifyConfigured) return;
|
||||
const btn = $('btn-psn-test');
|
||||
btn.disabled = true;
|
||||
$('psn-status').textContent = 'Sending test\u2026';
|
||||
try {
|
||||
await window.pork.psnotifyTest();
|
||||
$('psn-status').textContent = 'Test sent \u2713';
|
||||
if ($('psn-page-readiness')) {
|
||||
$('psn-page-readiness').textContent = 'Online';
|
||||
$('psn-page-readiness').className = 'operations-status operations-status-compatible';
|
||||
}
|
||||
await renderPsnHistory();
|
||||
} catch (e) {
|
||||
$('psn-status').textContent = 'Error: ' + e.message;
|
||||
if ($('psn-page-readiness')) {
|
||||
$('psn-page-readiness').textContent = 'Offline';
|
||||
$('psn-page-readiness').className = 'operations-status operations-status-warning';
|
||||
}
|
||||
}
|
||||
btn.disabled = !_psnotifyConfigured;
|
||||
});
|
||||
|
||||
$('btn-psn-save-settings').addEventListener('click', async () => {
|
||||
const port = parseInt($('psn-port').value) || 6969;
|
||||
await window.pork.setSettings({
|
||||
psnotifyEnabled: $('psn-enabled').checked,
|
||||
psnotifyPort: port,
|
||||
});
|
||||
$('psn-port').value = port;
|
||||
$('psn-status').textContent = 'Settings saved \u2713';
|
||||
});
|
||||
|
||||
$('btn-psn-refresh-history').addEventListener('click', renderPsnHistory);
|
||||
|
||||
// ── PS Notify colour/encoding experiments ────────────────────────────────────────
|
||||
|
||||
async function _sendExp(msg, sub = '') {
|
||||
const el = $('psn-exp-status');
|
||||
if (!_psnotifyConfigured) {
|
||||
el.textContent = 'Configure a PS5 host in Settings first.';
|
||||
return;
|
||||
}
|
||||
el.textContent = 'Sending…';
|
||||
try {
|
||||
await window.pork.psnotifySend(msg, sub);
|
||||
el.textContent = 'Sent \u2713 — check your PS5';
|
||||
await renderPsnHistory();
|
||||
} catch (e) {
|
||||
el.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-psn-push-payload').addEventListener('click', async () => {
|
||||
if (!_psnotifyConfigured) return;
|
||||
const btn = $('btn-psn-push-payload');
|
||||
const st = $('psn-push-status');
|
||||
btn.disabled = true;
|
||||
st.textContent = 'Queuing upload\u2026';
|
||||
try {
|
||||
const res = await window.pork.psnotifyPushPayload();
|
||||
st.textContent = `Queued \u2713 \u2014 sending to ${res.remotePath}. Reload the payload on your PS5 then emoji will work.`;
|
||||
} catch (e) {
|
||||
st.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
btn.disabled = !_psnotifyConfigured;
|
||||
});
|
||||
|
||||
// HTML markup
|
||||
$('btn-psn-exp-html-red').addEventListener('click', () =>
|
||||
_sendExp('<font color="red">This is red text</font>', 'HTML <font color> test'));
|
||||
|
||||
$('btn-psn-exp-html-multi').addEventListener('click', () =>
|
||||
_sendExp('<font color="red">Red</font> <font color="green">Green</font> <font color="blue">Blue</font>', 'HTML multi-colour test'));
|
||||
|
||||
$('btn-psn-exp-sony-tag').addEventListener('click', () =>
|
||||
_sendExp('$[color=ff0000]Red$[/color] $[color=00ff00]Green$[/color]', 'Sony $[color] tag test'));
|
||||
|
||||
// Emoji colour squares (4-byte UTF-8 — need TextDecoder patch in customPSNotify.js to arrive intact)
|
||||
$('btn-psn-exp-sq-red').addEventListener('click', () =>
|
||||
_sendExp('\uD83D\uDFE5 Red notification', 'Emoji \uD83D\uDFE5 (U+1F7E5, 4-byte UTF-8)'));
|
||||
|
||||
$('btn-psn-exp-sq-green').addEventListener('click', () =>
|
||||
_sendExp('\uD83D\uDFE9 Green notification', 'Emoji \uD83D\uDFE9 (U+1F7E9, 4-byte UTF-8)'));
|
||||
|
||||
$('btn-psn-exp-sq-blue').addEventListener('click', () =>
|
||||
_sendExp('\uD83D\uDFE6 Blue notification', 'Emoji \uD83D\uDFE6 (U+1F7E6, 4-byte UTF-8)'));
|
||||
|
||||
$('btn-psn-exp-sq-yellow').addEventListener('click', () =>
|
||||
_sendExp('\uD83D\uDFE8 Yellow notification', 'Emoji \uD83D\uDFE8 (U+1F7E8, 4-byte UTF-8)'));
|
||||
|
||||
$('btn-psn-exp-sq-all').addEventListener('click', () =>
|
||||
_sendExp('\uD83D\uDFE5\uD83D\uDFE7\uD83D\uDFE8\uD83D\uDFE9\uD83D\uDFE6\uD83D\uDFEA Colour squares test', 'All 6 colour squares'));
|
||||
|
||||
// ANSI escape codes
|
||||
$('btn-psn-exp-ansi-red').addEventListener('click', () =>
|
||||
_sendExp('\x1b[31mRed ANSI text\x1b[0m', 'ANSI \\x1b[31m colour test'));
|
||||
|
||||
$('btn-psn-exp-ansi-bold').addEventListener('click', () =>
|
||||
_sendExp('\x1b[1mBold ANSI text\x1b[0m', 'ANSI \\x1b[1m bold test'));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
if ('serviceWorker' in navigator && window.isSecureContext && window.pork?.isWebMode) {
|
||||
window.addEventListener('load', () => navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).catch(error => console.warn('[Chimera PWA]', error.message)));
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// ── Local Save Manager (native GarlicMgr UI) ────────────────────────────────
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
const smState = {
|
||||
saves: [], selectedIdx: -1, mounted: false,
|
||||
files: [], expanded: new Set(),
|
||||
busy: false, tab: 'browse',
|
||||
};
|
||||
let _smHasSuccessfulWarm = false;
|
||||
|
||||
// ── Terminal log ─────────────────────────────────────────────────────────────
|
||||
function smLog(msg, cls) {
|
||||
const logEl = $('sm-log');
|
||||
if (!logEl) return;
|
||||
const t = new Date().toLocaleTimeString();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sm-ll' + (cls ? ' sm-' + cls : '');
|
||||
div.innerHTML = `<span class="sm-lt">${t}</span><span>${escHtml(msg)}</span>`;
|
||||
logEl.appendChild(div);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Confirmation modal (native browser dialogs are blocked) ─────────────────
|
||||
function smConfirm(message) {
|
||||
return new Promise(resolve => {
|
||||
const bg = document.createElement('div');
|
||||
bg.style.cssText = 'position:fixed;inset:0;z-index:9900;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;';
|
||||
bg.setAttribute('aria-hidden', 'true');
|
||||
bg.innerHTML = `<div role="dialog" aria-modal="true" aria-labelledby="smconf-message" tabindex="-1" style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;max-width:360px;width:90%;">
|
||||
<p id="smconf-message" style="font-size:14px;color:var(--text);margin-bottom:20px;line-height:1.5;">${escHtml(message)}</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="btn btn-sm" id="smconf-skip">Skip</button>
|
||||
<button class="btn btn-sm btn-accent" id="smconf-ok">Replace</button>
|
||||
</div></div>`;
|
||||
document.body.appendChild(bg);
|
||||
const cleanup = result => {
|
||||
closeAccessibleDialog(bg);
|
||||
bg.remove();
|
||||
resolve(result);
|
||||
};
|
||||
bg.querySelector('#smconf-skip').addEventListener('click', () => cleanup(false));
|
||||
bg.querySelector('#smconf-ok').addEventListener('click', () => cleanup(true));
|
||||
bg.addEventListener('keydown', event => { if (event.key === 'Escape') cleanup(false); });
|
||||
openAccessibleDialog(bg, bg.querySelector('#smconf-skip'));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function smFmtBytes(b) {
|
||||
if (!b) return '0B';
|
||||
if (b < 1024) return b + 'B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
|
||||
return (b / 1048576).toFixed(1) + 'M';
|
||||
}
|
||||
function smSetBusy(v) {
|
||||
smState.busy = v;
|
||||
['btn-sm-dl-zip','btn-sm-dl-enc','btn-sm-dump-usb','btn-sm-upload','btn-sm-unmount']
|
||||
.forEach(id => { const el = $(id); if (el) el.disabled = v; });
|
||||
}
|
||||
function smShowUsbProgress(pct) {
|
||||
const bar = $('sm-usb-progress'); if (bar) bar.hidden = false;
|
||||
const fill = $('sm-usb-fill'); if (fill) fill.style.width = pct + '%';
|
||||
}
|
||||
function smHideUsbProgress() { const bar = $('sm-usb-progress'); if (bar) bar.hidden = true; }
|
||||
function smShowUploadProgress(pct, loaded, total) {
|
||||
const bar = $('sm-upload-progress'); if (bar) bar.hidden = false;
|
||||
const fill = $('sm-upload-fill'); if (fill) fill.style.width = pct + '%';
|
||||
const txt = $('sm-upload-text'); if (txt) txt.textContent = `${smFmtBytes(loaded)} / ${smFmtBytes(total)} (${pct}%)`;
|
||||
}
|
||||
function smHideUploadProgress() { const bar = $('sm-upload-progress'); if (bar) bar.hidden = true; }
|
||||
|
||||
// ── Load / reload saves ──────────────────────────────────────────────────────
|
||||
async function loadSaveMgr(options = {}) {
|
||||
const background = !!options.background;
|
||||
const s = await window.pork.getSettings();
|
||||
const ip = s.ftpHost || '';
|
||||
const port = s.savemgrPort || 8082;
|
||||
$('sm-ip-display').textContent = ip || '(not set — configure FTP Settings)';
|
||||
$('sm-port').value = port;
|
||||
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
|
||||
if (ip && (!background || _smHasSuccessfulWarm)) await smFetchSaves();
|
||||
}
|
||||
|
||||
async function smFetchSaves() {
|
||||
try {
|
||||
const d = await window.pork.savemgrRequest('/api/saves');
|
||||
smState.saves = d.saves || [];
|
||||
_smHasSuccessfulWarm = true;
|
||||
smRenderSaves();
|
||||
smLog(`Loaded ${smState.saves.length} saves`, 'ok');
|
||||
} catch (e) {
|
||||
smLog('Failed to load saves: ' + e.message, 'err');
|
||||
smLog('Start the session only when needed with “Start session”.');
|
||||
smState.saves = [];
|
||||
smRenderSaves();
|
||||
}
|
||||
}
|
||||
|
||||
function smRenderSaves() {
|
||||
const el = $('sm-saves-list');
|
||||
if (!smState.saves.length) {
|
||||
el.innerHTML = '<div class="sm-empty-list">No saves found</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = smState.saves.map((s, i) =>
|
||||
`<div class="sm-save-item${i === smState.selectedIdx ? ' active' : ''}" data-idx="${i}">
|
||||
<div class="sm-save-tid">${escHtml(s.title_id)}</div>
|
||||
<div class="sm-save-name">${escHtml(s.save_name)}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
el.querySelectorAll('.sm-save-item').forEach(item =>
|
||||
item.addEventListener('click', () => smSelectSave(parseInt(item.dataset.idx)))
|
||||
);
|
||||
}
|
||||
|
||||
async function smSelectSave(idx) {
|
||||
if (smState.busy || smState.tab !== 'browse') return;
|
||||
smState.selectedIdx = idx;
|
||||
smRenderSaves();
|
||||
await smMount(idx);
|
||||
}
|
||||
|
||||
async function smMount(idx) {
|
||||
if (smState.busy) return;
|
||||
smSetBusy(true);
|
||||
const s = smState.saves[idx];
|
||||
smLog(`Mounting ${s.title_id}/${s.save_name}...`);
|
||||
try {
|
||||
const d = await window.pork.savemgrRequest(`/api/mount?idx=${idx}`);
|
||||
if (d.error) { smLog('Mount failed: ' + d.error, 'err'); smSetBusy(false); return; }
|
||||
smState.mounted = true;
|
||||
smState.files = d.files || [];
|
||||
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
|
||||
$('sm-save-title-text').textContent = d.save_title || 'Untitled';
|
||||
$('sm-save-title-id').innerHTML = d.title_id ? `Title ID: <b>${escHtml(d.title_id)}</b>` : '';
|
||||
$('sm-save-account-id').innerHTML = d.account_id ? ` Account: <b style="font-family:monospace">${escHtml(d.account_id)}</b>` : '';
|
||||
$('sm-save-ftp-row').textContent = d.mount ? `FTP: ${d.mount}` : '';
|
||||
$('sm-save-header').hidden = false;
|
||||
$('sm-empty-state').hidden = true;
|
||||
smLoadIcon();
|
||||
smRenderFileTree();
|
||||
smLog(`Mounted ${[d.title_id, d.save_title, d.account_id ? '['+d.account_id+']' : ''].filter(Boolean).join(' ')}`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
async function smLoadIcon() {
|
||||
try {
|
||||
const dataUrl = await window.pork.savemgrIcon();
|
||||
const img = $('sm-game-icon');
|
||||
if (dataUrl) { img.src = dataUrl; img.hidden = false; }
|
||||
else img.hidden = true;
|
||||
} catch (_) { $('sm-game-icon').hidden = true; }
|
||||
}
|
||||
|
||||
// ── File tree ────────────────────────────────────────────────────────────────
|
||||
function smRenderFileTree() {
|
||||
const tree = $('sm-filetree');
|
||||
tree.hidden = false;
|
||||
let h = '';
|
||||
smState.files.forEach((f, i) => {
|
||||
const parts = f.name.split('/'); parts.pop();
|
||||
let visible = true, pp = '';
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
pp = j ? pp + '/' + parts[j] : parts[j];
|
||||
if (!smState.expanded.has(pp)) { visible = false; break; }
|
||||
}
|
||||
if (!visible) return;
|
||||
const depth = f.name.split('/').length - 1;
|
||||
const name = f.name.split('/').pop();
|
||||
const pad = 12 + depth * 16;
|
||||
if (f.dir) {
|
||||
const open = smState.expanded.has(f.name);
|
||||
h += `<div class="sm-fi sm-fi-dir" style="padding-left:${pad}px" data-fidx="${i}">
|
||||
<span class="sm-fi-arrow">${open ? '▾' : '▸'}</span> 📁 ${escHtml(name)}</div>`;
|
||||
} else {
|
||||
const fname = escHtml(f.name).replace(/'/g, "\\'");
|
||||
h += `<div class="sm-fi" style="padding-left:${pad}px" data-fname="${escHtml(f.name)}">
|
||||
📄 ${escHtml(name)}<span class="sm-fi-sz">${smFmtBytes(f.size)}</span></div>`;
|
||||
}
|
||||
});
|
||||
h += '<div class="sm-tip">Tip: drag & drop files here to add or replace them</div>';
|
||||
tree.innerHTML = h;
|
||||
tree.querySelectorAll('.sm-fi-dir').forEach(el =>
|
||||
el.addEventListener('click', () => {
|
||||
const name = smState.files[parseInt(el.dataset.fidx)].name;
|
||||
if (smState.expanded.has(name)) smState.expanded.delete(name);
|
||||
else smState.expanded.add(name);
|
||||
smRenderFileTree();
|
||||
})
|
||||
);
|
||||
tree.querySelectorAll('.sm-fi:not(.sm-fi-dir)').forEach(el =>
|
||||
el.addEventListener('contextmenu', e => { e.preventDefault(); smShowCtxMenu(e, el.dataset.fname); })
|
||||
);
|
||||
smSetupDropZone(tree, smHandleFileDrop);
|
||||
}
|
||||
|
||||
// ── Context menu ─────────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', () => { const m = $('sm-ctx-menu'); if (m) m.remove(); });
|
||||
|
||||
function smShowCtxMenu(e, fname) {
|
||||
const old = $('sm-ctx-menu'); if (old) old.remove();
|
||||
const m = document.createElement('div');
|
||||
m.className = 'sm-ctx'; m.id = 'sm-ctx-menu';
|
||||
m.innerHTML = `<div data-action="download">Download</div>
|
||||
<div data-action="upload">Upload File</div>
|
||||
<div class="sm-ctx-del" data-action="delete">Delete</div>`;
|
||||
m.style.left = e.clientX + 'px'; m.style.top = e.clientY + 'px';
|
||||
document.body.appendChild(m);
|
||||
const r = m.getBoundingClientRect();
|
||||
if (r.right > window.innerWidth) m.style.left = (window.innerWidth - r.width - 4) + 'px';
|
||||
if (r.bottom > window.innerHeight) m.style.top = (window.innerHeight - r.height - 4) + 'px';
|
||||
m.querySelectorAll('[data-action]').forEach(item => item.addEventListener('click', () => {
|
||||
m.remove();
|
||||
if (item.dataset.action === 'download') smDownloadFile(fname);
|
||||
else if (item.dataset.action === 'upload') smPickUploadFile();
|
||||
else if (item.dataset.action === 'delete') smDeleteFile(fname);
|
||||
}));
|
||||
}
|
||||
|
||||
// ── File operations ──────────────────────────────────────────────────────────
|
||||
async function smDownloadFile(fname) {
|
||||
smLog(`Downloading ${fname}...`);
|
||||
try {
|
||||
await window.pork.savemgrDownload(`/api/download_file?name=${encodeURIComponent(fname)}`, fname.split('/').pop());
|
||||
smLog(`Downloaded ${fname.split('/').pop()}`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
function smPickUploadFile() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file'; input.multiple = true;
|
||||
input.onchange = () => { if (input.files.length) smHandleFileDrop(Array.from(input.files)); };
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function smDeleteFile(fname) {
|
||||
const ok = await smConfirm(`Delete ${fname}?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const d = await window.pork.savemgrRequest(`/api/delete_file?name=${encodeURIComponent(fname)}`);
|
||||
if (d.error) throw new Error(d.error);
|
||||
smLog(`Deleted ${fname}`, 'ok');
|
||||
await smRefreshFiles();
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function smRefreshFiles() {
|
||||
const d = await window.pork.savemgrRequest('/api/files');
|
||||
smState.files = d.files || [];
|
||||
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
|
||||
smRenderFileTree();
|
||||
}
|
||||
|
||||
async function smHandleFileDrop(files) {
|
||||
if (smState.busy || !smState.mounted) return;
|
||||
smSetBusy(true);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.name.endsWith('.zip')) {
|
||||
smLog(`Extracting ${file.name} (${smFmtBytes(file.size)})...`);
|
||||
const buf = await file.arrayBuffer();
|
||||
const d = await window.pork.savemgrUpload('/api/upload', buf);
|
||||
smLog(`Extracted ${d.files} files`, 'ok');
|
||||
} else {
|
||||
const ce = await window.pork.savemgrRequest(`/api/file_exists?name=${encodeURIComponent(file.name)}`);
|
||||
if (ce.exists) {
|
||||
const replace = await smConfirm(`${file.name} already exists. Replace it?`);
|
||||
if (!replace) { smLog(`Skipped ${file.name}`); continue; }
|
||||
}
|
||||
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)})...`);
|
||||
const buf = await file.arrayBuffer();
|
||||
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(file.name)}`, buf);
|
||||
smHideUploadProgress();
|
||||
if (d.ok) smLog(`Added ${file.name}`, 'ok');
|
||||
else smLog(d.error || 'Upload failed', 'err');
|
||||
}
|
||||
}
|
||||
await smRefreshFiles();
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
smHideUploadProgress();
|
||||
}
|
||||
|
||||
// ── Browse actions ────────────────────────────────────────────────────────────
|
||||
async function smDownloadZip() {
|
||||
if (smState.busy) return; smSetBusy(true);
|
||||
const s = smState.saves[smState.selectedIdx];
|
||||
const filename = s ? `${s.title_id}_${s.save_name}.zip` : 'save.zip';
|
||||
smLog('Downloading decrypted zip...');
|
||||
try {
|
||||
await window.pork.savemgrDownload('/api/download', filename);
|
||||
smLog(`Downloaded ${filename}`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
async function smDownloadEncrypted() {
|
||||
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
|
||||
const s = smState.saves[smState.selectedIdx];
|
||||
smLog('Unmounting and downloading encrypted save...');
|
||||
try {
|
||||
await window.pork.savemgrRequest('/api/unmount');
|
||||
smState.mounted = false;
|
||||
await window.pork.savemgrDownload(`/api/download_raw?idx=${smState.selectedIdx}`, `${s.title_id}_${s.save_name}`);
|
||||
smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
|
||||
smRenderSaves();
|
||||
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
|
||||
smLog('Downloaded encrypted save', 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
async function smDumpToUsb() {
|
||||
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
|
||||
smLog('Dumping to USB...');
|
||||
window.pork.on('savemgr:usb-progress', data => { smShowUsbProgress(data.progress); });
|
||||
try {
|
||||
const result = await window.pork.savemgrDumpUsb(smState.selectedIdx);
|
||||
window.pork.off('savemgr:usb-progress');
|
||||
smHideUsbProgress();
|
||||
smLog(result.path ? `Dumped to ${result.path} (${smFmtBytes(result.size)})` : 'Dump complete', 'ok');
|
||||
} catch (e) { window.pork.off('savemgr:usb-progress'); smHideUsbProgress(); smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
async function smUnmount() {
|
||||
if (smState.busy) return; smSetBusy(true);
|
||||
try {
|
||||
await window.pork.savemgrRequest('/api/unmount');
|
||||
smState.mounted = false; smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
|
||||
smRenderSaves();
|
||||
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
|
||||
smLog('Unmounted', 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
// ── Sub-tab switching ─────────────────────────────────────────────────────────
|
||||
function smSwitchTab(tab) {
|
||||
smState.tab = tab;
|
||||
document.querySelectorAll('.sm-tab').forEach(btn =>
|
||||
btn.classList.toggle('active', btn.dataset.smTab === tab)
|
||||
);
|
||||
document.querySelectorAll('.sm-pane').forEach(pane =>
|
||||
pane.hidden = (pane.id !== `sm-pane-${tab}`)
|
||||
);
|
||||
if (tab === 'browse') smFetchSaves();
|
||||
}
|
||||
|
||||
// ── Decrypt drop ──────────────────────────────────────────────────────────────
|
||||
async function smHandleDecryptDrop(files) {
|
||||
const file = files[0]; if (!file || smState.busy) return;
|
||||
smSetBusy(true);
|
||||
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)}) for decrypt...`);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const filename = file.name.replace(/\.[^.]+$/, '') + '.zip';
|
||||
const r = await window.pork.savemgrDecrypt(buf, filename);
|
||||
if (r.canceled) smLog('Cancelled');
|
||||
else smLog(`Decrypted → ${filename} (${smFmtBytes(r.size)})`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
// ── Encrypt drop ──────────────────────────────────────────────────────────────
|
||||
async function smHandleEncryptDrop(files, folderName) {
|
||||
if (!files.length || smState.busy) return;
|
||||
const aid = $('sm-enc-aid').value.trim();
|
||||
smSetBusy(true);
|
||||
let totalSize = 0; for (const f of files) totalSize += f.size;
|
||||
smLog(`Creating PFS image (${smFmtBytes(totalSize)} of files)...`);
|
||||
try {
|
||||
const pfs = await window.pork.savemgrCreatePfs(totalSize);
|
||||
if (pfs.error) throw new Error(pfs.error);
|
||||
smLog('PFS image created', 'ok');
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
const raw = f._path || f.webkitRelativePath || f.name;
|
||||
const parts = raw.split('/');
|
||||
const relPath = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
smLog(`Uploading (${i+1}/${files.length}) ${relPath}...`);
|
||||
const buf = await f.arrayBuffer();
|
||||
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(relPath)}`, buf);
|
||||
if (!d.ok) throw new Error(d.error || `Upload failed: ${relPath}`);
|
||||
}
|
||||
smLog(`All ${files.length} files uploaded`, 'ok');
|
||||
const name = folderName || 'encrypted_save';
|
||||
smLog('Downloading encrypted save...');
|
||||
const r = await window.pork.savemgrDownloadNew(name, aid);
|
||||
if (r.canceled) smLog('Cancelled');
|
||||
else smLog(`Encrypted save downloaded (${smFmtBytes(r.size)})`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
// ── Resign drop ───────────────────────────────────────────────────────────────
|
||||
async function smHandleResignDrop(files) {
|
||||
const file = files[0]; if (!file || smState.busy) return;
|
||||
const aid = $('sm-resign-aid').value.trim();
|
||||
if (!aid) { smLog('Enter new Account ID first', 'err'); return; }
|
||||
smSetBusy(true);
|
||||
smLog(`Uploading ${file.name} for resign...`);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const filename = file.name.replace(/\.[^.]+$/, '') + '_resigned';
|
||||
const r = await window.pork.savemgrResign(buf, aid, filename);
|
||||
if (r.canceled) smLog('Cancelled');
|
||||
else smLog(`Resigned → ${filename} (${smFmtBytes(r.size)})`, 'ok');
|
||||
} catch (e) { smLog('Error: ' + e.message, 'err'); }
|
||||
smSetBusy(false);
|
||||
}
|
||||
|
||||
// ── Drop zone setup ───────────────────────────────────────────────────────────
|
||||
function smSetupDropZone(el, handler, folderMode) {
|
||||
el.ondragover = e => { e.preventDefault(); el.classList.add('sm-drag-over'); };
|
||||
el.ondragleave = e => { e.preventDefault(); el.classList.remove('sm-drag-over'); };
|
||||
el.ondrop = async e => {
|
||||
e.preventDefault(); el.classList.remove('sm-drag-over');
|
||||
if (folderMode) {
|
||||
const items = e.dataTransfer.items, entries = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const ent = items[i].webkitGetAsEntry?.(); if (ent) entries.push(ent);
|
||||
}
|
||||
if (entries.length && entries[0].isDirectory) {
|
||||
const files = await smReadFolderEntries(entries[0], '');
|
||||
handler(files, entries[0].name);
|
||||
} else if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
|
||||
} else {
|
||||
if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
const btn = el.querySelector('.sm-browse-btn');
|
||||
if (btn) btn.onclick = e => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input'); input.type = 'file';
|
||||
if (folderMode) input.webkitdirectory = true;
|
||||
input.onchange = () => {
|
||||
if (!input.files.length) return;
|
||||
if (folderMode) { const prefix = input.files[0].webkitRelativePath.split('/')[0]; handler(Array.from(input.files), prefix); }
|
||||
else handler(Array.from(input.files));
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
}
|
||||
|
||||
async function smReadFolderEntries(entry, basePath) {
|
||||
return new Promise(resolve => {
|
||||
if (entry.isFile) { entry.file(f => { f._path = basePath + f.name; resolve([f]); }); }
|
||||
else if (entry.isDirectory) {
|
||||
const reader = entry.createReader(), all = [];
|
||||
const readBatch = () => reader.readEntries(entries => {
|
||||
if (!entries.length) { Promise.all(all).then(arrs => resolve(arrs.flat())); return; }
|
||||
for (const e of entries) all.push(smReadFolderEntries(e, basePath + entry.name + '/'));
|
||||
readBatch();
|
||||
});
|
||||
readBatch();
|
||||
} else resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event wiring (run once on startup) ───────────────────────────────────────
|
||||
(function smInitEvents() {
|
||||
document.querySelectorAll('.sm-tab').forEach(btn =>
|
||||
btn.addEventListener('click', () => smSwitchTab(btn.dataset.smTab))
|
||||
);
|
||||
|
||||
$('btn-sm-start-session').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
const s = await window.pork.getSettings();
|
||||
const ip = s.ftpHost || '';
|
||||
const port = parseInt($('sm-port').value) || 8082;
|
||||
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
|
||||
button.disabled = true;
|
||||
await window.pork.setSettings({ savemgrPort: port });
|
||||
smLog('Starting Garlic Save Manager explicitly through PS5 Payload Manager…');
|
||||
try {
|
||||
await window.pork.savemgrStart();
|
||||
smLog('Garlic Save Manager started', 'ok');
|
||||
showToast('Save Manager session started.', 'ok');
|
||||
await smFetchSaves();
|
||||
} catch (error) {
|
||||
smLog('Start failed: ' + error.message, 'err');
|
||||
showToast(`Save Manager failed to start: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-sm-load').addEventListener('click', async () => {
|
||||
const s = await window.pork.getSettings();
|
||||
const ip = s.ftpHost || '';
|
||||
const port = parseInt($('sm-port').value) || 8082;
|
||||
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
|
||||
await window.pork.setSettings({ savemgrPort: port });
|
||||
smFetchSaves();
|
||||
});
|
||||
|
||||
$('btn-sm-save').addEventListener('click', async () => {
|
||||
const port = parseInt($('sm-port').value) || 8082;
|
||||
await window.pork.setSettings({ savemgrPort: port });
|
||||
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
|
||||
showToast('Save Manager port saved.', 'ok');
|
||||
});
|
||||
|
||||
$('btn-sm-open-ext').addEventListener('click', async () => {
|
||||
const s = await window.pork.getSettings();
|
||||
const ip = s.ftpHost || ''; const port = parseInt($('sm-port').value) || 8082;
|
||||
if (!ip) { showToast('PS5 IP not set.', 'error'); return; }
|
||||
window.pork.openShell(`http://${ip}:${port}`);
|
||||
});
|
||||
|
||||
$('btn-sm-dl-zip').addEventListener('click', smDownloadZip);
|
||||
$('btn-sm-dl-enc').addEventListener('click', smDownloadEncrypted);
|
||||
$('btn-sm-dump-usb').addEventListener('click', smDumpToUsb);
|
||||
$('btn-sm-unmount').addEventListener('click', smUnmount);
|
||||
$('btn-sm-upload').addEventListener('click', smPickUploadFile);
|
||||
|
||||
smSetupDropZone($('sm-decrypt-drop'), smHandleDecryptDrop, false);
|
||||
smSetupDropZone($('sm-encrypt-drop'), (f, n) => smHandleEncryptDrop(f, n), true);
|
||||
smSetupDropZone($('sm-resign-drop'), smHandleResignDrop, false);
|
||||
|
||||
// Drag-drop onto the right panel (file browser)
|
||||
const rp = $('sm-right-pane');
|
||||
rp.ondragover = e => { e.preventDefault(); if (smState.mounted) rp.classList.add('sm-drag-over'); };
|
||||
rp.ondragleave = e => { e.preventDefault(); rp.classList.remove('sm-drag-over'); };
|
||||
rp.ondrop = e => {
|
||||
e.preventDefault(); rp.classList.remove('sm-drag-over');
|
||||
if (smState.mounted && e.dataTransfer.files.length) smHandleFileDrop(Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
$('btn-sm-push-payload').addEventListener('click', async () => {
|
||||
const confirmation = 'INSTALLEER GARLIC AUTOLOAD';
|
||||
const entered = await showPrompt(
|
||||
`Deze actie schrijft garlic-savemgr.elf en autoload.txt naar je PS5. Typ exact ${confirmation} om door te gaan:`,
|
||||
''
|
||||
);
|
||||
if (entered !== confirmation) {
|
||||
showToast('Installation cancelled: the confirmation phrase did not match exactly.', 'info');
|
||||
return;
|
||||
}
|
||||
const btn = $('btn-sm-push-payload'), st = $('sm-push-status');
|
||||
btn.disabled = true; st.textContent = 'Installing\u2026';
|
||||
try {
|
||||
const res = await window.pork.savemgrPushPayload({ confirmation });
|
||||
st.textContent = res.appended
|
||||
? 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded and added to autoload.txt. Restart your PS5 to apply.'
|
||||
: 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded (already in autoload.txt). Restart your PS5 to apply.';
|
||||
} catch (e) { st.textContent = 'Error: ' + e.message; }
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
const ssBtn = $('btn-s-savemgr-save');
|
||||
if (ssBtn) ssBtn.addEventListener('click', async () => {
|
||||
const port = parseInt($('s-savemgr-port').value) || 8082;
|
||||
await window.pork.setSettings({ savemgrPort: port });
|
||||
showToast('Save Manager port saved.', 'ok');
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
|
||||
function darkenHex(hex, amount = 0.18, satCap = null) {
|
||||
const r = parseInt(hex.slice(1,3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3,5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5,7), 16) / 255;
|
||||
const max = Math.max(r,g,b), min = Math.min(r,g,b);
|
||||
let h = 0, s = 0, l = (max+min)/2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
|
||||
switch(max) {
|
||||
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
|
||||
case g: h = ((b-r)/d + 2)/6; break;
|
||||
case b: h = ((r-g)/d + 4)/6; break;
|
||||
}
|
||||
}
|
||||
l = Math.max(0, l - amount);
|
||||
if (satCap !== null) s = Math.min(s, satCap);
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t<0) t+=1; if (t>1) t-=1;
|
||||
if (t<1/6) return p+(q-p)*6*t;
|
||||
if (t<1/2) return q;
|
||||
if (t<2/3) return p+(q-p)*(2/3-t)*6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l*(1+s) : l+s-l*s;
|
||||
const p = 2*l - q;
|
||||
const toHex = v => Math.round(hue2rgb(p,q,v)*255).toString(16).padStart(2,'0');
|
||||
return `#${toHex(h+1/3)}${toHex(h)}${toHex(h-1/3)}`;
|
||||
}
|
||||
|
||||
function applyAccent(hex) {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
|
||||
// --accent = tint (text/borders); --accent-d = fill (buttons); --accent-rgb tracks the fill.
|
||||
const fill = darkenHex(hex, 0.26, 0.78);
|
||||
const r = parseInt(fill.slice(1,3), 16);
|
||||
const g = parseInt(fill.slice(3,5), 16);
|
||||
const b = parseInt(fill.slice(5,7), 16);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--accent', hex);
|
||||
root.style.setProperty('--accent-d', fill);
|
||||
root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
|
||||
const swatch = $('accent-swatch');
|
||||
const picker = $('s-accent-color');
|
||||
const hexEl = $('s-accent-hex');
|
||||
if (swatch) swatch.style.background = fill;
|
||||
if (picker) picker.value = hex;
|
||||
if (hexEl) hexEl.value = hex.toUpperCase();
|
||||
}
|
||||
|
||||
let settingsTimer = null;
|
||||
let remoteGamePaths = [];
|
||||
let localBackupPaths = [];
|
||||
|
||||
let _userDirty = false;
|
||||
let _passDirty = false;
|
||||
const settingsT = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
|
||||
|
||||
function renderConsoleProfiles(packet) {
|
||||
const select = $('console-profile-active');
|
||||
if (!select) return;
|
||||
select.innerHTML = (packet.profiles || []).map(profile => `<option value="${escHtml(profile.id)}">${escHtml(profile.label)} · ${escHtml(profile.host || settingsT('settings.status.hostUnknown', 'host unknown'))}${profile.active ? ` · ${settingsT('settings.status.active', 'active')}` : ''}</option>`).join('');
|
||||
select.value = packet.activeId || '';
|
||||
const active = (packet.profiles || []).find(profile => profile.id === packet.activeId);
|
||||
$('console-profile-status').textContent = active
|
||||
? `${active.platform} · Remote Play ${active.secrets?.remotePlayKey ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${active.secrets?.sentinelPairingKey ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}`
|
||||
: settingsT('settings.status.noActiveProfile', 'No active profile');
|
||||
}
|
||||
|
||||
function renderConsoleIdentity(identity) {
|
||||
if (!$('settings-identity-profile')) return;
|
||||
const unavailable = settingsT('settings.identity.unavailable', 'Not observed');
|
||||
$('settings-identity-profile').textContent = identity?.profileLabel || unavailable;
|
||||
$('settings-identity-local').textContent = identity?.local?.username || unavailable;
|
||||
$('settings-identity-account').textContent = identity?.local?.accountIdMasked || unavailable;
|
||||
$('settings-identity-psn').textContent = identity?.linkedPsn?.linked && identity.linkedPsn.onlineId ? identity.linkedPsn.onlineId : unavailable;
|
||||
const observed = identity?.provenance?.observedAt ? new Date(identity.provenance.observedAt).toLocaleString() : settingsT('settings.identity.noTimestamp', 'no timestamp');
|
||||
$('settings-identity-evidence').textContent = `${identity?.provenance?.source || 'unknown'} · ${observed}`;
|
||||
}
|
||||
|
||||
async function loadConsoleProfiles() {
|
||||
if (!window.pork.consoleProfiles || !$('console-profile-active')) return;
|
||||
renderConsoleProfiles(await window.pork.consoleProfiles());
|
||||
if (window.pork.consoleIdentity) renderConsoleIdentity(await window.pork.consoleIdentity());
|
||||
}
|
||||
|
||||
function renderCompanionWorkers(workers) {
|
||||
const target = $('companion-workers-list');
|
||||
if (!target) return;
|
||||
target.innerHTML = workers.length ? workers.map(worker => `<div class="ecosystem-service-row" data-worker-id="${escHtml(worker.id)}"><div><strong>${escHtml(worker.label)}</strong><span>${escHtml(worker.endpoint)} · ${worker.paired ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}</span></div><div class="operations-inline-actions"><button class="btn btn-sm companion-worker-test">Capabilities</button><button class="btn btn-sm companion-worker-pair">${worker.paired ? settingsT('settings.companion.rotateKey', 'Rotate key') : 'Pairing key'}</button><button class="btn btn-sm btn-danger companion-worker-remove">${settingsT('btn.remove', 'Remove')}</button></div></div>`).join('') : `<p class="hint">${settingsT('settings.companion.empty', 'No companion workers configured.')}</p>`;
|
||||
}
|
||||
|
||||
async function loadCompanionWorkers() {
|
||||
if (window.pork.companionWorkers && $('companion-workers-list')) renderCompanionWorkers(await window.pork.companionWorkers());
|
||||
}
|
||||
|
||||
async function loadTotpStatus() {
|
||||
if (!$('totp-settings-status')) return;
|
||||
if (!window.pork.totpStatus) {
|
||||
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Unavailable in this runtime');
|
||||
$('totp-settings-begin').disabled = true;
|
||||
$('totp-settings-disable').hidden = true;
|
||||
$('passkey-readiness').innerHTML = `<strong>${escHtml(settingsT('settings.passkeys.preparedTitle', 'Passkeys: safely prepared, not active yet'))}</strong><p>${escHtml(settingsT('settings.passkeys.runtimeUnavailable', 'This runtime does not expose the required enrollment service.'))}</p>`;
|
||||
return;
|
||||
}
|
||||
let status;
|
||||
try {
|
||||
status = await Promise.race([
|
||||
window.pork.totpStatus(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error(settingsT('settings.twoFactor.timeout', 'Status check timed out'))), 3500)),
|
||||
]);
|
||||
} catch (error) {
|
||||
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Temporarily unavailable');
|
||||
$('totp-settings-status').title = error.message;
|
||||
$('totp-settings-begin').disabled = true;
|
||||
return;
|
||||
}
|
||||
$('totp-settings-begin').disabled = false;
|
||||
$('totp-settings-status').textContent = status.enabled
|
||||
? `${settingsT('settings.status.active', 'Active')} · ${status.recoveryCodesRemaining} ${settingsT('settings.twoFactor.codesRemaining', 'recovery codes remaining')}`
|
||||
: status.pending ? settingsT('settings.twoFactor.pending', 'Pairing started but not confirmed') : settingsT('settings.status.inactive', 'Inactive');
|
||||
$('totp-settings-begin').textContent = status.enabled ? settingsT('settings.twoFactor.repair', 'Pair authenticator again') : settingsT('settings.twoFactor.pair', 'Pair authenticator');
|
||||
$('totp-settings-disable').hidden = !status.enabled;
|
||||
$('passkey-readiness').innerHTML = `<strong>Passkeys: ${status.passkeys?.available ? settingsT('settings.status.available', 'available') : settingsT('settings.passkeys.prepared', 'safely prepared, not active yet')}</strong><p>${escHtml(status.passkeys?.reason || settingsT('settings.status.none', 'No status.'))}</p>`;
|
||||
}
|
||||
|
||||
async function loadGenericNotificationStatus() {
|
||||
if (!window.pork.genericNotificationStatus || !$('generic-notification-status')) return;
|
||||
const status = await window.pork.genericNotificationStatus();
|
||||
$('generic-notification-enabled').checked = status.enabled;
|
||||
$('generic-notification-endpoint').value = '';
|
||||
$('generic-notification-endpoint').placeholder = status.endpointConfigured ? `${settingsT('settings.notifications.configuredFor', 'Configured for')} ${status.endpointHost}; ${settingsT('settings.notifications.leaveBlank', 'leave blank to keep')}` : 'https://notify.example/chimera';
|
||||
$('generic-notification-token').value = '';
|
||||
$('generic-notification-cooldown').value = status.cooldownMinutes || 60;
|
||||
$('generic-notification-rules').innerHTML = (status.availableRules || []).map(rule => `<label class="ecosystem-check"><input type="checkbox" data-notification-rule="${escHtml(rule)}" ${status.rules?.includes(rule) ? 'checked' : ''}> ${escHtml(settingsT(`settings.notifications.rule.${rule}`, rule.replace(/-/g, ' ')))}</label>`).join('');
|
||||
$('generic-notification-status').textContent = `${status.format} · endpoint ${status.endpointConfigured ? status.endpointHost : settingsT('settings.status.notConfigured', 'not configured')} · bearer ${status.bearerConfigured ? settingsT('settings.status.encrypted', 'configured and encrypted') : settingsT('settings.status.notConfigured', 'not configured')}`;
|
||||
}
|
||||
|
||||
function renderConsoleComparison(packet) {
|
||||
const target = $('console-profiles-comparison');
|
||||
target.hidden = false;
|
||||
target.innerHTML = `<div class="ecosystem-update-list">${(packet.profiles || []).map(profile => `<div class="ecosystem-service-row"><div><strong>${profile.active ? '● ' : ''}${escHtml(profile.label)}</strong><span>${escHtml(profile.host)} · firmware ${escHtml(profile.firmware)} · ${escHtml(profile.hen)} ${escHtml(profile.henVersion)}</span></div><span class="hint">Remote Play ${profile.pairing?.remotePlay ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${profile.pairing?.sentinel ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')} · ${settingsT('settings.status.storage', 'storage')} ${escHtml(profile.storage?.status || 'unknown')}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
$('console-profile-activate')?.addEventListener('click', async event => {
|
||||
try {
|
||||
const result = await window.pork.consoleProfileActivate($('console-profile-active').value);
|
||||
renderConsoleProfiles(result);
|
||||
await loadSettings();
|
||||
showToast('Console profile activated; no automatic connection was started.', 'ok', 7000);
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('console-profile-new')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await window.pork.consoleProfileSave({ label: $('console-profile-new-label').value.trim(), host: $('console-profile-new-host').value.trim(), makeActive: false });
|
||||
renderConsoleProfiles(result);
|
||||
event.target.reset();
|
||||
showToast(settingsT('settings.consoleProfiles.added', 'Console profile added without starting a connection.'), 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('console-profiles-compare')?.addEventListener('click', async event => {
|
||||
try { renderConsoleComparison(await window.pork.consoleProfilesCompare()); }
|
||||
catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('companion-worker-form')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
renderCompanionWorkers(await window.pork.companionWorkerSave({ id: $('companion-worker-id').value.trim(), label: $('companion-worker-label').value.trim(), endpoint: $('companion-worker-endpoint').value.trim() }));
|
||||
event.target.reset(); showToast('Companion worker saved; no job was started.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('companion-workers-list')?.addEventListener('click', async event => {
|
||||
const row = event.target.closest('[data-worker-id]'); if (!row) return; const id = row.dataset.workerId;
|
||||
try {
|
||||
if (event.target.closest('.companion-worker-test')) {
|
||||
const status = await window.pork.companionWorkerCapabilities(id);
|
||||
showToast(`Worker verified: ${status.capabilities.join(', ') || 'no supported capabilities'}; shell disabled.`, 'ok', 9000);
|
||||
}
|
||||
if (event.target.closest('.companion-worker-pair')) {
|
||||
const workers = await window.pork.companionWorkers(); const rotate = workers.find(worker => worker.id === id)?.paired === true;
|
||||
const phrase = `PAIR WORKER ${id}`; const confirmation = await showExactConfirmation(`${rotate ? 'Rotate' : 'Create'} the pairing key for this worker?`, phrase); if (!confirmation) return;
|
||||
const result = await window.pork.companionWorkerPair({ id, rotate, confirmation });
|
||||
if (result.pairingKey) { const output = $('companion-worker-pairing-output'); output.hidden = false; output.textContent = `Worker: ${id}\nPairing key (eenmalig zichtbaar): ${result.pairingKey}\n\nImporteer lokaal in Chimera Worker en wis deze weergave.`; }
|
||||
await loadCompanionWorkers();
|
||||
}
|
||||
if (event.target.closest('.companion-worker-remove') && await showExactConfirmation(`Remove ${id} and erase its pairing key?`, 'DELETE')) renderCompanionWorkers(await window.pork.companionWorkerRemove(id));
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
|
||||
$('totp-settings-begin')?.addEventListener('click', async event => {
|
||||
if (!await showConfirm('Pair a new authenticator? TOTP becomes active only after you confirm a valid code.')) return;
|
||||
try {
|
||||
const enrollment = await window.pork.totpBegin(); const output = $('totp-settings-output'); output.hidden = false; output.textContent = `One-time TOTP secret: ${enrollment.secret}\n\nAuthenticator URI:\n${enrollment.uri}`;
|
||||
const code = await showPrompt('Add the secret or URI to your authenticator, then enter the current 6-digit code:'); if (code == null) return;
|
||||
const confirmed = await window.pork.totpConfirm(code); output.textContent = `TOTP is active. Save these recovery codes now; they will not be shown again:\n\n${confirmed.recoveryCodes.join('\n')}`;
|
||||
await loadTotpStatus(); showToast('Two-factor authentication is active.', 'ok', 8000);
|
||||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||||
});
|
||||
|
||||
$('totp-settings-disable')?.addEventListener('click', async () => {
|
||||
const code = await showPrompt('Enter a valid authenticator or recovery code to disable TOTP:'); if (code == null) return;
|
||||
if (!await showExactConfirmation('Disable two-factor authentication?', 'UITSCHAKELEN')) return;
|
||||
try { await window.pork.totpDisable(code); $('totp-settings-output').hidden = true; await loadTotpStatus(); showToast('Two-factor authentication disabled.', 'ok'); }
|
||||
catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('generic-notification-form')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const token = $('generic-notification-token').value;
|
||||
const endpoint = $('generic-notification-endpoint').value.trim();
|
||||
const rules = [...document.querySelectorAll('[data-notification-rule]:checked')].map(input => input.dataset.notificationRule);
|
||||
await window.pork.genericNotificationSave({ enabled: $('generic-notification-enabled').checked, rules, cooldownMinutes: Number($('generic-notification-cooldown').value), ...(endpoint ? { endpoint } : {}), ...(token ? { bearerToken: token } : {}) });
|
||||
await loadGenericNotificationStatus(); showToast('Generic notification settings saved.', 'ok');
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('generic-notification-test')?.addEventListener('click', async event => {
|
||||
try { await window.pork.genericNotificationTest(); showToast('Test notification delivered.', 'ok'); }
|
||||
catch (error) { showToast(error.message, 'error'); }
|
||||
});
|
||||
|
||||
$('settings-automation-analyze')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const [sources, protection] = await Promise.all([window.pork.automationSourcesAnalyze(), window.pork.automationProtectionStatus()]);
|
||||
$('settings-automation-analysis').innerHTML = `
|
||||
<div class="ecosystem-metrics">
|
||||
<div class="operations-metric"><strong>${sources.roots.length}</strong><span>roots inspected</span></div>
|
||||
<div class="operations-metric"><strong>${sources.recommendations.length}</strong><span>high-confidence suggestions</span></div>
|
||||
<div class="operations-metric"><strong>${protection.saveVault.eligible ? 'Ready' : 'Setup'}</strong><span>Save Vault</span></div>
|
||||
</div>
|
||||
${(sources.roots || []).map(item => `<div class="ecosystem-service-row"><div><strong>${escHtml(item.root)}</strong><span>${escHtml(item.classification)} · ${escHtml(item.confidence)} confidence · ${item.writable ? 'writable' : 'read-only/unavailable'}</span></div><span class="badge">${escHtml(item.exists ? 'inspected' : item.error || 'missing')}</span></div>`).join('')}
|
||||
<p class="hint">No configuration changes were applied. Enable Save Vault, replication and restore drills from Settings → Automation after reviewing their targets.</p>`;
|
||||
} catch (error) { showToast(error.message, 'error'); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
function scheduleSettingsSave() {
|
||||
clearTimeout(settingsTimer);
|
||||
settingsTimer = setTimeout(commitSettingsSave, 600);
|
||||
}
|
||||
|
||||
let _webhookDirty = false;
|
||||
$('s-discord-webhook')?.addEventListener('input', () => { _webhookDirty = true; });
|
||||
|
||||
async function commitSettingsSave() {
|
||||
const payload = {
|
||||
ftpHost: $('s-host').value.trim(),
|
||||
ftpPorts: $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean),
|
||||
backupPaths: localBackupPaths,
|
||||
backupPath: localBackupPaths[0] || '',
|
||||
payloadLocalPath: $('s-payload-local').value.trim(),
|
||||
payloadRemotePath:$('s-payload-remote').value.trim(),
|
||||
cheatsRemotePath: $('s-cheats-remote').value.trim(),
|
||||
mediaPsNotifyEnabled: $('s-media-discord-notify').checked,
|
||||
autoConnect: $('s-auto-connect').checked,
|
||||
timeFormat: $('s-time-format').value,
|
||||
hashConcurrency: Number($('s-hash-concurrency')?.value) || 8,
|
||||
};
|
||||
if (_userDirty) payload.ftpUser = $('s-user').value.trim();
|
||||
if (_passDirty) payload.ftpPass = $('s-pass').value;
|
||||
if (_webhookDirty) payload.discordWebhookUrl = $('s-discord-webhook').value.trim();
|
||||
await window.pork.setSettings(payload);
|
||||
_passDirty = false;
|
||||
_webhookDirty = false;
|
||||
$('d-host').value = $('s-host').value;
|
||||
$('d-ports').value = $('s-ports').value;
|
||||
setStatus('Settings saved', 'ok');
|
||||
}
|
||||
|
||||
async function loadSettings(options = {}) {
|
||||
const background = !!options.background;
|
||||
const s = await window.pork.getSettings();
|
||||
$('s-host').value = s.ftpHost || '';
|
||||
$('s-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
|
||||
localBackupPaths = s.backupPaths || (s.backupPath ? [s.backupPath] : []);
|
||||
renderLocalBackupPaths();
|
||||
$('s-payload-local').value = s.payloadLocalPath || '';
|
||||
$('s-payload-remote').value = s.payloadRemotePath || '/data/payloads/';
|
||||
$('s-cheats-remote').value = s.cheatsRemotePath || '/data/etaHEN/cheats/';
|
||||
$('s-discord-webhook').value = '';
|
||||
$('s-discord-webhook').placeholder = s.discordWebhookConfigured ? 'Stored securely — enter to replace' : 'https://discord.com/api/webhooks/...';
|
||||
$('s-media-discord-notify').checked = s.mediaPsNotifyEnabled !== false; // default true
|
||||
$('media-discord-psn-detail').hidden = s.mediaPsNotifyEnabled === false;
|
||||
$('s-media-psn-download').checked = s.mediaPsNotifyDiscordDownload !== false;
|
||||
$('s-media-psn-convert').checked = s.mediaPsNotifyDiscordConvert !== false;
|
||||
$('s-media-psn-upload').checked = s.mediaPsNotifyDiscordUpload !== false;
|
||||
$('s-media-psn-done').checked = s.mediaPsNotifyDiscordDone !== false;
|
||||
$('s-media-psn-error').checked = s.mediaPsNotifyDiscordError !== false;
|
||||
$('s-auto-connect').checked = s.autoConnect === true;
|
||||
_timeFormat = s.timeFormat || '12h';
|
||||
$('s-time-format').value = _timeFormat;
|
||||
if ($('s-interface-density')) $('s-interface-density').value = applyDensity(s.interfaceDensity);
|
||||
if (!background) {
|
||||
$('d-host').value = s.ftpHost || '';
|
||||
$('d-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
|
||||
}
|
||||
applyAccent(s.accentColor || '#CBBAFF');
|
||||
remoteGamePaths = s.remoteGamePaths || [];
|
||||
renderRemoteGamePaths();
|
||||
await renderSettingsPayloadSources();
|
||||
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
|
||||
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
|
||||
Object.entries(hkMap).forEach(([id, action]) => {
|
||||
const el = $(id); if (el) el.value = _svHotkeys[action] || '';
|
||||
});
|
||||
|
||||
let convMode = s.gameConversionMode || 'pfs';
|
||||
if (window.pork.isWebMode) {
|
||||
for (const unavailable of ['ffpkg', 'exfat']) {
|
||||
const radio = document.querySelector(`input[name="conv-mode"][value="${unavailable}"]`);
|
||||
if (radio) radio.disabled = true;
|
||||
radio?.closest('.radio-card')?.setAttribute('title', settingsT('settings.windowsModeUnavailable', 'This Windows mode is unavailable in the Unraid container. Use FFPFSC.'));
|
||||
const queueButton = document.querySelector(`.conv-mode-btn[data-conv-mode="${unavailable}"]`);
|
||||
if (queueButton) queueButton.disabled = true;
|
||||
}
|
||||
if (!['pfs', 'ffpfsc'].includes(convMode)) {
|
||||
convMode = 'ffpfsc';
|
||||
await window.pork.setSettings({ gameConversionMode: convMode });
|
||||
}
|
||||
}
|
||||
const modeRadio = document.querySelector(`input[name="conv-mode"][value="${convMode}"]`);
|
||||
if (modeRadio) modeRadio.checked = true;
|
||||
applyConvModeUI(convMode);
|
||||
|
||||
$('s-conv-ufs2-path').value = s.ufs2ToolPath || '';
|
||||
$('s-conv-exfat-path').value = s.osfmountPath || s.exfatToolPath || '';
|
||||
$('s-conv-output-dir').value = s.convOutputDir || '';
|
||||
$('s-conv-temp-dir').value = s.convTempDir || '';
|
||||
$('s-conv-ftp-upload').checked = !!s.convFtpUpload;
|
||||
$('s-conv-delete-after').checked = !!s.convDeleteAfter;
|
||||
$('s-ps4-package-builder').value = s.ps4PackageBuilderPath || '';
|
||||
$('s-ps4-package-output').value = s.ps4PackageOutputPath || '';
|
||||
window.pork.ps4PackageBuilderStatus().then(status => {
|
||||
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
|
||||
$('s-ps4-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
|
||||
}).catch(error => { $('s-ps4-package-status').textContent = error.message; });
|
||||
$('s-ps5-native-package-builder').value = s.ps5NativePackageBuilderPath || '';
|
||||
$('s-ps5-native-package-output').value = s.ps5NativePackageOutputPath || '';
|
||||
window.pork.ps5NativePackageBuilderStatus().then(status => {
|
||||
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
|
||||
$('s-ps5-native-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
|
||||
}).catch(error => { $('s-ps5-native-package-status').textContent = error.message; });
|
||||
$('conv-delete-after-row').hidden = false;
|
||||
$('s-conv-delete-after').disabled = !s.convFtpUpload;
|
||||
$('conv-delete-after-row').style.opacity = s.convFtpUpload ? '' : '0.45';
|
||||
|
||||
$('s-conv-psn-enabled').checked = s.convPsNotifyEnabled !== false;
|
||||
$('conv-psn-detail').hidden = !s.convPsNotifyEnabled;
|
||||
$('s-conv-psn-queued').checked = s.convPsNotifyOnGameQueued !== false;
|
||||
$('s-conv-psn-batch').checked = s.convPsNotifyOnBatchQueued !== false;
|
||||
$('s-conv-psn-copy').checked = s.convPsNotifyOnCopyStart !== false;
|
||||
$('s-conv-psn-convert').checked = s.convPsNotifyOnConvertStart!== false;
|
||||
$('s-conv-psn-done').checked = s.convPsNotifyOnJobDone !== false;
|
||||
|
||||
await refreshConvToolStatus();
|
||||
|
||||
if ($('s-hash-concurrency')) $('s-hash-concurrency').value = s.hashConcurrency || 8;
|
||||
if ($('s-webui-port')) $('s-webui-port').value = s.webUiPort || 6967;
|
||||
if (window.pork.isWebMode) {
|
||||
$('s-webui-enabled').disabled = true;
|
||||
$('s-webui-port').disabled = true;
|
||||
$('btn-webui-apply').disabled = true;
|
||||
$('s-webui-enabled').closest('.toggle-row')?.setAttribute('title', settingsT('settings.serverManagedByUnraid', 'This web server is managed as an Unraid container.'));
|
||||
}
|
||||
await refreshWebuiStatus();
|
||||
|
||||
const _langCode = s.language || 'en';
|
||||
try {
|
||||
await refreshLangDropdown(_langCode);
|
||||
} catch (_) {}
|
||||
await window.i18n.load(_langCode);
|
||||
await Promise.all([
|
||||
loadConsoleProfiles(),
|
||||
loadCompanionWorkers(),
|
||||
loadTotpStatus(),
|
||||
loadGenericNotificationStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
['s-host', 's-ports', 's-hash-concurrency'].forEach(id => {
|
||||
const el = $(id);
|
||||
if (el) el.addEventListener('input', scheduleSettingsSave);
|
||||
});
|
||||
|
||||
async function refreshLangDropdown(selectedCode) {
|
||||
const langs = await window.pork.langList();
|
||||
const langSel = $('s-language');
|
||||
langSel.innerHTML = langs.map(l => {
|
||||
const label = l.source === 'imported' ? `${escHtml(l.name)} ✦` : escHtml(l.name);
|
||||
return `<option value="${escHtml(l.code)}"${l.code === selectedCode ? ' selected' : ''}>${label}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
$('s-language').addEventListener('change', async function() {
|
||||
const code = this.value;
|
||||
await window.pork.setSettings({ language: code });
|
||||
await window.i18n.load(code);
|
||||
await Promise.all([
|
||||
loadConsoleProfiles(),
|
||||
loadCompanionWorkers(),
|
||||
loadTotpStatus(),
|
||||
loadGenericNotificationStatus(),
|
||||
]);
|
||||
setStatus(window.t('status.languageChanged', 'Language changed'), 'ok');
|
||||
});
|
||||
|
||||
$('s-interface-density')?.addEventListener('change', async function() {
|
||||
const interfaceDensity = applyDensity(this.value);
|
||||
await window.pork.setSettings({ interfaceDensity });
|
||||
setStatus(window.t('settings.density.saved', 'Interface density updated.'), 'ok');
|
||||
});
|
||||
|
||||
$('btn-import-lang').addEventListener('click', async () => {
|
||||
const btn = $('btn-import-lang');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await window.pork.langImport();
|
||||
if (res.canceled) return;
|
||||
const currentCode = $('s-language').value;
|
||||
await refreshLangDropdown(currentCode);
|
||||
setStatus(`${window.t('lang.importDone', 'Language imported')}: ${res.name}`, 'ok');
|
||||
if (res.code === currentCode) await window.i18n.load(res.code);
|
||||
} catch (e) {
|
||||
setStatus(`${window.t('lang.importError', 'Import failed')}: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-export-lang').addEventListener('click', async () => {
|
||||
const btn = $('btn-export-lang');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await window.pork.langExportBase();
|
||||
if (!res.canceled) setStatus(window.t('lang.exportDone', 'English base file exported.'), 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`${window.t('lang.exportError', 'Export failed')}: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
$('d-host').addEventListener('input', () => {
|
||||
$('s-host').value = $('d-host').value;
|
||||
scheduleSettingsSave();
|
||||
});
|
||||
$('d-ports').addEventListener('input', () => {
|
||||
$('s-ports').value = $('d-ports').value;
|
||||
scheduleSettingsSave();
|
||||
});
|
||||
$('s-user').addEventListener('input', () => { _userDirty = true; scheduleSettingsSave(); });
|
||||
$('s-pass').addEventListener('input', () => { _passDirty = true; scheduleSettingsSave(); });
|
||||
|
||||
|
||||
function renderLocalBackupPaths() {
|
||||
const cont = $('settings-backup-paths-list');
|
||||
if (!cont) return;
|
||||
if (!localBackupPaths.length) {
|
||||
cont.innerHTML = '<p class="hint" style="margin:6px 0">No folders added yet. Click “+ Add Folder…” to get started.</p>';
|
||||
return;
|
||||
}
|
||||
cont.innerHTML = localBackupPaths.map((p, i) => `
|
||||
<div class="remote-path-row" data-backup-idx="${i}">
|
||||
<span class="remote-path-value" title="${escHtml(p)}" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>
|
||||
<button class="btn btn-sm btn-backup-path-remove" style="margin-left:auto;flex-shrink:0">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function saveLocalBackupPaths() {
|
||||
await window.pork.setSettings({
|
||||
backupPaths: localBackupPaths,
|
||||
backupPath: localBackupPaths[0] || '',
|
||||
});
|
||||
setStatus('Settings saved', 'ok');
|
||||
}
|
||||
|
||||
$('settings-backup-paths-list').addEventListener('click', async e => {
|
||||
const row = e.target.closest('[data-backup-idx]');
|
||||
if (!row || !e.target.closest('.btn-backup-path-remove')) return;
|
||||
const idx = Number(row.dataset.backupIdx);
|
||||
const ok = await showConfirm(`Remove "${escHtml(localBackupPaths[idx])}" from game source folders?`);
|
||||
if (!ok) return;
|
||||
localBackupPaths = localBackupPaths.filter((_, i) => i !== idx);
|
||||
await saveLocalBackupPaths();
|
||||
renderLocalBackupPaths();
|
||||
});
|
||||
|
||||
$('btn-add-backup-path').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (!folder) return;
|
||||
if (localBackupPaths.includes(folder)) { setStatus('That folder is already in the list', 'ok'); return; }
|
||||
localBackupPaths = [...localBackupPaths, folder];
|
||||
await saveLocalBackupPaths();
|
||||
renderLocalBackupPaths();
|
||||
});
|
||||
|
||||
$('btn-browse-payload-local').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (!folder) return;
|
||||
$('s-payload-local').value = folder;
|
||||
await commitSettingsSave();
|
||||
});
|
||||
|
||||
$('s-payload-remote').addEventListener('input', scheduleSettingsSave);
|
||||
|
||||
$('s-cheats-remote').addEventListener('input', scheduleSettingsSave);
|
||||
|
||||
$('s-discord-webhook').addEventListener('input', scheduleSettingsSave);
|
||||
|
||||
$('btn-browse-cheats-remote').addEventListener('click', async () => {
|
||||
const cur = $('s-cheats-remote').value.trim() || '/';
|
||||
const p = await openFtpBrowser(cur);
|
||||
if (p) { $('s-cheats-remote').value = p; scheduleSettingsSave(); }
|
||||
});
|
||||
|
||||
$('s-auto-connect').addEventListener('change', commitSettingsSave);
|
||||
|
||||
$('s-time-format').addEventListener('change', e => {
|
||||
_timeFormat = e.target.value;
|
||||
commitSettingsSave();
|
||||
renderMediaGrid();
|
||||
});
|
||||
|
||||
$('s-media-discord-notify').addEventListener('change', function() {
|
||||
$('media-discord-psn-detail').hidden = !this.checked;
|
||||
commitSettingsSave();
|
||||
});
|
||||
|
||||
[
|
||||
['s-media-psn-download', 'mediaPsNotifyDiscordDownload'],
|
||||
['s-media-psn-convert', 'mediaPsNotifyDiscordConvert'],
|
||||
['s-media-psn-upload', 'mediaPsNotifyDiscordUpload'],
|
||||
['s-media-psn-done', 'mediaPsNotifyDiscordDone'],
|
||||
['s-media-psn-error', 'mediaPsNotifyDiscordError'],
|
||||
].forEach(([id, key]) => {
|
||||
$(id).addEventListener('change', function() {
|
||||
window.pork.setSettings({ [key]: this.checked });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function applyConvModeUI(mode) {
|
||||
const isPfs = mode === 'pfs';
|
||||
const isFfpkg = mode === 'ffpkg';
|
||||
const isExfat = mode === 'exfat';
|
||||
const isConv = !isPfs;
|
||||
|
||||
$('conv-ffpkg-fields').hidden = !isFfpkg;
|
||||
$('conv-exfat-fields').hidden = !isExfat;
|
||||
$('conv-shared-fields').hidden = !isConv;
|
||||
$('conv-notify-fields').hidden = !isConv;
|
||||
|
||||
const badge = $('conv-queue-mode-badge');
|
||||
if (badge) badge.textContent = isPfs ? 'Raw Dumps' : isFfpkg ? 'FFPKG' : isExfat ? 'ExFAT' : 'FFPFSC';
|
||||
}
|
||||
|
||||
async function refreshConvToolStatus() {
|
||||
const mode = document.querySelector('input[name="conv-mode"]:checked')?.value || 'pfs';
|
||||
if (mode === 'ffpkg') {
|
||||
try {
|
||||
const info = await window.pork.convToolInfo();
|
||||
const el = $('s-conv-ufs2-status');
|
||||
if (!el) return;
|
||||
if (info.toolAvailable) {
|
||||
el.textContent = '✓ UFS2Tool.exe found';
|
||||
el.style.color = 'var(--green)';
|
||||
} else {
|
||||
el.textContent = info.toolPath ? '✗ File not found at configured path' : '✗ Not configured';
|
||||
el.style.color = 'var(--red)';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mode === 'exfat') {
|
||||
try {
|
||||
const info = await window.pork.convExfatToolInfo();
|
||||
const el = $('s-conv-exfat-status');
|
||||
if (!el) return;
|
||||
if (info.toolAvailable) {
|
||||
el.textContent = `✓ OSFMount found — native exFAT (${info.osfmountPath || info.toolPath})`;
|
||||
el.style.color = 'var(--green)';
|
||||
} else {
|
||||
el.textContent = '✗ OSFMount not found — install OSFMount or pick osfmount.com';
|
||||
el.style.color = 'var(--red)';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="conv-mode"]').forEach(radio => {
|
||||
radio.addEventListener('change', async () => {
|
||||
const mode = radio.value;
|
||||
applyConvModeUI(mode);
|
||||
await window.pork.setSettings({ gameConversionMode: mode });
|
||||
await refreshConvToolStatus();
|
||||
setStatus('Conversion mode saved', 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="ufs2-method"]').forEach(radio => {
|
||||
radio.addEventListener('change', async () => {
|
||||
await window.pork.setSettings({ ufs2Method: radio.value });
|
||||
setStatus('UFS2 method saved', 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
$('btn-conv-ufs2-pick').addEventListener('click', async () => {
|
||||
const info = await window.pork.convToolPick();
|
||||
if (!info) return;
|
||||
$('s-conv-ufs2-path').value = info.toolPath || '';
|
||||
const el = $('s-conv-ufs2-status');
|
||||
if (el) {
|
||||
el.textContent = info.toolAvailable ? '✓ UFS2Tool.exe found' : '✗ File not found';
|
||||
el.style.color = info.toolAvailable ? 'var(--green)' : 'var(--red)';
|
||||
}
|
||||
setStatus('UFS2Tool path saved', 'ok');
|
||||
});
|
||||
|
||||
$('btn-conv-exfat-pick').addEventListener('click', async () => {
|
||||
const info = await window.pork.convExfatToolPick();
|
||||
if (!info) return;
|
||||
$('s-conv-exfat-path').value = info.toolPath || '';
|
||||
await refreshConvToolStatus();
|
||||
setStatus('ExFAT tool path saved', 'ok');
|
||||
});
|
||||
|
||||
$('btn-conv-output-pick').addEventListener('click', async () => {
|
||||
const p = await window.pork.convPickOutputDir();
|
||||
if (!p) return;
|
||||
$('s-conv-output-dir').value = p;
|
||||
setStatus('Output directory saved', 'ok');
|
||||
});
|
||||
|
||||
$('btn-conv-temp-pick').addEventListener('click', async () => {
|
||||
const p = await window.pork.convPickTempDir();
|
||||
if (!p) return;
|
||||
$('s-conv-temp-dir').value = p;
|
||||
setStatus('Temp directory saved', 'ok');
|
||||
});
|
||||
|
||||
$('s-conv-ftp-upload').addEventListener('change', async function() {
|
||||
const ftpEnabled = this.checked;
|
||||
$('s-conv-delete-after').disabled = !ftpEnabled;
|
||||
$('conv-delete-after-row').style.opacity = ftpEnabled ? '' : '0.45';
|
||||
if (!ftpEnabled) {
|
||||
$('s-conv-delete-after').checked = false;
|
||||
await window.pork.setSettings({ convFtpUpload: false, convDeleteAfter: false });
|
||||
} else {
|
||||
await window.pork.setSettings({ convFtpUpload: true });
|
||||
}
|
||||
setStatus('Settings saved', 'ok');
|
||||
});
|
||||
|
||||
$('s-conv-delete-after').addEventListener('change', async function() {
|
||||
await window.pork.setSettings({ convDeleteAfter: this.checked });
|
||||
setStatus('Settings saved', 'ok');
|
||||
});
|
||||
|
||||
$('btn-ps4-package-builder').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder(); if (!folder) return;
|
||||
await window.pork.setSettings({ ps4PackageBuilderPath: folder });
|
||||
$('s-ps4-package-builder').value = folder;
|
||||
const status = await window.pork.ps4PackageBuilderStatus();
|
||||
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
|
||||
});
|
||||
$('btn-ps4-package-output').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder(); if (!folder) return;
|
||||
await window.pork.setSettings({ ps4PackageOutputPath: folder });
|
||||
$('s-ps4-package-output').value = folder;
|
||||
showToast(settingsT('ps4Packages.settings.restart', 'Restart Chimera to activate the new package vault.'), 'info');
|
||||
});
|
||||
$('btn-ps5-native-package-builder').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder(); if (!folder) return;
|
||||
await window.pork.setSettings({ ps5NativePackageBuilderPath: folder });
|
||||
$('s-ps5-native-package-builder').value = folder;
|
||||
const status = await window.pork.ps5NativePackageBuilderStatus();
|
||||
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
|
||||
});
|
||||
$('btn-ps5-native-package-output').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder(); if (!folder) return;
|
||||
await window.pork.setSettings({ ps5NativePackageOutputPath: folder });
|
||||
$('s-ps5-native-package-output').value = folder;
|
||||
showToast(settingsT('ps5NativePackages.settings.restart', 'Restart Chimera to activate the native package vault.'), 'info');
|
||||
});
|
||||
$('s-conv-psn-enabled').addEventListener('change', async function() {
|
||||
$('conv-psn-detail').hidden = !this.checked;
|
||||
await window.pork.setSettings({ convPsNotifyEnabled: this.checked });
|
||||
setStatus('Settings saved', 'ok');
|
||||
});
|
||||
|
||||
[
|
||||
['s-conv-psn-queued', 'convPsNotifyOnGameQueued'],
|
||||
['s-conv-psn-batch', 'convPsNotifyOnBatchQueued'],
|
||||
['s-conv-psn-copy', 'convPsNotifyOnCopyStart'],
|
||||
['s-conv-psn-convert', 'convPsNotifyOnConvertStart'],
|
||||
['s-conv-psn-done', 'convPsNotifyOnJobDone'],
|
||||
].forEach(([id, key]) => {
|
||||
$(id).addEventListener('change', function() {
|
||||
window.pork.setSettings({ [key]: this.checked });
|
||||
});
|
||||
});
|
||||
|
||||
$('settings-ftp-presets').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-add-port]');
|
||||
if (!btn) return;
|
||||
const port = btn.dataset.addPort;
|
||||
const current = $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean);
|
||||
if (!current.includes(port)) {
|
||||
current.push(port);
|
||||
$('s-ports').value = current.join(', ');
|
||||
commitSettingsSave();
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
|
||||
Object.entries(hkMap).forEach(([id, action]) => {
|
||||
const input = $(id);
|
||||
if (!input) return;
|
||||
input.addEventListener('focus', () => {
|
||||
input.dataset.prev = input.value;
|
||||
input.value = '';
|
||||
input.placeholder = 'press a key…';
|
||||
input.classList.add('capturing');
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
if (!input.value) input.value = input.dataset.prev || '';
|
||||
input.placeholder = '—';
|
||||
input.classList.remove('capturing');
|
||||
});
|
||||
input.addEventListener('keydown', async e => {
|
||||
e.preventDefault();
|
||||
if (e.key === 'Escape') { input.value = input.dataset.prev || ''; input.blur(); return; }
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
input.value = key;
|
||||
_svHotkeys[action] = key;
|
||||
input.blur();
|
||||
await window.pork.setSettings({ svHotkeys: { ..._svHotkeys } });
|
||||
setStatus('Hotkey saved', 'ok');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function renderRemoteGamePaths() {
|
||||
const cont = $('settings-remote-paths-list');
|
||||
if (!remoteGamePaths.length) {
|
||||
cont.innerHTML = '<p class="hint" style="margin:6px 0">No paths configured. Scan will use standard PS5 paths.</p>';
|
||||
return;
|
||||
}
|
||||
cont.innerHTML = remoteGamePaths.map((p, i) => `
|
||||
<div class="remote-path-row" data-idx="${i}">
|
||||
<span class="remote-path-label">${escHtml(p.label)}</span>
|
||||
<span class="remote-path-value" title="${escHtml(p.path)}">${escHtml(p.path)}</span>
|
||||
<button class="btn btn-sm btn-remote-path-remove" style="margin-left:auto">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function saveRemoteGamePaths() {
|
||||
await window.pork.setSettings({ remoteGamePaths });
|
||||
setStatus('Settings saved', 'ok');
|
||||
}
|
||||
|
||||
$('btn-add-remote-path').addEventListener('click', async () => {
|
||||
const label = $('new-remote-label').value.trim();
|
||||
const path = $('new-remote-path').value.trim();
|
||||
if (!path) { setStatus('Enter a remote path', 'error'); return; }
|
||||
remoteGamePaths = [...remoteGamePaths, { label: label || path, path }];
|
||||
await saveRemoteGamePaths();
|
||||
renderRemoteGamePaths();
|
||||
$('new-remote-label').value = '';
|
||||
$('new-remote-path').value = '';
|
||||
});
|
||||
|
||||
$('settings-remote-paths-list').addEventListener('click', async e => {
|
||||
const row = e.target.closest('[data-idx]');
|
||||
if (!row || !e.target.closest('.btn-remote-path-remove')) return;
|
||||
const idx = Number(row.dataset.idx);
|
||||
const ok = await showConfirm(`Remove "${escHtml(remoteGamePaths[idx]?.label)}" from remote game paths?`);
|
||||
if (!ok) return;
|
||||
remoteGamePaths = remoteGamePaths.filter((_, i) => i !== idx);
|
||||
await saveRemoteGamePaths();
|
||||
renderRemoteGamePaths();
|
||||
});
|
||||
|
||||
|
||||
async function refreshWebuiStatus() {
|
||||
try {
|
||||
const status = await window.pork.webuiStatus();
|
||||
const badge = $('webui-status-badge');
|
||||
const urlRow = $('webui-url-row');
|
||||
const urlEl = $('webui-url');
|
||||
const toggle = $('s-webui-enabled');
|
||||
|
||||
if (badge) badge.hidden = !status.running;
|
||||
if (toggle) toggle.checked = status.running;
|
||||
|
||||
if (urlRow && urlEl) {
|
||||
if (status.running) {
|
||||
const port = status.port;
|
||||
const hosts = Array.isArray(status.hosts) && status.hosts.length ? status.hosts : ['localhost'];
|
||||
const primary = (window.pork.isWebMode && location.hostname)
|
||||
? location.hostname
|
||||
: hosts[0];
|
||||
const primaryUrl = status.publicOrigin || (window.pork.isWebMode ? location.origin : `http://${primary}:${port}`);
|
||||
urlEl.textContent = primaryUrl;
|
||||
urlRow.hidden = false;
|
||||
const openBtn = $('btn-webui-open');
|
||||
if (openBtn) openBtn.onclick = () => window.pork.openShell ? window.pork.openShell(primaryUrl) : window.open(primaryUrl, '_blank');
|
||||
} else {
|
||||
urlRow.hidden = true;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
$('s-webui-enabled').addEventListener('change', async function () {
|
||||
const btn = $('btn-webui-apply');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (this.checked) {
|
||||
const port = Number($('s-webui-port').value) || 6967;
|
||||
await window.pork.setSettings({ webUiEnabled: true, webUiPort: port });
|
||||
const res = await window.pork.webuiStart();
|
||||
if (!res.ok) { setStatus(`Web UI failed: ${res.error}`, 'error'); this.checked = false; }
|
||||
else setStatus('Web UI server started', 'ok');
|
||||
} else {
|
||||
await window.pork.setSettings({ webUiEnabled: false });
|
||||
const res = await window.pork.webuiStop();
|
||||
if (res.ok) setStatus('Web UI server stopped', 'ok');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(`Web UI error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
await refreshWebuiStatus();
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-webui-apply').addEventListener('click', async () => {
|
||||
const port = Number($('s-webui-port').value) || 6967;
|
||||
await window.pork.setSettings({ webUiPort: port });
|
||||
if ($('s-webui-enabled').checked) {
|
||||
await window.pork.webuiStop();
|
||||
const res = await window.pork.webuiStart();
|
||||
if (res.ok) setStatus(`Web UI restarted on port ${port}`, 'ok');
|
||||
else setStatus(`Web UI restart failed: ${res.error}`, 'error');
|
||||
} else {
|
||||
setStatus('Port saved', 'ok');
|
||||
}
|
||||
await refreshWebuiStatus();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
'use strict';
|
||||
|
||||
// State
|
||||
const state = {
|
||||
connected: false,
|
||||
games: [],
|
||||
backups: [],
|
||||
sortCol: localStorage.getItem('chimera.games.sortCol') || 'title',
|
||||
sortDir: localStorage.getItem('chimera.games.sortDir') || 'asc',
|
||||
filter: localStorage.getItem('chimera.games.filter') || 'all',
|
||||
platformFilter: ['all', 'ps4', 'ps5', 'unknown'].includes(localStorage.getItem('chimera.games.platform'))
|
||||
? localStorage.getItem('chimera.games.platform')
|
||||
: 'all',
|
||||
search: '',
|
||||
firmwareFilter: 'all',
|
||||
gameView: localStorage.getItem('chimera.games.view') || 'grid',
|
||||
gameRenderLimit: 160,
|
||||
currentGame: null,
|
||||
};
|
||||
|
||||
// Shared hash summary maps — populated by loadGames() and loadBackporks().
|
||||
// _gameHashSummary: game_id → summary for hash_type='game'
|
||||
// _backporkHashSummary: 'game_id|firmware_label' → summary for hash_type='backpork'
|
||||
const _gameHashSummary = new Map();
|
||||
const _backporkHashSummary = new Map();
|
||||
const _pageLoadTimestamps = new Map();
|
||||
const _pageLoadModes = new Map();
|
||||
const _pageLoadInFlight = new Map();
|
||||
let _pageWarmTimer = null;
|
||||
let _pageWarmRunning = false;
|
||||
|
||||
// Feature code is route-loaded. Background refreshes only revisit modules that a user
|
||||
// already opened; they never download the entire application after startup.
|
||||
const _PAGE_WARM_ORDER = [];
|
||||
|
||||
const _PAGE_STALE_MS = {
|
||||
dashboard: 15_000,
|
||||
transfers: 5_000,
|
||||
'system-view': 20_000,
|
||||
media: 30_000,
|
||||
default: 45_000,
|
||||
};
|
||||
|
||||
function _populateHashMaps(summaries) {
|
||||
_gameHashSummary.clear();
|
||||
_backporkHashSummary.clear();
|
||||
for (const s of summaries) {
|
||||
if (s.hash_type === 'backpork') {
|
||||
_backporkHashSummary.set(s.game_id + '|' + (s.firmware_label || ''), s);
|
||||
} else {
|
||||
_gameHashSummary.set(s.game_id, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System View state
|
||||
let _svStream = null;
|
||||
let _svMuted = false;
|
||||
let _svAudioCtx = null; // AudioContext for low-latency capture-card audio
|
||||
let _svAudioGain = null; // GainNode — controls mute without stopping the stream
|
||||
let _svHotkeys = { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' };
|
||||
let _svRecording = false;
|
||||
let _svRecFrames = []; // Uint8ClampedArray[] — one per captured frame
|
||||
let _svRecInterval = null;
|
||||
let _svRecTimer = null;
|
||||
let _svRecStart = 0;
|
||||
let _svRecCanvas = null; // lazy-created hidden 640×360 canvas
|
||||
let _svRecCtx = null;
|
||||
const GIF_W = 640, GIF_H = 360;
|
||||
|
||||
// Video recording state
|
||||
let _svVidRecorder = null;
|
||||
let _svVidWritable = null;
|
||||
let _svVidChunks = null;
|
||||
let _svVidTimer = null;
|
||||
let _svVidStart = 0;
|
||||
let _svVidPaused = false;
|
||||
let _svVidBytes = 0;
|
||||
let _svVidMimeType = '';
|
||||
let _svVidExt = '';
|
||||
|
||||
// Helpers
|
||||
const $ = id => document.getElementById(id);
|
||||
const _dialogReturnFocus = new WeakMap();
|
||||
|
||||
function dialogFocusable(container) {
|
||||
if (!container) return [];
|
||||
return [...container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')]
|
||||
.filter(element => !element.hidden && element.getAttribute('aria-hidden') !== 'true' && element.getClientRects().length);
|
||||
}
|
||||
|
||||
function openAccessibleDialog(overlay, initialFocus = null) {
|
||||
if (!overlay) return;
|
||||
_dialogReturnFocus.set(overlay, document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
overlay.hidden = false;
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('chimera-dialog-open');
|
||||
requestAnimationFrame(() => (initialFocus || dialogFocusable(overlay)[0] || overlay.querySelector('[role="dialog"]'))?.focus?.());
|
||||
}
|
||||
|
||||
function closeAccessibleDialog(overlay) {
|
||||
if (!overlay) return;
|
||||
overlay.hidden = true;
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
const returnFocus = _dialogReturnFocus.get(overlay);
|
||||
_dialogReturnFocus.delete(overlay);
|
||||
if (![...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
|
||||
.some(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length)) {
|
||||
document.body.classList.remove('chimera-dialog-open');
|
||||
}
|
||||
if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const tab = event.target.closest?.('[role="tab"]');
|
||||
if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
const tabs = [...tab.closest('[role="tablist"]')?.querySelectorAll('[role="tab"]') || []].filter(item => !item.disabled && !item.hidden);
|
||||
if (tabs.length) {
|
||||
event.preventDefault();
|
||||
const current = Math.max(0, tabs.indexOf(tab));
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
|
||||
.filter(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length);
|
||||
const dialog = dialogs.at(-1);
|
||||
if (!dialog) return;
|
||||
const focusable = dialogFocusable(dialog);
|
||||
if (!focusable.length) { event.preventDefault(); dialog.focus(); return; }
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
});
|
||||
|
||||
document.addEventListener('error', event => {
|
||||
const image = event.target;
|
||||
if (!(image instanceof HTMLImageElement) || !image.dataset.imageError) return;
|
||||
const fallbackSrc = image.dataset.fallbackSrc;
|
||||
if (fallbackSrc && image.dataset.fallbackAttempted !== 'true' && image.currentSrc !== fallbackSrc) {
|
||||
image.dataset.fallbackAttempted = 'true';
|
||||
image.classList.add('is-fallback-artwork');
|
||||
image.src = fallbackSrc;
|
||||
return;
|
||||
}
|
||||
image.hidden = true;
|
||||
if (image.dataset.imageError === 'fallback' && image.nextElementSibling) image.nextElementSibling.hidden = false;
|
||||
}, true);
|
||||
|
||||
function setGameWorkspaceContext(gameId, target) {
|
||||
const normalized = String(gameId || '').trim().toUpperCase();
|
||||
if (!/^(?:CUSA|PPSA)\d{5}$/.test(normalized)) return false;
|
||||
sessionStorage.setItem('chimera.gameWorkspace', JSON.stringify({ gameId: normalized, target: String(target || ''), at: Date.now() }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function consumeGameWorkspaceContext(target) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('chimera.gameWorkspace');
|
||||
const context = raw ? JSON.parse(raw) : null;
|
||||
if (!context || context.target !== target || Date.now() - Number(context.at || 0) > 5 * 60 * 1000) return null;
|
||||
sessionStorage.removeItem('chimera.gameWorkspace');
|
||||
return /^(?:CUSA|PPSA)\d{5}$/.test(String(context.gameId || '')) ? context.gameId : null;
|
||||
} catch (_) {
|
||||
sessionStorage.removeItem('chimera.gameWorkspace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _isNarrowLayout() {
|
||||
return window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function setMobileNavOpen(open) {
|
||||
document.body.classList.toggle('mobile-nav-open', !!open && _isNarrowLayout());
|
||||
const isOpen = document.body.classList.contains('mobile-nav-open');
|
||||
const toggle = $('mobile-nav-toggle');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', String(isOpen));
|
||||
toggle.setAttribute('aria-label', isOpen ? 'Close navigation' : 'Open navigation');
|
||||
toggle.title = isOpen ? 'Close navigation' : 'Open navigation';
|
||||
toggle.textContent = isOpen ? '×' : '☰';
|
||||
}
|
||||
const companionMore = $('chimera-companion-more');
|
||||
if (companionMore) companionMore.setAttribute('aria-expanded', String(isOpen));
|
||||
const scrim = $('sidebar-scrim');
|
||||
if (scrim) scrim.hidden = !isOpen;
|
||||
const companionDock = $('chimera-companion-dock');
|
||||
if (companionDock) {
|
||||
companionDock.toggleAttribute('inert', isOpen);
|
||||
if (isOpen) companionDock.setAttribute('aria-hidden', 'true');
|
||||
else companionDock.removeAttribute('aria-hidden');
|
||||
}
|
||||
if (isOpen) {
|
||||
requestAnimationFrame(() => document.querySelector('.chimera-route-link.active, .chimera-domain-home.active')?.scrollIntoView({ block: 'nearest' }));
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
setMobileNavOpen(false);
|
||||
}
|
||||
|
||||
function applyDensity(value) {
|
||||
const density = value === 'compact' ? 'compact' : 'comfortable';
|
||||
document.body.dataset.density = density;
|
||||
return density;
|
||||
}
|
||||
|
||||
function _pageLoader(page, background = false) {
|
||||
const loaders = {
|
||||
dashboard: () => loadDashboard(),
|
||||
games: () => loadGames(),
|
||||
packages: () => loadPackages(),
|
||||
backporks: () => loadBackporks(),
|
||||
backups: () => loadBackups(),
|
||||
database: () => loadDatabase(),
|
||||
settings: () => loadSettings({ background }),
|
||||
jailbreak: () => loadJailbreak(),
|
||||
psnotify: () => loadPsNotify(),
|
||||
xavatar: () => loadXavatar(),
|
||||
'system-view': () => loadSystemView(background ? { passive: true } : {}),
|
||||
transfers: () => loadTransfers(),
|
||||
media: () => loadMedia(),
|
||||
cheats: () => loadCheats({ background }),
|
||||
garlic: () => loadGarlic(),
|
||||
'elf-arsenal': () => (typeof loadElfArsenal === 'function' ? loadElfArsenal({ background }) : null),
|
||||
'pfs-ripper': () => (typeof loadPfsRipper === 'function' ? loadPfsRipper({ background }) : null),
|
||||
savemgr: () => loadSaveMgr({ background }),
|
||||
ecosystem: () => (typeof loadEcosystem === 'function' ? loadEcosystem({ background }) : null),
|
||||
operations: () => (typeof loadOperations === 'function' ? loadOperations({ background }) : null),
|
||||
};
|
||||
return loaders[page] || null;
|
||||
}
|
||||
|
||||
function _pageStaleMs(page) {
|
||||
return _PAGE_STALE_MS[page] || _PAGE_STALE_MS.default;
|
||||
}
|
||||
|
||||
function pageIsStale(page) {
|
||||
const last = _pageLoadTimestamps.get(page) || 0;
|
||||
return (Date.now() - last) > _pageStaleMs(page);
|
||||
}
|
||||
|
||||
function ensurePageReady(page, { background = false, force = false } = {}) {
|
||||
const needsActiveHydrate = !background && _pageLoadModes.get(page) === 'background';
|
||||
if (!force && !needsActiveHydrate && !pageIsStale(page)) return _pageLoadInFlight.get(page) || Promise.resolve();
|
||||
if (_pageLoadInFlight.has(page)) return _pageLoadInFlight.get(page);
|
||||
const run = Promise.resolve()
|
||||
.then(() => window.ChimeraModuleLoader?.ensureForPage(page))
|
||||
.then(() => {
|
||||
const loader = _pageLoader(page, background);
|
||||
return loader ? loader() : null;
|
||||
})
|
||||
.then(() => {
|
||||
_pageLoadTimestamps.set(page, Date.now());
|
||||
const priorMode = _pageLoadModes.get(page);
|
||||
_pageLoadModes.set(page, background && priorMode === 'active' ? 'active' : (background ? 'background' : 'active'));
|
||||
})
|
||||
.catch(err => {
|
||||
if (background) {
|
||||
const method = isExpectedUnavailable(err) ? 'info' : 'warn';
|
||||
console[method](`[page-load] ${page} was not preloaded: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
if (_pageLoadInFlight.get(page) === run) _pageLoadInFlight.delete(page);
|
||||
});
|
||||
_pageLoadInFlight.set(page, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
function scheduleBackgroundWarm(delay = 600) {
|
||||
if (_pageWarmTimer || _pageWarmRunning) return;
|
||||
_pageWarmTimer = setTimeout(() => {
|
||||
_pageWarmTimer = null;
|
||||
startBackgroundWarm().catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function startBackgroundWarm() {
|
||||
if (_pageWarmRunning) return;
|
||||
_pageWarmRunning = true;
|
||||
try {
|
||||
for (const page of _PAGE_WARM_ORDER) {
|
||||
const active = document.querySelector('.page.active')?.id?.replace('page-', '');
|
||||
if (page === active) continue;
|
||||
await ensurePageReady(page, { background: true });
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
} finally {
|
||||
_pageWarmRunning = false;
|
||||
scheduleBackgroundWarm(_PAGE_STALE_MS.default);
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(bytes) {
|
||||
const n = Number(bytes);
|
||||
if (!n || isNaN(n)) return '—';
|
||||
const gb = n / 1073741824;
|
||||
return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function yesNo(v) {
|
||||
return v ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill pill-no">No</span>';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Pinnable cards registry
|
||||
const PINNABLE_CARDS = [
|
||||
{ id: 'payload-update', label: 'Payload Update Checker', page: 'jailbreak', hint: 'Check sources for payload updates and push to PS5.' },
|
||||
{ id: 'payload-mgr', label: 'Payload Sender', page: 'jailbreak', type: 'widget', hint: 'Quick-send a payload to PS5 via TCP.' },
|
||||
{ id: 'psnotify', label: 'Send Notification', page: 'psnotify', type: 'widget', hint: 'Send a PS5 notification from the dashboard.' },
|
||||
{ id: 'xavatar-convert', label: 'XAvatar Quick Send', page: 'xavatar', type: 'widget', hint: 'Convert an image and send to PS5 as .xavatar.' },
|
||||
{ id: 'xavatar-library', label: 'XAvatar Library', page: 'xavatar', hint: 'Manage .xavatar files on your PS5.' },
|
||||
{ id: 'transfers', label: 'Transfer Manager', page: 'transfers', hint: 'Monitor active and queued FTP transfers.' },
|
||||
{ id: 'games', label: 'Games Library', page: 'games', hint: 'Browse and manage your PS5 game collection.' },
|
||||
{ id: 'cheats', label: 'Cheats', page: 'cheats', hint: 'Browse and install game cheats.' },
|
||||
{ id: 'media', label: 'Media', page: 'media', hint: 'Browse screenshots and video clips from PS5.' },
|
||||
{ id: 'system-view', label: 'System View', page: 'system-view', hint: 'View your PS5 via capture card stream.' },
|
||||
{ id: 'backups', label: 'Backups', page: 'backups', hint: 'Browse and manage game backup files.' },
|
||||
// Other page widgets
|
||||
{ id: 'dash-savemgr', label: 'Save Manager', page: 'savemgr', type: 'widget', hint: 'Browse and download PS5 save files.' },
|
||||
];
|
||||
|
||||
const _PIN_PAGE_LABELS = {
|
||||
jailbreak: 'Payload Workspace', psnotify: 'Notifications', xavatar: 'XAvatar',
|
||||
transfers: 'Transfers', games: 'Games', cheats: 'Cheats & patches', media: 'Media',
|
||||
'system-view': 'System View', backups: 'Backups', savemgr: 'Local Save Manager',
|
||||
ecosystem: 'Console Status', operations: 'Operations',
|
||||
};
|
||||
|
||||
function setStatus(msg, type = '') {
|
||||
const el = $('sb-msg');
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'error' ? 'var(--red)' : type === 'ok' ? 'var(--green)' : 'var(--yellow)';
|
||||
if (msg) setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
function titleHue(id){let h=0;for(const c of String(id||''))h=(h*31+c.charCodeAt(0))%360;return h;}
|
||||
// Server messages are Dutch; translated at display time via serverError.* keys.
|
||||
function friendlyMessage(text) {
|
||||
const message = String(text ?? '').trim();
|
||||
if (!/^en(?:-|$)/i.test(document.documentElement.lang || 'en')) return message;
|
||||
const mapped = window.i18n?.strings?.['serverError.' + message];
|
||||
if (mapped) return mapped;
|
||||
return message
|
||||
.replace(/niet bereikbaar/gi, 'unreachable')
|
||||
.replace(/niet beschikbaar/gi, 'unavailable')
|
||||
.replace(/niet geconfigureerd/gi, 'not configured')
|
||||
.replace(/ontbreekt/gi, 'is missing');
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'info', duration = 5000) {
|
||||
const icons = { ok: '✓', error: '✕', info: '●' };
|
||||
const safeType = Object.hasOwn(icons, type) ? type : 'info';
|
||||
const message = friendlyMessage(msg);
|
||||
if (!message) return null;
|
||||
const container = $('toast-container');
|
||||
const key = `${safeType}:${message.replace(/\s+/g, ' ').toLocaleLowerCase()}`;
|
||||
const existing = [...container.querySelectorAll('.toast')].find(item => item.dataset.toastKey === key);
|
||||
if (existing) {
|
||||
const count = Number(existing.dataset.toastCount || 1) + 1;
|
||||
existing.dataset.toastCount = String(count);
|
||||
let badge = existing.querySelector('.toast-count');
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'toast-count';
|
||||
existing.querySelector('.toast-body')?.after(badge);
|
||||
}
|
||||
badge.textContent = `×${count}`;
|
||||
clearTimeout(existing._dismissTimer);
|
||||
existing._dismissTimer = setTimeout(() => existing._dismiss?.(), duration);
|
||||
return existing;
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${safeType}`;
|
||||
toast.dataset.toastKey = key;
|
||||
toast.dataset.toastCount = '1';
|
||||
toast.setAttribute('role', safeType === 'error' ? 'alert' : 'status');
|
||||
const closeLabel = window.t?.('notifications.close', 'Close notification') || 'Close notification';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'toast-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = icons[safeType];
|
||||
const body = document.createElement('span');
|
||||
body.className = 'toast-body';
|
||||
body.textContent = message;
|
||||
const close = document.createElement('button');
|
||||
close.className = 'toast-close';
|
||||
close.type = 'button';
|
||||
close.setAttribute('aria-label', closeLabel);
|
||||
close.textContent = '×';
|
||||
toast.append(icon, body, close);
|
||||
while (container.children.length >= 4) container.firstElementChild?.remove();
|
||||
container.appendChild(toast);
|
||||
const dismiss = () => {
|
||||
if (!toast.isConnected || toast.classList.contains('toast-out')) return;
|
||||
toast.classList.add('toast-out');
|
||||
toast.addEventListener('animationend', () => toast.remove(), { once: true });
|
||||
setTimeout(() => toast.remove(), 350);
|
||||
};
|
||||
toast._dismiss = dismiss;
|
||||
close.addEventListener('click', dismiss);
|
||||
toast._dismissTimer = setTimeout(dismiss, duration);
|
||||
toast.addEventListener('mouseenter', () => clearTimeout(toast._dismissTimer));
|
||||
toast.addEventListener('mouseleave', () => { toast._dismissTimer = setTimeout(dismiss, 2000); });
|
||||
return toast;
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function navigate(page) {
|
||||
document.querySelectorAll('.nav-link').forEach(a => a.classList.toggle('active', a.dataset.page === page));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
|
||||
|
||||
// Embed pages need #content to be overflow:hidden so the iframe can fill available height
|
||||
const isEmbed = page === 'savemgr';
|
||||
document.getElementById('content').classList.toggle('embed-page-active', isEmbed);
|
||||
ensurePageReady(page, { force: pageIsStale(page) });
|
||||
scheduleBackgroundWarm(800);
|
||||
closeMobileNav();
|
||||
syncPinStates();
|
||||
}
|
||||
|
||||
$('mobile-nav-toggle')?.addEventListener('click', () => {
|
||||
setMobileNavOpen(!document.body.classList.contains('mobile-nav-open'));
|
||||
});
|
||||
|
||||
$('sidebar-scrim')?.addEventListener('click', closeMobileNav);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (!_isNarrowLayout()) closeMobileNav();
|
||||
});
|
||||
|
||||
async function syncPinStates() {
|
||||
try {
|
||||
const s = await window.pork.getSettings();
|
||||
const pinned = new Set(s.pinnedCards || []);
|
||||
document.querySelectorAll('.btn-pin').forEach(btn =>
|
||||
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId))
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadJailbreak() {
|
||||
// Reset UI
|
||||
$('payload-check-status').textContent = '';
|
||||
$('payload-sources-check-list').innerHTML = '';
|
||||
if ($('payload-show-expert')) {
|
||||
$('payload-show-expert').checked = localStorage.getItem('chimera.payload.showExpert') === '1';
|
||||
}
|
||||
try { await loadPayloadCatalog(); } catch (error) {
|
||||
$('payload-sources-check-list').innerHTML = `<p class="hint">${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}</p>`;
|
||||
}
|
||||
await renderLocalPayloads();
|
||||
await renderPayloadSender();
|
||||
await renderAutoloaderSnapshots();
|
||||
try {
|
||||
const settings = await window.pork.getSettings();
|
||||
if ($('pldmgr-port')) $('pldmgr-port').value = settings.payloadManagerPort || 8084;
|
||||
if (!settings.ftpHost) {
|
||||
if ($('service-matrix-summary')) $('service-matrix-summary').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
|
||||
if ($('service-matrix-grid')) $('service-matrix-grid').innerHTML = `<p class="hint">${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}</p>`;
|
||||
if ($('pldmgr-version')) $('pldmgr-version').textContent = 'offline';
|
||||
if ($('pldmgr-status')) $('pldmgr-status').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
|
||||
return;
|
||||
}
|
||||
await loadPs5ServiceMatrix();
|
||||
await connectPs5PayloadManager({ quiet: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function isExpectedUnavailable(error) {
|
||||
if (error?.expected || ['NOT_CONFIGURED', 'UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'].includes(error?.code)) return true;
|
||||
return /niet (?:bereikbaar|verbonden|beschikbaar|actief|geconfigureerd|gevonden)|maak eerst verbinding|not (?:reachable|connected|available|active|configured|found|mounted)|connect (?:to )?(?:the )?PS5|offline|ECONNREFUSED|timed? out|reageerde niet op tijd|HTTP (?:403|429|5\d\d)|rate.?limit|ontbreekt/i.test(String(error?.message || ''));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// System State Controls
|
||||
|
||||
let pendingSystemAction = null;
|
||||
const systemStateLabels = {
|
||||
shutdown: 'Shutdown', reboot: 'Reboot', restmode: 'Enter Rest Mode', eject: 'Eject Disc',
|
||||
};
|
||||
|
||||
function systemStateStatus(text, error = false) {
|
||||
const el = $('system-state-status');
|
||||
el.textContent = text;
|
||||
el.style.color = error ? 'var(--red)' : '';
|
||||
}
|
||||
|
||||
async function refreshSystemStateStatus() {
|
||||
systemStateStatus('Checking service...');
|
||||
const result = await window.pork.systemStateStatus();
|
||||
systemStateStatus(result.connected ? `Service ready: ${result.response || 'port 9112'}` : (result.error || result.response || 'Service unavailable.'), !result.connected);
|
||||
}
|
||||
|
||||
$('btn-system-state-status').addEventListener('click', () => refreshSystemStateStatus().catch(error => systemStateStatus(error.message, true)));
|
||||
|
||||
$('btn-system-state-deploy').addEventListener('click', async () => {
|
||||
const button = $('btn-system-state-deploy');
|
||||
button.disabled = true;
|
||||
systemStateStatus('Sending payload to loader port 9021...');
|
||||
try {
|
||||
const result = await window.pork.systemStateDeploy();
|
||||
systemStateStatus(`${result.message} (${result.bytes} bytes)`);
|
||||
} catch (error) {
|
||||
systemStateStatus(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.system-state-action').forEach((button) => button.addEventListener('click', () => {
|
||||
pendingSystemAction = button.dataset.systemAction;
|
||||
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
|
||||
$('system-state-confirm-title').textContent = `Confirm: ${systemStateLabels[pendingSystemAction]}`;
|
||||
const shutdownWarning = pendingSystemAction === 'shutdown'
|
||||
? ' The upstream SystemStateManager project still marks shutdown as in testing; use it only if you accept that risk.'
|
||||
: '';
|
||||
$('system-state-confirm-copy').textContent = `This sends ${command} to SystemStateManager on your PS5. Type "CONFIRM ${command}" exactly to enable the action.${shutdownWarning}`;
|
||||
$('system-state-confirm-input').value = '';
|
||||
$('system-state-confirm').hidden = false;
|
||||
$('system-state-confirm-input').focus();
|
||||
}));
|
||||
|
||||
$('system-state-confirm-input').addEventListener('input', () => {
|
||||
if (!pendingSystemAction) return;
|
||||
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
|
||||
$('btn-system-state-confirm').disabled = $('system-state-confirm-input').value !== `CONFIRM ${command}`;
|
||||
});
|
||||
|
||||
$('btn-system-state-cancel').addEventListener('click', () => {
|
||||
pendingSystemAction = null;
|
||||
$('system-state-confirm').hidden = true;
|
||||
});
|
||||
|
||||
$('btn-system-state-confirm').addEventListener('click', async () => {
|
||||
if (!pendingSystemAction) return;
|
||||
const action = pendingSystemAction;
|
||||
const command = action === 'restmode' ? 'RESTMODE' : action.toUpperCase();
|
||||
const button = $('btn-system-state-confirm');
|
||||
button.disabled = true;
|
||||
systemStateStatus(`Sending ${command}...`);
|
||||
try {
|
||||
const result = await window.pork.systemStateCommand({ action, confirmation: `CONFIRM ${command}` });
|
||||
systemStateStatus(`${systemStateLabels[action]} command accepted: ${result.response || 'OK'}`);
|
||||
$('system-state-confirm').hidden = true;
|
||||
pendingSystemAction = null;
|
||||
} catch (error) {
|
||||
systemStateStatus(error.message, true);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,767 @@
|
||||
// ── System View ───────────────────────────────────────────────────────────────
|
||||
let _svGifWorker = null;
|
||||
let _svGifJobId = 0;
|
||||
let _svGifSavePath = '';
|
||||
let _svVidWriteQueue = Promise.resolve();
|
||||
const SV_GIF_MAX_WIDTH = 480;
|
||||
const SV_GIF_MAX_COLORS = 128;
|
||||
const SV_GIF_MAX_FPS = 8;
|
||||
|
||||
function updateSvSourceUi() {
|
||||
$('page-system-view')?.classList.toggle('sv-has-source', Boolean($('sv-video-device')?.value));
|
||||
}
|
||||
|
||||
async function loadSystemView(options = {}) {
|
||||
const passive = !!options.passive;
|
||||
if (window.pork.isWebMode) {
|
||||
if (!passive) await _loadSystemViewWeb();
|
||||
return;
|
||||
}
|
||||
$('sv-status-codec').textContent = svDetectCodec().label;
|
||||
if (!passive) await enumerateSvDevices();
|
||||
let autoLoad = false;
|
||||
// Restore all persisted system-view settings
|
||||
try {
|
||||
const s = await window.pork.getSettings();
|
||||
if (s.svResolution) $('sv-resolution').value = s.svResolution;
|
||||
if (s.svGifFps) $('sv-gif-fps').value = s.svGifFps;
|
||||
if (s.svVidQuality) $('sv-vid-quality').value = s.svVidQuality;
|
||||
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
|
||||
autoLoad = !!s.svAutoLoad;
|
||||
$('sv-auto-load').checked = autoLoad;
|
||||
// Restore saved device selections only if nothing is currently selected
|
||||
if (!passive) {
|
||||
const videoSel = $('sv-video-device');
|
||||
const audioSel = $('sv-audio-device');
|
||||
if (!videoSel.value && s.svVideoDevice &&
|
||||
[...videoSel.options].some(o => o.value === s.svVideoDevice)) {
|
||||
videoSel.value = s.svVideoDevice;
|
||||
}
|
||||
if (!audioSel.value && s.svAudioDevice &&
|
||||
[...audioSel.options].some(o => o.value === s.svAudioDevice)) {
|
||||
audioSel.value = s.svAudioDevice;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!$('sv-gif-fps').value) $('sv-gif-fps').value = '8';
|
||||
if (!$('sv-vid-quality').value) $('sv-vid-quality').value = '8000000';
|
||||
// Re-attach existing stream if still active (user navigated away and back)
|
||||
if (_svStream && !_svStream.active) _svStream = null;
|
||||
if (_svStream) {
|
||||
// Re-attach video-only (AudioContext keeps running independently)
|
||||
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
|
||||
$('sv-video').muted = true;
|
||||
$('sv-no-signal').classList.add('hidden');
|
||||
$('btn-sv-mute').disabled = false;
|
||||
$('btn-sv-popout').disabled = false;
|
||||
$('btn-sv-record').disabled = false;
|
||||
$('btn-sv-vid-rec').disabled = false;
|
||||
$('sv-ctx-popout').disabled = false;
|
||||
updateSvMuteBtn();
|
||||
svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
|
||||
} else if (!passive && autoLoad && $('sv-video-device').value) {
|
||||
// Auto-start stream only when the user explicitly enabled it
|
||||
await startSvStream();
|
||||
}
|
||||
updateSvSourceUi();
|
||||
}
|
||||
|
||||
async function enumerateSvDevices() {
|
||||
let devices;
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) throw new Error('MediaDevices is unavailable in this browser.');
|
||||
// Device discovery must never trigger a camera permission prompt merely by
|
||||
// opening the page. Permission is requested only when the user explicitly
|
||||
// starts a selected source.
|
||||
devices = await Promise.race([
|
||||
navigator.mediaDevices.enumerateDevices(),
|
||||
new Promise(resolve => setTimeout(() => resolve([]), 1800)),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('enumerateDevices failed:', e);
|
||||
$('sv-no-signal-msg').textContent = 'Capture devices could not be listed. Check the secure browser context and permissions, then retry.';
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const prevVideo = $('sv-video-device').value;
|
||||
const prevAudio = $('sv-audio-device').value;
|
||||
|
||||
// Video
|
||||
const videoSel = $('sv-video-device');
|
||||
while (videoSel.options.length > 1) videoSel.remove(1);
|
||||
devices.filter(d => d.kind === 'videoinput').forEach(d => {
|
||||
videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId));
|
||||
});
|
||||
|
||||
// Audio
|
||||
const audioSel = $('sv-audio-device');
|
||||
while (audioSel.options.length > 1) audioSel.remove(1);
|
||||
devices.filter(d => d.kind === 'audioinput').forEach(d => {
|
||||
audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId));
|
||||
});
|
||||
|
||||
if (!devices.some(device => device.kind === 'videoinput')) {
|
||||
$('sv-no-signal-msg').textContent = 'No capture source detected. Connect a capture device, then refresh the device list.';
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Restore previous selection if device still present
|
||||
if (prevVideo && [...videoSel.options].some(o => o.value === prevVideo)) videoSel.value = prevVideo;
|
||||
if (prevAudio && [...audioSel.options].some(o => o.value === prevAudio)) audioSel.value = prevAudio;
|
||||
updateSvSourceUi();
|
||||
}
|
||||
|
||||
function _svAudioStop() {
|
||||
if (_svAudioCtx) { _svAudioCtx.close().catch(() => {}); _svAudioCtx = null; }
|
||||
_svAudioGain = null;
|
||||
}
|
||||
|
||||
// ── System View — stream relay (web UI forwarding) ────────────────────────────
|
||||
let _relayWs = null;
|
||||
let _relayRecorder = null;
|
||||
let _relayRequested = false;
|
||||
let _webViewerWs = null; // browser-side viewer WS
|
||||
|
||||
const _RELAY_CODECS = [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm;codecs=vp9,opus',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
];
|
||||
|
||||
async function startSvRelay() {
|
||||
if (_relayWs || !_svStream || !_svStream.active) return;
|
||||
try {
|
||||
const status = await window.pork.webuiStatus();
|
||||
if (!status.running) return;
|
||||
|
||||
const mimeType = _RELAY_CODECS.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm';
|
||||
|
||||
_relayWs = new WebSocket(`ws://localhost:${status.port}/sv-stream?role=encoder`);
|
||||
|
||||
_relayWs.onopen = () => {
|
||||
_relayWs.send(JSON.stringify({ type: 'meta', mimeType }));
|
||||
|
||||
_relayRecorder = new MediaRecorder(_svStream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: 4_000_000,
|
||||
});
|
||||
_relayRecorder.ondataavailable = async (e) => {
|
||||
if (e.data.size === 0 || !_relayWs || _relayWs.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const buf = await e.data.arrayBuffer();
|
||||
if (_relayWs && _relayWs.readyState === WebSocket.OPEN) _relayWs.send(buf);
|
||||
} catch (_) {}
|
||||
};
|
||||
_relayRecorder.start(500);
|
||||
};
|
||||
|
||||
_relayWs.onclose = () => {
|
||||
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
|
||||
try { _relayRecorder.stop(); } catch (_) {}
|
||||
}
|
||||
_relayRecorder = null;
|
||||
_relayWs = null;
|
||||
};
|
||||
_relayWs.onerror = () => { try { _relayWs.close(); } catch (_) {} };
|
||||
} catch (e) {
|
||||
console.error('[SvRelay] Failed to start:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function stopSvRelay() {
|
||||
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
|
||||
try { _relayRecorder.stop(); } catch (_) {}
|
||||
}
|
||||
_relayRecorder = null;
|
||||
if (_relayWs) { try { _relayWs.close(); } catch (_) {} _relayWs = null; }
|
||||
}
|
||||
|
||||
window.pork.on('sv:relay:request', ({ active }) => {
|
||||
_relayRequested = active;
|
||||
if (active && _svStream && _svStream.active) {
|
||||
startSvRelay().catch(() => {});
|
||||
} else if (!active) {
|
||||
stopSvRelay();
|
||||
}
|
||||
});
|
||||
|
||||
// ── System View — web-mode viewer ─────────────────────────────────────────────
|
||||
async function _loadSystemViewWeb() {
|
||||
// In server mode the capture card belongs to the browser machine, so use
|
||||
// MediaDevices there directly instead of waiting for an Electron relay.
|
||||
const deviceBar = document.querySelector('.sv-device-bar');
|
||||
if (deviceBar) deviceBar.hidden = false;
|
||||
['btn-sv-refresh-devices', 'btn-sv-mute', 'btn-sv-record', 'sv-gif-fps',
|
||||
'btn-sv-vid-rec', 'sv-vid-quality',
|
||||
].forEach(id => { const el = $(id); if (el) el.hidden = false; });
|
||||
$('btn-sv-popout').hidden = true;
|
||||
$('sv-status-codec').textContent = typeof MediaRecorder !== 'undefined' ? svDetectCodec().label : 'Unavailable';
|
||||
|
||||
if (!window.isSecureContext || !navigator.mediaDevices?.enumerateDevices) {
|
||||
$('sv-no-signal-msg').textContent = window.t('systemView.httpsRequired.detail', 'Capture needs HTTPS or localhost, plus camera and microphone access.');
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
svSetIdle(window.t('systemView.httpsRequired', 'HTTPS required for capture'));
|
||||
return;
|
||||
}
|
||||
|
||||
await enumerateSvDevices();
|
||||
try {
|
||||
const settings = await window.pork.getSettings();
|
||||
if (settings.svResolution) $('sv-resolution').value = settings.svResolution;
|
||||
if (settings.svGifFps) $('sv-gif-fps').value = settings.svGifFps;
|
||||
if (settings.svVidQuality) $('sv-vid-quality').value = settings.svVidQuality;
|
||||
if (settings.svVideoDevice && [...$('sv-video-device').options].some(option => option.value === settings.svVideoDevice)) $('sv-video-device').value = settings.svVideoDevice;
|
||||
if (settings.svAudioDevice && [...$('sv-audio-device').options].some(option => option.value === settings.svAudioDevice)) $('sv-audio-device').value = settings.svAudioDevice;
|
||||
$('sv-auto-load').checked = settings.svAutoLoad === true;
|
||||
if (settings.svAutoLoad && $('sv-video-device').value) await startSvStream();
|
||||
updateSvSourceUi();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function startSvStream() {
|
||||
if (_svRecording) stopSvRecording();
|
||||
stopSvRelay();
|
||||
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
|
||||
_svAudioStop();
|
||||
$('sv-video').srcObject = null;
|
||||
|
||||
const videoId = $('sv-video-device').value;
|
||||
updateSvSourceUi();
|
||||
if (!videoId) {
|
||||
$('sv-no-signal-msg').textContent = 'Select a video source above to begin';
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
$('btn-sv-mute').disabled = true;
|
||||
$('btn-sv-popout').disabled = true;
|
||||
$('btn-sv-record').disabled = true;
|
||||
$('btn-sv-vid-rec').disabled = true;
|
||||
$('sv-ctx-popout').disabled = true;
|
||||
svSetIdle('No device selected');
|
||||
return;
|
||||
}
|
||||
|
||||
$('sv-no-signal-msg').textContent = 'Starting stream…';
|
||||
|
||||
const audioId = $('sv-audio-device').value;
|
||||
const res = $('sv-resolution').value; // "1920x1080" or ""
|
||||
const [rW, rH] = res ? res.split('x').map(Number) : [];
|
||||
|
||||
const _buildConstraints = (exactRes) => {
|
||||
const vc = { deviceId: { exact: videoId } };
|
||||
if (rW) {
|
||||
vc.width = exactRes ? { exact: rW } : { ideal: rW };
|
||||
vc.height = exactRes ? { exact: rH } : { ideal: rH };
|
||||
}
|
||||
return {
|
||||
video: vc,
|
||||
audio: audioId ? {
|
||||
deviceId: { exact: audioId },
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
channelCount: 2,
|
||||
} : false,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
// First try exact resolution; fall back to ideal if the device can't match
|
||||
try {
|
||||
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true));
|
||||
} catch (exactErr) {
|
||||
if (rW && exactErr.name === 'OverconstrainedError') {
|
||||
console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`);
|
||||
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false));
|
||||
} else {
|
||||
throw exactErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Feed only video tracks to the video element. Chromium syncs audio
|
||||
// to the video element's clock, which introduces ~150-300 ms of buffering.
|
||||
// Routing audio separately via AudioContext (latencyHint:'interactive')
|
||||
// drops that to ~20-40 ms and eliminates the choppiness.
|
||||
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
|
||||
$('sv-video').muted = true; // always; audio is handled by AudioContext below
|
||||
|
||||
const audioTracks = _svStream.getAudioTracks();
|
||||
if (audioId && audioTracks.length) {
|
||||
_svAudioCtx = new AudioContext({ latencyHint: 'interactive' });
|
||||
_svAudioGain = _svAudioCtx.createGain();
|
||||
_svAudioGain.gain.value = _svMuted ? 0 : 1;
|
||||
_svAudioCtx.createMediaStreamSource(new MediaStream(audioTracks))
|
||||
.connect(_svAudioGain)
|
||||
.connect(_svAudioCtx.destination);
|
||||
}
|
||||
|
||||
$('sv-no-signal').classList.add('hidden');
|
||||
$('btn-sv-mute').disabled = false;
|
||||
$('btn-sv-popout').disabled = false;
|
||||
$('btn-sv-record').disabled = false;
|
||||
$('btn-sv-vid-rec').disabled = false;
|
||||
$('sv-ctx-popout').disabled = false;
|
||||
updateSvMuteBtn();
|
||||
svSetLive($('sv-video-device').selectedOptions[0]?.text || videoId);
|
||||
if (_relayRequested) startSvRelay().catch(() => {});
|
||||
} catch (e) {
|
||||
$('sv-no-signal-msg').textContent = `Error: ${e.message}`;
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
$('btn-sv-mute').disabled = true;
|
||||
$('btn-sv-popout').disabled = true;
|
||||
$('btn-sv-record').disabled = true;
|
||||
$('btn-sv-vid-rec').disabled = true;
|
||||
$('sv-ctx-popout').disabled = true;
|
||||
svSetIdle(`Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSvMuteBtn() {
|
||||
$('btn-sv-mute').textContent = _svMuted ? '🔇 Unmute' : '🔊 Mute';
|
||||
}
|
||||
|
||||
// ── System View — status bar ───────────────────────────────────────────────────
|
||||
function svSetLive(label) {
|
||||
$('sv-status-dot').className = 'sv-status-dot live';
|
||||
$('sv-status-text').textContent = `Live — ${label}`;
|
||||
}
|
||||
function svSetIdle(msg = 'No device selected') {
|
||||
$('sv-status-dot').className = 'sv-status-dot';
|
||||
$('sv-status-text').textContent = msg;
|
||||
$('sv-status-res').textContent = '';
|
||||
}
|
||||
function svSetSaving() {
|
||||
$('sv-status-dot').className = 'sv-status-dot saving';
|
||||
$('sv-status-text').textContent = 'Saving video…';
|
||||
}
|
||||
|
||||
function svSetGifSaving() {
|
||||
$('sv-status-dot').className = 'sv-status-dot saving';
|
||||
$('sv-status-text').textContent = 'Saving GIF…';
|
||||
}
|
||||
|
||||
$('sv-video').addEventListener('loadedmetadata', () => {
|
||||
const { videoWidth: w, videoHeight: h } = $('sv-video');
|
||||
if (w && h) $('sv-status-res').textContent = `${w}×${h}`;
|
||||
});
|
||||
|
||||
// ── System View — codec detection ─────────────────────────────────────────────
|
||||
function svDetectCodec() {
|
||||
const candidates = [
|
||||
{ mime: 'video/mp4;codecs="avc1.640032,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
|
||||
{ mime: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
|
||||
{ mime: 'video/mp4;codecs=avc1,mp4a.40.2', ext: 'mp4', label: 'H264/AAC · MP4' },
|
||||
{ mime: 'video/mp4', ext: 'mp4', label: 'MP4' },
|
||||
{ mime: 'video/webm;codecs="vp9,opus"', ext: 'webm', label: 'VP9/Opus · WebM' },
|
||||
{ mime: 'video/webm;codecs="vp8,opus"', ext: 'webm', label: 'VP8/Opus · WebM' },
|
||||
{ mime: 'video/webm', ext: 'webm', label: 'WebM' },
|
||||
];
|
||||
return candidates.find(c => MediaRecorder.isTypeSupported(c.mime))
|
||||
|| { mime: '', ext: 'webm', label: 'WebM (default)' };
|
||||
}
|
||||
|
||||
// ── System View — video recording ─────────────────────────────────────────────
|
||||
function svFmtBytes(n) {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
function svFmtTime(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
function svUpdateVidStatus() {
|
||||
const elapsed = _svVidPaused
|
||||
? _svVidStart
|
||||
: Date.now() - _svVidStart;
|
||||
$('sv-vid-rec-time').textContent = svFmtTime(elapsed);
|
||||
$('sv-vid-rec-size').textContent = _svVidBytes > 0 ? ` · ${svFmtBytes(_svVidBytes)}` : '';
|
||||
}
|
||||
|
||||
async function startSvVidRecording() {
|
||||
if (!_svStream) return;
|
||||
|
||||
const codec = svDetectCodec();
|
||||
_svVidMimeType = codec.mime;
|
||||
_svVidExt = codec.ext;
|
||||
$('sv-status-codec').textContent = codec.label;
|
||||
|
||||
const bitrate = Math.max(2_500_000, Math.min(12_000_000, parseInt($('sv-vid-quality').value) || 8_000_000));
|
||||
const name = `chimera-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}.${_svVidExt}`;
|
||||
|
||||
_svVidChunks = [];
|
||||
if (!window.pork.isWebMode) {
|
||||
try {
|
||||
const picked = await window.pork.systemViewPickSave({
|
||||
defaultPath: name,
|
||||
filters: [{ name: `${_svVidExt.toUpperCase()} Video`, extensions: [_svVidExt] }],
|
||||
});
|
||||
if (picked?.canceled || !picked?.filePath) return;
|
||||
_svVidWritable = picked.filePath;
|
||||
_svVidWriteQueue = Promise.resolve();
|
||||
await window.pork.systemViewWriteFile({ filePath: _svVidWritable, buffer: new Uint8Array(0).buffer });
|
||||
} catch (e) {
|
||||
setStatus(`Video save dialog failed: ${e.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_svVidWritable = name;
|
||||
}
|
||||
|
||||
_svVidBytes = 0; _svVidPaused = false; _svVidStart = Date.now();
|
||||
|
||||
const recOpts = { videoBitsPerSecond: bitrate, audioBitsPerSecond: 256_000 };
|
||||
if (_svVidMimeType) recOpts.mimeType = _svVidMimeType;
|
||||
_svVidRecorder = new MediaRecorder(_svStream, recOpts);
|
||||
|
||||
_svVidRecorder.ondataavailable = async (e) => {
|
||||
if (!e.data || e.data.size === 0) return;
|
||||
_svVidBytes += e.data.size;
|
||||
if (window.pork.isWebMode) {
|
||||
_svVidChunks.push(e.data);
|
||||
} else if (_svVidWritable) {
|
||||
const filePath = _svVidWritable;
|
||||
_svVidWriteQueue = _svVidWriteQueue.then(async () => {
|
||||
const buffer = await e.data.arrayBuffer();
|
||||
await window.pork.systemViewAppendFile({ filePath, buffer });
|
||||
}).catch(err => {
|
||||
console.error('[system-view] video append failed', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_svVidRecorder.onstop = async () => {
|
||||
clearInterval(_svVidTimer);
|
||||
svSetSaving();
|
||||
await _svVidWriteQueue.catch(() => {});
|
||||
if (window.pork.isWebMode && _svVidChunks?.length) {
|
||||
const url = URL.createObjectURL(new Blob(_svVidChunks, { type: _svVidMimeType || 'video/webm' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = _svVidWritable || name;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
}
|
||||
_svVidChunks = null;
|
||||
_svVidWritable = null;
|
||||
_svVidRecorder = null;
|
||||
svSetVidIdle();
|
||||
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
|
||||
};
|
||||
|
||||
_svVidRecorder.start(500);
|
||||
|
||||
$('btn-sv-vid-rec').hidden = true;
|
||||
$('btn-sv-vid-pause').hidden = false;
|
||||
$('btn-sv-vid-stop').hidden = false;
|
||||
$('sv-vid-rec-status').hidden = false;
|
||||
$('sv-vid-rec-label').textContent = 'REC';
|
||||
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
|
||||
$('btn-sv-record').disabled = true; // no GIF while video recording
|
||||
|
||||
_svVidTimer = setInterval(svUpdateVidStatus, 500);
|
||||
}
|
||||
|
||||
function pauseSvVidRecording() {
|
||||
if (!_svVidRecorder) return;
|
||||
if (_svVidPaused) {
|
||||
_svVidRecorder.resume();
|
||||
_svVidStart = Date.now() - _svVidStart;
|
||||
_svVidPaused = false;
|
||||
$('btn-sv-vid-pause').textContent = '⏸ Pause';
|
||||
$('sv-vid-rec-label').textContent = 'REC';
|
||||
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
|
||||
} else {
|
||||
_svVidRecorder.pause();
|
||||
_svVidStart = Date.now() - _svVidStart;
|
||||
_svVidPaused = true;
|
||||
$('btn-sv-vid-pause').textContent = '▶ Resume';
|
||||
$('sv-vid-rec-label').textContent = 'PAUSED';
|
||||
$('sv-vid-rec-status').className = 'sv-vid-rec-status paused';
|
||||
}
|
||||
}
|
||||
|
||||
function stopSvVidRecording() {
|
||||
if (!_svVidRecorder) return;
|
||||
if (_svVidPaused) { _svVidRecorder.resume(); _svVidPaused = false; }
|
||||
_svVidRecorder.stop();
|
||||
}
|
||||
|
||||
function svSetVidIdle() {
|
||||
$('btn-sv-vid-rec').hidden = false;
|
||||
$('btn-sv-vid-pause').hidden = true;
|
||||
$('btn-sv-vid-stop').hidden = true;
|
||||
$('sv-vid-rec-status').hidden = true;
|
||||
$('btn-sv-vid-pause').textContent = '⏸ Pause';
|
||||
$('sv-vid-rec-label').textContent = 'REC';
|
||||
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
|
||||
$('btn-sv-record').disabled = !_svStream;
|
||||
_svVidPaused = false;
|
||||
}
|
||||
|
||||
// ── System View — recording helpers ───────────────────────────────────────────
|
||||
|
||||
function getSvRecCtx() {
|
||||
const vid = $('sv-video');
|
||||
const srcW = vid.videoWidth || GIF_W;
|
||||
const srcH = vid.videoHeight || GIF_H;
|
||||
const scale = srcW > SV_GIF_MAX_WIDTH ? (SV_GIF_MAX_WIDTH / srcW) : 1;
|
||||
const w = Math.max(2, Math.round(srcW * scale));
|
||||
const h = Math.max(2, Math.round(srcH * scale));
|
||||
if (!_svRecCanvas || _svRecCanvas.width !== w || _svRecCanvas.height !== h) {
|
||||
_svRecCanvas = document.createElement('canvas');
|
||||
_svRecCanvas.width = w; _svRecCanvas.height = h;
|
||||
_svRecCtx = _svRecCanvas.getContext('2d');
|
||||
}
|
||||
return _svRecCtx;
|
||||
}
|
||||
|
||||
function getSvGifWorker() {
|
||||
if (_svGifWorker) return _svGifWorker;
|
||||
const workerSource = `
|
||||
importScripts(${JSON.stringify(new URL('gif-encoder.js', location.href).href)});
|
||||
self.onmessage = e => {
|
||||
const { id, width, height, fps, maxColors, frames } = e.data || {};
|
||||
try {
|
||||
const enc = new self.GifEncoder(width, height, { fps, maxColors });
|
||||
for (const frame of frames || []) enc.addFrame(new Uint8ClampedArray(frame));
|
||||
const bytes = enc.encode();
|
||||
self.postMessage({ id, ok: true, bytes }, [bytes.buffer]);
|
||||
} catch (error) {
|
||||
self.postMessage({ id, ok: false, error: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
`;
|
||||
const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }));
|
||||
_svGifWorker = new Worker(workerUrl);
|
||||
URL.revokeObjectURL(workerUrl);
|
||||
return _svGifWorker;
|
||||
}
|
||||
|
||||
function encodeGifOffThread({ width, height, fps, frames, maxColors }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = getSvGifWorker();
|
||||
const id = ++_svGifJobId;
|
||||
const onMessage = (event) => {
|
||||
const msg = event.data || {};
|
||||
if (msg.id !== id) return;
|
||||
worker.removeEventListener('message', onMessage);
|
||||
if (!msg.ok) reject(new Error(msg.error || 'GIF encode failed'));
|
||||
else resolve(new Uint8Array(msg.bytes));
|
||||
};
|
||||
worker.addEventListener('message', onMessage);
|
||||
worker.postMessage({
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
maxColors,
|
||||
frames: frames.map(f => f.buffer),
|
||||
}, frames.map(f => f.buffer));
|
||||
});
|
||||
}
|
||||
|
||||
async function startSvRecording() {
|
||||
if (_svRecording || !_svStream) return;
|
||||
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
|
||||
const defaultName = `chimera-${Date.now()}.gif`;
|
||||
if (window.pork.isWebMode) {
|
||||
_svGifSavePath = defaultName;
|
||||
} else {
|
||||
const picked = await window.pork.systemViewPickSave({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'GIF Image', extensions: ['gif'] }],
|
||||
});
|
||||
if (picked?.canceled || !picked?.filePath) return;
|
||||
_svGifSavePath = picked.filePath;
|
||||
}
|
||||
_svRecording = true; _svRecFrames = []; _svRecStart = Date.now();
|
||||
$('btn-sv-record').textContent = '⏹ Stop & Save';
|
||||
$('sv-rec-status').hidden = false;
|
||||
const ctx = getSvRecCtx();
|
||||
const vid = $('sv-video');
|
||||
const gifW = _svRecCanvas.width, gifH = _svRecCanvas.height;
|
||||
_svRecInterval = setInterval(() => {
|
||||
ctx.drawImage(vid, 0, 0, gifW, gifH);
|
||||
_svRecFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data));
|
||||
}, Math.round(1000 / fps));
|
||||
_svRecTimer = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - _svRecStart) / 1000);
|
||||
$('sv-rec-time').textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function stopSvRecording() {
|
||||
if (!_svRecording) return;
|
||||
clearInterval(_svRecInterval); clearInterval(_svRecTimer);
|
||||
_svRecording = false;
|
||||
$('btn-sv-record').textContent = '⏺ Record GIF';
|
||||
$('sv-rec-status').hidden = true;
|
||||
$('sv-rec-time').textContent = '0:00';
|
||||
if (!_svRecFrames.length) return;
|
||||
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
|
||||
const gifW = _svRecCanvas?.width || GIF_W;
|
||||
const gifH = _svRecCanvas?.height || GIF_H;
|
||||
const frames = _svRecFrames;
|
||||
const filePath = _svGifSavePath;
|
||||
_svRecFrames = [];
|
||||
_svGifSavePath = '';
|
||||
svSetGifSaving();
|
||||
try {
|
||||
const bytes = await encodeGifOffThread({
|
||||
width: gifW,
|
||||
height: gifH,
|
||||
fps,
|
||||
frames,
|
||||
maxColors: SV_GIF_MAX_COLORS,
|
||||
});
|
||||
if (window.pork.isWebMode) {
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filePath || `chimera-${Date.now()}.gif`;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
} else {
|
||||
await window.pork.systemViewWriteFile({ filePath, buffer: bytes.buffer });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[system-view] gif save failed', e);
|
||||
setStatus(`GIF save failed: ${e.message}`, 'error');
|
||||
}
|
||||
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
|
||||
}
|
||||
|
||||
// System View — event handlers (attached once at startup)
|
||||
$('sv-video-device').addEventListener('change', () => {
|
||||
window.pork.setSettings({ svVideoDevice: $('sv-video-device').value });
|
||||
updateSvSourceUi();
|
||||
startSvStream();
|
||||
});
|
||||
$('sv-audio-device').addEventListener('change', () => {
|
||||
window.pork.setSettings({ svAudioDevice: $('sv-audio-device').value });
|
||||
startSvStream();
|
||||
});
|
||||
$('btn-sv-refresh-devices').addEventListener('click', () => enumerateSvDevices());
|
||||
|
||||
$('sv-resolution').addEventListener('change', () => {
|
||||
window.pork.setSettings({ svResolution: $('sv-resolution').value });
|
||||
if ($('sv-video-device').value) startSvStream();
|
||||
});
|
||||
$('sv-gif-fps').addEventListener('change', () =>
|
||||
window.pork.setSettings({ svGifFps: $('sv-gif-fps').value }));
|
||||
$('sv-vid-quality').addEventListener('change', () =>
|
||||
window.pork.setSettings({ svVidQuality: $('sv-vid-quality').value }));
|
||||
|
||||
$('sv-auto-load').addEventListener('change', () => {
|
||||
window.pork.setSettings({ svAutoLoad: $('sv-auto-load').checked });
|
||||
});
|
||||
|
||||
$('btn-sv-mute').addEventListener('click', () => {
|
||||
_svMuted = !_svMuted;
|
||||
if (_svAudioGain) _svAudioGain.gain.value = _svMuted ? 0 : 1;
|
||||
updateSvMuteBtn();
|
||||
});
|
||||
|
||||
// In-app fullscreen: CSS-based fixed overlay — requestFullscreen() is
|
||||
// unreliable in Electron's frameless window and silently fails.
|
||||
function svToggleFullscreen() {
|
||||
const wrap = $('sv-viewer-wrap');
|
||||
const entering = !wrap.classList.contains('sv-viewer-wrap--fullscreen');
|
||||
wrap.classList.toggle('sv-viewer-wrap--fullscreen', entering);
|
||||
$('btn-sv-fullscreen').title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)';
|
||||
$('btn-sv-fullscreen').innerHTML = entering ? '✕' : '⛶';
|
||||
}
|
||||
|
||||
$('btn-sv-fullscreen').addEventListener('click', svToggleFullscreen);
|
||||
|
||||
$('sv-viewer-wrap').addEventListener('dblclick', svToggleFullscreen);
|
||||
|
||||
$('btn-sv-record').addEventListener('click', () => {
|
||||
(_svRecording ? stopSvRecording() : startSvRecording()).catch(err => {
|
||||
setStatus(err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
$('btn-sv-vid-rec').addEventListener('click', startSvVidRecording);
|
||||
$('btn-sv-vid-pause').addEventListener('click', pauseSvVidRecording);
|
||||
$('btn-sv-vid-stop').addEventListener('click', stopSvVidRecording);
|
||||
|
||||
// Right-click context menu on video area
|
||||
const _svCtxMenu = $('sv-ctx-menu');
|
||||
$('sv-viewer-wrap').addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
const x = Math.min(e.clientX, window.innerWidth - 164);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 80);
|
||||
_svCtxMenu.style.left = `${x}px`;
|
||||
_svCtxMenu.style.top = `${y}px`;
|
||||
_svCtxMenu.hidden = false;
|
||||
});
|
||||
document.addEventListener('click', () => { _svCtxMenu.hidden = true; });
|
||||
$('sv-ctx-fs').addEventListener('click', svToggleFullscreen);
|
||||
$('sv-ctx-popout').addEventListener('click', () => $('btn-sv-popout').click());
|
||||
|
||||
if (navigator.mediaDevices) {
|
||||
navigator.mediaDevices.addEventListener('devicechange', () => enumerateSvDevices().catch(() => {}));
|
||||
}
|
||||
|
||||
$('btn-sv-popout').addEventListener('click', async () => {
|
||||
const videoId = $('sv-video-device').value;
|
||||
const audioId = $('sv-audio-device').value;
|
||||
const resolution = $('sv-resolution').value;
|
||||
if (_svRecording) stopSvRecording();
|
||||
stopSvRelay();
|
||||
// Stop the main stream — the pop-out will start its own
|
||||
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
|
||||
_svAudioStop();
|
||||
$('sv-video').srcObject = null;
|
||||
$('sv-no-signal').classList.remove('hidden');
|
||||
$('sv-no-signal-msg').textContent = 'Stream moved to pop-out window';
|
||||
$('btn-sv-mute').disabled = true;
|
||||
$('btn-sv-popout').disabled = true;
|
||||
$('btn-sv-record').disabled = true;
|
||||
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
|
||||
});
|
||||
|
||||
// When pop-out closes, auto-restart stream in main window if device still selected
|
||||
window.pork.on('system-view:popout-closed', () => {
|
||||
if ($('page-system-view')?.classList.contains('active') && $('sv-video-device').value) {
|
||||
startSvStream();
|
||||
}
|
||||
});
|
||||
|
||||
// ── System View mini-cards (Transfers / Jailbreak) ────────────────────────────
|
||||
|
||||
function svMiniGetStatus() {
|
||||
const sel = $('sv-video-device');
|
||||
const opt = sel?.selectedOptions?.[0];
|
||||
return (opt && opt.value) ? `Capture device: ${opt.text}` : 'No capture device selected.';
|
||||
}
|
||||
|
||||
['transfers', 'jailbreak'].forEach(pageId => {
|
||||
$(`sv-mini-${pageId}-hdr`).addEventListener('click', () => {
|
||||
const card = $(`sv-mini-${pageId}`);
|
||||
card.classList.toggle('open');
|
||||
if (card.classList.contains('open')) {
|
||||
$(`sv-mini-${pageId}-status`).textContent = svMiniGetStatus();
|
||||
}
|
||||
});
|
||||
|
||||
$(`btn-sv-mini-${pageId}-nav`).addEventListener('click', () => navigate('system-view'));
|
||||
|
||||
$(`btn-sv-mini-${pageId}-popout`).addEventListener('click', async () => {
|
||||
const videoId = $('sv-video-device').value;
|
||||
const audioId = $('sv-audio-device').value;
|
||||
const resolution = $('sv-resolution').value;
|
||||
try {
|
||||
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.nav-link').forEach(a =>
|
||||
a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.page); })
|
||||
);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
(function initTitleWorkspace(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.chimeraTitleWorkspace = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createTitleWorkspace() {
|
||||
'use strict';
|
||||
|
||||
const array = value => Array.isArray(value) ? value : [];
|
||||
const text = value => value == null || value === '' ? null : String(value);
|
||||
const first = (...values) => values.map(text).find(Boolean) || null;
|
||||
const lower = value => String(value || '').trim().toLowerCase();
|
||||
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted']);
|
||||
|
||||
function deviceState(item) {
|
||||
return first(
|
||||
item && item.deviceAvailabilityState,
|
||||
item && item.availabilityState,
|
||||
item && item.device && item.device.availabilityState,
|
||||
item && item.storageDevice && item.storageDevice.availabilityState,
|
||||
item && item.volume && item.volume.device && item.volume.device.availabilityState
|
||||
);
|
||||
}
|
||||
|
||||
function locationLabel(item, fallback) {
|
||||
return first(
|
||||
item && item.volume && item.volume.label,
|
||||
item && item.volume && item.volume.name,
|
||||
item && item.volumeLabel,
|
||||
item && item.device && item.device.label,
|
||||
item && item.storageDevice && item.storageDevice.label,
|
||||
item && item.console && item.console.name,
|
||||
item && item.consoleName,
|
||||
item && item.path,
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function isConfirmedInstall(install) {
|
||||
const state = lower(first(install && install.presenceState, install && install.state, install && install.installState));
|
||||
return state === 'present' || state === 'installed' || state === 'confirmed';
|
||||
}
|
||||
|
||||
function isMissing(item) {
|
||||
return missingStates.has(lower(deviceState(item)));
|
||||
}
|
||||
|
||||
function collectActivity(title, locations) {
|
||||
const entries = [];
|
||||
const add = (kind, value, label) => {
|
||||
if (!value) return;
|
||||
const timestamp = new Date(value);
|
||||
if (Number.isNaN(timestamp.getTime())) return;
|
||||
entries.push({ kind, label, timestamp: timestamp.toISOString() });
|
||||
};
|
||||
add('title', title.updatedAt || title.updated_at, 'Title metadata updated');
|
||||
add('title', title.createdAt || title.created_at, 'Title first recorded');
|
||||
locations.forEach(location => {
|
||||
add(location.kind, location.raw.lastSeenAt || location.raw.last_seen_at, `${location.label} last seen`);
|
||||
add(location.kind, location.raw.verifiedAt || location.raw.verified_at, `${location.label} verified`);
|
||||
add(location.kind, location.raw.detectedAt || location.raw.detected_at, `${location.label} detected`);
|
||||
});
|
||||
array(title.media).forEach(item => add('media', item.importedAt || item.provenance?.importedAt, `Imported ${item.kind || 'media'}: ${item.originalName || item.name || 'asset'}`));
|
||||
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
}
|
||||
|
||||
function project(title = {}) {
|
||||
const localCopies = array(title.localCopies);
|
||||
const consoleInstalls = array(title.consoleInstalls);
|
||||
const packages = array(title.packages);
|
||||
const saves = array(title.saves);
|
||||
const trophies = array(title.trophies);
|
||||
const media = array(title.media);
|
||||
const locations = [
|
||||
...localCopies.map((raw, index) => ({
|
||||
kind: 'local-copy',
|
||||
label: locationLabel(raw, `Local copy ${index + 1}`),
|
||||
state: deviceState(raw) || 'unknown',
|
||||
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
|
||||
format: first(raw.format, raw.copyType, raw.type),
|
||||
path: first(raw.path, raw.relativePath),
|
||||
missing: isMissing(raw),
|
||||
raw
|
||||
})),
|
||||
...consoleInstalls.map((raw, index) => ({
|
||||
kind: 'console-install',
|
||||
label: locationLabel(raw, `Console install ${index + 1}`),
|
||||
state: first(raw.presenceState, raw.state, raw.installState, 'unknown'),
|
||||
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
|
||||
format: first(raw.format, raw.packageType),
|
||||
path: first(raw.path, raw.installPath),
|
||||
missing: isMissing(raw),
|
||||
confirmed: isConfirmedInstall(raw),
|
||||
raw
|
||||
}))
|
||||
];
|
||||
const missingDrive = Number(title.availability && title.availability.missingDeviceCount || 0) > 0 || locations.some(location => location.missing);
|
||||
const installed = consoleInstalls.some(isConfirmedInstall) || Number(title.availability && title.availability.confirmedInstallCount || 0) > 0;
|
||||
const protectionState = lower(first(title.protection && title.protection.state, title.protection && title.protection.status));
|
||||
const protectedCopy = ['protected', 'healthy', 'verified', 'complete'].includes(protectionState)
|
||||
|| Number(title.protection && title.protection.protectedCopyCount || 0) > 0;
|
||||
const badges = [];
|
||||
if (installed) badges.push({ code: 'installed', tone: 'positive' });
|
||||
if (localCopies.length) badges.push({ code: 'local-copy', tone: 'info' });
|
||||
if (missingDrive) badges.push({ code: 'missing-drive', tone: 'warning' });
|
||||
if (protectedCopy) badges.push({ code: 'protected', tone: 'positive' });
|
||||
if ((installed || localCopies.length) && !protectedCopy) badges.push({ code: 'needs-protection', tone: 'danger' });
|
||||
if (!locations.length) badges.push({ code: 'metadata-only', tone: 'neutral' });
|
||||
|
||||
return Object.freeze({
|
||||
title,
|
||||
badges,
|
||||
locations,
|
||||
localCopies,
|
||||
consoleInstalls,
|
||||
packages,
|
||||
saves,
|
||||
trophies,
|
||||
media,
|
||||
missingDrive,
|
||||
installed,
|
||||
protectedCopy,
|
||||
activity: collectActivity(title, locations)
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function tr(key, fallback) {
|
||||
if (typeof window === 'undefined' || typeof window.t !== 'function') return fallback;
|
||||
const translated = window.t(key);
|
||||
return translated && translated !== key ? translated : fallback;
|
||||
}
|
||||
|
||||
function setCount(id, count) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.textContent = count ? String(count) : '';
|
||||
node.hidden = !count;
|
||||
}
|
||||
|
||||
function emptyState(key, fallback, action) {
|
||||
return `<div class="title-workspace-empty"><p>${escapeHtml(tr(key, fallback))}</p>${action || ''}</div>`;
|
||||
}
|
||||
|
||||
function renderLocation(location) {
|
||||
const kind = location.kind === 'console-install'
|
||||
? tr('library.workspace.consoleInstall', 'Console install')
|
||||
: tr('library.workspace.localCopy', 'Local copy');
|
||||
const details = [location.version && `v${location.version}`, location.format, location.path].filter(Boolean);
|
||||
return `<article class="title-location-card${location.missing ? ' is-missing' : ''}">
|
||||
<div><span class="title-location-kind">${escapeHtml(kind)}</span><h4>${escapeHtml(location.label)}</h4></div>
|
||||
<span class="title-location-state">${escapeHtml(location.missing ? tr('library.workspace.driveMissing', 'Drive unavailable') : location.state)}</span>
|
||||
${details.length ? `<p>${details.map(escapeHtml).join(' · ')}</p>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderList(id, items, emptyKey, emptyFallback, mapper, action) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.innerHTML = items.length ? items.map(mapper).join('') : emptyState(emptyKey, emptyFallback, action);
|
||||
}
|
||||
|
||||
function render(title) {
|
||||
if (typeof document === 'undefined') return project(title);
|
||||
const workspace = project(title);
|
||||
const labels = {
|
||||
installed: tr('library.badge.installed', 'Installed'),
|
||||
'local-copy': tr('library.badge.localCopy', 'Local copy'),
|
||||
'missing-drive': tr('library.badge.missingDrive', 'Drive unavailable'),
|
||||
protected: tr('library.badge.protected', 'Protected'),
|
||||
'needs-protection': tr('library.badge.needsProtection', 'Needs protection'),
|
||||
'metadata-only': tr('library.badge.metadataOnly', 'Metadata only')
|
||||
};
|
||||
const badges = document.getElementById('modal-derived-badges');
|
||||
if (badges) badges.innerHTML = workspace.badges.map(badge => `<span class="title-badge tone-${badge.tone}">${escapeHtml(labels[badge.code])}</span>`).join('');
|
||||
|
||||
renderList('modal-copies-content', workspace.locations, 'library.workspace.noCopies', 'No local copies or confirmed console installations recorded.', renderLocation);
|
||||
renderList('modal-packages-content', workspace.packages, 'library.workspace.noPackages', 'No package evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, item.packageId, 'Package'))}</strong><span>${escapeHtml(first(item.state, item.status, item.type, 'recorded'))}</span></article>`);
|
||||
window.chimeraRecipeEditor?.mount?.({ title, workspace });
|
||||
renderList('modal-saves-content', workspace.saves, 'library.workspace.noSaves', 'No save evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.accountLabel, 'Save'))}</strong><span>${escapeHtml(first(item.state, item.status, 'recorded'))}</span></article>`, `<button class="btn" type="button" data-route="protection/saves">${escapeHtml(tr('library.workspace.openSaves', 'Open Save Vault'))}</button>`);
|
||||
renderList('modal-trophies-content', workspace.trophies, 'library.workspace.noTrophies', 'No verified read-only trophy evidence imported.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, 'Trophy evidence'))}</strong><span>${escapeHtml(first(item.state, item.status, 'read-only'))}</span></article>`);
|
||||
renderList('modal-media-content', workspace.media, 'library.workspace.noMedia', 'No media assets linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.originalName, item.name, item.label, item.kind, 'Media'))}</strong><span>${escapeHtml(first(item.provenance?.provider, item.state, item.status, 'available'))}${item.preferred ? ` · ${escapeHtml(tr('media.import.preferredActive', 'Preferred'))}` : ''}</span></article>`, `<button class="btn" type="button" data-route="library/media">${escapeHtml(tr('library.workspace.openMedia', 'Open Media Library'))}</button>`);
|
||||
renderList('modal-activity-content', workspace.activity, 'library.workspace.noActivity', 'No timestamped evidence recorded yet.', item => `<article class="title-evidence-row"><strong>${escapeHtml(item.label)}</strong><time datetime="${escapeHtml(item.timestamp)}">${escapeHtml(new Date(item.timestamp).toLocaleString())}</time></article>`);
|
||||
|
||||
const compatibility = document.getElementById('modal-compatibility-content');
|
||||
if (compatibility) {
|
||||
const versions = workspace.locations.map(item => item.version).filter(Boolean);
|
||||
const formats = workspace.locations.map(item => item.format).filter(Boolean);
|
||||
compatibility.innerHTML = `<dl class="title-fact-grid">
|
||||
<div><dt>${escapeHtml(tr('library.workspace.availability', 'Availability'))}</dt><dd>${escapeHtml(workspace.missingDrive ? tr('library.workspace.partialAvailability', 'Partial - drive unavailable') : workspace.locations.length ? tr('library.workspace.availableEvidence', 'Evidence available') : tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
||||
<div><dt>${escapeHtml(tr('library.workspace.versions', 'Observed versions'))}</dt><dd>${escapeHtml([...new Set(versions)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
||||
<div><dt>${escapeHtml(tr('library.workspace.formats', 'Observed formats'))}</dt><dd>${escapeHtml([...new Set(formats)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
||||
<div><dt>${escapeHtml(tr('library.workspace.protection', 'Protection'))}</dt><dd>${escapeHtml(workspace.protectedCopy ? tr('library.workspace.protected', 'Protected copy recorded') : tr('library.workspace.unprotected', 'No protected copy recorded'))}</dd></div>
|
||||
</dl>`;
|
||||
window.chimeraCompatibilityGuidance?.load?.(compatibility, {
|
||||
titleId: first(title.externalTitleId, title.game_id, title.gameId),
|
||||
version: versions[0] || '', firmware: first(title.consoleFirmware, title.firmware),
|
||||
loader: first(title.loader, title.hen), format: formats[0] || ''
|
||||
});
|
||||
}
|
||||
|
||||
setCount('mtab-copies-count', workspace.locations.length);
|
||||
setCount('mtab-packages-count', workspace.packages.length);
|
||||
setCount('mtab-saves-count', workspace.saves.length);
|
||||
setCount('mtab-trophies-count', workspace.trophies.length);
|
||||
setCount('mtab-media-count', workspace.media.length);
|
||||
setCount('mtab-activity-count', workspace.activity.length);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
return Object.freeze({ project, render });
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// ── Payload Manager Settings modal ────────────────────────────────────────────
|
||||
|
||||
$('btn-payload-settings').addEventListener('click', async () => {
|
||||
const s = await window.pork.getSettings();
|
||||
$('pm-local-path').value = s.payloadLocalPath || '';
|
||||
$('pm-remote-path').value = s.payloadRemotePath || '';
|
||||
openAccessibleDialog($('payload-settings-overlay'), $('pm-remote-path'));
|
||||
});
|
||||
|
||||
$('btn-pm-browse-local').addEventListener('click', async () => {
|
||||
const folder = await window.pork.selectFolder();
|
||||
if (folder) $('pm-local-path').value = folder;
|
||||
});
|
||||
|
||||
$('btn-pm-settings-save').addEventListener('click', async () => {
|
||||
await window.pork.setSettings({
|
||||
payloadLocalPath: $('pm-local-path').value.trim(),
|
||||
payloadRemotePath: $('pm-remote-path').value.trim(),
|
||||
});
|
||||
closeAccessibleDialog($('payload-settings-overlay'));
|
||||
});
|
||||
|
||||
['payload-settings-close', 'btn-pm-settings-cancel'].forEach(id =>
|
||||
$(id).addEventListener('click', () => closeAccessibleDialog($('payload-settings-overlay')))
|
||||
);
|
||||
|
||||
$('payload-settings-overlay').addEventListener('click', event => {
|
||||
if (event.target === $('payload-settings-overlay')) closeAccessibleDialog($('payload-settings-overlay'));
|
||||
});
|
||||
$('payload-settings-overlay').addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeAccessibleDialog($('payload-settings-overlay'));
|
||||
});
|
||||
|
||||
// ── Transfer Manager ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtSpeed(bps) {
|
||||
if (!bps || bps < 0) return '—';
|
||||
if (bps < 1024) return `${Math.round(bps)} B/s`;
|
||||
if (bps < 1048576) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${(bps / 1048576).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
let _transferState = { jobs: [], paused: false, maxConcurrent: 1, activeCount: 0, queuedCount: 0, interruptedCount: 0, policy: {} };
|
||||
|
||||
function renderTransferPage(st) {
|
||||
_transferState = st;
|
||||
|
||||
// Stats bar
|
||||
$('tstat-active').textContent = st.activeCount;
|
||||
$('tstat-queued').textContent = st.queuedCount;
|
||||
$('tstat-interrupted').textContent = st.interruptedCount || 0;
|
||||
$('tstat-concurrent').textContent = st.maxConcurrent;
|
||||
|
||||
const slider = $('transfer-concurrent-slider');
|
||||
if (slider && parseInt(slider.value) !== st.maxConcurrent) slider.value = st.maxConcurrent;
|
||||
if ($('transfer-window-start') && document.activeElement !== $('transfer-window-start')) $('transfer-window-start').value = st.policy?.windowStart || '';
|
||||
if ($('transfer-window-end') && document.activeElement !== $('transfer-window-end')) $('transfer-window-end').value = st.policy?.windowEnd || '';
|
||||
if ($('transfer-policy-state')) $('transfer-policy-state').textContent = st.policy?.windowStart && st.policy?.windowEnd
|
||||
? `${st.policy.windowStart}–${st.policy.windowEnd} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`
|
||||
: `${window.t('transfers.policy.noWindow', 'No time window')} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`;
|
||||
|
||||
// Total speed from all active jobs
|
||||
const totalSpeed = st.jobs
|
||||
.filter(j => j.status === 'active')
|
||||
.reduce((sum, j) => sum + (j.progress?.speedBps || 0), 0);
|
||||
$('tstat-speed').textContent = fmtSpeed(totalSpeed);
|
||||
|
||||
// Pause/resume button
|
||||
const pauseBtn = $('btn-transfer-pause');
|
||||
if (pauseBtn) {
|
||||
pauseBtn.textContent = st.paused
|
||||
? `▶ ${window.t('transfers.resumeAll', 'Resume All')}`
|
||||
: `⏸ ${window.t('transfers.pauseAll', 'Pause All')}`;
|
||||
pauseBtn.disabled = !st.paused && !st.jobs.some(job => ['queued', 'active', 'retrying'].includes(job.status));
|
||||
}
|
||||
const clearBtn = $('btn-transfer-clear');
|
||||
if (clearBtn) clearBtn.disabled = !st.jobs.some(job => ['done', 'error', 'cancelled'].includes(job.status));
|
||||
|
||||
// Job list
|
||||
const list = $('transfers-list');
|
||||
const empty = $('transfers-empty');
|
||||
const visibleJobs = st.jobs.filter(j => j.status !== 'cancelled');
|
||||
|
||||
if (!visibleJobs.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
|
||||
list.innerHTML = visibleJobs.map(job => {
|
||||
const statusText = {
|
||||
queued: 'Queued',
|
||||
active: 'Transferring…',
|
||||
done: 'Done',
|
||||
error: 'Error',
|
||||
cancelled: 'Cancelled',
|
||||
interrupted: window.t('transfers.status.interrupted', 'Interrupted after restart'),
|
||||
retrying: 'Retrying…',
|
||||
}[job.status] || job.status;
|
||||
|
||||
const typeLabel = { upload: 'UPLOAD', download: 'DOWNLOAD', pork: 'PORK' }[job.type] || (job.type || '').toUpperCase();
|
||||
const typeClass = { upload: 'type-upload', download: 'type-download', pork: 'type-pork' }[job.type] || '';
|
||||
|
||||
const pct = job.progress?.percent || 0;
|
||||
const speed = job.status === 'active' ? fmtSpeed(job.progress?.speedBps) : '';
|
||||
const file = job.progress?.file || '';
|
||||
const filesDone = job.progress?.filesDone || 0;
|
||||
const filesTotal = job.progress?.filesTotal || 0;
|
||||
const transferred = job.progress?.transferred || 0;
|
||||
const total = job.progress?.total || 0;
|
||||
|
||||
const showCancel = job.cancellable !== false && ['queued', 'active', 'retrying'].includes(job.status);
|
||||
const showRetry = ['error', 'interrupted'].includes(job.status) && job.retryable;
|
||||
const showBar = job.status === 'active' || job.status === 'done';
|
||||
|
||||
const currentBytes = job.progress?.currentBytes || 0;
|
||||
const currentFileSize = job.progress?.currentFileSize || 0;
|
||||
|
||||
// File counter or single-file byte progress
|
||||
const sizeStr = filesTotal > 1
|
||||
? `File ${filesDone}/${filesTotal}`
|
||||
: (total > 0 ? `${fmt(transferred)} / ${fmt(total)}` : '');
|
||||
|
||||
// Current-file byte progress for directory uploads (shows activity on large files)
|
||||
const fileByteStr = (filesTotal > 1 && currentFileSize > 0 && job.status === 'active')
|
||||
? `${fmt(currentBytes)} / ${fmt(currentFileSize)}`
|
||||
: '';
|
||||
|
||||
const pctLabel = (showBar && job.status === 'active') ? `${pct}%` : (job.status === 'done' ? '100%' : '');
|
||||
|
||||
return `
|
||||
<div class="transfer-item transfer-item-${job.status}">
|
||||
<div class="transfer-item-header">
|
||||
<span class="transfer-label" title="${escHtml(job.localPath || job.remotePath || '')}">${escHtml(job.label)}</span>
|
||||
<span class="transfer-type-badge ${typeClass}">${typeLabel}</span>
|
||||
<span class="transfer-status-text">${statusText}</span>
|
||||
${showRetry ? `<button class="btn btn-sm btn-retry-transfer" data-job-id="${escHtml(job.id)}">Retry</button>` : ''}
|
||||
${showCancel ? `<button class="btn-cancel-transfer" data-job-id="${job.id}" title="Cancel">×</button>` : ''}
|
||||
</div>
|
||||
${showBar ? `
|
||||
<div class="transfer-progress-wrap">
|
||||
<div class="transfer-progress-bar">
|
||||
<div class="transfer-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
${pctLabel ? `<span class="transfer-pct-label">${pctLabel}</span>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="transfer-meta">
|
||||
${speed ? `<span class="transfer-speed">${speed}</span>` : ''}
|
||||
${file ? `<span class="transfer-file">${escHtml(file)}</span>` : ''}
|
||||
${fileByteStr ? `<span class="transfer-sizes">${fileByteStr}</span>` : sizeStr ? `<span class="transfer-sizes">${sizeStr}</span>` : ''}
|
||||
${job.error ? `<span class="transfer-error">${escHtml(job.error)}</span>` : ''}
|
||||
${job.cancellable === false && ['queued', 'active', 'retrying'].includes(job.status) ? '<span class="transfer-error">Upstream offers no safe cancellation; only task status is tracked.</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateNavTransferBadge(st) {
|
||||
const badge = $('nav-transfer-badge');
|
||||
if (!badge) return;
|
||||
const n = (st.activeCount || 0) + (st.queuedCount || 0);
|
||||
badge.hidden = n === 0;
|
||||
badge.textContent = n;
|
||||
}
|
||||
|
||||
async function loadTransfers() {
|
||||
const st = await window.pork.transferState();
|
||||
renderTransferPage(st);
|
||||
updateNavTransferBadge(st);
|
||||
}
|
||||
|
||||
$('btn-transfer-pause').addEventListener('click', async () => {
|
||||
if (_transferState.paused) await window.pork.transferResume();
|
||||
else await window.pork.transferPause();
|
||||
});
|
||||
|
||||
$('btn-transfer-clear').addEventListener('click', () => window.pork.transferClearDone());
|
||||
|
||||
$('transfer-concurrent-slider').addEventListener('input', async event => {
|
||||
const n = parseInt(event.target.value);
|
||||
$('tstat-concurrent').textContent = n;
|
||||
await window.pork.transferSetConcurrent(n);
|
||||
});
|
||||
|
||||
$('transfers-list').addEventListener('click', event => {
|
||||
const retry = event.target.closest('.btn-retry-transfer');
|
||||
if (retry) {
|
||||
window.pork.transferRetry?.(retry.dataset.jobId).catch(error => showToast(error.message, 'error'));
|
||||
return;
|
||||
}
|
||||
const cancel = event.target.closest('.btn-cancel-transfer');
|
||||
if (cancel?.dataset.jobId) window.pork.transferCancel(cancel.dataset.jobId);
|
||||
});
|
||||
|
||||
$('btn-transfer-policy-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await window.pork.transferSetPolicy?.({
|
||||
windowStart: $('transfer-window-start').value || null,
|
||||
windowEnd: $('transfer-window-end').value || null,
|
||||
});
|
||||
showToast(window.t('transfers.policy.saved', 'Transfer policy saved.'), 'ok');
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
window.pork.on('transfer:update', state => {
|
||||
renderTransferPage(state);
|
||||
updateNavTransferBadge(state);
|
||||
});
|
||||
|
||||
window.addEventListener('chimera:language-changed', () => {
|
||||
if (document.querySelector('#page-transfers.active')) renderTransferPage(_transferState);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
// ── PS Avatar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _xavatarClient = null;
|
||||
let _xavatarLastResult = null; // { buffer: ArrayBuffer, filename: string }
|
||||
const _xavatarThumbCache = new Map(); // remotePath → blob URL
|
||||
let _ps5ProfileInventory = null;
|
||||
let _ps5ProfileSelectedUid = null;
|
||||
let _ps5ProfileBound = false;
|
||||
|
||||
function ps5ProfileText(key, fallback) {
|
||||
return typeof window.t === 'function' ? window.t(`ps5Profile.${key}`, fallback) : fallback;
|
||||
}
|
||||
|
||||
function ps5ProfileSetAction(message, state = '') {
|
||||
const target = $('ps5-profile-action-state');
|
||||
if (!target) return;
|
||||
target.textContent = message || '';
|
||||
target.className = `ps5-profile-action-state${state ? ` is-${state}` : ''}`;
|
||||
}
|
||||
|
||||
function ps5ProfileSelectedUser() {
|
||||
return _ps5ProfileInventory?.users?.find(user => user.uid === _ps5ProfileSelectedUid) || null;
|
||||
}
|
||||
|
||||
async function loadPs5ProfileRuntime() {
|
||||
const card = document.querySelector('.ps5-profile-runtime');
|
||||
const title = $('ps5-profile-runtime-title');
|
||||
const state = $('ps5-profile-runtime-state');
|
||||
const detail = $('ps5-profile-runtime-detail');
|
||||
const button = $('btn-ps5-profile-start-payload');
|
||||
if (!card || !state || !detail || !button || !window.pork?.ps5ProfileRuntimePreflight) return null;
|
||||
card.classList.remove('is-ready');
|
||||
button.disabled = true;
|
||||
state.textContent = ps5ProfileText('runtimeCheckingShort', 'Checking…');
|
||||
try {
|
||||
const result = await window.pork.ps5ProfileRuntimePreflight();
|
||||
if (title) title.textContent = `PS5Upload ${result.version || '5.4.19'}`;
|
||||
const artifactsReady = result.engine?.verified && result.engine?.available && result.payload?.verified && result.console?.configured;
|
||||
if (result.ready) {
|
||||
card.classList.add('is-ready');
|
||||
state.textContent = ps5ProfileText('runtimeReady', 'Ready to start');
|
||||
detail.textContent = ps5ProfileText('runtimeReadyDetail', 'Exact engine and ELF hashes verified; the PS5 loader is reachable on port 9021.');
|
||||
button.disabled = false;
|
||||
} else if (artifactsReady && result.loader?.reason === 'unreachable') {
|
||||
state.textContent = ps5ProfileText('runtimeLoaderClosed', 'Loader waiting');
|
||||
detail.textContent = ps5ProfileText('runtimeLoaderHelp', 'Engine and ELF are verified. Start the jailbreak ELF loader on the PS5, then refresh this check.');
|
||||
} else {
|
||||
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
|
||||
detail.textContent = ps5ProfileText('runtimeBlockedDetail', 'A pinned artifact, configured PS5 host or local engine check failed.');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
|
||||
detail.textContent = error.message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPs5ProfileOperations(operations = []) {
|
||||
const target = $('ps5-profile-operations');
|
||||
if (!target) return;
|
||||
if (!operations.length) {
|
||||
target.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noActivity', 'No prepared profile changes yet.'))}</div>`;
|
||||
return;
|
||||
}
|
||||
target.innerHTML = operations.slice().reverse().slice(0, 8).map(operation => {
|
||||
const canRollback = ['verified', 'verification_failed', 'failed', 'interrupted'].includes(operation.status);
|
||||
const label = operation.kind === 'rename' ? ps5ProfileText('renameActivity', 'Display name') : ps5ProfileText('avatarActivity', 'Profile image');
|
||||
const detail = operation.kind === 'rename' ? `${operation.snapshot?.username || ''} → ${operation.desired?.username || ''}` : operation.uidHex;
|
||||
return `<article class="ps5-profile-operation" data-profile-operation="${escHtml(operation.id)}" data-profile-uid="${escHtml(operation.uidHex)}">
|
||||
<div><strong>${escHtml(label)} <span class="ps5-profile-operation-status">${escHtml(operation.status)}</span></strong><small>${escHtml(detail)} · ${escHtml(new Date(operation.updatedAt || operation.createdAt).toLocaleString())}</small></div>
|
||||
${canRollback ? `<button type="button" class="btn btn-sm ps5-profile-rollback">${escHtml(ps5ProfileText('rollback', 'Restore previous state'))}</button>` : ''}
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectPs5LocalProfile(uid) {
|
||||
_ps5ProfileSelectedUid = Number(uid);
|
||||
const user = ps5ProfileSelectedUser();
|
||||
document.querySelectorAll('.ps5-profile-user').forEach(card => card.classList.toggle('is-selected', Number(card.dataset.profileUid) === _ps5ProfileSelectedUid));
|
||||
const name = $('ps5-profile-name');
|
||||
const rename = $('btn-ps5-profile-rename');
|
||||
const applyAvatar = $('btn-ps5-profile-apply-avatar');
|
||||
if (!user) return;
|
||||
$('ps5-profile-selected-title').textContent = user.username;
|
||||
$('ps5-profile-selected-uid').textContent = user.uidHex;
|
||||
$('ps5-profile-avatar-initial').textContent = [...user.username][0]?.toUpperCase() || '?';
|
||||
name.value = user.username;
|
||||
name.disabled = false;
|
||||
rename.disabled = false;
|
||||
applyAvatar.disabled = !_xavatarClient?._masterCanvas;
|
||||
ps5ProfileSetAction(user.avatar?.available
|
||||
? ps5ProfileText('avatarObserved', 'Current profile image evidence was read successfully.')
|
||||
: ps5ProfileText('avatarDefault', 'This user currently has no recoverable custom image. Applying is blocked until exact recovery is possible.'));
|
||||
const preview = $('ps5-profile-avatar-preview');
|
||||
const shell = preview?.closest('.ps5-profile-avatar-shell');
|
||||
if (preview && shell && !_xavatarClient?._masterCanvas) {
|
||||
shell.classList.remove('has-preview');
|
||||
window.pork.ps5ProfileCurrentAvatar({ uid: user.uid }).then(result => {
|
||||
if (_ps5ProfileSelectedUid !== user.uid || !result?.dataUrl || _xavatarClient?._masterCanvas) return;
|
||||
const image = new Image();
|
||||
image.onload = () => { const context = preview.getContext('2d'); context.clearRect(0, 0, preview.width, preview.height); context.drawImage(image, 0, 0, preview.width, preview.height); shell.classList.add('has-preview'); };
|
||||
image.src = result.dataUrl;
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPs5LocalProfiles() {
|
||||
const provider = $('ps5-profile-provider-state');
|
||||
const usersTarget = $('ps5-profile-users');
|
||||
if (!provider || !usersTarget || !window.pork?.ps5ProfileInspect) return;
|
||||
if (!_ps5ProfileBound) bindPs5ProfileWorkspace();
|
||||
loadPs5ProfileRuntime();
|
||||
provider.className = 'ps5-profile-provider-state';
|
||||
provider.textContent = ps5ProfileText('checking', 'Checking the guarded profile bridge…');
|
||||
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('loading', 'Reading local users…'))}</div>`;
|
||||
try {
|
||||
const [inventory, operations] = await Promise.all([window.pork.ps5ProfileInspect(), window.pork.ps5ProfileListOperations()]);
|
||||
_ps5ProfileInventory = inventory;
|
||||
renderPs5ProfileOperations(operations);
|
||||
if (!inventory.available) {
|
||||
provider.classList.add('is-blocked');
|
||||
provider.textContent = ps5ProfileText('bridgeBlocked', 'Profile bridge unavailable · configure PS5 host and pinned PS5Upload 5.4.19');
|
||||
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('bridgeHelp', 'Start the pinned loopback bridge and configure the PS5 host. No console change is possible while this gate is closed.'))}</div>`;
|
||||
return;
|
||||
}
|
||||
provider.classList.add('is-ready');
|
||||
provider.textContent = `${ps5ProfileText('bridgeReady', 'Verified bridge')} · ${inventory.provider?.version || '5.4.19'}`;
|
||||
usersTarget.innerHTML = inventory.users.length ? inventory.users.map(user => `<button type="button" class="ps5-profile-user" data-profile-uid="${user.uid}">
|
||||
<span class="ps5-profile-user-avatar">${escHtml([...user.username][0]?.toUpperCase() || '?')}</span><span><strong>${escHtml(user.username)}</strong><small>${escHtml(user.uidHex)}</small></span>${user.foreground ? `<em>${escHtml(ps5ProfileText('active', 'Active'))}</em>` : ''}
|
||||
</button>`).join('') : `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noUsers', 'No local users were reported.'))}</div>`;
|
||||
const preferred = inventory.users.find(user => user.uid === _ps5ProfileSelectedUid) || inventory.users.find(user => user.foreground) || inventory.users[0];
|
||||
if (preferred) selectPs5LocalProfile(preferred.uid);
|
||||
} catch (error) {
|
||||
provider.classList.add('is-blocked');
|
||||
provider.textContent = ps5ProfileText('bridgeError', 'Profile bridge check failed');
|
||||
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function bindPs5ProfileWorkspace() {
|
||||
if (_ps5ProfileBound) return;
|
||||
_ps5ProfileBound = true;
|
||||
$('btn-ps5-profile-refresh')?.addEventListener('click', loadPs5LocalProfiles);
|
||||
$('btn-ps5-profile-start-payload')?.addEventListener('click', async () => {
|
||||
const button = $('btn-ps5-profile-start-payload');
|
||||
const confirmation = await showExactConfirmation(
|
||||
ps5ProfileText('startPayloadConfirm', 'Send only the verified PS5Upload 5.4.19 ELF to the configured PS5 loader? This payload remains active until rest mode or reboot.'),
|
||||
'START PS5 PROFILE PAYLOAD',
|
||||
);
|
||||
if (!confirmation) return;
|
||||
button.disabled = true;
|
||||
ps5ProfileSetAction(ps5ProfileText('startingPayload', 'Sending the verified payload and checking the management service…'));
|
||||
try {
|
||||
const result = await window.pork.ps5ProfileStartPayload({ confirmation });
|
||||
if (!result.managementReady) throw new Error(ps5ProfileText('payloadNotReady', 'The ELF was sent, but the PS5 management service did not become ready. Check the jailbreak and firmware compatibility.'));
|
||||
ps5ProfileSetAction(ps5ProfileText('payloadReady', 'Verified payload active. Reading local profiles…'), 'ok');
|
||||
await loadPs5LocalProfiles();
|
||||
} catch (error) {
|
||||
ps5ProfileSetAction(error.message, 'error');
|
||||
await loadPs5ProfileRuntime();
|
||||
}
|
||||
});
|
||||
$('ps5-profile-users')?.addEventListener('click', event => {
|
||||
const card = event.target.closest('.ps5-profile-user');
|
||||
if (card) selectPs5LocalProfile(card.dataset.profileUid);
|
||||
});
|
||||
$('btn-ps5-profile-rename')?.addEventListener('click', async () => {
|
||||
const user = ps5ProfileSelectedUser();
|
||||
if (!user) return;
|
||||
ps5ProfileSetAction(ps5ProfileText('preparing', 'Creating recovery snapshot and checking fresh evidence…'));
|
||||
try {
|
||||
const operation = await window.pork.ps5ProfilePrepareRename({ uid: user.uid, name: $('ps5-profile-name').value });
|
||||
const confirmation = await showExactConfirmation(ps5ProfileText('renameConfirm', 'Apply this local display name after the recovery snapshot and fresh-evidence check?'), operation.confirmation);
|
||||
if (!confirmation) return void loadPs5LocalProfiles();
|
||||
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
|
||||
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
|
||||
await loadPs5LocalProfiles();
|
||||
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
|
||||
});
|
||||
$('btn-ps5-profile-apply-avatar')?.addEventListener('click', async () => {
|
||||
const user = ps5ProfileSelectedUser();
|
||||
if (!user || !_xavatarClient?._masterCanvas) return;
|
||||
ps5ProfileSetAction(ps5ProfileText('converting', 'Building the canonical profile image set…'));
|
||||
try {
|
||||
const converted = await _xavatarClient.convert();
|
||||
const operation = await window.pork.ps5ProfilePrepareAvatar({ uid: user.uid, archive: converted.buffer });
|
||||
const confirmation = await showExactConfirmation(ps5ProfileText('avatarConfirm', 'Apply this reviewed image after preserving the current profile image?'), operation.confirmation);
|
||||
if (!confirmation) return void loadPs5LocalProfiles();
|
||||
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
|
||||
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
|
||||
await loadPs5LocalProfiles();
|
||||
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
|
||||
});
|
||||
$('ps5-profile-operations')?.addEventListener('click', async event => {
|
||||
const button = event.target.closest('.ps5-profile-rollback');
|
||||
const row = button?.closest('.ps5-profile-operation');
|
||||
if (!row) return;
|
||||
const phrase = `ROLLBACK PS5 PROFILE ${row.dataset.profileUid}`;
|
||||
const confirmation = await showExactConfirmation(ps5ProfileText('rollbackConfirm', 'Restore the exact local profile state captured before this operation?'), phrase);
|
||||
if (!confirmation) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await window.pork.ps5ProfileRollback({ id: row.dataset.profileOperation, confirmation });
|
||||
ps5ProfileSetAction(ps5ProfileText('rollbackVerified', 'Previous local profile state restored and verified.'), 'ok');
|
||||
await loadPs5LocalProfiles();
|
||||
} catch (error) { ps5ProfileSetAction(error.message, 'error'); button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
async function loadXavatarCompanion() {
|
||||
const status = $('xavatar-companion-status');
|
||||
const button = $('btn-xavatar-install-companion');
|
||||
if (!status || !button || !window.pork.xavatarCompanion) return;
|
||||
try {
|
||||
const companion = await window.pork.xavatarCompanion();
|
||||
button.disabled = !companion.installerReady;
|
||||
button.dataset.ready = companion.installerReady ? 'true' : 'false';
|
||||
status.textContent = companion.installerReady
|
||||
? `${companion.titleId} · v${companion.version} · ${(companion.size / 1024 / 1024).toFixed(1)} MB · etaHEN DPI ready`
|
||||
: `${companion.titleId} · v${companion.version} · ${companion.installerError}`;
|
||||
status.title = `Expected SHA-256: ${companion.sha256}`;
|
||||
} catch (error) {
|
||||
button.disabled = true;
|
||||
status.textContent = `Installer check failed: ${error.message}`;
|
||||
}
|
||||
if (button.dataset.bound) return;
|
||||
button.dataset.bound = 'true';
|
||||
button.addEventListener('click', async () => {
|
||||
const confirmation = await showExactConfirmation(
|
||||
"Install Lapy's Avatar Changer through etaHEN Direct Package Installer? The PS5 downloads the fixed reviewed package URL; Chimera does not launch the app.",
|
||||
'INSTALL XAVATAR',
|
||||
);
|
||||
if (!confirmation) return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'Submitting package to etaHEN DPI…';
|
||||
try {
|
||||
await window.pork.xavatarInstallCompanion(confirmation);
|
||||
status.textContent = 'Installation submitted to etaHEN DPI. Follow the package progress on your PS5.';
|
||||
showToast('Avatar Changer installation submitted.', 'ok', 9000);
|
||||
} catch (error) {
|
||||
status.textContent = `Installation failed: ${error.message}`;
|
||||
showToast(error.message, 'error', 10000);
|
||||
}
|
||||
await loadXavatarCompanion();
|
||||
});
|
||||
}
|
||||
|
||||
function loadXavatar() {
|
||||
// Refresh the library on every navigate; only initialise the converter once.
|
||||
loadXavatarLibrary();
|
||||
loadXavatarCompanion();
|
||||
loadPs5LocalProfiles();
|
||||
|
||||
if (_xavatarClient) return;
|
||||
|
||||
if (!window.XavatarClient) {
|
||||
$('xavatar-status').textContent = 'XavatarClient not available — ensure the module script loaded.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the API object — prefer the dedicated xavatarAPI bridge; fall back to
|
||||
// the pork bridge channel so conversion still works even if the separate
|
||||
// xavatarElectronModule preload failed to expose window.xavatarAPI.
|
||||
let _xavApi = window.xavatarAPI;
|
||||
if (!_xavApi) {
|
||||
console.info('[Chimera] Dedicated XAvatar bridge unavailable; using the supported web compatibility path.');
|
||||
_xavApi = {
|
||||
async convertFromCanvas(canvas, opts = {}) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const rgba = new Uint8Array(imgData.data.buffer);
|
||||
const res = await window.pork.xavatarConvertCanvas(
|
||||
rgba, canvas.width, canvas.height, opts.filename || 'avatar'
|
||||
);
|
||||
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
|
||||
return { buffer: res.buffer && res.buffer.buffer ? res.buffer.buffer : res.buffer, filename: res.filename };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_xavatarClient = new window.XavatarClient({ api: _xavApi });
|
||||
|
||||
const dropZone = $('xavatar-drop-zone');
|
||||
const fileInput = $('xavatar-file-input');
|
||||
const preview = $('xavatar-preview');
|
||||
const status = $('xavatar-status');
|
||||
const convertBtn = $('btn-xavatar-convert');
|
||||
const sendBtn = $('btn-xavatar-send-ps5');
|
||||
|
||||
const convProgress = $('xavatar-conv-progress');
|
||||
const convBar = $('xavatar-conv-bar');
|
||||
|
||||
function _xavatarSetProgress(pct, label) {
|
||||
if (!convProgress) return;
|
||||
convProgress.hidden = pct == null;
|
||||
if (pct != null) {
|
||||
convBar.style.width = `${pct}%`;
|
||||
convBar.textContent = label || '';
|
||||
}
|
||||
}
|
||||
|
||||
_xavatarClient.on('image-ready', (canvas) => {
|
||||
const ctx = preview.getContext('2d');
|
||||
preview.width = canvas.width;
|
||||
preview.height = canvas.height;
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
preview.hidden = false;
|
||||
convertBtn.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
_xavatarLastResult = null; // new image — invalidate previous conversion
|
||||
_xavatarSetProgress(null);
|
||||
status.textContent = 'Image loaded — convert & download, or send directly to PS5';
|
||||
dropZone.classList.add('xavatar-drop-zone--has-image');
|
||||
const profilePreview = $('ps5-profile-avatar-preview');
|
||||
const profileShell = profilePreview?.closest('.ps5-profile-avatar-shell');
|
||||
if (profilePreview && profileShell) {
|
||||
const profileContext = profilePreview.getContext('2d');
|
||||
profileContext.clearRect(0, 0, profilePreview.width, profilePreview.height);
|
||||
profileContext.drawImage(canvas, 0, 0, profilePreview.width, profilePreview.height);
|
||||
profileShell.classList.add('has-preview');
|
||||
$('btn-ps5-profile-apply-avatar').disabled = !ps5ProfileSelectedUser();
|
||||
}
|
||||
});
|
||||
|
||||
_xavatarClient.on('load-error', (err) => {
|
||||
status.textContent = 'Failed to load image: ' + err.message;
|
||||
});
|
||||
|
||||
_xavatarClient.on('convert-start', () => {
|
||||
convertBtn.disabled = true;
|
||||
convertBtn.textContent = 'Converting…';
|
||||
status.textContent = 'Converting…';
|
||||
});
|
||||
|
||||
_xavatarClient.on('convert-error', (err) => {
|
||||
convertBtn.disabled = false;
|
||||
convertBtn.textContent = 'Convert & Download';
|
||||
_xavatarSetProgress(null);
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
});
|
||||
|
||||
_xavatarClient.wireFileInput(fileInput);
|
||||
_xavatarClient.wireDropZone(dropZone);
|
||||
_xavatarClient.wirePaste(document);
|
||||
|
||||
$('btn-xavatar-browse').addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('click', (e) => {
|
||||
if (e.target !== $('btn-xavatar-browse')) fileInput.click();
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', () => dropZone.classList.add('xavatar-drop-zone--over'));
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('xavatar-drop-zone--over'));
|
||||
dropZone.addEventListener('drop', () => dropZone.classList.remove('xavatar-drop-zone--over'));
|
||||
|
||||
convertBtn.addEventListener('click', async () => {
|
||||
status.textContent = '';
|
||||
_xavatarSetProgress(null);
|
||||
try {
|
||||
const result = await _xavatarClient.convert();
|
||||
_xavatarLastResult = result;
|
||||
convertBtn.disabled = false;
|
||||
convertBtn.textContent = 'Convert & Download';
|
||||
const saved = await window.pork.xavatarSaveFile(result.buffer, result.filename);
|
||||
status.textContent = saved?.saved ? `Saved: ${result.filename}` : 'Converted — not saved.';
|
||||
} catch (err) {
|
||||
convertBtn.disabled = false;
|
||||
convertBtn.textContent = 'Convert & Download';
|
||||
if (!$('xavatar-status').textContent) {
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (!_xavatarClient?._masterCanvas) return;
|
||||
convertBtn.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = 'Working…';
|
||||
status.textContent = '';
|
||||
try {
|
||||
// ── Step 1: Convert ──────────────────────────────────────────────────
|
||||
_xavatarSetProgress(10, 'Converting…');
|
||||
status.textContent = 'Converting image to .xavatar…';
|
||||
const result = await _xavatarClient.convert();
|
||||
_xavatarLastResult = result;
|
||||
|
||||
// ── Step 2: Random filename ──────────────────────────────────────────
|
||||
const pad = n => String(n).padStart(5, '0');
|
||||
const rnd = pad(Math.floor(Math.random() * 100000));
|
||||
const rndName = `chimera${rnd}.xavatar`;
|
||||
_xavatarSetProgress(55, 'Uploading…');
|
||||
status.textContent = `Queuing upload → /data/AVATARS/${rndName}…`;
|
||||
|
||||
// ── Step 3: Upload ──────────────────────────────────────────────────
|
||||
await window.pork.xavatarUploadToPs5(result.buffer, rndName);
|
||||
_xavatarSetProgress(100, 'Queued ✓');
|
||||
status.textContent = `Queued → /data/AVATARS/${rndName}`;
|
||||
sendBtn.textContent = '↗ Send to PS5';
|
||||
sendBtn.disabled = false;
|
||||
convertBtn.disabled = false;
|
||||
convertBtn.textContent = 'Convert & Download';
|
||||
if ($('xavatar-notify-toggle')?.checked) {
|
||||
window.pork.psnotifySend('XAvatar Queued', rndName).catch(() => {});
|
||||
}
|
||||
setTimeout(() => _xavatarSetProgress(null), 3000);
|
||||
} catch (e) {
|
||||
_xavatarSetProgress(null);
|
||||
convertBtn.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '↗ Send to PS5';
|
||||
convertBtn.textContent = 'Convert & Download';
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
$('btn-xavatar-refresh-lib').addEventListener('click', loadXavatarLibrary);
|
||||
}
|
||||
|
||||
function _xavatarFmtSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function loadXavatarLibrary() {
|
||||
const listEl = $('xavatar-library-list');
|
||||
const emptyEl = $('xavatar-library-empty');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="jb-list-placeholder" style="padding:18px 14px">Loading…</div>';
|
||||
emptyEl.hidden = true;
|
||||
|
||||
try {
|
||||
const ftpStatus = await window.pork.ftpStatus().catch(() => ({ connected: false }));
|
||||
if (!ftpStatus?.connected) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = 'Connect to PS5 FTP to browse /data/AVATARS.';
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await window.pork.ftpListDir('/data/AVATARS');
|
||||
const xavs = files.filter(f => !f.isDir);
|
||||
|
||||
if (!xavs.length) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.hidden = true;
|
||||
listEl.innerHTML = '<div class="xavatar-lib-grid">' +
|
||||
xavs.map(f => {
|
||||
const rpath = `/data/AVATARS/${escHtml(f.name)}`;
|
||||
return `<div class="xavatar-lib-card" data-xa-card-path="${rpath}" data-xa-card-name="${escHtml(f.name)}">
|
||||
<div class="xavatar-thumb-wrap">
|
||||
<img class="xavatar-thumb" data-xa-path="${rpath}" src="" alt="${escHtml(f.name)}">
|
||||
<div class="xavatar-thumb-placeholder">👤</div>
|
||||
</div>
|
||||
<div class="xavatar-lib-card-info">
|
||||
<span class="xavatar-lib-card-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||||
<span class="xavatar-lib-card-size">${_xavatarFmtSize(f.size)}</span>
|
||||
</div>
|
||||
<div class="xavatar-lib-card-actions">
|
||||
<button class="btn btn-xs" title="Download .xavatar" data-xa="download" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">⇓</button>
|
||||
<button class="btn btn-xs btn-danger" title="Delete from PS5" data-xa="delete" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
|
||||
_setupXavatarThumbObserver(listEl);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = 'Failed to load library: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach IntersectionObserver to lazily load thumbnails as cards scroll into view */
|
||||
function _setupXavatarThumbObserver(container) {
|
||||
const imgs = container.querySelectorAll('img.xavatar-thumb');
|
||||
if (!imgs.length) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
observer.unobserve(img);
|
||||
_loadXavatarThumb(img, img.dataset.xaPath);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '120px', threshold: 0.01 });
|
||||
imgs.forEach(img => observer.observe(img));
|
||||
}
|
||||
|
||||
/** Fetch + extract avatar.png from a .xavatar on FTP, cache as blob URL */
|
||||
async function _getXavatarThumbUrl(rpath) {
|
||||
let url = _xavatarThumbCache.get(rpath);
|
||||
if (!url) {
|
||||
const xavBuf = await window.pork.ftpDownloadFile(rpath);
|
||||
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
|
||||
const blob = new Blob([pngBuf], { type: 'image/png' });
|
||||
url = URL.createObjectURL(blob);
|
||||
_xavatarThumbCache.set(rpath, url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Load one thumbnail into an img element */
|
||||
async function _loadXavatarThumb(img, rpath) {
|
||||
const card = img.closest?.('.xavatar-lib-card');
|
||||
try {
|
||||
const url = await _getXavatarThumbUrl(rpath);
|
||||
img.src = url;
|
||||
img.onload = () => card?.classList.add('xavatar-thumb-loaded');
|
||||
img.onerror = () => card?.classList.add('xavatar-thumb-error');
|
||||
} catch (_) {
|
||||
card?.classList.add('xavatar-thumb-error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── XAvatar full-size modal ───────────────────────────────────────────────────
|
||||
|
||||
let _xavatarModalPath = null;
|
||||
|
||||
function _xavatarOpenModal(rpath, name) {
|
||||
_xavatarModalPath = rpath;
|
||||
const modal = $('xavatar-thumb-modal');
|
||||
const img = $('xavatar-modal-img');
|
||||
const namEl = $('xavatar-modal-name');
|
||||
img.src = '';
|
||||
img.alt = 'Loading…';
|
||||
namEl.textContent = name;
|
||||
openAccessibleDialog(modal, $('btn-xavatar-modal-close'));
|
||||
// reset modal send button
|
||||
const sendBtn = $('btn-xavatar-modal-send');
|
||||
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↗ Send to PS5'; }
|
||||
_getXavatarThumbUrl(rpath)
|
||||
.then(url => { if (_xavatarModalPath === rpath) { img.src = url; img.alt = name; } })
|
||||
.catch(() => { if (_xavatarModalPath === rpath) img.alt = 'Preview unavailable'; });
|
||||
}
|
||||
|
||||
// Close modal on backdrop click or close button
|
||||
document.addEventListener('click', e => {
|
||||
const modal = $('xavatar-thumb-modal');
|
||||
if (!modal || modal.hidden) return;
|
||||
if (e.target === modal || e.target.id === 'btn-xavatar-modal-close') {
|
||||
closeAccessibleDialog(modal);
|
||||
_xavatarModalPath = null;
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
const modal = $('xavatar-thumb-modal');
|
||||
if (event.key === 'Escape' && modal && !modal.hidden) {
|
||||
closeAccessibleDialog(modal);
|
||||
_xavatarModalPath = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── XAvatar: format picker dialog ──────────────────────────────────────────
|
||||
function _xavatarPickFormat() {
|
||||
return new Promise(resolve => {
|
||||
const dlg = document.createElement('dialog');
|
||||
dlg.style.cssText = [
|
||||
'border:none',
|
||||
'border-radius:10px',
|
||||
'padding:22px 26px 18px',
|
||||
'background:var(--bg2,#1e1e2e)',
|
||||
'color:var(--fg,#cdd6f4)',
|
||||
'box-shadow:0 8px 32px rgba(0,0,0,.65)',
|
||||
'text-align:center',
|
||||
'min-width:260px',
|
||||
'position:fixed',
|
||||
'top:50%',
|
||||
'left:50%',
|
||||
'transform:translate(-50%,-50%)',
|
||||
'margin:0',
|
||||
].join(';');
|
||||
dlg.innerHTML = [
|
||||
'<p id="_xa-fmt-title" style="margin:0 0 16px;font-size:14px;font-weight:600;">Choose download format</p>',
|
||||
'<div style="display:flex;gap:10px;justify-content:center;">',
|
||||
' <button class="btn btn-teal" id="_xa-fmt-xav" style="min-width:96px;">.xavatar</button>',
|
||||
' <button class="btn" id="_xa-fmt-png" style="min-width:96px;">.png</button>',
|
||||
' <button class="btn" id="_xa-fmt-cancel" style="min-width:74px;opacity:.7;">Cancel</button>',
|
||||
'</div>',
|
||||
].join('');
|
||||
dlg.setAttribute('aria-labelledby', '_xa-fmt-title');
|
||||
document.body.appendChild(dlg);
|
||||
dlg.showModal();
|
||||
const done = (val) => { dlg.removeEventListener('close', onClose); dlg.close(); dlg.remove(); resolve(val); };
|
||||
const onClose = () => { dlg.remove(); resolve(null); };
|
||||
dlg.addEventListener('close', onClose);
|
||||
dlg.querySelector('#_xa-fmt-xav').addEventListener('click', () => done('xavatar'));
|
||||
dlg.querySelector('#_xa-fmt-png').addEventListener('click', () => done('png'));
|
||||
dlg.querySelector('#_xa-fmt-cancel').addEventListener('click', () => done(null));
|
||||
});
|
||||
}
|
||||
|
||||
// Modal action buttons
|
||||
function _xavatarBindModalButtons() {
|
||||
const modalDownload = $('btn-xavatar-modal-download');
|
||||
const modalToPng = $('btn-xavatar-modal-topng');
|
||||
const modalSend = $('btn-xavatar-modal-send');
|
||||
if (!modalDownload) return;
|
||||
|
||||
modalDownload.addEventListener('click', async () => {
|
||||
if (!_xavatarModalPath) return;
|
||||
const fmt = await _xavatarPickFormat();
|
||||
if (!fmt) return;
|
||||
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
|
||||
modalDownload.disabled = true;
|
||||
try {
|
||||
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
|
||||
if (fmt === 'png') {
|
||||
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
|
||||
const pngBuf = await window.pork.xavatarExtractPng(buf);
|
||||
await window.pork.xavatarSaveFile(pngBuf, pngName);
|
||||
} else {
|
||||
await window.pork.xavatarSaveFile(buf, name);
|
||||
}
|
||||
} catch (err) { showToast('Download failed: ' + err.message, 'error'); }
|
||||
modalDownload.disabled = false;
|
||||
});
|
||||
|
||||
modalToPng.addEventListener('click', async () => {
|
||||
if (!_xavatarModalPath) return;
|
||||
const name = $('xavatar-modal-name')?.textContent || 'avatar';
|
||||
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
|
||||
modalToPng.disabled = true;
|
||||
try {
|
||||
const xavBuf = await window.pork.ftpDownloadFile(_xavatarModalPath);
|
||||
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
|
||||
await window.pork.xavatarSaveFile(pngBuf, pngName);
|
||||
} catch (err) { showToast('PNG save failed: ' + err.message, 'error'); }
|
||||
modalToPng.disabled = false;
|
||||
});
|
||||
|
||||
modalSend.addEventListener('click', async () => {
|
||||
if (!_xavatarModalPath) return;
|
||||
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
|
||||
modalSend.disabled = true;
|
||||
modalSend.textContent = 'Sending…';
|
||||
try {
|
||||
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
|
||||
await window.pork.xavatarUploadToPs5(buf, name);
|
||||
modalSend.textContent = '↗ Send to PS5';
|
||||
modalSend.disabled = false;
|
||||
showToast(`Queued: /data/AVATARS/${name}`, 'success');
|
||||
} catch (err) {
|
||||
showToast('Upload failed: ' + err.message, 'error');
|
||||
modalSend.textContent = '↗ Send to PS5';
|
||||
modalSend.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Bind now if the DOM is already parsed, otherwise wait — this module may be
|
||||
// loaded after DOMContentLoaded has already fired.
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _xavatarBindModalButtons);
|
||||
else _xavatarBindModalButtons();
|
||||
|
||||
// ── XAvatar: card thumbnail click → open modal ────────────────────────────────
|
||||
document.addEventListener('click', e => {
|
||||
const card = e.target.closest('.xavatar-lib-card');
|
||||
if (card && !e.target.closest('[data-xa]')) {
|
||||
_xavatarOpenModal(card.dataset.xaCardPath, card.dataset.xaCardName);
|
||||
}
|
||||
});
|
||||
|
||||
// ── XAvatar: library card action buttons (↓ download / ✕ delete) ─────────────
|
||||
document.addEventListener('click', async e => {
|
||||
const btn = e.target.closest('[data-xa]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.xa;
|
||||
const rpath = btn.dataset.xaPath;
|
||||
const name = btn.dataset.xaName;
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
if (action === 'download') {
|
||||
const fmt = await _xavatarPickFormat();
|
||||
if (!fmt) { btn.disabled = false; return; }
|
||||
const buf = await window.pork.ftpDownloadFile(rpath);
|
||||
if (fmt === 'png') {
|
||||
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
|
||||
const pngBuf = await window.pork.xavatarExtractPng(buf);
|
||||
await window.pork.xavatarSaveFile(pngBuf, pngName);
|
||||
} else {
|
||||
await window.pork.xavatarSaveFile(buf, name);
|
||||
}
|
||||
|
||||
} else if (action === 'delete') {
|
||||
// Native browser dialogs are blocked in this app; use the in-app modal.
|
||||
const confirmation = await showExactConfirmation(`Delete /data/AVATARS/${name} from your PS5?`, 'VERWIJDER VAN PS5');
|
||||
if (!confirmation) { btn.disabled = false; return; }
|
||||
await window.pork.ftpDeleteFile(rpath, confirmation);
|
||||
btn.closest('.xavatar-lib-card')?.remove();
|
||||
_xavatarThumbCache.delete(rpath);
|
||||
const grid = $('xavatar-library-list')?.querySelector('.xavatar-lib-grid');
|
||||
if (grid && !grid.children.length) $('xavatar-library-empty').hidden = false;
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('XAvatar: ' + err.message, 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// ── Y2JB Update Generator ───────────────────────────────────────────────────
|
||||
|
||||
const y2jbGen = { entries: [], payloads: [], catalog: [] };
|
||||
const y2jbGenBytes = value => {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
function y2jbGenRender() {
|
||||
const list = $('y2jbgen-list');
|
||||
if (!list) return;
|
||||
if (!y2jbGen.entries.length) {
|
||||
list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then order payloads, delays, and messages.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = y2jbGen.entries.map((entry, index) => {
|
||||
const value = escHtml(entry.value || '');
|
||||
const title = entry.type === 'payload' ? 'Payload' : entry.type === 'delay' ? 'Delay (ms)' : 'Message';
|
||||
const placeholder = entry.type === 'payload' ? 'payload.elf' : entry.type === 'delay' ? '1000' : 'Shown during autoload';
|
||||
return `<div class="autoloader-row" data-y2jb-index="${index}">
|
||||
<span class="autoload-row-num">${index + 1}</span>
|
||||
<select class="y2jbgen-type" data-y2jb-type="${index}" aria-label="Y2JB entry type">
|
||||
<option value="payload" ${entry.type === 'payload' ? 'selected' : ''}>Payload</option>
|
||||
<option value="delay" ${entry.type === 'delay' ? 'selected' : ''}>Delay</option>
|
||||
<option value="message" ${entry.type === 'message' ? 'selected' : ''}>Message</option>
|
||||
</select>
|
||||
<input class="y2jbgen-value" data-y2jb-value="${index}" value="${value}" placeholder="${placeholder}" aria-label="${title}">
|
||||
<button class="btn btn-xs" data-y2jb-up="${index}" title="Move up">↑</button>
|
||||
<button class="btn btn-xs" data-y2jb-down="${index}" title="Move down">↓</button>
|
||||
<button class="btn btn-xs" data-y2jb-remove="${index}" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function y2jbGenSetStatus(text, type = '') {
|
||||
const status = $('y2jbgen-status');
|
||||
status.textContent = text;
|
||||
status.className = `hint ${type}`;
|
||||
}
|
||||
|
||||
async function y2jbGenLoadCatalog() {
|
||||
const select = $('y2jbgen-catalog');
|
||||
if (!select || !window.pork.y2jbGenCatalog) return;
|
||||
select.disabled = true;
|
||||
select.innerHTML = '<option>Loading local payloads…</option>';
|
||||
try {
|
||||
const packet = await window.pork.y2jbGenCatalog();
|
||||
y2jbGen.catalog = packet.items || [];
|
||||
select.innerHTML = y2jbGen.catalog.length
|
||||
? y2jbGen.catalog.map(item => `<option value="${escHtml(item.id)}">${escHtml(item.name)}${item.version ? ` · ${escHtml(item.version)}` : ''} · ${y2jbGenBytes(item.size)}</option>`).join('')
|
||||
: '<option disabled>No downloaded payloads yet — use the Online Payload Catalog first.</option>';
|
||||
y2jbGenSetStatus(y2jbGen.catalog.length
|
||||
? `${y2jbGen.catalog.length} local catalog payload${y2jbGen.catalog.length === 1 ? '' : 's'} available.`
|
||||
: 'The local Payload Catalog is empty.');
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option disabled>Catalog unavailable</option>';
|
||||
y2jbGenSetStatus(`Catalog error: ${error.message}`, 'error');
|
||||
}
|
||||
select.disabled = false;
|
||||
}
|
||||
|
||||
$('btn-y2jbgen-refresh-catalog').addEventListener('click', y2jbGenLoadCatalog);
|
||||
|
||||
$('btn-y2jb-discover')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const release = await window.pork.y2jbDiscover(true);
|
||||
$('y2jb-upstream-status').innerHTML = `<strong>${escHtml(release.version || 'Latest official Y2JB release')}</strong><span>${escHtml(release.source)} · ${y2jbGenBytes(release.size || 0)} · ${release.digest ? 'published SHA-256 available' : 'ZIP validation; no published SHA-256'}</span>`;
|
||||
} catch (error) {
|
||||
$('y2jb-upstream-status').innerHTML = `<strong>Release discovery unavailable</strong><span>${escHtml(error.message)}</span>`;
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$('btn-y2jb-stage')?.addEventListener('click', async event => {
|
||||
if (!await showConfirm('Download the discovered official Y2JB ZIP, validate it and copy it to the configured PS5 update folder? The update is not executed.')) return;
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await window.pork.y2jbApply();
|
||||
$('y2jb-upstream-status').innerHTML = `<strong>Verified update staged</strong><span>${escHtml(result.version || 'Y2JB')} · ${escHtml(result.remotePath)} · SHA-256 ${escHtml(result.sha256.slice(0, 16))}… · not executed</span>`;
|
||||
} catch (error) {
|
||||
$('y2jb-upstream-status').innerHTML = `<strong>Staging failed</strong><span>${escHtml(error.message)}</span>`;
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$('btn-y2jbgen-add-catalog').addEventListener('click', () => {
|
||||
const selected = [...$('y2jbgen-catalog').selectedOptions];
|
||||
let added = 0;
|
||||
for (const option of selected) {
|
||||
const payload = y2jbGen.catalog.find(item => item.id === option.value);
|
||||
if (!payload || y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
|
||||
y2jbGen.entries.push({ type: 'payload', value: payload.name, catalogId: payload.id });
|
||||
added += 1;
|
||||
}
|
||||
y2jbGenRender();
|
||||
y2jbGenSetStatus(added ? `Added ${added} catalog payload${added === 1 ? '' : 's'} ✓` : 'No new catalog payloads selected.');
|
||||
});
|
||||
|
||||
$('btn-y2jbgen-add-payloads').addEventListener('click', async () => {
|
||||
const btn = $('btn-y2jbgen-add-payloads');
|
||||
btn.disabled = true;
|
||||
y2jbGenSetStatus('Choosing payload files…');
|
||||
try {
|
||||
const chosen = await window.pork.y2jbGenSelectPayloads();
|
||||
let added = 0;
|
||||
for (const payload of chosen || []) {
|
||||
if (y2jbGen.payloads.some((item) => item.name === payload.name)
|
||||
|| y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
|
||||
y2jbGen.payloads.push(payload);
|
||||
y2jbGen.entries.push({ type: 'payload', value: payload.name });
|
||||
added += 1;
|
||||
}
|
||||
y2jbGenRender();
|
||||
y2jbGenSetStatus(added ? `Added ${added} payload file${added === 1 ? '' : 's'} ✓` : 'No new payload files selected.');
|
||||
} catch (error) {
|
||||
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
$('btn-y2jbgen-add-delay').addEventListener('click', () => {
|
||||
y2jbGen.entries.push({ type: 'delay', value: '1000' });
|
||||
y2jbGenRender();
|
||||
});
|
||||
|
||||
$('btn-y2jbgen-add-message').addEventListener('click', () => {
|
||||
y2jbGen.entries.push({ type: 'message', value: 'Starting next payload' });
|
||||
y2jbGenRender();
|
||||
});
|
||||
|
||||
$('y2jbgen-list').addEventListener('input', (event) => {
|
||||
const index = Number(event.target.dataset.y2jbValue);
|
||||
if (Number.isInteger(index) && y2jbGen.entries[index]) y2jbGen.entries[index].value = event.target.value;
|
||||
});
|
||||
|
||||
$('y2jbgen-list').addEventListener('change', (event) => {
|
||||
const index = Number(event.target.dataset.y2jbType);
|
||||
if (Number.isInteger(index) && y2jbGen.entries[index]) {
|
||||
y2jbGen.entries[index].type = event.target.value;
|
||||
if (event.target.value === 'delay' && !/^\d+$/.test(y2jbGen.entries[index].value)) y2jbGen.entries[index].value = '1000';
|
||||
y2jbGenRender();
|
||||
}
|
||||
});
|
||||
|
||||
$('y2jbgen-list').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
const up = Number(button.dataset.y2jbUp);
|
||||
const down = Number(button.dataset.y2jbDown);
|
||||
const remove = Number(button.dataset.y2jbRemove);
|
||||
if (Number.isInteger(up) && y2jbGen.entries[up] && up > 0) {
|
||||
[y2jbGen.entries[up - 1], y2jbGen.entries[up]] = [y2jbGen.entries[up], y2jbGen.entries[up - 1]];
|
||||
} else if (Number.isInteger(down) && y2jbGen.entries[down] && down < y2jbGen.entries.length - 1) {
|
||||
[y2jbGen.entries[down + 1], y2jbGen.entries[down]] = [y2jbGen.entries[down], y2jbGen.entries[down + 1]];
|
||||
} else if (Number.isInteger(remove) && y2jbGen.entries[remove]) {
|
||||
y2jbGen.entries.splice(remove, 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
y2jbGenRender();
|
||||
});
|
||||
|
||||
$('btn-y2jbgen-build').addEventListener('click', async () => {
|
||||
const btn = $('btn-y2jbgen-build');
|
||||
btn.disabled = true;
|
||||
y2jbGenSetStatus('Validating and building ZIP…');
|
||||
try {
|
||||
const referenced = new Set(y2jbGen.entries.filter(entry => entry.type === 'payload').map(entry => entry.value));
|
||||
const catalogPayloadIds = y2jbGen.entries
|
||||
.filter(entry => entry.type === 'payload' && entry.catalogId && referenced.has(entry.value))
|
||||
.map(entry => entry.catalogId);
|
||||
const result = await window.pork.y2jbGenBuild({
|
||||
entries: y2jbGen.entries.map(({ catalogId, ...entry }) => entry),
|
||||
payloads: y2jbGen.payloads.filter(payload => referenced.has(payload.name)),
|
||||
catalogPayloadIds,
|
||||
});
|
||||
if (result?.canceled) {
|
||||
y2jbGenSetStatus('Save cancelled.');
|
||||
} else {
|
||||
y2jbGenSetStatus(`Built ✓ ${result.outputZip} (${result.entryCount} entries)`);
|
||||
}
|
||||
} catch (error) {
|
||||
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
y2jbGenLoadCatalog();
|
||||
@@ -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>
|
||||
@@ -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);};})();
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
})();
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)}
|
||||
@@ -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; } }
|
||||
@@ -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}
|
||||
@@ -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)}}
|
||||
@@ -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}}
|
||||
@@ -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;}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%}}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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;}}
|
||||
@@ -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)}}
|
||||
@@ -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; } }
|
||||
@@ -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}}
|
||||
@@ -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; } }
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
Vendored
+11
@@ -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}}
|
||||
@@ -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}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>🔊 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>⏺ 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">⛶</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 ? '✕' : '⛶';
|
||||
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>
|
||||
Reference in new issue
Block a user