diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 6e939e4..30edf52 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -109,7 +109,9 @@ typedef struct dl_job { int pieces_failed; int inflight; int fd; /* O_RDWR dest fd, -1 until admit */ - int rc; /* 0 ok, -1 net/io, -2 verify */ + int rc; /* 0 ok, -1 net/io, -2 verify, -3 no space */ + long long need_bytes; /* rc -3: bytes the package still needed */ + long long free_bytes; /* rc -3: bytes free on /data at the time */ int last_curl_rc; /* CURLcode from the most recent piece */ long last_http_code; /* HTTP status from the most recent piece */ struct dl_job *next; @@ -640,6 +642,28 @@ data_free_mb(void) { return 0; } +/* Headroom kept free on /data so a download never fills the console's + internal storage to the last byte. */ +#define PATCHDL_SPACE_MARGIN (2LL * 1024 * 1024 * 1024) + +/* Bytes still to be written for a package of `total` bytes at `path`: a + partial from an earlier run already occupies part of it. */ +static long long +space_needed(const char *path, long long total) { + struct stat st; + long long have = 0; + if (stat(path, &st) == 0) have = (long long)st.st_blocks * 512; + return total > have ? total - have : 0; +} + +/* 1 if `need` bytes plus the margin fit; *free_out gets the free bytes. */ +static int +space_ok(long long need, long long *free_out) { + long long free_b = data_free_mb() * 1024LL * 1024LL; + if (free_out) *free_out = free_b; + return need + PATCHDL_SPACE_MARGIN <= free_b; +} + /* Built fresh per request so free space stays live (a 60+ GB download moves it a lot). g_fw is read-only after startup, so this is thread-safe. */ /* The console's LAN address, so a page opened on the PS5 itself (127.0.0.1) @@ -950,6 +974,9 @@ build_downloads_json(void) { jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total); jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld", job->rc, job->last_curl_rc, job->last_http_code); + if (job->rc == -3) + jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld", + job->need_bytes, job->free_bytes); jbuf_append(&j, "}"); } jbuf_append(&j, "]"); @@ -1500,8 +1527,8 @@ admit_next(void) { dl_job_t *q = NULL; patchdl_manifest_t mf; char murl[768], dest[320], dir[288]; - int is_manifest, fd = -1, ok = 0; - long long total = 0, old_size = 0; + int is_manifest, fd = -1, ok = 0, no_space = 0; + long long total = 0, old_size = 0, need = 0, free_b = 0; dl_pstate_t *ps = NULL; unsigned char *bitmap = NULL; @@ -1526,7 +1553,9 @@ admit_next(void) { if (is_manifest) { if (patchdl_fetch_manifest(murl, &mf) == 0 && mf.count > 0) { total = mf.total; - fd = open(dest, O_RDWR | O_CREAT, 0666); + need = space_needed(dest, total); + if (!space_ok(need, &free_b)) no_space = 1; + else fd = open(dest, O_RDWR | O_CREAT, 0666); if (fd >= 0) { struct stat st; if (fstat(fd, &st) == 0) old_size = (long long)st.st_size; @@ -1563,7 +1592,12 @@ admit_next(void) { free(ps); free(bitmap); if (q->state == JOB_CANCELLING) finalize_cancel_locked(q); - else { q->state = JOB_FAILED; q->rc = -1; } + else { + q->state = JOB_FAILED; + q->rc = no_space ? -3 : -1; + q->need_bytes = need; + q->free_bytes = free_b; + } pthread_cond_broadcast(&g_pool.cv); return; } @@ -2125,6 +2159,15 @@ handle_download_url(struct MHD_Connection *conn, const char *body) { found = mf.total; pieces = mf.count; patchdl_manifest_free(&mf); + { + long long free_b; + if (!space_ok(found, &free_b)) { + snprintf(resp, sizeof resp, + "{\"ok\":false,\"reason\":\"no_space\"," + "\"need_bytes\":%lld,\"free_bytes\":%lld}", found, free_b); + return queue_json_owned(conn, 507, strdup(resp)); + } + } if (size_mismatch(tid, ver, found, expect, sizeof expect) && !json_get_bool(body, "force", 0)) { snprintf(resp, sizeof resp, diff --git a/web/app.js b/web/app.js index 9ef8c1d..7bb29c8 100644 --- a/web/app.js +++ b/web/app.js @@ -357,11 +357,20 @@ function gameCategory(game) { return "blocked"; if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState) return "updates"; + if (regionLockedUpdate(game)) return "blocked"; if (game.status === "checking" || game.status === "available" || hasOlderPatch(game)) return "updates"; return "uptodate"; } +// A disc or store install whose update Sony serves from another region's +// master package. Sony's installer rejected exactly this kind of install in +// testing, so offering the download would only lead nowhere. +function regionLockedUpdate(game) { + return game.status === "available" && hasSharedStorage(game) && + Boolean(sourcePolicy(game).allow_install); +} + // 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) { @@ -510,6 +519,7 @@ 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 (regionLockedUpdate(game) && !game.downloading && !game.resumable && !game.downloaded) 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)) @@ -553,6 +563,13 @@ function versionsHtml(game) { } else if (game.status === "incompatible_fw" && !game.prospero_done) { parts.push(`Looking for a patch for this firmware\u2026`); } + if (regionLockedUpdate(game)) { + const master = (game.patch_storage_title_id || "").slice(0, 9); + parts.push(`Sony serves this update from another region's package${master ? ` (${escapeHtml(master)})` : ""}. The PS5 refuses to install it from homebrew.`); + } + const job = state.downloads.find((d) => d.title_id === game.title_id); + if (job && job.state === "error" && Number(job.rc) === -3) + parts.push(`${noSpaceText(job.need_bytes, job.free_bytes)}`); if (game.latest_version && game.latest_version !== offered && game.latest_version > (game.installed_version || "")) { const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : ""; @@ -567,6 +584,7 @@ function statusPill(game) { if (sourceType(game) === "fake") return `Not supported`; if (game.patch_title_match === false) return `Title mismatch`; if (game.manual_version || hasOlderPatch(game)) return `Via Prospero`; + if (regionLockedUpdate(game)) return `Region patch \u00b7 can't install`; if (game.status === "available" && !isInstallAllowed(game)) return `Download only`; if (game.status === "available") return `Update available`; @@ -577,7 +595,7 @@ function statusPill(game) { } function storagePill(game) { - return hasSharedStorage(game) ? `Shared master` : ""; + return hasSharedStorage(game) && !regionLockedUpdate(game) ? `Shared master` : ""; } function sourcePill(game) { @@ -654,6 +672,7 @@ function reconcileFromJobs(jobs) { 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; @@ -677,7 +696,10 @@ function reconcileFromJobs(jobs) { // 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 (g._wasActive) { + 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; @@ -1110,10 +1132,16 @@ const REASON_TEXT = { 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"; @@ -1378,26 +1406,24 @@ function clearGuideHash() { 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 handoff = matrix - ? `
${escapeHtml(link)}
- Old FW patch
@@ -1405,7 +1431,26 @@ function openGuide(game, fromLink = false) {${escapeHtml(game.name)}
Scan this code with your phone, or open this link on your phone:
+${escapeHtml(link)}
+ Open PatchDL on your phone (this console's IP address, port ${escapeHtml(String(state.status.http_port || 12880))}) and pick the game there.
`; + body += ``; + } else { + const handoff = matrix + ? `${escapeHtml(link)}
+ Sony only offers ${escapeHtml(game.latest_version || "the newest patch")}, which needs FW ${escapeHtml(game.latest_required_fw || "?")}. Prospero Patches still lists ${ver}, which runs on FW ${escapeHtml(game.prospero_required_fw || "?")}.
${handoff}