Files
knutwurst--ps5-patchdl/web/app.js
T
Knutwurst 70863fb54e Show the newest version and its firmware on every card again
Since the version line rework, "Newest … · needs FW …" only appeared when
the newest patch was newer than the installed one, so up-to-date games,
fake packages and Shadowmount titles lost the firmware information that
0.0.8 showed. It is back whenever Sony reports a newest version, unless
the Update entry already names the same version.
2026-10-15 22:16:20 +02:00

1676 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API = {
status: "/api/status",
titles: "/api/titles",
config: "/api/config",
downloads: "/api/downloads",
installStatus: "/api/installstatus",
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
};
// title_id -> loaded <img>, so re-rendered cards show icons instantly.
const iconCache = new Map();
const SOURCE_TYPES = {
official: { label: "Official", className: "ok", installAllowed: true, description: "Native console installation" },
external: { label: "External", className: "external", installAllowed: true, description: "Real external installation" },
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Not supported" },
unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" },
fake: { label: "Fake package", className: "blocked", installAllowed: false, description: "Not supported" },
};
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",
],
},
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", () => {
bindElements();
bindEvents();
loadInitialData().then(openGuideFromHash);
window.addEventListener("hashchange", openGuideFromHash);
});
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"),
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"),
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", loadInitialData);
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));
}
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));
}
async function loadInitialData() {
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.
if (old && g.status !== "up_to_date" &&
(g.status !== "incompatible_fw" || g.manual_version)) {
if (old.downloaded) g.downloaded = true;
if (old.installing) g.installing = true;
if (old._autoInstalled) g._autoInstalled = true;
if (old._localDownloading) g._localDownloading = true;
}
});
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();
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data 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 || "System version";
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;
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
if (els.railSpace) els.railSpace.textContent = `${space} free`;
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(`Parallel connections: ${v}`);
} catch (e) {
showToast("Could not save connections — API not reachable.");
}
}, 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 = "No titles match this filter.";
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 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)
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="subline">
<span>${escapeHtml(game.title_id)}</span>
<span>${escapeHtml(game.content_id || "")}</span>
</div>
<div class="pills">
${game.downloading && game._jobState === "queued" ? `<span class="pill">Queued</span>` : ""}
${game.downloading && game._jobState !== "queued" ? `<span class="pill live">Downloading</span>` : ""}
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
${statusPill(game)}
${storagePill(game)}
${sourcePill(game)}
</div>
<div class="versions">${versionsHtml(game)}</div>
`;
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);
});
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 note = document.createElement("div");
note.className = "card-progress";
note.innerHTML = `
<div class="progress"><i style="width:${pct}%"></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>Paused — <b>${formatBytes(game.partial_bytes)}</b> downloaded</span>
<span>Resume to continue</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", `Enable updates for ${game.name}`);
const track = document.createElement("span");
track.className = "track";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = cb.checked ? "On" : "Off";
cb.addEventListener("change", () => {
lbl.textContent = cb.checked ? "On" : "Off";
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: "Installing…", variant: "ghost", disabled: true };
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
if (game.patch_title_match === false) return null;
if (canGetOlderPatch(game))
return { label: "Old FW patch", action: "guide", variant: "update", hint: `Get ${game.prospero_version} from Prospero Patches, the newest patch that runs on this firmware.` };
if (game.resumable && isDownloadAllowed(game))
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
const hasPatch = game.status === "available" || Boolean(game.manual_version);
if (game.downloaded && hasPatch && isInstallAllowed(game))
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
if (game.downloaded && hasPatch) return null;
if (!hasPatch) return null;
if (!isDownloadAllowed(game)) return null;
return state.config.install_after_download && isInstallAllowed(game)
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
}
// 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.downloading || game.resumable || game.downloaded)
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
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 versionsHtml(game) {
const v = (x) => `<b>${escapeHtml(x)}</b>`;
const parts = [`<span>Installed ${v(game.installed_version || "--")}</span>`];
let offered = "";
if (game.manual_version) {
offered = game.manual_version;
parts.push(`<span>Update ${v(offered)} · from Prospero</span>`);
} else if (game.status === "available" && game.compatible_version) {
offered = game.compatible_version;
parts.push(`<span>Update ${v(offered)}</span>`);
} else if (hasOlderPatch(game)) {
offered = game.prospero_version;
const fw = game.prospero_required_fw ? ` · needs FW ${escapeHtml(game.prospero_required_fw)}` : "";
parts.push(`<span class="prospero-line">Update ${v(offered)}${fw} · <a href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Prospero</a></span>`);
} else if (game.status === "incompatible_fw" && !game.prospero_done) {
parts.push(`<span>Looking for a patch for this firmware\u2026</span>`);
}
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
parts.push(`<span class="card-note is-warn">${noSpaceText(job.need_bytes, job.free_bytes)}</span>`);
if (game.latest_version && game.latest_version !== offered) {
const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : "";
parts.push(`<span>Newest ${v(game.latest_version)}${fw}</span>`);
}
return parts.join("");
}
function statusPill(game) {
if (game.installing) return `<span class="pill warn">Installing…</span>`;
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
if (game.status === "blocked" || !sourcePolicy(game).allow_install)
return `<span class="pill">Not supported</span>`;
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
if (game.manual_version || hasOlderPatch(game)) return `<span class="pill prospero">Via Prospero</span>`;
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
if (game.status === "incompatible_fw")
return `<span class="pill">Newest needs FW ${escapeHtml(game.latest_required_fw || "?")}</span>`;
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
return `<span class="pill">No patch info</span>`;
}
function storagePill(game) {
return hasSharedStorage(game) && sourcePolicy(game).allow_install
? `<span class="pill warn">Shared master</span>` : "";
}
function sourcePill(game) {
const info = sourceInfo(game);
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</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>Queued — waiting for a free slot</span>`;
const parts = [];
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>ETA <b>${formatEta((total - done) / speed)}</b></span>`);
else if (!total) parts.push(`<span>Fetching manifest…</span>`);
parts.push(`<span><b>${pctOf(d)}%</b></span>`);
return parts.join("");
}
function installProgressHtml(game) {
const status = game.installStatus || "waiting";
const done = Number(game.installDone) || 0;
const total = Number(game.installTotal) || 0;
const parts = [`<span>Status <b>${escapeHtml(status)}</b></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;
}
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") {
g.downloading = false;
g.resumable = false;
g.downloaded = true;
g._wasActive = false;
if (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(`${g.name}: ${noSpaceText(j.need_bytes, j.free_bytes)}`);
g._spaceWarned = true;
} else if (g._wasActive) {
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
}
g._wasActive = false;
g.downloading = false;
g.resumable = bytes > 0;
g.partial_bytes = bytes || g.partial_bytes || 0;
}
});
}
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;
}
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";
state.logs.push(`[${timeNow()}] Install ${ok ? "completed" : "stopped"} for ${game.title_id}: ${s.status || "unknown"}${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 ? "Downloading" : "Queued";
els.globalDlName.textContent = active.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 = `${pos} of ${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> waiting`;
els.globalDlSpeed.innerHTML = isActive && speed > 0
? `<b>${formatBytes(speed)}/s</b>`
: (isActive && !total_ ? "fetching manifest…" : "—");
els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_
? `ETA <b>${formatEta((total_ - done_) / speed)}</b>`
: "";
// 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 ---------------- */
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(info.label)}</span>
<strong>${policy.allow_install ? "Install allowed" : "Install blocked"}</strong>
<em>${policy.allow_download ? "Download allowed" : "Download blocked"} - ${escapeHtml(info.description)}</em>
`;
return row;
});
els.sourcePolicyList.replaceChildren(...rows);
}
function renderLogs() {
els.logOutput.textContent = state.logs.length ? state.logs.join("\n") : "No events yet.";
}
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("Configuration saved.");
} catch (error) {
state.config = config;
showToast("Configuration updated locally. API is not reachable yet.");
}
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("No installable updates to queue.");
return;
}
showToast(`Queueing ${targets.length} update${targets.length === 1 ? "" : "s"}…`);
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 {
state.logs.push(`[${timeNow()}] Download queued: ${game.title_id} ${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);
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`);
renderGames(); renderLogs();
}
}
async function doInstall(game) {
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.installing = false;
const why = reasonText(error);
state.logs.push(`[${timeNow()}] install ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`);
renderGames(); renderLogs();
return false;
}
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
showToast(`${game.name}: installing update.`);
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(`${game ? game.name : titleId}: download cancelled / deleted.`);
} catch (error) {
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
}
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);
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${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(`${game.name}: pausing…`);
} catch (error) {
showToast(`${game.name}: ${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(() => { state.logs.push(`[${timeNow()}] Saved: ${titleId} ${wantEnabled ? "enabled" : "disabled"}`); renderLogs(); })
.catch(() => showToast(`${titleId}: could not save toggle (API not reachable).`));
}
/* ---------------- policy helpers ---------------- */
function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; }
function hasSharedStorage(game) {
if (game.patch_storage_match === false) return true;
const storage = (game.patch_storage_title_id || "").slice(0, 9);
const target = (game.title_id || "").slice(0, 9);
return Boolean(storage && target && storage !== target);
}
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;
}
const REASON_TEXT = {
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
install_not_allowed_for_source: "Install blocked for this source.",
source_unknown: "Source unknown — blocked.",
source_fake: "Fake packages are not supported.",
source_shadowmount: "Shadowmount titles are not supported.",
no_compatible_patch: "No compatible patch available.",
download_failed: "Download failed.",
download_in_progress: "Another download is already running.",
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
title_disabled: "This title is disabled.",
download_paused: "Download paused.",
not_downloading: "Nothing is downloading for this title.",
not_an_sc_link: "That is not the SC link. Under Additional Files, copy the link of the SC button.",
url_title_mismatch: "That link belongs to a different game.",
url_not_allowed: "Only Sony CDN links (https) are accepted.",
url_invalid: "The link contains unexpected characters.",
no_space: "Not enough free space on the PS5.",
manifest_unavailable: "The PS5 could not load the package list from Sony. Copy the SC link again and retry.",
download_exists: "A download already exists for this game. Cancel it first.",
source_not_supported: "Downloads are not supported for this source.",
};
// The server keeps 2 GB free on /data on top of the package itself.
function noSpaceText(need, free) {
return `Not enough space on the PS5: needs ${formatBytes(need)} plus 2 GB headroom, ${formatBytes(free)} free.`;
}
function reasonText(error) {
const r = error && error.body && error.body.reason;
return (r && REASON_TEXT[r]) || (error && error.message) || "failed";
}
/* ---------------- 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="QR code"><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, fromLink: false, started: false, busy: false,
lastUrl: "", timer: null, returnFocus: null, onKey: null };
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) {
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}/#get/${encodeURIComponent(game.title_id)}`;
}
function openGuideFromHash() {
const m = location.hash.match(/^#get\/([A-Za-z0-9_.-]+)$/);
if (!m) return;
const game = state.titles.find((g) => g.title_id === m[1]);
if (!game) { showToast("That game is not installed on this PS5."); clearGuideHash(); return; }
if (!canGetOlderPatch(game)) {
showToast(game.manual_version || game.downloading || game.downloaded
? `${game.name}: the older patch is already on its way.`
: `${game.name}: there is no older patch to fetch.`);
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) {
closeGuide();
Object.assign(guide, { game, fromLink, started: false, busy: false, lastUrl: "",
returnFocus: document.activeElement });
const ver = escapeHtml(game.prospero_version);
const size = game.prospero_size ? ` (${escapeHtml(game.prospero_size)})` : "";
const onConsole = !fromLink && isConsoleBrowser();
const link = fromLink ? null : phoneLink(game);
const matrix = link ? qrEncode(link) : null;
const head = `
<header class="guide-head">
<div>
<p class="eyebrow">Old FW patch</p>
<h2 id="guideTitle">Get ${ver}</h2>
<p class="guide-game">${escapeHtml(game.name)}</p>
</div>
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
</header>`;
let body;
if (onConsole) {
body = matrix
? `<div class="guide-handoff">${qrSvg(matrix)}
<p>Scan this code with your phone, or open this link on your phone:</p>
<code>${escapeHtml(link)}</code>
</div>`
: `<p class="guide-why">Open PatchDL on your phone (this console's IP address, port ${escapeHtml(String(state.status.http_port || 12880))}) and pick the game there.</p>`;
body += `<div class="guide-status" role="status" aria-live="polite"></div>`;
} else {
const handoff = matrix
? `<div class="guide-qr">${qrSvg(matrix)}<div>
<strong>Easier on your phone</strong>
<span>Scan this with your phone camera and continue there. This screen follows along.</span>
<code>${escapeHtml(link)}</code>
</div></div>`
: "";
body = `
<p class="guide-why">Sony only offers ${escapeHtml(game.latest_version || "the newest patch")}, which needs FW ${escapeHtml(game.latest_required_fw || "?")}. Prospero Patches still lists <b>${ver}</b>, which runs on FW ${escapeHtml(game.prospero_required_fw || "?")}.</p>
${handoff}
<ol class="guide-steps">
<li>
<div class="guide-step-text"><strong>Open Prospero Patches</strong>
<span>Tap version <b>${ver}</b>${size}. If a human check shows up, confirm it.</span></div>
<a class="row-button is-update guide-open" href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Open Prospero</a>
</li>
<li>
<div class="guide-step-text"><strong>Copy the SC link</strong>
<span>Under <b>Additional Files</b>, press and hold the <b>SC</b> button and choose <i>Copy Link</i>. PatchDL takes the full piece list from it.</span></div>
</li>
<li>
<div class="guide-step-text"><strong>Paste it here</strong>
<span>The download starts on your PS5 as soon as the link is in.</span></div>
<div class="guide-paste">
<input class="guide-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="https://…_sc.pkg" aria-label="SC link">
<button class="row-button guide-start" type="button">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 ? "Waiting for the link from your phone…" : "");
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;
if (!url) { guideStatus("error", "Paste the SC link from Prospero first."); return; }
guide.busy = true;
guide.lastUrl = url;
const start = guide.el.querySelector(".guide-start");
start.disabled = true;
guideStatus("busy", "Checking the link on your PS5…");
try {
await postJson("/api/download_url", {
title_id: game.title_id, url, version: game.prospero_version, 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",
`This link is ${formatBytes(b.found_bytes)} in ${Number(b.pieces) || "?"} pieces, but ${escapeHtml(game.prospero_version)} should be about ${escapeHtml(b.expected || "?")}. It is probably a different version.`,
{ label: "Download anyway", run: () => guideSubmit(url, true) });
} else {
guideStatus("error", escapeHtml(reasonText(error)) + (b.detail ? ` (${escapeHtml(b.detail)})` : ""));
}
state.logs.push(`[${timeNow()}] Prospero link for ${game.title_id} refused: ${reasonText(error)}`);
renderLogs();
return;
}
guide.busy = false;
manualDownloadStarted(game);
if (!guide.fromLink) {
showToast(`${game.name}: download of ${game.prospero_version} started.`);
closeGuide();
return;
}
guide.started = true;
guideStatus("ok", "Download started on your PS5.");
}
// 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) {
game.manual_version = game.prospero_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 });
state.logs.push(`[${timeNow()}] Download queued (Prospero): ${game.title_id} ${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);
if (!guide.fromLink) {
showToast(`${game.name}: link received, download of ${game.prospero_version} started.`);
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: "Queued on your PS5…",
active: "Downloading on your PS5. You can close this page.",
paused: "Paused on your PS5.",
done: state.config.install_after_download
? "Downloaded. Your PS5 installs it now."
: "Downloaded. Press Install on the game in PatchDL.",
error: "The download stopped. Resume it from the game in PatchDL.",
}[j.state] || "";
guideStatus(j.state === "error" ? "error" : "ok",
`${note}<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 `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
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);
}