From 3f40dc1d7cb53b7f001aa74950425af1102b4125 Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:48:54 +0200 Subject: [PATCH] Rework the web UI: tile-based progress, view nav, responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-page layout with a view-based one (Games / Settings / Logs via the left nav) and fold all download UI into the game tile — the separate download queue is gone. Per tile while downloading: an in-tile progress bar with auto-scaled size (B/KB/MB/GB/TB), live transfer speed and ETA from the poll deltas, and a green "Downloading" marker. One fixed-width action button that no longer reflows with its label: a blue Update/Download/Install that morphs into an amber Cancel while the download runs, plus a ghost Delete for a finished package. Settings and Logs moved to their own pages. Mobile-first responsive layout (rail collapses to a top bar, icon-only nav, single-column tiles, >=44px touch targets, 16px inputs). Real free space (statvfs) is shown auto-scaled in the rail and status strip. New "Home-screen shortcut" toggle (default on, persisted as home_shortcut in config.json). The actual PS5 tile install is not wired yet: it needs a prebuilt deeplinkUri stub PKG installed via sceAppInstUtil. Fixes from an adversarial review pass: - Reconcile in-flight downloads from /api/downloads onto the cards, so progress + Cancel appear after a reload or a download started elsewhere, and stop the per-poll full-grid rebuild. - Clear the downloaded flag on install and once the server reports the title up to date, so a patched title no longer shows Install/Delete forever. - Zero a stale speed/ETA if the byte counter goes backwards. - a11y: nav buttons keep an accessible name when the label is hidden on small screens; visible focus ring on the search box; filter group is role=group with aria-pressed; drop the noisy grid-level aria-live; fold the transient "checking" state into a visible filter bucket. --- src/patchdl_websrv.c | 19 +- web/app.js | 776 +++++++++++++++++++---------------------- web/index.html | 249 ++++++------- web/styles.css | 809 +++++++++++++------------------------------ 4 files changed, 734 insertions(+), 1119 deletions(-) diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 5e4fa2b..d6659d4 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -38,7 +38,8 @@ static struct { int install_after_download; int delete_pkg_after_install; int verify_downloads; /* SHA-256 each manifest piece (default off) */ -} g_cfg = { "deny", 0, 1, 0 }; + int home_shortcut; /* user wants a home-screen browser tile */ +} g_cfg = { "deny", 0, 1, 0, 1 }; static struct { int active; @@ -438,11 +439,13 @@ build_config_json(void) { "{\"default_policy\":\"%s\"," "\"install_after_download\":%s," "\"delete_pkg_after_install\":%s," - "\"verify_downloads\":%s,", + "\"verify_downloads\":%s," + "\"home_shortcut\":%s,", g_cfg.default_policy[0] ? g_cfg.default_policy : "deny", g_cfg.install_after_download ? "true" : "false", g_cfg.delete_pkg_after_install ? "true" : "false", - g_cfg.verify_downloads ? "true" : "false"); + g_cfg.verify_downloads ? "true" : "false", + g_cfg.home_shortcut ? "true" : "false"); pthread_mutex_unlock(&g_mutex); char *out = malloc(strlen(head) + sizeof(config_tail_json)); @@ -467,11 +470,13 @@ save_config(void) { "{\n\"default_policy\":\"%s\",\n" "\"install_after_download\":%s,\n" "\"delete_pkg_after_install\":%s,\n" - "\"verify_downloads\":%s,\n\"titles\":{", + "\"verify_downloads\":%s,\n" + "\"home_shortcut\":%s,\n\"titles\":{", g_cfg.default_policy[0] ? g_cfg.default_policy : "deny", g_cfg.install_after_download ? "true" : "false", g_cfg.delete_pkg_after_install ? "true" : "false", - g_cfg.verify_downloads ? "true" : "false"); + g_cfg.verify_downloads ? "true" : "false", + g_cfg.home_shortcut ? "true" : "false"); for (size_t i = 0; i < g_title_count; i++) fprintf(f, "%s\"%s\":%s", i ? "," : "", g_titles[i].title_id, g_titles[i].enabled ? "true" : "false"); @@ -507,6 +512,8 @@ load_config(void) { json_get_bool(buf, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); g_cfg.verify_downloads = json_get_bool(buf, "verify_downloads", g_cfg.verify_downloads); + g_cfg.home_shortcut = + json_get_bool(buf, "home_shortcut", g_cfg.home_shortcut); /* per-title overrides live under "titles": { "": true|false, ... } */ const char *titles = strstr(buf, "\"titles\""); @@ -1093,6 +1100,8 @@ handle_config_post(struct MHD_Connection *conn, const char *body) { json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); g_cfg.verify_downloads = json_get_bool(body, "verify_downloads", g_cfg.verify_downloads); + g_cfg.home_shortcut = + json_get_bool(body, "home_shortcut", g_cfg.home_shortcut); pthread_mutex_unlock(&g_mutex); save_config(); diff --git a/web/app.js b/web/app.js index 21dea37..45b3f31 100644 --- a/web/app.js +++ b/web/app.js @@ -7,30 +7,10 @@ const API = { }; 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: "Download only", - }, - unknown: { - label: "Unknown", - className: "blocked", - installAllowed: false, - description: "Blocked", - }, + 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: "Download only" }, + unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" }, }; const fallback = { @@ -39,7 +19,7 @@ const fallback = { firmware_build: "0x11600000", dns_guard: "Active", resolver: "Internal allowlist", - free_space_mb: 64218, + free_space_mb: 771916, download_dir: "/data/patchdl (internal)", }, config: { @@ -48,6 +28,7 @@ const fallback = { install_after_download: false, delete_pkg_after_install: true, verify_downloads: false, + home_shortcut: true, source_policy: { official: { allow_check: true, allow_download: true, allow_install: true }, external: { allow_check: true, allow_download: true, allow_install: true }, @@ -62,69 +43,28 @@ const fallback = { }, titles: [ { - title_id: "PPSA01628_00", - name: "Call of Duty Black Ops Cold War", + 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", - mount_from: "/dev/ssd0.system_ex", - enabled: true, - mode: "latest_compatible", - queued: false, - status: "available", + 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", + mount_from: "/dev/ssd0.system_ex", enabled: true, status: "available", }, { - title_id: "PPSA01284_00", - name: "Demon's Souls", + title_id: "PPSA01284_00", name: "Demon's Souls", content_id: "UP9000-PPSA01284_00-DEMONSSOULS00001", - installed_version: "01.002.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", - mount_from: "/mnt/ext0/user/app/PPSA01284_00", - enabled: true, - mode: "pin", - max_content_ver: "01.004.000", - queued: true, - status: "queued", + 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", + mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, status: "up_to_date", }, { - title_id: "PPSA08329_00", - name: "Example Future Game", - content_id: "EP0000-PPSA08329_00-FUTUREPATCH00001", - installed_version: "01.000.000", - compatible_version: null, - latest_version: "01.012.000", - latest_required_fw: "12.50", - source_type: "unknown", - source_path: "/system_ex/app/PPSA08329_00", - mount_from: "", - enabled: false, - mode: "disabled", - queued: false, - status: "blocked", - }, - { - title_id: "PPSA90001_00", - name: "Shadowmounted Test Title", + 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", - mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", - enabled: true, - mode: "download_only", - queued: false, - status: "available", + 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", + mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available", }, ], downloads: [], @@ -137,11 +77,15 @@ let state = { titles: fallback.titles, downloads: fallback.downloads, logs: fallback.logs, + view: "games", filter: "all", query: "", usingFallback: false, }; + let downloadPollTimer = null; +let emptyPolls = 0; +const dlMeta = {}; // per-title speed tracking: { bytes, t, speed } const els = {}; @@ -159,8 +103,9 @@ function bindElements() { resolverValue: document.getElementById("resolverValue"), spaceValue: document.getElementById("spaceValue"), downloadDirValue: document.getElementById("downloadDirValue"), + railFw: document.getElementById("railFw"), + railSpace: document.getElementById("railSpace"), gameGrid: document.getElementById("gameGrid"), - queueList: document.getElementById("queueList"), logOutput: document.getElementById("logOutput"), allowlistHosts: document.getElementById("allowlistHosts"), sourcePolicyList: document.getElementById("sourcePolicyList"), @@ -170,23 +115,31 @@ function bindElements() { installAfterDownload: document.getElementById("installAfterDownload"), deleteAfterInstall: document.getElementById("deleteAfterInstall"), verifyDownloads: document.getElementById("verifyDownloads"), + homeShortcut: document.getElementById("homeShortcut"), refreshBtn: document.getElementById("refreshBtn"), saveBtn: document.getElementById("saveBtn"), - pauseAllBtn: document.getElementById("pauseAllBtn"), + clearLogBtn: document.getElementById("clearLogBtn"), toast: document.getElementById("toast"), }); } function bindEvents() { - els.searchInput.addEventListener("input", (event) => { - state.query = event.target.value.trim().toLowerCase(); + 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((item) => item.classList.remove("is-selected")); + 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(); }); @@ -194,7 +147,18 @@ function bindEvents() { els.refreshBtn.addEventListener("click", loadInitialData); els.saveBtn.addEventListener("click", saveConfig); - els.pauseAllBtn.addEventListener("click", () => showToast("Queue paused.")); + els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); }); +} + +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() { @@ -206,25 +170,27 @@ async function loadInitialData() { getJson(API.downloads, fallback.downloads), ]); - // /api/titles has no client-only progress fields, so carry them across a - // refresh — otherwise an in-flight download/install would flip the card back - // to a clickable "Download/Install" mid-operation. + // /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. const prev = new Map(state.titles.map((g) => [g.title_id, g])); titles.forEach((g) => { const old = prev.get(g.title_id); - if (!old) return; - if (old.downloading) g.downloading = true; - if (old.downloaded) g.downloaded = true; - if (old.installing) g.installing = true; + // Carry client-only progress flags across a refresh — unless the server now + // reports the title up to date (the patch applied), in which case drop them. + if (old && g.status !== "up_to_date") { + if (old.downloading) g.downloading = true; + if (old.downloaded) g.downloaded = true; + if (old.installing) g.installing = true; + if (old._localDownloading) g._localDownloading = true; + } }); + // Reconcile active downloads the server reports, so progress + Cancel show even + // after a hard reload or a download started from another session/device. + const activeDl = new Set(downloads.map((d) => d.title_id)); + titles.forEach((g) => { if (activeDl.has(g.title_id)) g.downloading = true; }); - state = { - ...state, - status, - config, - titles, - downloads, - }; + state = { ...state, status, config, titles, downloads }; render(); if (state.downloads.length) startDownloadPolling(); @@ -246,7 +212,6 @@ function render() { renderStatus(); renderSettings(); renderGames(); - renderQueue(); renderLogs(); } @@ -255,8 +220,11 @@ function renderStatus() { els.firmwareBuild.textContent = state.status.firmware_build || "System version"; els.dnsValue.textContent = state.status.dns_guard || "--"; els.resolverValue.textContent = state.status.resolver || "--"; - els.spaceValue.textContent = formatMb(state.status.free_space_mb); + 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`; } function renderSettings() { @@ -264,8 +232,8 @@ function renderSettings() { 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.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads); + if (els.homeShortcut) els.homeShortcut.checked = state.config.home_shortcut !== false; els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => { const chip = document.createElement("span"); chip.className = "host-chip"; @@ -275,6 +243,8 @@ function renderSettings() { renderSourcePolicies(); } +/* ---------------- games ---------------- */ + function renderGames() { const visible = state.titles.filter(matchesFilter).filter(matchesQuery); els.gameGrid.replaceChildren(); @@ -286,20 +256,15 @@ function renderGames() { els.gameGrid.appendChild(empty); return; } - visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game))); } -// One mutually-exclusive "what can I do with this title" bucket per game. -// updatable : a newer compatible patch exists AND this source may be installed -// uptodate : already on the newest compatible patch -// needsfw : a newer patch exists but needs a newer firmware than installed -// blocked : source can't be safely patched (shadowmount / preinstall / unknown) -// checking : version lookup still running +// Mutually-exclusive bucket per game for the filter chips. function gameCategory(game) { - if (game.installing) return "updatable"; // keep visible while the patch installs - if (game.status === "checking") return "checking"; - if (game.patch_title_match === false) return "blocked"; // target-title mismatch + // checking is transient (version lookup still running); keep it visible under + // Updatable rather than letting it fall out of every specific filter. + if (game.installing || game.downloading || game.status === "checking") return "updatable"; + if (game.patch_title_match === false) return "blocked"; if (!sourcePolicy(game).allow_install) return "blocked"; if (game.status === "available") return "updatable"; if (game.status === "incompatible_fw") return "needsfw"; @@ -308,126 +273,133 @@ function gameCategory(game) { function matchesFilter(game) { if (state.filter === "all") return true; - if (state.filter === "queued") return Boolean(game.queued) || game.status === "queued"; return gameCategory(game) === state.filter; } function matchesQuery(game) { if (!state.query) return true; - return [game.name, game.title_id, game.content_id] - .concat([game.source_type, game.source_path, game.mount_from]) + return [game.name, game.title_id, game.content_id, game.source_type, game.source_path, game.mount_from] .filter(Boolean) - .some((value) => value.toLowerCase().includes(state.query)); + .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"); - const title = document.createElement("div"); - title.className = "game-title"; - const installBlocked = isInstallBlocked(game); - title.innerHTML = ` -
-
${initials(game.name)}
+ // ---- 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); + lead.appendChild(cover); + if (sourcePolicy(game).allow_install) lead.appendChild(buildToggle(game)); + + const body = document.createElement("div"); + body.className = "card-body"; + body.innerHTML = ` +

${escapeHtml(game.name)}

+
+ ${escapeHtml(game.title_id)} + ${escapeHtml(game.content_id || "")}
-
-

${escapeHtml(game.name)}

-
- ${escapeHtml(game.title_id)} - ${escapeHtml(game.content_id || "")} -
-
- ${statusPill(game)} - ${sourcePill(game)} - ${installBlocked ? `Install blocked` : `Install allowed`} -
-
${escapeHtml(sourceDetail(game))}
+
+ ${game.downloading ? `Downloading` : ""} + ${statusPill(game)} + ${sourcePill(game)} +
+
+ Installed ${escapeHtml(game.installed_version || "--")} + Compatible ${escapeHtml(game.compatible_version || "None")} + Latest ${escapeHtml(game.latest_version || "--")}${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""}
`; - // Per-game enable/disable toggle, grouped under the game icon on the left - // (only for sources that can actually be patched). - if (sourcePolicy(game).allow_install) { - const lead = title.querySelector(".lead"); - 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, mode: cb.checked ? "latest_compatible" : "disabled" }); - }); - toggle.append(cb, track, lbl); - lead.appendChild(toggle); - } - - const versions = document.createElement("div"); - versions.className = "version-grid"; - versions.innerHTML = ` - ${versionBox("Installed", game.installed_version || "--")} - ${versionBox("Compatible", game.compatible_version || "None")} - ${versionBox("Latest", `${game.latest_version || "--"} / FW ${game.latest_required_fw || "--"}`)} - `; - - const controls = document.createElement("div"); - controls.className = "controls"; - const actions = document.createElement("div"); - actions.className = "actions-row"; - const act = rowAction(game); + actions.className = "card-actions"; + const act = tileButton(game); if (act) { const btn = document.createElement("button"); - btn.className = "row-button is-primary"; - btn.innerHTML = ` ${escapeHtml(act.label)}`; - btn.title = escapeHtml(act.hint || act.label); - if (act.disabled || !act.action) { - btn.disabled = true; - } else { - btn.addEventListener("click", () => runTitleAction(game.title_id, act.action)); - } + 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); } - - // Cancel an in-progress download, or delete a finished/leftover one. - if (game.downloading || game.downloaded) { + // Delete a finished (not-yet-installed) download. + if (game.downloaded && !game.installing && !game.downloading) { const del = document.createElement("button"); - del.className = "row-button is-danger"; - del.textContent = game.downloading ? "Cancel" : "Delete"; - del.title = game.downloading - ? "Stop the download and delete the partial file" - : "Delete the downloaded package"; + del.className = "row-button is-ghost"; + del.textContent = "Delete"; + del.title = "Delete the downloaded package"; del.addEventListener("click", () => cancelDownload(game.title_id)); actions.appendChild(del); } - controls.append(actions); - card.append(title, versions, controls); + 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 = ` +
+
${progressMetaHtml(d)}
+ `; + card.appendChild(prog); + } + return card; } -function versionBox(label, value) { - return ` -
- ${escapeHtml(label)} - ${escapeHtml(value)} -
- `; +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; +} + +// The single morphing action button: blue Update/Download/Install, or amber +// Cancel while a download runs. Fixed width (CSS) so the label never reflows. +function tileButton(game) { + if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true }; + if (game.downloading) return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" }; + if (game.patch_title_match === false) return null; + if (!isInstallAllowed(game)) return null; + if (game.downloaded && game.status === "available") + return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." }; + if (game.status !== "available") return null; + return state.config.install_after_download + ? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." } + : { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." }; } -// Status pill = update state only (version/firmware/target title). The source type -// has its own pill, so this one never repeats "Shadowmount" etc. function statusPill(game) { if (game.installing) return `Installing…`; if (game.status === "checking") return `Checking…`; - if (game.queued || game.status === "queued") return `In queue`; if (game.patch_title_match === false) return `Title mismatch`; if (game.status === "available") return `Update available`; if (game.status === "incompatible_fw") return `Needs FW ${escapeHtml(game.latest_required_fw || "")}`; @@ -439,61 +411,115 @@ function sourcePill(game) { const info = sourceInfo(game); return `${escapeHtml(info.label)}`; } +function sourceInfo(game) { return SOURCE_TYPES[sourceType(game)] || SOURCE_TYPES.unknown; } +function sourceType(game) { return game.source_type || "unknown"; } -function sourceInfo(game) { - return SOURCE_TYPES[sourceType(game)] || SOURCE_TYPES.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 sourceType(game) { - return game.source_type || "unknown"; +function progressMetaHtml(d) { + const done = Number(d.bytes) || 0; + const total = Number(d.total_bytes) || 0; + const speed = Number(d._speed) || 0; + const parts = []; + parts.push(`${formatBytes(done)}${total > 0 ? ` / ${formatBytes(total)}` : ""}`); + if (speed > 0) parts.push(`${formatBytes(speed)}/s`); + if (speed > 0 && total > done) parts.push(`ETA ${formatEta((total - done) / speed)}`); + else if (!total) parts.push(`Fetching manifest…`); + parts.push(`${pctOf(d)}%`); + return parts.join(""); } -function sourceDetail(game) { - const src = sourceType(game); - if (src === "shadowmount") return `Mount: ${game.mount_from || "unknown source"}`; - if (src === "external") return `External: ${game.source_path || "external app metadata"}`; - if (src === "official") return game.source_path || "Official installation"; - return "Source could not be identified"; +function startDownloadPolling() { + emptyPolls = 0; + if (downloadPollTimer) return; + downloadPollTimer = setInterval(refreshDownloads, 2000); + refreshDownloads(); } -function renderQueue() { - els.queueList.replaceChildren(); - if (!state.downloads.length) { - const empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = "No active downloads."; - els.queueList.appendChild(empty); - return; +function stopDownloadPolling() { + if (!downloadPollTimer) return; + clearInterval(downloadPollTimer); + downloadPollTimer = null; +} + +async function refreshDownloads() { + let downloads; + try { + const response = await fetch(API.downloads, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + downloads = await response.json(); + } catch (error) { + return; // keep last state on a transient failure } - state.downloads.forEach((item) => { - const pct = Math.max(0, Math.min(100, Number(item.progress) || 0)); - const row = document.createElement("div"); - row.className = "queue-item"; - - const info = document.createElement("div"); - info.innerHTML = ` - ${escapeHtml(item.name)} - ${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")} -
- `; - - const right = document.createElement("div"); - right.className = "queue-actions"; - const span = document.createElement("span"); - span.textContent = `${pct}%`; - const cancel = document.createElement("button"); - cancel.className = "row-button is-danger"; - cancel.textContent = "Cancel"; - cancel.title = "Stop the download and delete the partial file"; - cancel.addEventListener("click", () => cancelDownload(item.title_id)); - right.append(span, cancel); - - row.append(info, right); - els.queueList.appendChild(row); + const now = Date.now(); + const activeIds = new Set(); + downloads.forEach((d) => { + activeIds.add(d.title_id); + const done = Number(d.bytes) || 0; + const prev = dlMeta[d.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, no stale speed + } + } + const meta = prev || (dlMeta[d.title_id] = { speed: 0 }); + meta.bytes = done; + meta.t = now; + d._speed = meta.speed || 0; }); + Object.keys(dlMeta).forEach((id) => { if (!activeIds.has(id)) delete dlMeta[id]; }); + + state.downloads = downloads; + + // Reconcile downloading flags with the server. A structural change (a download + // appeared or finished) needs a full re-render to add/remove the progress block + // and morph the button; otherwise update the bar in place. + const before = downloadingIds(); + state.titles.forEach((g) => { + if (activeIds.has(g.title_id)) g.downloading = true; + else if (g.downloading && !g._localDownloading) g.downloading = false; + }); + if (downloadingIds() !== before) renderGames(); + else applyDownloadProgress(); + + if (!downloads.length && !state.titles.some((g) => g.downloading)) { + if (++emptyPolls >= 3) stopDownloadPolling(); + } else { + emptyPolls = 0; + } } +function downloadingIds() { + return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(","); +} + +// Update the progress bar/meta in place to avoid rebuilding every card each tick. +function applyDownloadProgress() { + let needRender = false; + state.downloads.forEach((d) => { + 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]; @@ -514,62 +540,6 @@ function renderLogs() { els.logOutput.textContent = state.logs.length ? state.logs.join("\n") : "No events yet."; } -let emptyPolls = 0; - -function startDownloadPolling() { - emptyPolls = 0; - if (downloadPollTimer) return; - downloadPollTimer = setInterval(refreshDownloads, 2000); - refreshDownloads(); -} - -function stopDownloadPolling() { - if (!downloadPollTimer) return; - clearInterval(downloadPollTimer); - downloadPollTimer = null; -} - -async function refreshDownloads() { - try { - const response = await fetch(API.downloads, { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const downloads = await response.json(); - state.downloads = downloads; - renderQueue(); - // A single empty poll can happen between manifest fetch and first piece; - // only give up after a few in a row so a slow multi-GB job isn't dropped. - if (!downloads.length && !state.titles.some((game) => game.downloading)) { - if (++emptyPolls >= 3) stopDownloadPolling(); - } else { - emptyPolls = 0; - } - } catch (error) { - /* Keep the last local queue row visible if polling fails transiently. */ - } -} - -// Cancel a running download (server aborts and deletes the partial) or delete -// an already-downloaded 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.downloaded = false; - } - state.downloads = state.downloads.filter((item) => item.title_id !== titleId); - state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`); - renderGames(); - renderQueue(); - renderLogs(); - refreshDownloads(); -} - async function saveConfig() { const config = { ...state.config, @@ -578,8 +548,8 @@ async function saveConfig() { 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, }; - try { await postJson(API.config, config); state.config = config; @@ -588,87 +558,61 @@ async function saveConfig() { state.config = config; showToast("Configuration updated locally. API is not reachable yet."); } - renderSettings(); + renderGames(); // install_after_download changes the button label } -// One per-row action button. Returns null when there is nothing to do. -// install_after_download ON -> "Update" (download + install in one go) -// install_after_download OFF -> "Download", then "Install" once downloaded -// download-only source -> "Download" (no install step) -function rowAction(game) { - if (game.installing) return { label: "Installing…", disabled: true }; - if (game.downloading) return { label: "Downloading…", disabled: true }; - if (game.patch_title_match === false) return null; // target-title mismatch - if (game.status !== "available") return null; // nothing newer & compatible - if (!isInstallAllowed(game)) return null; // not a patchable source (e.g. shadowmount) - if (state.config.install_after_download) - return { label: "Update", action: "update", hint: "Download and install the update." }; - return game.downloaded - ? { label: "Install", action: "install", hint: "Install the downloaded patch (modifies the game)." } - : { label: "Download", action: "download", hint: "Download the patch internally." }; -} +/* ---------------- actions (data layer) ---------------- */ async function doDownload(game) { game.downloading = true; - state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id); - state.downloads.push({ - title_id: game.title_id, - name: game.name, - version: game.compatible_version || "", - progress: 0, - detail: "Downloading manifest package internally. Large PS5 patches can take a long time.", - }); + game._localDownloading = true; // this client owns it; don't let a poll clear it + 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 || "", progress: 0, bytes: 0, total_bytes: 0 }); state.logs.push(`[${timeNow()}] Download started: ${game.title_id} ${game.compatible_version}`); - renderQueue(); - renderLogs(); renderGames(); + renderLogs(); startDownloadPolling(); + let r; try { r = await postJson(API.action(game.title_id, "download"), {}); } catch (error) { game.downloading = false; - state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id); + 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(); - renderQueue(); - renderLogs(); - stopDownloadPolling(); + renderGames(); renderLogs(); stopDownloadPolling(); return false; } - game.downloading = false; - state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id); - // A cancel (or soft failure) comes back as HTTP 200 with ok:false, so it - // isn't thrown — handle it here instead of marking the patch downloaded. + game.downloading = false; + game._localDownloading = false; + state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id); + + // Cancel / soft failure returns HTTP 200 with ok:false (not thrown). if (!r || r.ok === false) { game.downloaded = false; const what = r && r.cancelled ? "cancelled" : "failed"; state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`); showToast(`${game.name}: download ${what}.`); - renderGames(); - renderQueue(); - renderLogs(); - stopDownloadPolling(); + renderGames(); renderLogs(); stopDownloadPolling(); return false; } game.downloaded = true; - const mb = r && r.bytes ? (r.bytes / (1024 * 1024)).toFixed(1) : "?"; - state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB, internal)`); - showToast(`${game.name}: downloaded ${mb} MB.`); - renderGames(); - renderQueue(); - renderLogs(); - stopDownloadPolling(); + const sz = r && r.bytes ? formatBytes(r.bytes) : "?"; + state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${sz}, internal)`); + showToast(`${game.name}: downloaded ${sz}.`); + renderGames(); renderLogs(); stopDownloadPolling(); return true; } async function doInstall(game) { game.installing = true; + game.downloaded = false; // the package is being consumed by the install renderGames(); try { await postJson(API.action(game.title_id, "install"), {}); @@ -677,91 +621,69 @@ async function doInstall(game) { const why = reasonText(error); state.logs.push(`[${timeNow()}] install ${game.title_id} blocked: ${why}`); showToast(`${game.name}: ${why}`); - renderGames(); - renderLogs(); + renderGames(); renderLogs(); return false; } - // rc=0 = accepted; keep "Installing…" (the PS5 applies it in the background; - // it clears on the next refresh once the version updates). state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`); showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`); - renderGames(); - renderLogs(); + renderGames(); renderLogs(); return true; } -async function runTitleAction(titleId, action) { - const game = state.titles.find((item) => item.title_id === titleId); - if (!game) return; +// 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; } + 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; if (action === "download") await doDownload(game); else if (action === "install") await doInstall(game); + else if (action === "cancel") await cancelDownload(titleId); else if (action === "update") { if (await doDownload(game)) await doInstall(game); } } function updateGame(titleId, patch) { - const game = state.titles.find((item) => item.title_id === titleId); + const game = state.titles.find((i) => i.title_id === titleId); if (!game) return; - if (sourceType(game) === "unknown" && patch.mode && !["disabled", "check_only"].includes(patch.mode)) { - patch.mode = "check_only"; - patch.enabled = true; - showToast(`${titleId}: unknown source, check only is allowed.`); - } Object.assign(game, patch); - if (patch.mode === "disabled") game.enabled = false; renderGames(); - // Persist the toggle on the PS5 (survives refresh and payload restart). 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).`); - }); + .then(() => { state.logs.push(`[${timeNow()}] Saved: ${titleId} ${wantEnabled ? "enabled" : "disabled"}`); renderLogs(); }) + .catch(() => showToast(`${titleId}: could not save toggle (API not reachable).`)); } -function isDownloadAllowed(game) { - const policy = sourcePolicy(game); - return Boolean(game.enabled && game.compatible_version && policy.allow_download); -} +/* ---------------- policy helpers ---------------- */ -function isInstallBlocked(game) { - return !sourcePolicy(game).allow_install; -} +function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; } function isInstallAllowed(game) { return Boolean( - game.enabled && + game.enabled !== false && game.compatible_version && game.patch_title_match !== false && sourcePolicy(game).allow_install ); } -function installTitle(game) { - if (game.patch_title_match === false) return "Patch metadata targets a different title - install blocked."; - if (isInstallBlocked(game)) return "Install blocked for this source."; - if (!game.compatible_version) return "No compatible update to install."; - return "Install the downloaded patch (modifies the game)."; -} - -function sourcePolicy(game) { - return sourcePolicyForType(sourceType(game)); -} - +function sourcePolicy(game) { return sourcePolicyForType(sourceType(game)); } function sourcePolicyForType(type) { - const fallbackPolicy = fallback.config.source_policy[type] || fallback.config.source_policy.unknown; - const configuredPolicy = (state.config.source_policy || {})[type] || {}; - return { ...fallbackPolicy, ...configuredPolicy }; -} - -function downloadTitle(game) { - if (!game.enabled) return "Title is disabled."; - if (!game.compatible_version) return "No compatible update version is available."; - if (!sourcePolicy(game).allow_download) return "Downloads are blocked for this source."; - if (isInstallBlocked(game)) return "Download allowed; installs stay blocked for this source."; - return "Download compatible update."; + 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) { @@ -788,26 +710,45 @@ const REASON_TEXT = { 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.", }; function reasonText(error) { const r = error && error.body && error.body.reason; return (r && REASON_TEXT[r]) || (error && error.message) || "failed"; } +/* ---------------- formatting ---------------- */ + function initials(name) { - return (name || "PD") - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join("") - .toUpperCase(); + return (name || "PD").split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase(); } -function formatMb(value) { - if (!Number.isFinite(Number(value))) return "--"; - if (value > 1024) return `${(value / 1024).toFixed(1)} GB`; - return `${value} MB`; +// 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) { @@ -819,13 +760,8 @@ function escapeHtml(value) { .replace(/'/g, "'"); } -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -function timeNow() { - return new Date().toLocaleTimeString("en-US", { hour12: false }); -} +function clone(value) { return JSON.parse(JSON.stringify(value)); } +function timeNow() { return new Date().toLocaleTimeString("en-US", { hour12: false }); } let toastTimer; function showToast(message) { diff --git a/web/index.html b/web/index.html index 3855f84..c8acac8 100644 --- a/web/index.html +++ b/web/index.html @@ -2,48 +2,34 @@ - + PatchDL @@ -57,107 +43,95 @@
-