mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 08:00:21 +02:00
Harden AppInstUtil install path and status tracking
This commit is contained in:
1 parent
79e1c377ed
commit
983f39fa89
6 files changed
+322
-27
No files matched your search
+106
-8
@@ -3,6 +3,7 @@ const API = {
|
||||
titles: "/api/titles",
|
||||
config: "/api/config",
|
||||
downloads: "/api/downloads",
|
||||
installStatus: "/api/installstatus",
|
||||
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
|
||||
};
|
||||
|
||||
@@ -49,6 +50,7 @@ const fallback = {
|
||||
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",
|
||||
},
|
||||
{
|
||||
@@ -57,6 +59,7 @@ const fallback = {
|
||||
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",
|
||||
},
|
||||
{
|
||||
@@ -65,6 +68,7 @@ const fallback = {
|
||||
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: "available",
|
||||
},
|
||||
],
|
||||
@@ -85,6 +89,7 @@ let state = {
|
||||
};
|
||||
|
||||
let downloadPollTimer = null;
|
||||
let installPollTimer = null;
|
||||
let emptyPolls = 0;
|
||||
const dlMeta = {}; // per-title speed tracking: { bytes, t, speed }
|
||||
|
||||
@@ -201,6 +206,7 @@ async function loadInitialData() {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -307,8 +313,8 @@ function gameCategory(game) {
|
||||
// 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 === "available" && isDownloadAllowed(game)) return "updatable";
|
||||
if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked";
|
||||
if (game.status === "incompatible_fw") return "needsfw";
|
||||
return "uptodate";
|
||||
}
|
||||
@@ -356,6 +362,7 @@ function createGameCard(game) {
|
||||
${game.downloading ? `<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">
|
||||
@@ -391,6 +398,15 @@ function createGameCard(game) {
|
||||
<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");
|
||||
@@ -433,13 +449,14 @@ 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 (!isInstallAllowed(game)) return null;
|
||||
if (game.resumable)
|
||||
if (game.resumable && isDownloadAllowed(game))
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
|
||||
if (game.downloaded && game.status === "available")
|
||||
if (game.downloaded && game.status === "available" && isInstallAllowed(game))
|
||||
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
|
||||
if (game.downloaded && game.status === "available") return null;
|
||||
if (game.status !== "available") return null;
|
||||
return state.config.install_after_download
|
||||
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." };
|
||||
}
|
||||
@@ -463,6 +480,10 @@ function statusPill(game) {
|
||||
return `<span class="pill">No patch info</span>`;
|
||||
}
|
||||
|
||||
function storagePill(game) {
|
||||
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
|
||||
}
|
||||
|
||||
function sourcePill(game) {
|
||||
const info = sourceInfo(game);
|
||||
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</span>`;
|
||||
@@ -493,6 +514,16 @@ function progressMetaHtml(d) {
|
||||
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;
|
||||
@@ -538,7 +569,8 @@ function reconcileFromJobs(jobs) {
|
||||
g.resumable = false;
|
||||
g.downloaded = true;
|
||||
g._wasActive = false;
|
||||
if (state.config.install_after_download && !g.installing && !g._autoInstalled) {
|
||||
if (state.config.install_after_download && isInstallAllowed(g) &&
|
||||
!g.installing && !g._autoInstalled) {
|
||||
g._autoInstalled = true;
|
||||
doInstall(g);
|
||||
}
|
||||
@@ -558,6 +590,53 @@ function reconcileFromJobs(jobs) {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -709,6 +788,8 @@ async function doDownload(game) {
|
||||
|
||||
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 {
|
||||
@@ -722,7 +803,8 @@ async function doInstall(game) {
|
||||
return false;
|
||||
}
|
||||
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.`);
|
||||
showToast(`${game.name}: installing update.`);
|
||||
startInstallPolling();
|
||||
renderGames(); renderLogs();
|
||||
return true;
|
||||
}
|
||||
@@ -787,11 +869,26 @@ function updateGame(titleId, patch) {
|
||||
/* ---------------- 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.patch_title_match !== false &&
|
||||
sourcePolicy(game).allow_download
|
||||
);
|
||||
}
|
||||
function isInstallAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
game.compatible_version &&
|
||||
game.patch_title_match !== false &&
|
||||
!hasSharedStorage(game) &&
|
||||
sourcePolicy(game).allow_install
|
||||
);
|
||||
}
|
||||
@@ -820,6 +917,7 @@ async function postJson(url, body) {
|
||||
|
||||
const REASON_TEXT = {
|
||||
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
|
||||
cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.",
|
||||
install_not_allowed_for_source: "Install blocked for this source.",
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
|
||||
Reference in new issue
Block a user