Files
Knutwurst 73d317a04c Continue failed pieces where they stopped
A piece that failed was fetched again from its first byte. With pieces
of several GB, a connection that drops every few minutes never got one
finished. A retry now asks for the rest with a Range request; with
verification on, the part on disk goes into the hash first (pause and
cancel interrupt that). A server that sends the whole piece instead
overwrites it in place; one that sends another range, or 416, gets the
piece from the start without spending an attempt. A continued piece
that fails its hash is fetched whole once more before the package
counts as corrupt.

Also:
- A reply longer than its piece no longer writes into the next piece.
- Oversized replies and redirect loops are not retried as network errors.
- The diagnosis shows the no-space text for a job that ran out of space.
- README names Quad9 and Orbis Patches.
2026-10-17 20:40:40 +02:00

2260 lines
92 KiB
JavaScript

const API = {
status: "/api/status",
titles: "/api/titles",
config: "/api/config",
downloads: "/api/downloads",
installStatus: "/api/installstatus",
rescan: "/api/rescan",
storage: "/api/storage",
storageDelete: (titleId) => `/api/storage/${encodeURIComponent(titleId)}/delete`,
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
};
// title_id -> loaded <img>, so re-rendered cards show icons instantly.
const iconCache = new Map();
// Labels come from i18n.js: source.<type> and source.<type>.desc.
const SOURCE_TYPES = {
official: { className: "ok", installAllowed: true },
external: { className: "external", installAllowed: true },
shadowmount: { className: "shadow", installAllowed: false },
unknown: { className: "blocked", installAllowed: false },
fake: { className: "blocked", installAllowed: false },
};
function sourceLabel(type) { return t(`source.${type}`); }
function sourceDescription(type) { return t(`source.${type}.desc`); }
// Text for a code from a server/state map: <prefix><code> if translated,
// else the fallback.
function tCode(prefix, code, fallbackText) {
return code && tHas(prefix + code) ? t(prefix + code) : fallbackText;
}
const fallback = {
status: {
firmware: "11.60",
firmware_build: "0x11600000",
dns_guard: "Active",
resolver: "Internal allowlist",
free_space_mb: 771916,
download_dir: "/data/patchdl (internal)",
lan_ip: "192.168.178.90",
http_port: 12880,
},
config: {
default_policy: "deny",
download_dir: "/data/patchdl (internal)",
install_after_download: false,
delete_pkg_after_install: true,
verify_downloads: false,
home_shortcut: true,
max_connections: 4,
source_policy: {
official: { allow_check: true, allow_download: true, allow_install: true },
external: { allow_check: true, allow_download: true, allow_install: true },
shadowmount: { allow_check: true, allow_download: false, allow_install: false },
unknown: { allow_check: true, allow_download: false, allow_install: false },
fake: { allow_check: true, allow_download: false, allow_install: false },
},
cdn_allowlist: [
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
"gs-sec.ww.np.dl.playstation.net",
],
},
titles: [
{
title_id: "PPSA01628_00", name: "Call of Duty Black Ops Cold War",
content_id: "UP0002-PPSA01628_00-CODCWTHEGAME0001",
installed_version: "01.032.000", compatible_version: "01.041.000",
latest_version: "01.041.000", latest_required_fw: "11.60",
source_type: "official", source_path: "/system_ex/app/PPSA01628_00",
patch_storage_match: true,
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "available",
},
{
title_id: "PPSA01284_00", name: "Demon's Souls",
content_id: "UP9000-PPSA01284_00-DEMONSSOULS00001",
installed_version: "01.004.000", compatible_version: "01.004.000",
latest_version: "01.004.000", latest_required_fw: "10.01",
source_type: "external", source_path: "/system_data/priv/appmeta/external/PPSA01284_00",
patch_storage_match: true,
mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, status: "up_to_date",
},
{
title_id: "PPSA90001_00", name: "Shadowmounted Test Title",
content_id: "UP0000-PPSA90001_00-SHADOWMOUNT0001",
installed_version: "01.000.000", compatible_version: "01.006.000",
latest_version: "01.009.000", latest_required_fw: "12.00",
source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00",
patch_storage_match: true,
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "blocked",
},
{
title_id: "PPSA03396", name: "The Last of Us Part I",
content_id: "UP9000-PPSA03396_00-THELASTOFUSPART1",
installed_version: "01.000.000", compatible_version: null,
latest_version: "02.002.000", latest_required_fw: "10.20",
prospero_version: "01.003.000", prospero_required_fw: "7.01", prospero_size: "71.7 GB", prospero_done: true,
source_type: "official", source_path: "/system_ex/app/PPSA03396",
patch_storage_match: true,
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "incompatible_fw",
},
],
downloads: [],
logs: [],
};
let state = {
status: fallback.status,
config: fallback.config,
titles: fallback.titles,
downloads: fallback.downloads,
logs: fallback.logs,
view: "games",
filter: "updates",
query: "",
usingFallback: false,
};
let downloadPollTimer = null;
let installPollTimer = null;
let emptyPolls = 0;
const dlMeta = {}; // per-title speed tracking: { bytes, t, speed }
const els = {};
document.addEventListener("DOMContentLoaded", () => {
applyI18n();
bindElements();
fillLanguageSelect();
bindEvents();
loadInitialData().then(() => { openGuideFromHash(); openDiagFromHash(); });
window.addEventListener("hashchange", openGuideFromHash);
setInterval(watchTitles, 5000);
watchCoverErrors();
document.querySelectorAll("[data-donate]").forEach((b) => b.addEventListener("click", openDonate));
// Cards are re-rendered often; listen once on the document.
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-diag]");
if (b) openDiag(b.dataset.diag);
const v = e.target.closest("[data-versions]");
if (v) openVersions(v.dataset.versions);
});
window.addEventListener("hashchange", openDiagFromHash);
});
/* ---------------- dialogs ---------------- */
function icon(name) { return `<svg aria-hidden="true"><use href="#icon-${name}"></use></svg>`; }
function closeButtonHtml() {
return `<button class="icon-button guide-close" type="button" aria-label="${escapeHtml(t("common.close"))}">${icon("close")}</button>`;
}
// Modal sheet: Escape, the close button and a click on the backdrop close
// it, and focus goes back to where it was.
function openSheet(html, labelledBy, extraClass = "") {
const back = document.activeElement;
const wrap = document.createElement("div");
wrap.className = "guide-backdrop";
wrap.innerHTML = `<section class="guide ${extraClass}" role="dialog" aria-modal="true" aria-labelledby="${labelledBy}">${html}</section>`;
const close = () => {
document.removeEventListener("keydown", onKey);
wrap.remove();
if (back && document.contains(back)) back.focus();
};
const onKey = (e) => { if (e.key === "Escape") close(); };
wrap.querySelector(".guide-close").addEventListener("click", close);
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
document.addEventListener("keydown", onKey);
document.body.appendChild(wrap);
wrap.querySelector(".guide-close").focus();
return { wrap, close };
}
// The game's icon for a dialog header; initials until it has loaded.
// A broken icon falls back to the initials (see watchCoverErrors).
function coverHtml(game, cls) {
const src = game && game.has_icon ? `/api/icon/${encodeURIComponent(game.title_id)}` : "";
const ini = escapeHtml(initials(game ? game.name : ""));
return `<div class="cover ${cls}" aria-hidden="true" data-initials="${ini}">${src
? `<img src="${src}" alt="">` : ini}</div>`;
}
// Image errors don't bubble; catch them on the way down instead.
function watchCoverErrors() {
document.addEventListener("error", (e) => {
const img = e.target;
if (img.tagName !== "IMG" || !img.parentElement) return;
const cover = img.parentElement;
if (cover.dataset.initials !== undefined) cover.textContent = cover.dataset.initials;
}, true);
}
/* ---------------- other versions ---------------- */
// Every version Prospero (PS5) or Orbis (PS4) lists for a game, grouped:
// what can be picked here first, then what needs a newer firmware, the
// installed one, and older ones folded away. Picking one opens the guide
// for exactly that version.
async function openVersions(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
if (!game) return;
const { wrap, close } = openSheet(`
<header class="guide-head">
<div class="sheet-title">
${coverHtml(game, "cover-sm")}
<div>
<p class="eyebrow">${t("versions.title")}</p>
<h2 id="verTitle">${escapeHtml(game.name)}</h2>
<p class="guide-game">${escapeHtml(t("card.installed", { version: game.installed_version || "?" }))}</p>
</div>
</div>
${closeButtonHtml()}
</header>
<div class="sheet-body"><div class="vskeleton" aria-busy="true"><i></i><i></i><i></i></div>
<p class="visually-hidden" role="status">${t("versions.loading")}</p></div>`, "verTitle", "is-sheet");
const data = await fetchJson(`/api/versions?title=${encodeURIComponent(titleId)}`);
if (!document.contains(wrap)) return;
const body = wrap.querySelector(".sheet-body");
if (!data || !data.ok) {
body.innerHTML = `<div class="callout is-warn">${icon("alert")}<span>${t("versions.loadFailed")}</span></div>`;
return;
}
const rows = (data.versions || []).slice().sort((a, b) => (a.version < b.version ? 1 : -1));
const group = (st) => rows.filter((r) => r.state === st);
const ok = group("ok"), locked = group("needs_fw"), installed = group("installed"), older = group("older");
// A locked row names the firmware in its badge already.
const meta = (r) => [r.size, !data.ps4 && r.fw && r.state !== "needs_fw" ? `FW ${r.fw}` : ""]
.filter(Boolean).map(escapeHtml).join(" · ");
const row = (r, extra) => `
<li class="vrow is-${r.state}">
<div class="vrow-main"><strong>${escapeHtml(r.version)}</strong>${meta(r) ? `<span>${meta(r)}</span>` : ""}</div>
${extra}
</li>`;
const section = (title, list, render, cls = "") => list.length ? `
<section class="vgroup ${cls}">
<h3>${title} <span>${list.length}</span></h3>
<ul class="vlist">${list.map(render).join("")}</ul>
</section>` : "";
body.innerHTML = `
${ok.length
? `<p class="guide-why">${t("versions.pickHint")}</p>`
: `<div class="callout">${icon("check")}<span>${rows.length ? t("versions.noneNewer") : t("versions.none")}</span></div>`}
${section(t("versions.available"), ok, (r) => row(r,
`<button type="button" class="row-button is-update" data-pick="${escapeHtml(r.version)}" data-size="${escapeHtml(r.size || "")}">${icon("download")}${t("versions.get")}</button>`))}
${section(t("versions.needsFwGroup"), locked, (r) => row(r,
`<span class="vbadge is-warn">${icon("lock")}${t("card.needsFw", { fw: escapeHtml(r.fw || "?") })}</span>`))}
${installed.length ? `<ul class="vlist vcurrent">${installed.map((r) => row(r,
`<span class="vbadge is-ok">${icon("check")}${t("versions.installedGroup")}</span>`)).join("")}</ul>` : ""}
${older.length ? `
<details class="vgroup volder">
<summary>${icon("chevron")}${t("versions.olderGroup", { count: older.length })}</summary>
<ul class="vlist">${older.map((r) => row(r, "")).join("")}</ul>
</details>` : ""}
<p class="sheet-source">${t("versions.source", { site: `<a href="${escapeHtml(data.page)}" target="_blank" rel="noopener">${escapeHtml(data.site)}${icon("external")}</a>` })}</p>`;
body.querySelectorAll("[data-pick]").forEach((btn) => btn.addEventListener("click", () => {
close();
openGuide(game, false, pickFor(game, btn.dataset.pick, btn.dataset.size));
}));
const first = body.querySelector("[data-pick]");
if (first) first.focus();
}
/* ---------------- download diagnosis ---------------- */
function diagLink(titleId) {
const h = location.hostname;
let base = location.origin;
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
if (!state.status.lan_ip) return null;
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
}
return `${base}/#diag/${encodeURIComponent(titleId)}`;
}
function openDiagFromHash() {
const m = location.hash.match(/^#(diag|versions)\/([A-Za-z0-9_.-]+)$/);
if (!m) return;
history.replaceState(null, "", location.pathname + location.search);
if (m[1] === "diag") openDiag(m[2]);
else openVersions(m[2]);
}
async function fetchJson(url) {
try {
const r = await fetch(url, { cache: "no-store" });
return r.ok ? await r.json() : null;
} catch (_) { return null; }
}
function diagRow(ok, label, detail) {
return `<li class="diag-row ${ok ? "is-ok" : "is-bad"}"><b>${ok ? "✓" : "✗"}</b>
<span><strong>${escapeHtml(label)}</strong>${detail ? `<br><em>${escapeHtml(detail)}</em>` : ""}</span></li>`;
}
// Runs the network check for one game and shows it as a checklist plus a
// report to paste into an issue. On the PS5 (no clipboard) a QR code opens
// the same dialog on a phone.
async function openDiag(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
const name = game ? game.name : titleId;
const { wrap } = openSheet(`
<header class="guide-head">
<div class="sheet-title">
${coverHtml(game, "cover-sm")}
<div>
<p class="eyebrow">${t("diag.eyebrow")}</p>
<h2 id="diagTitle">${escapeHtml(name)}</h2>
<p class="guide-game">${escapeHtml(titleId)}</p>
</div>
</div>
${closeButtonHtml()}
</header>
<div class="diag-body"><p class="guide-why">${t("diag.checking")}</p></div>`, "diagTitle");
const [check, status, downloads, pkg, inst] = await Promise.all([
fetchJson(`/api/netcheck?title=${encodeURIComponent(titleId)}`),
fetchJson(API.status),
fetchJson(API.downloads),
fetchJson("/api/pkgdiag"),
fetchJson(API.installStatus),
]);
if (!document.contains(wrap)) return;
const job = (downloads || []).find((d) => d.title_id === titleId) || null;
const ul = check && check.update_list, mf = check && check.manifest;
const mfDetail = !mf ? "" : mf.ok && mf.step === "direct" ? t("diag.singlePackage") :
mf.ok ? t("diag.pieces", { count: mf.pieces }) :
mf.url ? failStepText({ fail_step: mf.step || "manifest", last_http_code: mf.http }) :
t("diag.noPackage");
const rows = [
diagRow(Boolean(ul && ul.ok), t("diag.updateList"),
!ul ? t("diag.noAnswer")
: ul.ok ? t("diag.newest", { version: ul.latest || "?" })
: !ul.url ? t("diag.noUpdateUrl")
: failStepText({ fail_step: ul.step || "network", last_http_code: ul.http })),
diagRow(Boolean(mf && mf.ok), t("diag.manifest"), mfDetail),
];
if (job) rows.push(diagRow(job.state !== "error", t("diag.lastDownload"),
job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes)
: job.fail_step ? failStepText(job) : t("diag.failedPieces"))
: tCode("diag.jobState.", job.state, job.state)));
const report = [
"PatchDL diagnostic report",
`PatchDL ${status ? status.version : "?"} · FW ${status ? status.firmware : "?"}`,
`Game: ${name} (${titleId})${game ? ` · installed ${game.installed_version || "?"} · ${game.source_type}${game.sideloaded ? ", sideloaded" : ""}` : ""}`,
`Download: ${job ? JSON.stringify(job) : "none"}`,
`Network check: ${check ? JSON.stringify(check) : "unavailable"}`,
`Install: ${inst ? JSON.stringify(inst.streams || inst) : "unavailable"}`,
`Console requests: ${pkg && pkg.log ? pkg.log.slice(-8).join(" | ") : "none"}`,
...(state.logs.length ? ["Log:", ...state.logs.slice(-15).map((e) => logLine(e, tEn))] : []),
].join("\n");
const onConsole = isConsoleBrowser();
const link = onConsole ? diagLink(titleId) : null;
const matrix = link ? qrEncode(link) : null;
wrap.querySelector(".diag-body").innerHTML = `
<ul class="diag-list">${rows.join("")}</ul>
${onConsole
? (matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
<span>${t("diag.reportPhone")}</span></div></div>` : "")
: `<p class="guide-why">${t("diag.reportHint")}</p>`}
<textarea class="diag-report" readonly rows="8" aria-label="${t("diag.reportAria")}">${escapeHtml(report)}</textarea>
${onConsole ? "" : `<button type="button" class="row-button is-update diag-copy">${t("diag.copy")}</button>`}`;
const copy = wrap.querySelector(".diag-copy");
if (copy) copy.addEventListener("click", async () => {
const ta = wrap.querySelector(".diag-report");
try { await navigator.clipboard.writeText(report); }
catch (_) { ta.select(); document.execCommand("copy"); }
showToast(t("diag.copied"));
});
}
const DONATE_URL = "https://paypal.me/oliverkoester";
// Donation dialog. On the TV a link is no use, so it shows a QR code for
// the phone (hidden on phones, where the button opens PayPal directly).
function openDonate() {
const matrix = qrEncode(DONATE_URL);
openSheet(`
<header class="guide-head">
<div>
<p class="eyebrow">${t("donate.eyebrow")}</p>
<h2 id="donateTitle">${t("donate.title")}</h2>
</div>
${closeButtonHtml()}
</header>
<p class="guide-why">${t("donate.why")}</p>
${matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
<span>${t("donate.scan")}</span>
<code>${escapeHtml(DONATE_URL)}</code></div></div>` : ""}
<a class="row-button is-update donate-open" href="${DONATE_URL}" target="_blank" rel="noopener">${t("donate.open")}</a>
<p class="guide-why"><small>${escapeHtml(t("donate.small"))}</small></p>`, "donateTitle");
}
// The server rescans on its own when games are added or removed and bumps
// titles_gen whenever the list or an update check changes; reload quietly then.
let watchBusy = false;
async function watchTitles() {
if (watchBusy) return; // a slow reload is still running
watchBusy = true;
try {
const response = await fetch(API.status, { cache: "no-store" });
if (!response.ok) return;
const status = await response.json();
const changed = state.status && status.titles_gen !== state.status.titles_gen;
if (changed) await loadInitialData({ quiet: true });
else { state.status = status; renderStatus(); }
} catch (_) { /* offline for a moment; the next tick retries */ }
finally { watchBusy = false; }
}
// Refresh button: ask for a rescan right away, then reload once it had time.
async function rescanNow() {
try { await postJson(API.rescan, {}); } catch (_) { /* older server: plain reload */ }
await new Promise((resolve) => setTimeout(resolve, 1500));
await loadInitialData();
}
function bindElements() {
Object.assign(els, {
firmwareValue: document.getElementById("firmwareValue"),
firmwareBuild: document.getElementById("firmwareBuild"),
dnsValue: document.getElementById("dnsValue"),
resolverValue: document.getElementById("resolverValue"),
spaceValue: document.getElementById("spaceValue"),
downloadDirValue: document.getElementById("downloadDirValue"),
railFw: document.getElementById("railFw"),
railSpace: document.getElementById("railSpace"),
gameGrid: document.getElementById("gameGrid"),
logOutput: document.getElementById("logOutput"),
allowlistHosts: document.getElementById("allowlistHosts"),
sourcePolicyList: document.getElementById("sourcePolicyList"),
storageList: document.getElementById("storageList"),
storageTotal: document.getElementById("storageTotal"),
searchInput: document.getElementById("searchInput"),
defaultPolicy: document.getElementById("defaultPolicy"),
downloadDir: document.getElementById("downloadDir"),
installAfterDownload: document.getElementById("installAfterDownload"),
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
verifyDownloads: document.getElementById("verifyDownloads"),
homeShortcut: document.getElementById("homeShortcut"),
connValue: document.getElementById("connValue"),
connMinus: document.getElementById("connMinus"),
connPlus: document.getElementById("connPlus"),
refreshBtn: document.getElementById("refreshBtn"),
updateAllBtn: document.getElementById("updateAllBtn"),
brandVersion: document.getElementById("brandVersion"),
globalDl: document.getElementById("globalDl"),
globalDlState: document.getElementById("globalDlState"),
globalDlName: document.getElementById("globalDlName"),
globalDlPosition: document.getElementById("globalDlPosition"),
globalDlPct: document.getElementById("globalDlPct"),
globalDlSpeed: document.getElementById("globalDlSpeed"),
globalDlEta: document.getElementById("globalDlEta"),
globalDlBar: document.getElementById("globalDlBar"),
saveBtn: document.getElementById("saveBtn"),
language: document.getElementById("language"),
clearLogBtn: document.getElementById("clearLogBtn"),
toast: document.getElementById("toast"),
});
}
function bindEvents() {
document.querySelectorAll(".nav-link").forEach((b) =>
b.addEventListener("click", () => setView(b.dataset.view)));
els.searchInput.addEventListener("input", (e) => {
state.query = e.target.value.trim().toLowerCase();
renderGames();
});
document.querySelectorAll(".segmented button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".segmented button").forEach((i) => {
i.classList.remove("is-selected");
i.setAttribute("aria-pressed", "false");
});
button.classList.add("is-selected");
button.setAttribute("aria-pressed", "true");
state.filter = button.dataset.filter;
renderGames();
});
});
els.refreshBtn.addEventListener("click", rescanNow);
if (els.updateAllBtn) els.updateAllBtn.addEventListener("click", updateAll);
els.saveBtn.addEventListener("click", saveConfig);
els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); });
if (els.connMinus)
els.connMinus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) - 1));
if (els.connPlus)
els.connPlus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) + 1));
}
// Language selector in Settings: applies at once (reloads the page), no Save.
function fillLanguageSelect() {
const sel = document.getElementById("language");
if (!sel) return;
const current = languageChoice();
sel.replaceChildren(...LANGUAGES.map(([code, name]) => {
const opt = document.createElement("option");
opt.value = code;
opt.textContent = code === "auto" ? t("settings.languageAuto") : name;
if (code !== "auto") opt.lang = code;
opt.selected = code === current;
return opt;
}));
sel.addEventListener("change", () => setLanguage(sel.value));
}
function setView(view) {
state.view = view;
document.querySelectorAll(".nav-link").forEach((b) => {
const on = b.dataset.view === view;
b.classList.toggle("is-active", on);
if (on) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current");
});
document.querySelectorAll(".view").forEach((s) => s.classList.toggle("is-active", s.dataset.view === view));
if (view === "settings") loadStorage();
}
async function loadInitialData({ quiet = false } = {}) {
state.usingFallback = false;
const [status, config, titles, downloads] = await Promise.all([
getJson(API.status, fallback.status),
getJson(API.config, fallback.config),
getJson(API.titles, fallback.titles),
getJson(API.downloads, fallback.downloads),
]);
// /api/titles carries no client-only progress flags, so preserve them across a
// refresh — otherwise an in-flight download/install flips back to a clickable
// button mid-operation.
// Carry client-only flags across a refresh; the pool's job list is the source
// of truth for download state and is reconciled right after.
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
titles.forEach((g) => {
const old = prev.get(g.title_id);
// Neither state has a patch left to fetch, so drop stale job flags.
// An install ends when the install poll says so, not when the list
// already reports the new version (the console writes it mid-install).
if (old && old.installing) {
g.installing = true;
g.installStatus = old.installStatus;
g.installProgress = old.installProgress;
g.installDone = old.installDone;
g.installTotal = old.installTotal;
}
if (old && g.status !== "up_to_date" &&
(g.status !== "incompatible_fw" || g.manual_version)) {
if (old._autoInstalled) g._autoInstalled = true;
if (old._localDownloading) g._localDownloading = true;
if (old._wasActive) g._wasActive = true;
}
});
// A background reload must not swap the real list for demo data because
// one request failed for a moment.
if (quiet && state.usingFallback) { state.usingFallback = false; return; }
state = { ...state, status, config, titles, downloads };
reconcileFromJobs(downloads);
render();
if (downloads.some((j) => j.state === "active" || j.state === "queued") ||
state.titles.some((g) => g._localDownloading))
startDownloadPolling();
if (state.titles.some((g) => g.installing)) startInstallPolling();
// Pick up installs that are running while this page was (re)loaded.
if (!quiet) refreshInstallStatus();
if (!quiet)
showToast(state.usingFallback ? t("toast.demoData") : t("toast.refreshed"));
}
async function getJson(url, fallbackValue) {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
state.usingFallback = true;
return clone(fallbackValue);
}
}
function render() {
renderStatus();
renderSettings();
renderGames();
renderLogs();
}
function renderStatus() {
els.firmwareValue.textContent = state.status.firmware || "--";
els.firmwareBuild.textContent = state.status.firmware_build || t("metric.firmwareBuild");
els.dnsValue.textContent = state.status.dns_guard || "--";
els.resolverValue.textContent = state.status.resolver || "--";
const space = formatMb(state.status.free_space_mb);
els.spaceValue.textContent = space;
const kept = Number(state.status.downloads_bytes) || 0;
if (kept > 0) {
// Packages on disk take space too; the link opens the list to manage them.
els.downloadDirValue.innerHTML =
`<button type="button" class="link-button">${escapeHtml(t("metric.downloadsManage", { size: formatBytes(kept) }))}</button>`;
els.downloadDirValue.querySelector("button").addEventListener("click", () => setView("settings"));
} else {
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || t("metric.downloadTarget");
}
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
if (els.railSpace) els.railSpace.textContent = t("rail.free", { space });
if (els.brandVersion)
els.brandVersion.textContent = state.status.version ? `v${state.status.version}` : "v--";
}
const CONN_MIN = 1, CONN_MAX = 16;
function clampConn(n) {
n = parseInt(n, 10);
if (!Number.isFinite(n)) n = 4;
return Math.max(CONN_MIN, Math.min(CONN_MAX, n));
}
function renderConnStepper() {
const n = clampConn(state.config.max_connections);
if (els.connValue) els.connValue.textContent = String(n);
if (els.connMinus) els.connMinus.disabled = n <= CONN_MIN;
if (els.connPlus) els.connPlus.disabled = n >= CONN_MAX;
}
let connSaveTimer = null;
// Stepper +/-: update + re-render immediately, then persist just this field
// (debounced) so rapid taps collapse into one POST and other unsaved form
// fields stay untouched. The server applies the new count live (no restart).
function setConnections(n) {
const v = clampConn(n);
if (v === clampConn(state.config.max_connections)) { renderConnStepper(); return; }
state.config.max_connections = v;
renderConnStepper();
clearTimeout(connSaveTimer);
connSaveTimer = setTimeout(async () => {
try {
await postJson(API.config, { max_connections: v });
showToast(t("toast.connections", { count: v }));
} catch (e) {
showToast(t("toast.connectionsFailed"));
}
}, 450);
}
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
els.installAfterDownload.checked = Boolean(state.config.install_after_download);
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads);
if (els.homeShortcut) els.homeShortcut.checked = state.config.home_shortcut !== false;
renderConnStepper();
els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => {
const chip = document.createElement("span");
chip.className = "host-chip";
chip.textContent = host;
return chip;
}));
renderSourcePolicies();
}
/* ---------------- games ---------------- */
function renderGames() {
renderGlobalStatus();
const counts = { updates: 0, uptodate: 0, blocked: 0, all: state.titles.length };
state.titles.forEach((g) => { counts[gameCategory(g)]++; });
document.querySelectorAll(".segmented button").forEach((b) => {
const c = b.querySelector(".chip-count");
if (c) c.textContent = String(counts[b.dataset.filter] ?? "");
});
const visible = state.titles.filter(matchesFilter).filter(matchesQuery)
.sort((a, b) => (a.name || a.title_id).localeCompare(b.name || b.title_id, undefined, { sensitivity: "base", numeric: true }));
els.gameGrid.replaceChildren();
if (!visible.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = t("games.empty");
els.gameGrid.appendChild(empty);
return;
}
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
}
// One bucket per game for the filter chips, answering a single question: is
// there something to get for this console? Where the patch comes from (Sony,
// Prospero) and whether it installs or only downloads is shown on the card.
function isPs4(game) { return (game.title_id || "").startsWith("CUSA"); }
function gameCategory(game) {
const policy = sourcePolicy(game);
// Not supported = PatchDL can't install it (fake packages, mounted images).
if (game.patch_title_match === false || !policy.allow_install || game.status === "blocked")
return "blocked";
if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState)
return "updates";
if (game.status === "checking" || game.status === "available" || hasOlderPatch(game))
return "updates";
return "uptodate";
}
// Sony's newest patch needs newer firmware, but Prospero knows an older one
// that runs here and is newer than what is installed.
function hasOlderPatch(game) {
return game.status === "incompatible_fw" && Boolean(game.prospero_version) &&
game.prospero_version > (game.installed_version || "");
}
function matchesFilter(game) {
if (state.filter === "all") return true;
return gameCategory(game) === state.filter;
}
function matchesQuery(game) {
if (!state.query) return true;
return [game.name, game.title_id, game.content_id, game.source_type, game.source_path, game.mount_from]
.filter(Boolean)
.some((v) => v.toLowerCase().includes(state.query));
}
function createGameCard(game) {
const card = document.createElement("article");
card.className = "game-card";
card.dataset.titleId = game.title_id;
if (game.downloading) card.classList.add("is-downloading");
if (game.installing) card.classList.add("is-installing");
// ---- top row: icon (+ toggle) | body | action ----
const row = document.createElement("div");
row.className = "card-row";
const lead = document.createElement("div");
lead.className = "lead";
const cover = document.createElement("div");
cover.className = "cover";
cover.textContent = initials(game.name);
if (game.has_icon) {
// Cards re-render on every poll. Reuse an icon that already loaded so
// it doesn't flash back to the initials; a failed icon keeps them.
const loaded = iconCache.get(game.title_id);
if (loaded) {
cover.replaceChildren(loaded.cloneNode());
} else {
const img = new Image();
img.alt = "";
img.decoding = "async";
img.onload = () => { iconCache.set(game.title_id, img); cover.replaceChildren(img.cloneNode()); };
img.src = `/api/icon/${encodeURIComponent(game.title_id)}`;
}
}
lead.appendChild(cover);
if (sourcePolicy(game).allow_install) lead.appendChild(buildToggle(game));
const body = document.createElement("div");
body.className = "card-body";
body.innerHTML = `
<h3>${escapeHtml(game.name)}</h3>
<div class="pills">
<span class="tid" title="${escapeHtml(game.content_id || "")}">${escapeHtml(game.title_id)}</span>
${isPs4(game) ? `<span class="pill">PS4</span>` : ""}
${game.downloading && game._jobState === "queued" ? `<span class="pill">${t("pill.queued")}</span>` : ""}
${game.downloading && game._jobState !== "queued" ? `<span class="pill live">${t("pill.downloading")}</span>` : ""}
${game.resumable && !game.downloading ? `<span class="pill warn">${t("pill.paused")}</span>` : ""}
${statusPill(game)}
${sourcePill(game)}
${game.sideloaded ? `<span class="pill warn" title="${escapeHtml(t("pill.sideloadedTitle"))}">${t("pill.sideloaded")}</span>` : ""}
</div>
${versionTrackHtml(game)}
${cardNotesHtml(game)}
`;
const actions = document.createElement("div");
actions.className = "card-actions";
[primaryButton(game), stopButton(game)].forEach((act) => {
if (!act) return;
const btn = document.createElement("button");
btn.className = `row-button is-${act.variant}`;
btn.textContent = act.label;
if (act.hint) btn.title = act.hint;
if (act.disabled) btn.disabled = true;
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
actions.appendChild(btn);
});
if (canPickVersion(game, "99") && !game.manual_version && game.status !== "blocked") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "row-button is-ghost";
btn.dataset.versions = game.title_id;
btn.innerHTML = `${icon("layers")}<span>${t("versions.title")}</span>`;
actions.appendChild(btn);
}
row.append(lead, body, actions);
card.appendChild(row);
// ---- progress block (only while downloading) ----
if (game.downloading) {
const d = state.downloads.find((x) => x.title_id === game.title_id) || {};
const prog = document.createElement("div");
prog.className = "card-progress";
prog.innerHTML = `
<div class="progress"><i style="width:${pctOf(d)}%"></i></div>
<div class="progress-meta">${progressMetaHtml(d)}</div>
`;
card.appendChild(prog);
} else if (game.installing) {
const pct = Math.max(0, Math.min(100, Number(game.installProgress) || 0));
const indet = game.installStatus === "finishing" || game.installStatus === "waiting";
const note = document.createElement("div");
note.className = "card-progress";
note.innerHTML = `
<div class="progress${indet ? " is-indeterminate" : ""}"><i style="width:${indet ? 32 : pct}%${indet ? `;animation-delay:-${Date.now() % INDET_MS}ms` : ""}"></i></div>
<div class="progress-meta">${installProgressHtml(game)}</div>
`;
card.appendChild(note);
} else if (game.resumable && game.partial_bytes > 0) {
// ---- paused partial (survived a reboot) ----
const note = document.createElement("div");
note.className = "card-progress";
note.innerHTML = `
<div class="progress-meta">
<span>${t("card.pausedDownloaded", { size: formatBytes(game.partial_bytes) })}</span>
<span>${t("card.resumeToContinue")}</span>
</div>
`;
card.appendChild(note);
}
return card;
}
function buildToggle(game) {
const toggle = document.createElement("label");
toggle.className = "toggle";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = game.enabled !== false;
cb.setAttribute("aria-label", t("card.enableAria", { name: game.name }));
const track = document.createElement("span");
track.className = "track";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = cb.checked ? t("card.updatesOn") : t("card.updatesOff");
cb.addEventListener("change", () => {
lbl.textContent = cb.checked ? t("card.updatesOn") : t("card.updatesOff");
updateGame(game.title_id, { enabled: cb.checked });
});
toggle.append(cb, track, lbl);
return toggle;
}
// Primary play/pause button (green to go, amber while downloading). Fixed width
// (CSS) so the label never reflows. Returns null when there is nothing to do.
function primaryButton(game) {
if (game.installing) return { label: t("btn.installing"), variant: "ghost", disabled: true };
if (game.downloading) return { label: t("btn.pause"), action: "pause", variant: "pause", hint: t("btn.pauseHint") };
if (game.patch_title_match === false) return null;
if (canGetOlderPatch(game))
return { label: t("btn.oldFw"), action: "guide", variant: "update", hint: t("btn.oldFwHint", { version: game.prospero_version }) };
if (game.resumable && isDownloadAllowed(game))
return { label: t("btn.resume"), action: "download", variant: "update", hint: t("btn.resumeHint") };
const hasPatch = game.status === "available" || Boolean(game.manual_version);
if (game.downloaded && hasPatch && isInstallAllowed(game))
return { label: t("btn.install"), action: "install", variant: "update", hint: t("btn.installHint") };
if (game.downloaded && hasPatch) return null;
if (!hasPatch) return null;
if (!isDownloadAllowed(game)) return null;
return state.config.install_after_download && isInstallAllowed(game)
? { label: t("btn.update"), action: "update", variant: "update", hint: t("btn.updateHint") }
: { label: t("btn.download"), action: "download", variant: "update", hint: t("btn.downloadHint") };
}
// Red stop button: present whenever there is a download to stop or discard.
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
function stopButton(game) {
if (game.installing) return null;
if (game.downloaded && !game.downloading)
return { label: t("btn.delete"), action: "cancel", variant: "cancel", hint: t("btn.deleteHint") };
if (game.downloading || game.resumable)
return { label: t("btn.cancel"), action: "cancel", variant: "cancel", hint: t("btn.cancelHint") };
return null;
}
// Installed -> what PatchDL can get for this console -> newest overall with
// the firmware it needs (left out when it's the same as the offered update).
function versionTrackHtml(game) {
const step = (label, version, note, cls = "") => `
<div class="vstep ${cls}"><span>${label}</span><b>${escapeHtml(version)}</b>${note ? `<em>${note}</em>` : ""}</div>`;
const parts = [step(t("card.lblInstalled"), game.installed_version || "--", "")];
let offered = "";
if (game.manual_version) {
offered = game.manual_version;
parts.push(step(t("card.lblUpdate"), offered, "", "is-target"));
} else if (game.status === "available" && game.compatible_version) {
offered = game.compatible_version;
parts.push(step(t("card.lblUpdate"), offered, "", "is-target"));
} else if (hasOlderPatch(game)) {
offered = game.prospero_version;
parts.push(step(t("card.lblUpdate"), offered,
game.prospero_required_fw ? t("card.needsFw", { fw: escapeHtml(game.prospero_required_fw) }) : "", "is-target"));
}
if (game.latest_version && game.latest_version !== offered && game.latest_version !== game.installed_version)
parts.push(step(t("card.lblNewest"), game.latest_version,
game.latest_required_fw ? t("card.needsFw", { fw: escapeHtml(game.latest_required_fw) }) : "", "is-dim"));
const arrow = `<span class="vtrack-arrow">${icon("arrow")}</span>`;
// The arrow only leads to something PatchDL can get.
return `<div class="vtrack">${offered ? parts[0] + arrow + parts.slice(1).join("") : parts.join("")}</div>`;
}
// Problems and hints under the versions, each with what to do about it.
function cardNotesHtml(game) {
const notes = [];
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes))}</span></div>`);
else if (job && job.state === "error")
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(t("card.downloadFailed"))}${job.fail_step ? " " + escapeHtml(failStepText(job)) : ""}</span>
<button type="button" class="link-button" data-diag="${escapeHtml(game.title_id)}">${t("card.details")}</button></div>`);
if (!game.manual_version && !hasOlderPatch(game) && game.status === "incompatible_fw" && !game.prospero_done)
notes.push(`<div class="callout is-quiet"><span class="spinner" aria-hidden="true"></span><span>${t("card.lookingForPatch")}</span></div>`);
if (game.sideloaded)
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(t("card.sideloadedNote"))}</span></div>`);
return notes.join("");
}
function statusPill(game) {
if (game.installing) return `<span class="pill warn">${t("pill.installing")}</span>`;
if (game.status === "checking") return `<span class="pill">${t("pill.checking")}</span>`;
if (game.status === "blocked" || !sourcePolicy(game).allow_install)
return `<span class="pill">${t("pill.notSupported")}</span>`;
if (game.patch_title_match === false) return `<span class="pill blocked">${t("pill.titleMismatch")}</span>`;
if (game.manual_version || hasOlderPatch(game))
return `<span class="pill prospero">${t("pill.via", { site: isPs4(game) ? "Orbis" : "Prospero" })}</span>`;
if (game.status === "available") return `<span class="pill ok">${t("pill.available")}</span>`;
if (game.status === "incompatible_fw")
return `<span class="pill">${t("pill.newestNeedsFw", { fw: escapeHtml(game.latest_required_fw || "?") })}</span>`;
if (game.status === "up_to_date") return `<span class="pill">${t("pill.upToDate")}</span>`;
return `<span class="pill">${t("pill.noInfo")}</span>`;
}
// Official installs are the normal case and need no badge; every other
// source gets one.
function sourcePill(game) {
const info = sourceInfo(game);
const type = SOURCE_TYPES[sourceType(game)] ? sourceType(game) : "unknown";
if (type === "official") return "";
return `<span class="pill ${info.className}">${escapeHtml(sourceLabel(type))}</span>`;
}
function sourceInfo(game) { return SOURCE_TYPES[sourceType(game)] || SOURCE_TYPES.unknown; }
function sourceType(game) { return game.source_type || "unknown"; }
/* ---------------- download progress (in-tile) ---------------- */
function pctOf(d) {
const done = Number(d.bytes) || 0;
const total = Number(d.total_bytes) || 0;
if (total > 0) return Math.max(0, Math.min(100, Math.round((done * 100) / total)));
return Math.max(0, Math.min(100, Number(d.progress) || 0));
}
function progressMetaHtml(d) {
const done = Number(d.bytes) || 0;
const total = Number(d.total_bytes) || 0;
const speed = Number(d._speed) || 0;
if (d.state === "queued") return `<span>${t("progress.queued")}</span>`;
const parts = [];
if (d.verifying) parts.push(`<span><b>${t("progress.verifying")}</b></span>`);
if (d.waiting && !(Number(d._speed) > 0)) parts.push(`<span><b>${t("progress.retrying")}</b></span>`);
parts.push(`<span><b>${formatBytes(done)}</b>${total > 0 ? ` / ${formatBytes(total)}` : ""}</span>`);
if (speed > 0) parts.push(`<span><b>${formatBytes(speed)}/s</b></span>`);
if (speed > 0 && total > done) parts.push(`<span>${t("progress.eta", { eta: formatEta((total - done) / speed) })}</span>`);
else if (!total) parts.push(`<span>${t("progress.fetchingManifest")}</span>`);
parts.push(`<span><b>${pctOf(d)}%</b></span>`);
return parts.join("");
}
// One sweep of the indeterminate bar. Cards are rebuilt on every list
// update; a negative delay taken from the clock lets a rebuilt bar continue
// where the old one was instead of starting over.
const INDET_MS = 1600;
// Install phases with their own text (install.phase.<status> in i18n.js).
const INSTALL_PHASES = ["waiting", "reading", "finishing"];
function installProgressHtml(game) {
const status = game.installStatus || "waiting";
const done = Number(game.installDone) || 0;
const total = Number(game.installTotal) || 0;
const phase = INSTALL_PHASES.includes(status) ? t(`install.phase.${status}`) : "";
if (phase) {
const parts = [`<span><b>${phase}</b></span>`];
if (status === "reading" && total > 0) {
parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
}
return parts.join("");
}
const parts = [`<span>${t("install.status", { status: escapeHtml(status) })}</span>`];
if (total > 0) parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
return parts.join("");
}
function startDownloadPolling() {
emptyPolls = 0;
if (downloadPollTimer) return;
downloadPollTimer = setInterval(refreshDownloads, 2000);
refreshDownloads();
}
function stopDownloadPolling() {
if (!downloadPollTimer) return;
clearInterval(downloadPollTimer);
downloadPollTimer = null;
}
// Map the pool's job list onto per-title flags, and auto-install once a job
// finishes if "install after download" is on.
function reconcileFromJobs(jobs) {
const byId = new Map((jobs || []).map((j) => [j.title_id, j]));
state.titles.forEach((g) => {
const j = byId.get(g.title_id);
if (!j) {
// The pool never produced a job for our local intent: time it out so the
// card can't wedge in "Downloading" forever (server restart between POST
// and poll, or an unexpected response shape).
if (g._localDownloading && g._localSince &&
Date.now() - g._localSince > 12000) {
g._localDownloading = false;
}
if (g.downloading && !g._localDownloading) g.downloading = false;
g._jobState = null;
return;
}
// A download started from this page counts as watched even if it
// finished before a poll saw it running (a verified package on disk).
if (g._localDownloading) g._wasActive = true;
g._localDownloading = false; // the pool now tracks it
g._jobState = j.state;
if (j.state === "active" || j.state === "queued") {
g._spaceWarned = false;
g.downloading = true;
g.resumable = false;
g._wasActive = true;
} else if (j.state === "paused") {
g.downloading = false;
g.resumable = true;
g.partial_bytes = Number(j.bytes) || g.partial_bytes || 0;
g._wasActive = false;
} else if (j.state === "done") {
// Auto-install once, on the download finishing while this page watched
// it, and only while the update is still pending. A finished job stays
// in the pool; without these checks every poll would retry the install
// (refused as already installed) and redraw the list each time.
const justFinished = g._wasActive;
const pending = g.status === "available" || Boolean(g.manual_version);
g.downloading = false;
g.resumable = false;
g.downloaded = true;
g._wasActive = false;
if (justFinished && pending && state.config.install_after_download &&
isInstallAllowed(g) && !g.installing && !g._autoInstalled) {
g._autoInstalled = true;
doInstall(g);
}
} else if (j.state === "error") {
// The server keeps the partial + sidecar (resumable) on a post-retry
// network failure. Reflect that so primaryButton shows "Resume" and the
// red Cancel stays available to delete the kept partial.
const bytes = Number(j.bytes) || 0;
if (Number(j.rc) === -3) {
if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes) }));
g._spaceWarned = true;
} else if (g._wasActive) {
const why = j.fail_step ? ` ${failStepText(j)}` : "";
showToast(t(bytes > 0 ? "toast.downloadFailedPartial" : "toast.downloadFailed", { name: g.name, why }));
if (j.fail_step) {
addLog("log.fetchFailed", { id: g.title_id, step: j.fail_step, curl: j.last_curl_rc, http: j.last_http_code });
renderLogs();
}
}
g._wasActive = false;
g.downloading = false;
g.resumable = bytes > 0;
g.partial_bytes = bytes || g.partial_bytes || 0;
}
});
}
// Why the package list (manifest) couldn't be fetched, in words.
function failStepText(j) {
const http = Number(j.last_http_code) || 0;
// "connect": DNS answered, but no address took the connection.
const step = j.fail_step === "connect" ? "network" : j.fail_step;
const key = `failstep.${step}`;
return step && step !== "unknown" && tHas(key)
? t(key, { http })
: t("failstep.unknown", { step });
}
function startInstallPolling() {
if (installPollTimer) return;
installPollTimer = setInterval(refreshInstallStatus, 2000);
refreshInstallStatus();
}
function stopInstallPolling() {
if (!installPollTimer) return;
clearInterval(installPollTimer);
installPollTimer = null;
}
async function refreshInstallStatus() {
let s;
try {
const response = await fetch(API.installStatus, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
s = await response.json();
} catch (error) {
return;
}
if (!s || !s.active) {
if (!state.titles.some((g) => g.installing)) stopInstallPolling();
return;
}
// Installs streamed from PatchDL: what PatchDL measured itself (how much
// of each package the console read) beats Sony's status query, which fails
// or reports nothing useful for them. One entry per title.
const streams = Array.isArray(s.streams) ? s.streams : [];
if (streams.length) {
let ended = false, changed = false;
streams.forEach((st) => {
const game = state.titles.find((g) => g.title_id.startsWith(st.title_id));
if (!game) return;
const over = st.phase === "done" || st.phase === "stalled";
if (over && !game.installing) return; // finished before this page watched it
const total = Number(st.total) || 0;
const read = Math.min(Number(st.read) || 0, total || Infinity);
const pct = total ? Math.floor((read * 100) / total) : 0;
// Re-render only on a visible change: a rebuilt card restarts the
// indeterminate bar's animation.
if (game.installing !== !over || game.installStatus !== st.phase ||
game.installProgress !== pct) changed = true;
game.installing = !over;
game.installStatus = st.phase;
game.installProgress = pct;
game.installDone = read;
game.installTotal = total;
if (over) {
const msg = st.phase === "stalled"
? t("install.msg.stalled")
: st.verified
? t(st.deleted ? "install.msg.verifiedDeleted" : "install.msg.verifiedKept")
: t("install.msg.unconfirmed");
addLog("log.installEnded", { id: game.title_id, msg });
showToast(t("toast.installEnded", { name: game.name, msg }));
ended = true;
}
});
if (changed) renderGames();
if (state.titles.some((g) => g.installing)) startInstallPolling();
if (ended) {
renderLogs();
if (!state.titles.some((g) => g.installing)) stopInstallPolling();
loadInitialData({ quiet: true });
}
return;
}
const titleId = s.target_title_id || "";
const game = state.titles.find((g) => g.title_id === titleId || g.title_id.slice(0, 9) === titleId.slice(0, 9));
if (!game) return;
game.installing = !s.terminal;
game.installStatus = s.status || "running";
game.installProgress = Number(s.progress) || 0;
game.installDone = Number(s.downloaded_size) || 0;
game.installTotal = Number(s.total_size) || 0;
renderGames();
if (s.terminal) {
const ok = s.status === "playable";
addLog(ok ? "log.installCompleted" : "log.installStopped", {
id: game.title_id,
status: s.status || t("common.unknown"),
code: s.error_code ? ` (0x${Number(s.error_code >>> 0).toString(16)})` : "",
});
renderLogs();
stopInstallPolling();
if (ok) loadInitialData();
}
}
async function refreshDownloads() {
let jobs;
try {
const response = await fetch(API.downloads, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
jobs = await response.json();
} catch (error) {
return; // keep last state on a transient failure
}
const now = Date.now();
const ids = new Set();
jobs.forEach((j) => {
ids.add(j.title_id);
const done = Number(j.bytes) || 0;
const prev = dlMeta[j.title_id];
if (prev && now > prev.t) {
if (done >= prev.bytes) {
const inst = ((done - prev.bytes) * 1000) / (now - prev.t); // bytes/s
prev.speed = prev.speed ? prev.speed * 0.5 + inst * 0.5 : inst; // smoothed
} else {
prev.speed = 0; // counter went backwards -> re-baseline
}
}
const meta = prev || (dlMeta[j.title_id] = { speed: 0 });
meta.bytes = done;
meta.t = now;
j._speed = meta.speed || 0;
});
Object.keys(dlMeta).forEach((id) => { if (!ids.has(id)) delete dlMeta[id]; });
state.downloads = jobs;
const before = downloadingIds();
reconcileFromJobs(jobs);
if (downloadingIds() !== before) renderGames();
else applyDownloadProgress();
const busy = jobs.some((j) => j.state === "active" || j.state === "queued") ||
state.titles.some((g) => g._localDownloading);
if (!busy) { if (++emptyPolls >= 3) stopDownloadPolling(); }
else emptyPolls = 0;
}
function downloadingIds() {
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
}
// Top-of-page status banner. Visible while any job in this batch is queued,
// active, or has just finished (done jobs linger one or two polls before the
// pool reaps them, which is what gives us the "5 of 9" position).
function renderGlobalStatus() {
const el = els.globalDl;
if (!el) return;
const jobs = state.downloads || [];
const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state));
const flying = batch.filter((j) => j.state === "queued" || j.state === "active");
if (flying.length === 0) { el.hidden = true; return; }
el.hidden = false;
const active = batch.find((j) => j.state === "active") || flying[0];
const total = batch.length;
const done = batch.filter((j) => j.state === "done").length;
const pos = Math.min(total, done + 1);
const isActive = active && active.state === "active";
// Eyebrow + name
els.globalDlState.textContent = !isActive ? t("dl.queued") : active.verifying ? t("dl.verifying") : t("dl.downloading");
const activeGame = state.titles.find((g) => g.title_id === active.title_id);
els.globalDlName.textContent = active.name || (activeGame && activeGame.name) || active.title_id || "—";
// Position chip ("3 of 9") only when there's more than one game in this run
if (total > 1) {
els.globalDlPosition.hidden = false;
els.globalDlPosition.textContent = t("dl.position", { pos, total });
} else {
els.globalDlPosition.hidden = true;
}
// Progress line
const pct = pctOf(active);
const speed = Number(active._speed) || 0;
const done_ = Number(active.bytes) || 0;
const total_ = Number(active.total_bytes) || 0;
els.globalDlPct.innerHTML = isActive ? `<b>${pct}%</b>` : `<b>—</b> ${t("dl.waiting")}`;
els.globalDlSpeed.innerHTML = isActive && speed > 0
? `<b>${formatBytes(speed)}/s</b>`
: (isActive && !total_ ? t("dl.fetchingManifest") : "—");
els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_
? t("progress.eta", { eta: formatEta((total_ - done_) / speed) })
: "";
// Bar: animated indeterminate while fetching manifest, otherwise width=pct
if (isActive && total_ > 0) {
els.globalDlBar.classList.remove("is-indeterminate");
els.globalDlBar.style.width = pct + "%";
} else {
els.globalDlBar.classList.add("is-indeterminate");
}
}
// Update the progress bar/meta in place to avoid rebuilding every card each tick.
function applyDownloadProgress() {
renderGlobalStatus();
let needRender = false;
state.downloads.forEach((d) => {
// Only titles currently downloading render a progress tile; paused/done/error
// jobs linger in the pool list but have no .progress bar — skip them so a
// missing tile for a non-downloading title doesn't force a full rebuild.
const g = state.titles.find((t) => t.title_id === d.title_id);
if (!g || !g.downloading) return;
const card = els.gameGrid.querySelector(`[data-title-id="${d.title_id}"]`);
const bar = card && card.querySelector(".card-progress .progress > i");
const meta = card && card.querySelector(".card-progress .progress-meta");
if (!bar || !meta) { needRender = true; return; }
bar.style.width = pctOf(d) + "%";
meta.innerHTML = progressMetaHtml(d);
});
if (needRender) renderGames();
}
/* ---------------- settings ---------------- */
// Labels: storage.state.<state> in i18n.js.
const STORAGE_STATE = {
complete: { className: "ok" },
paused: { className: "warn" },
downloading: { className: "live" },
installing: { className: "live" },
failed: { className: "blocked" },
};
// What PatchDL keeps under /data/patchdl, one row per title folder, with a
// delete button that also works for games that are no longer installed.
async function loadStorage() {
if (!els.storageList) return;
let data;
try {
const response = await fetch(API.storage, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
data = await response.json();
} catch (error) {
els.storageList.innerHTML = `<p class="storage-empty">${t("storage.readFailed")}</p>`;
return;
}
const items = (data.items || []).sort((a, b) => b.bytes - a.bytes);
els.storageTotal.textContent = items.length ? t("storage.total", { size: formatBytes(data.total_bytes) }) : "";
if (!items.length) {
els.storageList.innerHTML = `<p class="storage-empty">${t("storage.empty")}</p>`;
return;
}
els.storageList.replaceChildren(...items.map((item) => {
const stateKey = STORAGE_STATE[item.state] ? item.state : "complete";
const info = STORAGE_STATE[stateKey];
const busy = item.state === "installing" || item.state === "downloading";
const row = document.createElement("div");
row.className = "storage-row";
row.innerHTML = `
<span><strong>${escapeHtml(item.name || item.title_id)}</strong><br>
<em>${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${escapeHtml(storageRelation(item))}</em></span>
<span class="pill ${info.className}">${t(`storage.state.${stateKey}`)}</span>
<b>${formatBytes(item.bytes)}</b>
`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "row-button is-cancel";
btn.textContent = t("btn.delete");
btn.disabled = busy;
btn.title = busy ? t("storage.busy") : t("storage.deleteTitle", { size: formatBytes(item.bytes), name: item.name || item.title_id });
btn.addEventListener("click", () => deleteStorage(item));
row.appendChild(btn);
return row;
}));
}
// How the package relates to the installed game, so a kept package can be
// judged: still useful (newer), spent (installed), or for a game not present.
function storageRelation(item) {
if (!item.installed) return t("storage.rel.notInstalled");
if (!item.version || !item.installed_version) return t("storage.rel.versionUnknown");
return item.version > item.installed_version ? t("storage.rel.newer") : t("storage.rel.installed");
}
async function deleteStorage(item) {
const label = item.name || item.title_id;
if (!window.confirm(t("storage.confirmDelete", { name: label, size: formatBytes(item.bytes) }))) return;
try {
await postJson(API.storageDelete(item.title_id), {});
showToast(t("toast.storageDeleted", { name: label }));
} catch (error) {
showToast(t("toast.nameMessage", { name: label, message: reasonText(error) }));
}
await loadStorage();
await loadInitialData({ quiet: true });
}
function renderSourcePolicies() {
const rows = Object.keys(SOURCE_TYPES).map((type) => {
const info = SOURCE_TYPES[type];
const policy = sourcePolicyForType(type);
const row = document.createElement("div");
row.className = "policy-row";
row.innerHTML = `
<span class="pill ${info.className}">${escapeHtml(sourceLabel(type))}</span>
<strong>${t(policy.allow_install ? "policy.installAllowed" : "policy.installBlocked")}</strong>
<em>${t(policy.allow_download ? "policy.downloadAllowed" : "policy.downloadBlocked")} - ${escapeHtml(sourceDescription(type))}</em>
`;
return row;
});
els.sourcePolicyList.replaceChildren(...rows);
}
// Log entries keep their key and values, so the Logs view shows them in the
// chosen language while the diagnostic report (pasted into issues) stays
// English.
function addLog(key, vars) {
state.logs.push({ time: timeNow(), key, vars });
}
function logLine(entry, tr) {
return `[${entry.time}] ${tr(entry.key, entry.vars)}`;
}
function renderLogs() {
els.logOutput.textContent = state.logs.length
? state.logs.map((e) => logLine(e, t)).join("\n") : t("logs.empty");
}
async function saveConfig() {
const config = {
...state.config,
default_policy: els.defaultPolicy.value,
download_dir: els.downloadDir.value.trim(),
install_after_download: els.installAfterDownload.checked,
delete_pkg_after_install: els.deleteAfterInstall.checked,
verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads),
home_shortcut: els.homeShortcut ? els.homeShortcut.checked : state.config.home_shortcut !== false,
max_connections: clampConn(state.config.max_connections),
};
try {
await postJson(API.config, config);
state.config = config;
showToast(t("toast.configSaved"));
} catch (error) {
state.config = config;
showToast(t("toast.configLocal"));
}
renderSettings();
renderGames(); // install_after_download changes the button label
}
/* ---------------- actions (data layer) ---------------- */
// Queue a download for every game that has an available update AND could
// actually be installed afterwards. Shadowmounts pass isDownloadAllowed but
// fail isInstallAllowed (their app slot has no real source medium), so a
// sweep would otherwise pull tens of GB that AppInstUtil will refuse — the
// user picks those up by hand when the disc is ready. The server tolerates
// duplicate requests, so a second click is harmless. If install_after_download
// is on, the per-job auto-install pipeline kicks in once each download
// finishes — no further client action needed.
async function updateAll() {
const targets = state.titles.filter((g) =>
g.status === "available" && isInstallAllowed(g) &&
!g.downloading && !g.downloaded);
if (!targets.length) {
showToast(t("toast.noUpdates"));
return;
}
showToast(t(targets.length === 1 ? "toast.queueingOne" : "toast.queueingMany", { count: targets.length }));
for (const g of targets) {
// Sequential await: the pool returns quickly (202 Accepted) and we want
// a stable order in the queue, not a thundering-herd of concurrent POSTs.
try { await doDownload(g); } catch (_) { /* per-job errors already toast */ }
}
}
// Enqueue a download. The pool returns immediately (202); progress, completion
// and (if configured) auto-install are driven by reconcileFromJobs() on poll.
async function doDownload(game) {
game.downloading = true;
game._localDownloading = true; // until the pool reports a job for this title
game._localSince = Date.now(); // bounded in reconcileFromJobs if no job appears
game._autoInstalled = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
state.downloads.push({ title_id: game.title_id, name: game.name,
version: game.compatible_version || "",
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
renderGames();
startDownloadPolling();
try {
const r = await postJson(API.action(game.title_id, "download"), {});
if (r && r.downloaded && r.already) {
game.downloading = false;
game._localDownloading = false;
game.downloaded = true;
renderGames();
} else {
addLog("log.downloadQueued", { id: game.title_id, version: game.compatible_version || "" });
renderLogs();
}
} catch (error) {
game.downloading = false;
game._localDownloading = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
const why = reasonText(error);
addLog("log.downloadBlocked", { id: game.title_id, why });
showToast(t("toast.nameMessage", { name: game.name, message: why }));
renderGames(); renderLogs();
}
}
async function doInstall(game) {
const wasDownloaded = game.downloaded;
game.installing = true;
game.installStatus = "starting";
game.installProgress = 0;
game.downloaded = false; // the package is being consumed by the install
renderGames();
try {
await postJson(API.action(game.title_id, "install"), {});
} catch (error) {
game.downloaded = wasDownloaded; // refused: the package is untouched
if (error.body && error.body.reason === "install_running") {
// Another tab or device started it: follow that install here.
startInstallPolling();
} else {
game.installing = false;
}
const why = reasonText(error);
addLog("log.installBlocked", { id: game.title_id, why });
showToast(t("toast.nameMessage", { name: game.name, message: why }));
renderGames(); renderLogs();
return false;
}
addLog("log.installStarted", { id: game.title_id, version: game.compatible_version });
showToast(t("toast.installing", { name: game.name }));
startInstallPolling();
renderGames(); renderLogs();
return true;
}
// Cancel a running download (server aborts + deletes the partial) or delete a
// finished package. Same endpoint handles both.
async function cancelDownload(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
try {
await postJson(API.action(titleId, "cancel"), {});
showToast(t("toast.cancelled", { name: game ? game.name : titleId }));
} catch (error) {
showToast(t("toast.nameMessage", { name: game ? game.name : titleId, message: reasonText(error) }));
return; // refused (e.g. the console is installing from it): nothing changed
}
if (game) {
game.downloading = false;
game._localDownloading = false;
game.downloaded = false;
game.resumable = false;
game.partial_bytes = 0;
game.manual_version = null; // the server drops the pasted link on cancel
}
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
addLog("log.cancelled", { id: titleId });
renderGames(); renderLogs();
refreshDownloads();
}
async function runTitleAction(titleId, action) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
// "update" and "download" both just enqueue; for "update" (install-after-
// download on) the reconciler auto-installs once the pool reports it done.
if (action === "download" || action === "update") await doDownload(game);
else if (action === "install") await doInstall(game);
else if (action === "pause") await doPause(game);
else if (action === "cancel") await cancelDownload(titleId);
else if (action === "guide") openGuide(game);
}
// Pause only sends the signal; the in-flight doDownload() request returns its
// "paused" result and updates the card (resumable + partial bytes).
async function doPause(game) {
try {
await postJson(API.action(game.title_id, "pause"), {});
showToast(t("toast.pausing", { name: game.name }));
} catch (error) {
showToast(t("toast.nameMessage", { name: game.name, message: reasonText(error) }));
}
}
function updateGame(titleId, patch) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
Object.assign(game, patch);
renderGames();
const wantEnabled = game.enabled !== false;
postJson(API.action(titleId, wantEnabled ? "enable" : "disable"), {})
.then(() => { addLog(wantEnabled ? "log.savedEnabled" : "log.savedDisabled", { id: titleId }); renderLogs(); })
.catch(() => showToast(t("toast.toggleFailed", { id: titleId })));
}
/* ---------------- policy helpers ---------------- */
function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; }
function isDownloadAllowed(game) {
return Boolean(
game.enabled !== false &&
(game.compatible_version || game.manual_version) &&
game.patch_title_match !== false &&
sourcePolicy(game).allow_download
);
}
function isInstallAllowed(game) {
return Boolean(
game.enabled !== false &&
(game.compatible_version || game.manual_version) &&
game.patch_title_match !== false &&
sourcePolicy(game).allow_install
);
}
function sourcePolicy(game) { return sourcePolicyForType(sourceType(game)); }
function sourcePolicyForType(type) {
const fb = fallback.config.source_policy[type] || fallback.config.source_policy.unknown;
const cfg = (state.config.source_policy || {})[type] || {};
return { ...fb, ...cfg };
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const err = new Error(data.reason || data.message || `HTTP ${response.status}`);
err.body = data;
err.status = response.status;
throw err;
}
return data;
}
// Server refusal reasons are translated as reason.<code> in i18n.js.
// The server keeps 2 GB free on /data on top of the package itself.
function noSpaceText(need, free) {
return t("reason.noSpace", { need: formatBytes(need), free: formatBytes(free) });
}
function reasonText(error) {
const r = error && error.body && error.body.reason;
return (r && r !== "fallback" && r !== "noSpace" && tHas(`reason.${r}`) ? t(`reason.${r}`) : "") ||
(error && error.message) || t("reason.fallback");
}
/* ---------------- QR code (byte mode, ECC level M, versions 1-6) ----------------
Enough for ~100 bytes, i.e. any PatchDL link. Follows ISO/IEC 18004; the
structure mirrors Project Nayuki's reference encoder. */
const QR_ECC_M = [0, 10, 16, 26, 18, 24, 16]; // ECC codewords per block
const QR_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4]; // number of blocks
function qrRawModules(ver) {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
}
return n;
}
function qrDataCodewords(ver) {
return Math.floor(qrRawModules(ver) / 8) - QR_ECC_M[ver] * QR_BLOCKS_M[ver];
}
function qrGfMul(x, y) {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
function qrRsDivisor(degree) {
const r = new Array(degree - 1).fill(0);
r.push(1);
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < r.length; j++) {
r[j] = qrGfMul(r[j], root);
if (j + 1 < r.length) r[j] ^= r[j + 1];
}
root = qrGfMul(root, 0x02);
}
return r;
}
function qrRsRemainder(data, divisor) {
const r = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ r.shift();
r.push(0);
divisor.forEach((coef, i) => { r[i] ^= qrGfMul(coef, factor); });
}
return r;
}
// Returns a square boolean matrix (true = dark), or null if text is too long.
function qrEncode(text) {
const bytes = Array.from(new TextEncoder().encode(text));
let ver = 1;
while (ver <= 6 && 4 + 8 + bytes.length * 8 > qrDataCodewords(ver) * 8) ver++;
if (ver > 6) return null;
// Data bit stream: byte mode, 8-bit length, payload, terminator, padding.
const bits = [];
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
push(0x4, 4);
push(bytes.length, 8);
bytes.forEach((b) => push(b, 8));
const cap = qrDataCodewords(ver) * 8;
push(0, Math.min(4, cap - bits.length));
push(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) push(pad, 8);
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
// Split into blocks, add Reed-Solomon ECC, interleave.
const numBlocks = QR_BLOCKS_M[ver];
const eccLen = QR_ECC_M[ver];
const rawCodewords = Math.floor(qrRawModules(ver) / 8);
const numShort = numBlocks - (rawCodewords % numBlocks);
const shortLen = Math.floor(rawCodewords / numBlocks);
const divisor = qrRsDivisor(eccLen);
const blocks = [];
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
k += dat.length;
const ecc = qrRsRemainder(dat, divisor);
if (i < numShort) dat.push(0);
blocks.push(dat.concat(ecc));
}
const codewords = [];
for (let i = 0; i < blocks[0].length; i++)
blocks.forEach((blk, j) => {
if (i !== shortLen - eccLen || j >= numShort) codewords.push(blk[i]);
});
// Function patterns.
const size = ver * 4 + 17;
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(([cx, cy]) => {
for (let dy = -4; dy <= 4; dy++)
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx, y = cy + dy;
if (x < 0 || x >= size || y < 0 || y >= size) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
set(x, y, d !== 2 && d !== 4);
}
});
if (ver >= 2) {
const pos = [6, size - 7]; // versions 2-6 have one alignment pattern
const c = pos[1];
for (let dy = -2; dy <= 2; dy++)
for (let dx = -2; dx <= 2; dx++)
set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
const drawFormat = (mask) => {
const data5 = (0 << 3) | mask; // ECC level M = 0b00
let rem = data5;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const f = ((data5 << 10) | rem) ^ 0x5412;
const bit = (i) => ((f >>> i) & 1) === 1;
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
set(8, 7, bit(6));
set(8, 8, bit(7));
set(7, 8, bit(8));
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
set(8, size - 8, true);
};
drawFormat(0); // reserve the format areas before placing data
// Data placement: two-column zigzag from the bottom right.
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++)
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!fn[y][x] && i < codewords.length * 8) {
mod[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
i++;
}
}
}
const maskFn = [
(x, y) => (x + y) % 2 === 0,
(x, y) => y % 2 === 0,
(x) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];
const applyMask = (m) => {
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++)
if (!fn[y][x] && maskFn[m](x, y)) mod[y][x] = !mod[y][x];
};
// Simplified penalty (runs, 2x2 blocks, balance) to pick a readable mask.
const penalty = () => {
let p = 0, dark = 0;
for (let a = 0; a < size; a++) {
let runR = 1, runC = 1;
for (let b = 0; b < size; b++) {
if (mod[a][b]) dark++;
if (b > 0) {
runR = mod[a][b] === mod[a][b - 1] ? runR + 1 : 1;
runC = mod[b][a] === mod[b - 1][a] ? runC + 1 : 1;
if (runR === 5) p += 3; else if (runR > 5) p++;
if (runC === 5) p += 3; else if (runC > 5) p++;
}
if (a > 0 && b > 0) {
const c = mod[a][b];
if (c === mod[a - 1][b] && c === mod[a][b - 1] && c === mod[a - 1][b - 1]) p += 3;
}
}
}
const total = size * size;
p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
return p;
};
let best = 0, bestScore = Infinity;
for (let m = 0; m < 8; m++) {
applyMask(m);
drawFormat(m);
const score = penalty();
if (score < bestScore) { best = m; bestScore = score; }
applyMask(m); // XOR again to undo
}
applyMask(best);
drawFormat(best);
return mod;
}
// SVG markup for a QR matrix, dark on white with the required 4-module margin.
function qrSvg(matrix) {
const n = matrix.length, q = 4, s = n + q * 2;
let d = "";
matrix.forEach((row, y) => row.forEach((dark, x) => {
if (dark) d += `M${x + q},${y + q}h1v1h-1z`;
}));
return `<svg xmlns="http://www.w3.org/2000/svg" class="qr" viewBox="0 0 ${s} ${s}" shape-rendering="crispEdges" role="img" aria-label="${escapeHtml(t("qr.aria"))}"><rect width="${s}" height="${s}" fill="#fff" stroke="none"/><path d="${d}" fill="#000" stroke="none"/></svg>`;
}
/* ---------------- older patch guide (Prospero) ---------------- */
// Sony's version.xml only lists the newest patch. When that needs newer
// firmware, Prospero Patches still has older ones, but its links sit behind a
// human check, so the user copies one link and PatchDL does the rest. Copying
// is awkward in the PS5 browser, hence the QR hand-off to a phone.
const guide = { el: null, game: null, pick: null, fromLink: false, started: false, busy: false,
lastUrl: "", timer: null, returnFocus: null, onKey: null };
// The version the guide fetches: the older-firmware patch Prospero found, or
// one the user picked under "Other versions" (Prospero for PS5, Orbis for PS4).
function pickFor(game, version, size) {
const ps4 = isPs4(game);
const site = ps4 ? "Orbis Patches" : "Prospero Patches";
const page = `https://${ps4 ? "orbispatches.com" : "prosperopatches.com"}/${encodeURIComponent(game.title_id.slice(0, 9))}`;
if (version)
return { version, size: size || "", fw: "", site, page, ps4, chosen: true };
return { version: game.prospero_version, size: game.prospero_size || "",
fw: game.prospero_required_fw || "", site, page, ps4, chosen: false };
}
// No download or install in the way, and downloads allowed for this game.
function canPickVersion(game, version) {
return !game.downloading && !game.downloaded && !game.resumable && !game.installing &&
isDownloadAllowed({ ...game, manual_version: version });
}
function canGetOlderPatch(game) {
return hasOlderPatch(game) &&
!game.manual_version && !game.downloading && !game.downloaded &&
!game.resumable && !game.installing &&
isDownloadAllowed({ ...game, manual_version: game.prospero_version });
}
// A link a phone can open. A page opened on the PS5 itself runs on 127.0.0.1,
// so the server reports its LAN address for this.
function phoneLink(game, pick) {
const h = location.hostname;
let base = location.origin;
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
if (!state.status.lan_ip) return null;
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
}
const extra = pick && pick.chosen
? `/${encodeURIComponent(pick.version)}/${encodeURIComponent(pick.size)}` : "";
return `${base}/#get/${encodeURIComponent(game.title_id)}${extra}`;
}
function openGuideFromHash() {
const m = location.hash.match(/^#get\/([A-Za-z0-9_.-]+)(?:\/([0-9.]+)\/([A-Za-z0-9. %]*))?$/);
if (!m) return;
const game = state.titles.find((g) => g.title_id === m[1]);
if (!game) { showToast(t("toast.notInstalled")); clearGuideHash(); return; }
if (m[2]) {
// a version picked under "Other versions" on the console
let size = m[3] || "";
try { size = decodeURIComponent(size); } catch (_) { /* keep it as it came */ }
const pick = pickFor(game, m[2], size);
if (!canPickVersion(game, pick.version)) {
showToast(t("toast.alreadyOnWay", { name: game.name }));
clearGuideHash();
return;
}
openGuide(game, true, pick);
return;
}
if (!canGetOlderPatch(game)) {
showToast(game.manual_version || game.downloading || game.downloaded
? t("toast.olderOnWay", { name: game.name })
: t("toast.noOlderPatch", { name: game.name }));
clearGuideHash();
return;
}
openGuide(game, true);
}
function clearGuideHash() {
if (location.hash.startsWith("#get/"))
history.replaceState(null, "", location.pathname + location.search);
}
// The PS5 browser can't copy links, so on the console the guide only hands
// over to a phone. Opened via the home-screen tile the page runs on 127.0.0.1.
function isConsoleBrowser() {
const h = location.hostname;
return /PlayStation/i.test(navigator.userAgent) || h === "127.0.0.1" || h === "localhost";
}
function openGuide(game, fromLink = false, pick = null) {
closeGuide();
pick = pick || pickFor(game);
Object.assign(guide, { game, pick, fromLink, started: false, busy: false, lastUrl: "",
returnFocus: document.activeElement });
const ver = escapeHtml(pick.version);
const size = pick.size ? ` (${escapeHtml(pick.size)})` : "";
const site = escapeHtml(pick.site);
const onConsole = !fromLink && isConsoleBrowser();
const link = fromLink ? null : phoneLink(game, pick);
const matrix = link ? qrEncode(link) : null;
const head = `
<header class="guide-head">
<div>
<p class="eyebrow">${t(pick.chosen ? "guide.eyebrowOther" : "guide.eyebrowOld")}</p>
<h2 id="guideTitle">${t("guide.title", { version: ver })}</h2>
<p class="guide-game">${escapeHtml(game.name)}</p>
</div>
${closeButtonHtml()}
</header>`;
let body;
if (onConsole) {
body = matrix
? `<div class="guide-handoff">${qrSvg(matrix)}
<p>${t("guide.scanConsole")}</p>
<code>${escapeHtml(link)}</code>
</div>`
: `<p class="guide-why">${t("guide.openOnPhone", { port: escapeHtml(String(state.status.http_port || 12880)) })}</p>`;
body += `<div class="guide-status" role="status" aria-live="polite"></div>`;
} else {
const handoff = matrix
? `<div class="guide-qr">${qrSvg(matrix)}<div>
<strong>${t("guide.easier")}</strong>
<span>${t("guide.easierText")}</span>
<code>${escapeHtml(link)}</code>
</div></div>`
: "";
const why = pick.chosen
? t("guide.whyChosen", { site, version: ver, size })
: t("guide.whyOld", {
latest: escapeHtml(game.latest_version || t("guide.newestPatch")),
fw: escapeHtml(game.latest_required_fw || "?"),
version: ver,
pickFw: escapeHtml(pick.fw || "?"),
});
const copyStep = pick.ps4
? `<strong>${t("guide.copyLinks")}</strong>
<span>${t("guide.copyLinksText")}</span>`
: `<strong>${t("guide.copySc")}</strong>
<span>${t("guide.copyScText")}</span>`;
body = `
<p class="guide-why">${why}</p>
${handoff}
<ol class="guide-steps">
<li>
<div class="guide-step-text"><strong>${t("guide.openSite", { site })}</strong>
<span>${t("guide.tapVersion", { version: ver, size })}</span></div>
<a class="row-button is-update guide-open" href="${escapeHtml(pick.page)}" target="_blank" rel="noopener">${t("guide.openSite", { site: pick.ps4 ? "Orbis" : "Prospero" })}</a>
</li>
<li>
<div class="guide-step-text">${copyStep}</div>
</li>
<li>
<div class="guide-step-text"><strong>${t("guide.paste")}</strong>
<span>${t("guide.pasteText")}</span></div>
<div class="guide-paste">
<input class="guide-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="${pick.ps4 ? "http://…_0.pkg" : "https://…_sc.pkg"}" aria-label="${escapeHtml(t("guide.linkAria"))}">
<button class="row-button guide-start" type="button">${t("guide.start")}</button>
</div>
<div class="guide-status" role="status" aria-live="polite"></div>
</li>
</ol>`;
}
const wrap = document.createElement("div");
wrap.className = "guide-backdrop";
wrap.innerHTML = `<section class="guide${onConsole ? " is-handoff" : ""}" role="dialog" aria-modal="true" aria-labelledby="guideTitle">${head}${body}</section>`;
document.body.appendChild(wrap);
guide.el = wrap;
const input = wrap.querySelector(".guide-input");
if (input) {
const tryInput = () => {
const url = extractLink(input.value);
if (url && url !== guide.lastUrl && /\.(pkg|crc|json)$/i.test(url)) guideSubmit(url, false);
};
input.addEventListener("focus", () => input.select()); // a paste then replaces the old link
input.addEventListener("input", tryInput);
input.addEventListener("paste", () => setTimeout(tryInput, 0));
wrap.querySelector(".guide-start").addEventListener("click",
() => guideSubmit(extractLink(input.value), false));
}
wrap.querySelector(".guide-close").addEventListener("click", closeGuide);
wrap.addEventListener("click", (e) => { if (e.target === wrap) closeGuide(); });
guide.onKey = (e) => { if (e.key === "Escape") closeGuide(); };
document.addEventListener("keydown", guide.onKey);
guideStatus("idle", matrix ? t("guide.waitingPhone") : "");
guide.timer = setInterval(guidePoll, 2000);
(fromLink ? wrap.querySelector(".guide-open") : wrap.querySelector(".guide-close")).focus();
}
function closeGuide() {
if (!guide.el) return;
clearInterval(guide.timer);
document.removeEventListener("keydown", guide.onKey);
guide.el.remove();
guide.el = null;
guide.game = null;
clearGuideHash();
if (guide.returnFocus && document.contains(guide.returnFocus)) guide.returnFocus.focus();
}
// The last link in the field wins, so pasting a new link next to a rejected
// one (instead of over it) still picks up the new one.
function extractLink(text) {
const all = String(text || "").match(/https?:\/\/(?:(?!https?:\/\/)[^\s"'<>])+/g) || [];
const pkgs = all.filter((u) => /\.(pkg|crc|json)$/i.test(u));
return (pkgs.length ? pkgs[pkgs.length - 1] : all[all.length - 1]) || "";
}
function guideStatus(kind, message, action) {
if (!guide.el) return;
const box = guide.el.querySelector(".guide-status");
box.innerHTML = message ? `<p class="guide-msg is-${kind}">${message}</p>` : "";
if (action) {
const b = document.createElement("button");
b.type = "button";
b.className = "row-button is-ghost guide-action";
b.textContent = action.label;
b.addEventListener("click", action.run);
box.appendChild(b);
}
}
async function guideSubmit(url, force) {
const game = guide.game;
if (!game || guide.busy) return;
const pick = guide.pick;
if (!url) { guideStatus("error", t(pick.ps4 ? "guide.pasteOrbis" : "guide.pasteSc")); return; }
guide.busy = true;
guide.lastUrl = url;
const start = guide.el.querySelector(".guide-start");
start.disabled = true;
guideStatus("busy", t("guide.checkingLink"));
try {
await postJson("/api/download_url", {
title_id: game.title_id, url, version: pick.version, size: pick.size, force,
});
} catch (error) {
guide.busy = false;
if (!guide.el) return;
start.disabled = false;
const b = (error && error.body) || {};
if (b.reason === "no_space") {
guideStatus("error", escapeHtml(noSpaceText(b.need_bytes, b.free_bytes)));
} else if (b.reason === "size_mismatch") {
guideStatus("warn",
t("guide.sizeMismatch", {
found: formatBytes(b.found_bytes),
pieces: Number(b.pieces) || "?",
version: escapeHtml(pick.version),
expected: escapeHtml(b.expected || "?"),
}),
{ label: t("guide.downloadAnyway"), run: () => guideSubmit(url, true) });
} else if (pick.ps4 && (b.reason === "not_an_sc_link" || b.reason === "url_not_allowed")) {
// Orbis offers piece links, not Prospero's SC link
guideStatus("error", escapeHtml(t("reason.notOrbisLink")));
} else {
guideStatus("error", escapeHtml(reasonText(error)) + (b.detail ? ` (${escapeHtml(b.detail)})` : ""));
}
addLog("log.linkRefused", { site: pick.site, id: game.title_id, why: reasonText(error) });
renderLogs();
return;
}
guide.busy = false;
manualDownloadStarted(game, pick.version);
if (!guide.fromLink) {
showToast(t("toast.downloadStarted", { name: game.name, version: pick.version }));
closeGuide();
return;
}
guide.started = true;
guideStatus("ok", t("guide.started"));
}
// Card state for a download that the server has accepted (from this page or
// from a phone); the pool's job list takes over from here.
function manualDownloadStarted(game, version) {
game.manual_version = version;
game.downloading = true;
game._localDownloading = true;
game._localSince = Date.now();
game._autoInstalled = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
state.downloads.push({ title_id: game.title_id, name: game.name, version: game.manual_version,
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
addLog("log.downloadQueuedFrom", { site: isPs4(game) ? "Orbis" : "Prospero", id: game.title_id, version: game.manual_version });
renderGames(); renderLogs();
startDownloadPolling();
}
async function guidePoll() {
const game = guide.game;
if (!game) return;
let jobs;
try {
const r = await fetch(API.downloads, { cache: "no-store" });
if (!r.ok) return;
jobs = await r.json();
} catch (error) {
return;
}
if (guide.game !== game) return;
const j = (jobs || []).find((x) => x.title_id === game.title_id);
if (!j) return;
if (!guide.started) {
// Only a job that is actually running means a link arrived from elsewhere;
// an old failed or paused entry does not.
if (j.state !== "queued" && j.state !== "active") return;
manualDownloadStarted(game, guide.pick.version);
if (!guide.fromLink) {
showToast(t("toast.linkReceived", { name: game.name, version: guide.pick.version }));
closeGuide();
return;
}
guide.started = true;
}
const total = Number(j.total_bytes) || 0;
const pct = total ? Math.min(100, Math.round((Number(j.bytes) || 0) * 100 / total)) : 0;
const note = {
queued: () => t("guide.note.queued"),
active: () => t("guide.note.active"),
paused: () => t("guide.note.paused"),
done: () => t(state.config.install_after_download ? "guide.note.doneInstall" : "guide.note.doneManual"),
error: () => t("guide.note.error"),
}[j.state];
const noteText = note ? note() : "";
guideStatus(j.state === "error" ? "error" : "ok",
`${noteText}<span class="guide-progress"><span class="progress"><i style="width:${pct}%"></i></span>` +
`<span>${total ? `${formatBytes(j.bytes)} / ${formatBytes(total)} · ` : ""}${pct}%</span></span>`);
}
/* ---------------- formatting ---------------- */
function initials(name) {
return (name || "PD").split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
}
// Auto-scaled byte sizes: B / KB / MB / GB / TB.
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n < 0) return "--";
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let v = n / 1024, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}
// Free space comes from the backend in MB.
function formatMb(mb) {
const n = Number(mb);
if (!Number.isFinite(n)) return "--";
return formatBytes(n * 1024 * 1024);
}
function formatEta(seconds) {
const s = Math.round(Number(seconds) || 0);
if (!Number.isFinite(s) || s <= 0) return "--";
if (s < 60) return t("fmt.etaSec", { s });
const m = Math.floor(s / 60);
if (m < 60) return t("fmt.etaMin", { m, s: s % 60 });
const h = Math.floor(m / 60);
return t("fmt.etaHour", { h, m: m % 60 });
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function clone(value) { return JSON.parse(JSON.stringify(value)); }
function timeNow() { return new Date().toLocaleTimeString("en-US", { hour12: false }); }
let toastTimer;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 2600);
}