diff --git a/README.md b/README.md index 1901545..8473664 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ http://:12880/ ## Status -0.0.2, early. Title scan, source classification, version resolution, +0.0.3, early. Title scan, source classification, version resolution, firmware-compatibility filtering, target/storage-id handling, and the local AppInstUtil HTTP stream have been verified on firmware 11.60. PatchDL now downloads PS5 update manifests as merged piece packages under `/data/patchdl`; diff --git a/src/patchdl_net.c b/src/patchdl_net.c index 560441e..44548e6 100644 --- a/src/patchdl_net.c +++ b/src/patchdl_net.c @@ -344,14 +344,20 @@ curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow, } /* Returns 0 on success, -1 on download/network failure, -2 when an expected - SHA-256 was given and the downloaded bytes did not match it. */ + SHA-256 was given and the downloaded bytes did not match it, -3 when a byte + range was requested (range_start>0) but the server ignored it (no HTTP 206). + When range_start>0 the body is appended at the file's current position, so + the caller must have it positioned at range_start and must not verify. */ static int http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, progress_state_t *progress, - const char *expected_sha256_hex) { + const char *expected_sha256_hex, + long long range_start) { CURL *curl; CURLcode res; char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512]; + char range_hdr[48]; + long http_code = 0; struct curl_slist *rl = NULL; struct curl_blob ca_blob; curl_off_t dl = 0; @@ -400,6 +406,10 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0"); + if (range_start > 0) { + snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start); + curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr); + } if (progress && progress->cb) { curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb); @@ -407,6 +417,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, } res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl); curl_easy_cleanup(curl); curl_slist_free_all(rl); @@ -415,6 +426,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, if (sink.md) EVP_MD_CTX_free(sink.md); return -1; } + /* Asked for a byte range but the server sent the whole file (no 206): the + caller must drop the piece and re-fetch it whole. */ + if (range_start > 0 && http_code != 206) { + if (sink.md) EVP_MD_CTX_free(sink.md); + return -3; + } if (sink.md) { unsigned char dig[EVP_MAX_MD_SIZE]; @@ -440,7 +457,7 @@ patchdl_http_download_progress(const char *url, const char *dest_path, int rc; if (!fp) return -1; - rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL); + rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL, 0); fclose(fp); if (rc) { @@ -531,9 +548,9 @@ patchdl_http_download_manifest_progress(const char *manifest_url, json_u64_after(manifest.data, "originalFileSize", &manifest_total); /* Resume: reopen the existing partial and keep its bytes; else start clean. - Pieces already fully on disk are skipped, and the one piece that was only - partially written is dropped and re-fetched whole (piece-granular resume, - no HTTP range needed). */ + Fully-downloaded pieces are skipped; the one piece that was only partially + written continues mid-piece via an HTTP byte range (with a fall back to + re-fetching it whole if the CDN ignores the range). */ if (resume) { fp = fopen(dest_path, "r+b"); if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; } @@ -546,10 +563,11 @@ patchdl_http_download_manifest_progress(const char *manifest_url, while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) { char url[768]; char hash[80] = {0}; - long long got = 0; + long long got = 0, range_start = 0; unsigned long long expected = 0; unsigned long long offset = 0; int have_offset, drc; + const char *want_hash; const char *obj_end = strchr(p, '}'); if (json_string_after(p, "url", url, sizeof(url))) @@ -568,39 +586,68 @@ patchdl_http_download_manifest_progress(const char *manifest_url, continue; } - /* First piece we must (re)download: drop any partial bytes of it so the - appended data lines up exactly, then append from here on. */ + /* First piece to (re)download while resuming. If part of it is already + on disk, resume WITHIN it with a byte range; otherwise drop any stray + bytes and fetch it whole. After this, every piece is fetched whole. */ if (!started) { - long long start_at = have_offset ? (long long)offset : 0; - fflush(fp); - if (ftruncate(fileno(fp), (off_t)start_at) != 0) - goto done; /* can't resume cleanly; keep partial */ - fseek(fp, 0, SEEK_END); - total = start_at; + if (have_offset && expected && have > (long long)offset && + have < (long long)(offset + expected)) { + range_start = have - (long long)offset; /* this piece's bytes on disk */ + fseek(fp, 0, SEEK_END); /* append at `have` */ + total = have; + } else { + long long start_at = have_offset ? (long long)offset : 0; + fflush(fp); + if (ftruncate(fileno(fp), (off_t)start_at) != 0) + goto done; /* can't resume cleanly; keep partial */ + fseek(fp, 0, SEEK_END); + total = start_at; + } started = 1; } - /* Pieces are concatenated in array order; each one's fileOffset must - equal the bytes written so far, or the package would be corrupt. */ - if (have_offset && offset != (unsigned long long)total) + /* Whole pieces are concatenated in array order; a ranged (partial) piece + starts mid-piece, so the contiguity guard applies only to whole ones. */ + if (have_offset && range_start == 0 && offset != (unsigned long long)total) goto done; - if (verify) + /* A ranged piece can't be hashed (only its tail is fetched). */ + want_hash = NULL; + if (range_start == 0 && verify) { json_string_after(p, "hashValue", hash, sizeof(hash)); + want_hash = hash[0] ? hash : NULL; + } { progress_state_t progress = { cb, ctx, total, manifest_total ? (long long)manifest_total : 0 }; - /* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch. */ + /* drc: 0 ok, -1 network/cancel, -2 SHA-256, -3 range ignored. */ drc = http_download_to_file_progress(url, fp, &got, &progress, - hash[0] ? hash : NULL); + want_hash, range_start); + if (drc == -3) { + /* Server ignored the range: drop the piece and fetch it whole. */ + fflush(fp); + if (ftruncate(fileno(fp), (off_t)offset) != 0) + goto done; + fseek(fp, 0, SEEK_END); + total = (long long)offset; + range_start = 0; + if (verify) { + json_string_after(p, "hashValue", hash, sizeof(hash)); + want_hash = hash[0] ? hash : NULL; + } + progress.base = total; + drc = http_download_to_file_progress(url, fp, &got, &progress, + want_hash, 0); + } } if (drc) { if (drc == -2) rc = -2; goto done; } - if (expected && (unsigned long long)got != expected) + /* range_start + got = this piece's bytes now on disk. */ + if (expected && (unsigned long long)(range_start + got) != expected) goto done; total += got; diff --git a/src/patchdl_version.h b/src/patchdl_version.h index 2907afc..5fb897b 100644 --- a/src/patchdl_version.h +++ b/src/patchdl_version.h @@ -1,3 +1,3 @@ #pragma once -#define PATCHDL_VERSION "0.0.2" +#define PATCHDL_VERSION "0.0.3" diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 96220ac..05aec8c 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -43,7 +43,8 @@ static struct { static struct { int active; - int cancel; /* set by a cancel request; the worker aborts */ + int cancel; /* set by a cancel request; worker aborts + deletes */ + int pause; /* set by a pause request; worker aborts, keeps partial */ char title_id[32]; char name[128]; char version[16]; @@ -788,19 +789,19 @@ url_is_manifest(const char *url) { return n > 5 && !strcmp(url + n - 5, ".json"); } -/* Returns non-zero to abort the download when a cancel has been requested. */ +/* Returns non-zero to abort the download when a cancel OR pause is requested. */ static int download_progress_cb(void *ctx, long long downloaded, long long total) { - int cancel; + int abort_now; (void)ctx; pthread_mutex_lock(&g_mutex); if (g_dl.active) { g_dl.downloaded = downloaded; g_dl.total = total; } - cancel = g_dl.cancel; + abort_now = g_dl.cancel || g_dl.pause; pthread_mutex_unlock(&g_mutex); - return cancel; + return abort_now; } /* Delete a title's internal download directory and its contents (a partial or @@ -857,6 +858,23 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) { return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp)); } +/* Pause an in-progress download: the worker aborts but the partial is kept on + disk (and marked resumable), so it can be continued later. */ +static enum MHD_Result +do_pause(struct MHD_Connection *conn, const char *title_id) { + int active = 0; + pthread_mutex_lock(&g_mutex); + if (g_dl.active && !strcmp(g_dl.title_id, title_id)) { + g_dl.pause = 1; + active = 1; + } + pthread_mutex_unlock(&g_mutex); + if (!active) + return queue_json(conn, MHD_HTTP_CONFLICT, + "{\"ok\":false,\"reason\":\"not_downloading\"}"); + return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"paused\":true}"); +} + /* ---------- resume sidecar (/data/patchdl//state.json) ----------- */ static long long @@ -985,15 +1003,18 @@ do_download(struct MHD_Connection *conn, const char *title_id, : patchdl_http_download_progress(patch_url, dest, &bytes, download_progress_cb, NULL); if (dlrc) { - int was_cancel; + int was_cancel, was_pause; + long long have; pthread_mutex_lock(&g_mutex); was_cancel = g_dl.cancel; + was_pause = g_dl.pause; g_dl.active = 0; g_dl.cancel = 0; + g_dl.pause = 0; pthread_mutex_unlock(&g_mutex); if (was_cancel) { - /* user cancelled: drop the partial + its resume sidecar */ + /* user cancelled (stop): drop the partial + its resume sidecar */ unlink(dest); remove_dl_state(title_id); rmdir(dir); @@ -1002,6 +1023,16 @@ do_download(struct MHD_Connection *conn, const char *title_id, "{\"ok\":false,\"cancelled\":true," "\"reason\":\"download_cancelled\"}"); } + if (was_pause) { + /* user paused: keep the partial + sidecar; it is now resumable. */ + have = file_size(dest); + if (have < 0) have = 0; + set_title_resumable(title_id, have > 0, have); + snprintf(resp, sizeof(resp), + "{\"ok\":false,\"paused\":true,\"reason\":\"download_paused\"," + "\"bytes\":%lld}", have); + return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp)); + } if (dlrc == -2) { /* corrupt data (failed SHA-256): don't keep it for resume */ unlink(dest); @@ -1152,6 +1183,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) { if (!strcmp(action, "cancel")) return do_cancel(conn, title_id); + if (!strcmp(action, "pause")) + return do_pause(conn, title_id); + if (!strcmp(action, "check")) return queue_json(conn, MHD_HTTP_ACCEPTED, "{\"ok\":true,\"queued\":true,\"action\":\"check\"}"); diff --git a/web/app.js b/web/app.js index 8d746e8..981623c 100644 --- a/web/app.js +++ b/web/app.js @@ -325,8 +325,8 @@ function createGameCard(game) { const actions = document.createElement("div"); actions.className = "card-actions"; - const act = tileButton(game); - if (act) { + [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; @@ -334,16 +334,7 @@ function createGameCard(game) { if (act.disabled) btn.disabled = true; else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action)); actions.appendChild(btn); - } - // Delete a finished or paused (partial) download. - if ((game.downloaded || game.resumable) && !game.installing && !game.downloading) { - const del = document.createElement("button"); - del.className = "row-button is-ghost"; - del.textContent = "Delete"; - del.title = game.resumable ? "Delete the partial download" : "Delete the downloaded package"; - del.addEventListener("click", () => cancelDownload(game.title_id)); - actions.appendChild(del); - } + }); row.append(lead, body, actions); card.appendChild(row); @@ -394,23 +385,32 @@ function buildToggle(game) { 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) { +// 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: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" }; + 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 (!isInstallAllowed(game)) return null; + if (game.resumable) + return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." }; if (game.downloaded && game.status === "available") return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." }; - if (game.resumable) - return { label: "Resume", action: "download", variant: "update", hint: "Resume the interrupted download where it stopped." }; 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." }; } +// 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; +} + function statusPill(game) { if (game.installing) return `<span class="pill warn">Installing…</span>`; if (game.status === "checking") return `<span class="pill">Checking…</span>`; @@ -606,12 +606,20 @@ async function doDownload(game) { 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). + // Cancel / pause / 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}.`); + if (r && r.paused) { + // Paused: keep the partial, mark resumable so the tile shows Resume. + game.resumable = true; + game.partial_bytes = r.bytes || game.partial_bytes || 0; + state.logs.push(`[${timeNow()}] Download paused: ${game.title_id} (${formatBytes(game.partial_bytes)})`); + showToast(`${game.name}: paused at ${formatBytes(game.partial_bytes)}.`); + } else { + const what = r && r.cancelled ? "cancelled" : "failed"; + state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`); + showToast(`${game.name}: download ${what}.`); + } renderGames(); renderLogs(); stopDownloadPolling(); return false; } @@ -674,10 +682,22 @@ async function runTitleAction(titleId, action) { if (!game) return; if (action === "download") 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 === "update") { if (await doDownload(game)) await doInstall(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; @@ -733,6 +753,8 @@ const REASON_TEXT = { 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.", }; function reasonText(error) { const r = error && error.body && error.body.reason; diff --git a/web/index.html b/web/index.html index c8acac8..c4abd94 100644 --- a/web/index.html +++ b/web/index.html @@ -39,7 +39,7 @@ <div class="brand-mark">PD</div> <div> <strong>PatchDL</strong> - <span>by Knutwurst · v0.0.2</span> + <span>by Knutwurst · v0.0.3</span> </div> </div> diff --git a/web/styles.css b/web/styles.css index d21b95b..ca238ad 100644 --- a/web/styles.css +++ b/web/styles.css @@ -331,10 +331,13 @@ h2 { font-size: 18px; line-height: 1.2; } width: 100%; /* fills the fixed-width actions column -> never reflows */ padding: 0 12px; } -.row-button.is-update { border-color: var(--blue); color: #04111f; background: var(--blue); } -.row-button.is-update:hover { background: var(--blue-dark); } -.row-button.is-cancel { border-color: var(--amber); color: #1c1402; background: var(--amber); } -.row-button.is-cancel:hover { background: var(--amber-dark); } +/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */ +.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); } +.row-button.is-update:hover { background: var(--green-dark); } +.row-button.is-pause { border-color: var(--amber); color: #1c1402; background: var(--amber); } +.row-button.is-pause:hover { background: var(--amber-dark); } +.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); } +.row-button.is-cancel:hover { background: #ff8a8a; } .row-button.is-ghost { background: transparent; color: var(--muted); } .row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); } .row-button:disabled { opacity: 0.6; cursor: default; }