mirror of
https://github.com/MrLowPr/overdrive.git
synced 2026-10-23 10:01:25 +02:00
1370 lines
62 KiB
JavaScript
1370 lines
62 KiB
JavaScript
// ../pub-game-store/native/state/download-queue.js
|
|
// reconstructed from the esbuild bundle in eboot.bin
|
|
// MODIFIED: an Archive.org refusal fails over or fails as a dead link instead of asking for the browser.
|
|
// MODIFIED: an archive the catalog's password doesn't open is tried with the known release passwords.
|
|
// MODIFIED: any provider that needs the console browser (LinkVault's mirrors, Akirabox, Datanodes)
|
|
// has its file link captured from the browser, like Vikingfile's.
|
|
|
|
import { INCOMPLETE_PART, STALE_PARTIAL, cause } from '../downloads/errors.js';
|
|
import { t } from '../i18n/core.js';
|
|
import { archivePassword } from './archive-password.js';
|
|
import { BrowserRequired, archiveRefusal, cancellation, createSourceResolver, sourceKind, webURL } from './download-sources.js';
|
|
import { LINK_PREFIX, captureAnchor, validateCapturedLink } from './browser-links.js';
|
|
import { PKG_FIRMWARE_ERROR, fakePackagesInstall, isPs5Title } from './firmware.js';
|
|
import { readJSON } from './read-json.js';
|
|
import { mountedImages, shadowRequest, within2 } from './shadowmount.js';
|
|
import { MARGIN2, spaceShortfall, stillNeeded } from './space.js';
|
|
import { VIKING_PREFIX, capturedVikingFile, describeVikingCapture, linkPageMetadata, validateVikingCapture, vikingLinkPage } from './viking-capture.js';
|
|
|
|
var ACTIVE = ["resolving", "connecting", "downloading", "verifying", "extracting", "installing", "pausing"];
|
|
var BUSY = ["queued", ...ACTIVE];
|
|
var COMPLETE = ["downloaded", "file-downloaded", "installer-required", "installed", "extraction-required", "extracted"];
|
|
var ADOPTED = "A partial download from another source holds this name. Resume it from its original source, or delete it in Files.";
|
|
var DELETED = "Its partial files were deleted. Restart to download it again, or remove it.";
|
|
var REMOVAL_CUT = "The app closed while deleting its files. Delete it again.";
|
|
// The passwords the catalog's archives are published with. Some entries name none or the wrong one,
|
|
// so after the catalog's own (or none) these are tried in turn. A wrong or missing password is caught
|
|
// on the archive's headers before anything is written ("RAR archive password is missing.",
|
|
// "Incorrect RAR archive password.", "Incorrect RAR5 archive password."), and a failed attempt leaves
|
|
// no staging behind, so each one costs a moment. They are checked on the console: nothing counts them.
|
|
var KNOWN_PASSWORDS = ["DLPSGAME.COM", "[DLPSGAME.COM]", "www.DLPSGAME.COM", "SuperPSX", "downloadgameps3.com", "hako"];
|
|
var PASSWORD_FAILURE = /archive password|passphrase/i;
|
|
function durable(fs, stateDir2) {
|
|
try {
|
|
fs.mkdir(stateDir2, { recursive: true });
|
|
fs.exists(`${stateDir2}/downloads.json`);
|
|
return true;
|
|
} catch (error2) {
|
|
console.log(`[download-queue] ${stateDir2} unavailable, queue kept in the app data: ${error2.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
function restore(saved) {
|
|
return saved.jobs.slice(0, 32).filter((job2) => {
|
|
const valid = job2?.release?.id && job2.root && job2.artifact?.destination;
|
|
if (!valid) console.log(`[download-queue] dropped an invalid queue entry ${job2?.id ?? ""}`);
|
|
return valid;
|
|
}).map((job2) => {
|
|
delete job2.captureState;
|
|
delete job2.captureError;
|
|
if (job2.state === "installing") return { ...job2, state: "installer-required" };
|
|
if (job2.state === "extracting") job2.written = job2.total;
|
|
if (job2.state === "removing") {
|
|
job2.state = job2.removingFrom ?? "failed";
|
|
job2.error = REMOVAL_CUT;
|
|
}
|
|
delete job2.phase;
|
|
delete job2.removingFrom;
|
|
const interrupted = BUSY.includes(job2.state);
|
|
return {
|
|
...job2,
|
|
state: interrupted ? "paused" : job2.state,
|
|
error: interrupted ? "Interrupted. Resume to continue." : job2.error,
|
|
...interrupted && job2.state !== "pausing" && { restore: job2.state === "queued" ? "queued" : "running" }
|
|
};
|
|
});
|
|
}
|
|
function createDownloadQueue({
|
|
fs,
|
|
downloads,
|
|
http,
|
|
formats,
|
|
dataDir,
|
|
stateDir: stateDir2 = `${dataDir}/overdrive`,
|
|
platform,
|
|
notifications,
|
|
power,
|
|
linking,
|
|
sources,
|
|
archives,
|
|
packages,
|
|
releasesOf,
|
|
browserCapture,
|
|
wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
}) {
|
|
const legacy = `${dataDir}/overdrive/downloads.json`;
|
|
const directory = stateDir2 === `${dataDir}/overdrive` || durable(fs, stateDir2) ? stateDir2 : `${dataDir}/overdrive`;
|
|
const file = `${directory}/downloads.json`;
|
|
const listeners4 = /* @__PURE__ */ new Set();
|
|
sources ?? (sources = createSourceResolver({ http }));
|
|
const captures = /* @__PURE__ */ new Map();
|
|
let jobs = [];
|
|
let active2 = null;
|
|
let ticks = 0;
|
|
let awake2 = false;
|
|
const modified = (path) => {
|
|
try {
|
|
return fs.stat(path)?.modified ?? 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
const candidates = [.../* @__PURE__ */ new Set([file, `${file}.tmp`, legacy])].filter((path) => {
|
|
try {
|
|
return fs.exists(path);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}).sort((a, b) => modified(b) - modified(a));
|
|
let stored = null;
|
|
for (const path of candidates) {
|
|
const saved = readJSON(
|
|
fs,
|
|
path,
|
|
null,
|
|
(value) => value?.version === 1 && Array.isArray(value.jobs),
|
|
(message) => console.log(`[download-queue] ${message}`)
|
|
);
|
|
if (saved) {
|
|
jobs = restore(saved);
|
|
stored = path;
|
|
break;
|
|
}
|
|
}
|
|
const snapshot = () => jobs.map((job2) => ({ ...job2 }));
|
|
const emit2 = () => {
|
|
const busy = captures.size > 0 || jobs.some((job2) => BUSY.includes(job2.state));
|
|
if (busy !== awake2) {
|
|
awake2 = busy;
|
|
power.keepAwake(busy);
|
|
}
|
|
const value = snapshot();
|
|
for (const listener of listeners4) listener(value);
|
|
};
|
|
const save = ({ sync = true } = {}) => {
|
|
fs.mkdir(directory, { recursive: true });
|
|
fs.writeFile(`${file}.tmp`, JSON.stringify({ version: 1, jobs }), { sync });
|
|
fs.rename(`${file}.tmp`, file);
|
|
};
|
|
const change = () => {
|
|
save();
|
|
emit2();
|
|
};
|
|
if (legacy !== file && fs.exists(legacy)) {
|
|
try {
|
|
if (stored === legacy) save();
|
|
fs.rename(legacy, `${legacy}.moved`);
|
|
} catch (error2) {
|
|
console.log(`[download-queue] queue not moved to ${file}: ${error2.message}`);
|
|
}
|
|
}
|
|
if (stored === `${file}.tmp`) {
|
|
try {
|
|
save();
|
|
} catch (error2) {
|
|
console.log(`[download-queue] queue not saved to ${file}: ${error2.message}`);
|
|
}
|
|
}
|
|
const find = (id) => {
|
|
const job2 = jobs.find((item) => item.id === id);
|
|
if (!job2) throw new Error("Download is no longer in the queue.");
|
|
return job2;
|
|
};
|
|
const artifactFor = (release, root2) => release.parts ? {
|
|
directory: `${root2}/downloads/sets/${release.filename}`,
|
|
destination: `${root2}/downloads/extracted/${release.filename}`,
|
|
nextAction: "extracted"
|
|
} : formats.artifact(release, root2);
|
|
function failOver(job2, error2) {
|
|
if (job2.written || job2.parts?.some((part) => part.written) || /space|storage|drive|disconnected/i.test(cause(error2.message))) return false;
|
|
while (job2.fallbacks?.length) {
|
|
const next = job2.fallbacks.shift();
|
|
const artifact2 = artifactFor(next, job2.root);
|
|
if (fs.exists(artifact2.destination) || fs.exists(`${artifact2.destination}.part`) || jobs.some((other) => other !== job2 && other.artifact.destination === artifact2.destination)) continue;
|
|
job2.message = t("downloads.switchedSource", { failed: job2.release.provider, next: next.provider });
|
|
job2.release = { ...next };
|
|
job2.artifact = artifact2;
|
|
job2.total = next.sizeBytes || null;
|
|
for (const key of ["options", "browserUrl", "directURL", "parts", "partIndex", "currentSource", "downloadDestination", "inspected", "adopted"]) delete job2[key];
|
|
job2.error = "";
|
|
job2.state = "queued";
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function reresolve(job2) {
|
|
const source2 = job2.parts ? job2.currentSource : job2.release;
|
|
if ((job2.reresolved || 0) >= 2 || ["direct", "browser", "datanodes", "manifest", "unavailable"].includes(sourceKind(source2))) return false;
|
|
job2.reresolved = (job2.reresolved || 0) + 1;
|
|
delete job2.options;
|
|
if (job2.parts) delete job2.parts[job2.partIndex].options;
|
|
job2.error = "";
|
|
job2.state = "queued";
|
|
return true;
|
|
}
|
|
async function install(job2) {
|
|
const paths = job2.packages ?? [job2.downloadDestination || job2.artifact.destination];
|
|
const backport = (path) => /backport/i.test(path);
|
|
try {
|
|
for (const path of [...paths.filter((path2) => !backport(path2)), ...paths.filter(backport)]) {
|
|
job2.state = "installing";
|
|
job2.written = 0;
|
|
job2.total = null;
|
|
job2.error = "";
|
|
job2.message = "";
|
|
change();
|
|
const task = packages.install({ path, name: job2.title });
|
|
active2.task = task;
|
|
const off = task.subscribe((progress) => {
|
|
job2.written = progress.written || 0;
|
|
job2.total = progress.total ?? null;
|
|
emit2();
|
|
});
|
|
try {
|
|
await task.done;
|
|
} catch (error2) {
|
|
console.log(`[download-queue] install refused ${path}:`, error2.message);
|
|
if (!backport(path) || paths.every(backport)) throw error2;
|
|
} finally {
|
|
off();
|
|
}
|
|
}
|
|
job2.state = "installed";
|
|
job2.message = t("downloads.installed");
|
|
notifications.show(t("downloads.notice.installed"), job2.title);
|
|
} catch (error2) {
|
|
job2.state = "installer-required";
|
|
// The install is always tried: the firmware the title is told can be raised by the jailbreak.
|
|
// Refused where it reads above 11.60 (an archive can turn out to hold a PKG, issue #6), the
|
|
// likely reason is said instead of the service's code; a full drive still reads as one.
|
|
job2.error = isPs5Title(job2.gameId) && !fakePackagesInstall() && !/space/i.test(error2.message) ? PKG_FIRMWARE_ERROR : error2.message;
|
|
job2.message = t("downloads.installFailed");
|
|
}
|
|
job2.bytesPerSecond = 0;
|
|
delete job2.install;
|
|
change();
|
|
}
|
|
const canInstall = () => Boolean(packages) && platform === "ps5";
|
|
async function execute(job2) {
|
|
let task;
|
|
let unsubscribe;
|
|
if (job2.install) {
|
|
try {
|
|
await install(job2);
|
|
} finally {
|
|
active2 = null;
|
|
pump();
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
active2.cancel = cancellation();
|
|
const signal = active2.cancel.signal;
|
|
job2.state = "resolving";
|
|
job2.error = "";
|
|
change();
|
|
if (job2.release.parts) {
|
|
await executeSet(job2, signal);
|
|
return;
|
|
}
|
|
job2.downloadDestination || (job2.downloadDestination = job2.artifact.destination);
|
|
if (job2.routing) finishRouting(job2);
|
|
if (!job2.options) {
|
|
if (job2.release.manifestUrl) {
|
|
const response = await http.request(job2.release.manifestUrl, { maxBytes: 4 * 1024 * 1024, signal });
|
|
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}. Choose another source or retry.`);
|
|
job2.options = formats.manifest(await response.json());
|
|
} else {
|
|
if (!job2.release.url && !job2.release.browserUrl) throw new Error("This release has no download URL.");
|
|
const resolved = job2.directURL || sourceKind(job2.release) !== "direct" ? await sources.resolve(job2.release, { signal, directURL: job2.directURL }) : { url: webURL(job2.release.url).url };
|
|
job2.options = {
|
|
...resolved,
|
|
rejectHtml: true,
|
|
expectedBytes: job2.release.sizeBytes || void 0,
|
|
sha256: job2.release.sha256 || void 0
|
|
};
|
|
delete job2.directURL;
|
|
}
|
|
job2.total = job2.options.expectedBytes || null;
|
|
change();
|
|
}
|
|
if (job2.state === "pausing") return;
|
|
const root2 = storageIdentity(job2);
|
|
if (!job2.rootIdentity) {
|
|
job2.rootIdentity = root2;
|
|
change();
|
|
}
|
|
const held = Math.max(job2.written, fs.stat(`${job2.downloadDestination}.part`)?.size || 0);
|
|
if (!fs.exists(job2.downloadDestination) && fs.diskUsage(job2.root).free < stillNeeded({ ...job2, written: held }) + MARGIN2)
|
|
throw new Error("Not enough space on the selected drive. Free space and retry.");
|
|
fs.mkdir(job2.artifact.directory, { recursive: true });
|
|
active2.transferring = true;
|
|
task = downloads.enqueue({ ...job2.options, rejectHtml: true, mirrors: void 0, connections: 64, adaptive: true, rangeBytes: 16 * 1024 * 1024, destination: job2.downloadDestination, storageRoot: job2.root, recoverCompleted: true });
|
|
active2.task = task;
|
|
unsubscribe = task.subscribe((progress) => {
|
|
if (job2.state === "pausing") return;
|
|
if (ACTIVE.includes(progress.state)) job2.state = progress.state;
|
|
job2.written = progress.written || 0;
|
|
job2.total = progress.total ?? job2.total;
|
|
job2.received = progress.received || 0;
|
|
job2.connections = progress.connections || 0;
|
|
job2.bytesPerSecond = progress.bytesPerSecond || 0;
|
|
if (job2.written > 0) delete job2.adopted;
|
|
if (++ticks % 20 === 0) {
|
|
delete job2.launches;
|
|
try {
|
|
save({ sync: false });
|
|
} catch (error2) {
|
|
job2.error = `Queue save failed: ${error2.message}`;
|
|
task.cancel();
|
|
}
|
|
if (["extraction-required", "downloaded"].includes(job2.artifact.nextAction)) checkHeaders(job2, `${job2.downloadDestination}.part`, task);
|
|
}
|
|
emit2();
|
|
});
|
|
await task.done;
|
|
if (job2.artifact.nextAction === "downloaded") route(job2);
|
|
job2.state = job2.artifact.nextAction === "shadowmount" ? "downloaded" : job2.artifact.nextAction === "downloaded" ? "file-downloaded" : job2.artifact.nextAction;
|
|
job2.written = job2.total || job2.written;
|
|
job2.bytesPerSecond = 0;
|
|
if ((job2.artifact.nextAction === "extraction-required" || job2.output) && archives) await extract(job2, [job2.downloadDestination]);
|
|
delete job2.reresolved;
|
|
notifications.show(t("downloads.notice.complete"), job2.title);
|
|
if (job2.state === "installer-required" && canInstall()) await install(job2);
|
|
} catch (error2) {
|
|
console.log("[download-queue] transfer stopped:", error2.message);
|
|
// The engine words every 401/403 as a possible browser check; from Archive.org it is a
|
|
// restricted or removed item, so it fails over like a dead link and reads as one.
|
|
const refused = archiveRefusal(job2.parts ? job2.currentSource : job2.release, cause(error2.message));
|
|
if (refused) error2 = new Error(`Source unavailable: HTTP ${refused}. Choose another source.`);
|
|
const browser = !refused && (error2 instanceof BrowserRequired || /browser verification required/i.test(cause(error2.message)));
|
|
const retried = job2.state !== "pausing" && !job2.cancelling && browser && active2.transferring && reresolve(job2);
|
|
const switched = !retried && job2.state !== "pausing" && !job2.cancelling && failOver(job2, error2);
|
|
if (!retried && !switched && job2.state !== "pausing") {
|
|
job2.state = browser ? "awaiting-browser" : "failed";
|
|
job2.error || (job2.error = job2.adopted && STALE_PARTIAL.test(cause(error2.message)) ? ADOPTED : error2.message);
|
|
if (browser) {
|
|
job2.browserUrl = error2.url || job2.currentSource?.browserUrl || job2.currentSource?.url || job2.release.browserUrl || job2.release.url;
|
|
delete job2.options;
|
|
if (job2.parts) delete job2.parts[job2.partIndex].options;
|
|
} else if (!job2.cancelling) notifications.show(t("downloads.notice.failed"), job2.title);
|
|
}
|
|
} finally {
|
|
unsubscribe?.();
|
|
if (job2.state === "pausing") job2.state = "paused";
|
|
if (COMPLETE.includes(job2.state)) delete job2.launches;
|
|
if (job2.cancelling) {
|
|
try {
|
|
await forget(job2);
|
|
} catch (error2) {
|
|
delete job2.cancelling;
|
|
job2.error = error2.message;
|
|
}
|
|
}
|
|
active2 = null;
|
|
try {
|
|
change();
|
|
} catch (error2) {
|
|
job2.error = `Queue save failed: ${error2.message}`;
|
|
emit2();
|
|
}
|
|
pump();
|
|
}
|
|
}
|
|
const inspections = /* @__PURE__ */ new WeakMap();
|
|
function checkHeaders(job2, path, task) {
|
|
const tries = inspections.get(job2) || 0;
|
|
if (job2.inspected || tries >= 3 || !archives?.inspect || !fs.exists(path)) return;
|
|
inspections.set(job2, tries + 1);
|
|
const started = Date.now();
|
|
const { kind, refusal } = archives.inspect([path]);
|
|
if (Date.now() - started > 250) console.log(`[download-queue] header check took ${Date.now() - started} ms`);
|
|
if (!kind) return;
|
|
job2.inspected = kind;
|
|
if (refusal) {
|
|
job2.error = refusal;
|
|
task.cancel();
|
|
}
|
|
}
|
|
function route(job2) {
|
|
if (!archives?.inspect) return;
|
|
const { kind, refusal } = archives.inspect([job2.downloadDestination]);
|
|
if (refusal) throw new Error(refusal);
|
|
// A source listed without a format is kept only for what its headers turn out to be: when they
|
|
// are none Overdrive knows, the host sent something else, usually a web page. Leaving it as a
|
|
// downloaded ".binary" (issue #8) told the user nothing.
|
|
if (!kind && job2.release.format === "binary") throw new Error("Not a game file: the host sent something else.");
|
|
if (!kind) return;
|
|
const artifact2 = formats.detected(job2.release, job2.root, kind);
|
|
if (artifact2.nextAction === "extraction-required") {
|
|
job2.artifact = { ...job2.artifact, format: kind, nextAction: artifact2.nextAction };
|
|
return;
|
|
}
|
|
if (fs.exists(artifact2.destination)) throw new Error("Destination already exists. Choose another release or inspect the existing file.");
|
|
fs.mkdir(artifact2.directory, { recursive: true });
|
|
job2.routing = artifact2;
|
|
change();
|
|
finishRouting(job2);
|
|
}
|
|
function finishRouting(job2) {
|
|
const from = job2.downloadDestination, to = job2.routing.destination;
|
|
if (fs.exists(from)) fs.rename(from, to);
|
|
if (fs.exists(`${from}.complete`)) fs.rename(`${from}.complete`, `${to}.complete`);
|
|
job2.artifact = job2.routing;
|
|
job2.downloadDestination = to;
|
|
delete job2.routing;
|
|
change();
|
|
}
|
|
function gameFolder(output) {
|
|
for (let depth = 0; depth <= 16; depth++) {
|
|
if (fs.exists(`${output}/eboot.bin`) && fs.exists(`${output}/sce_sys/param.json`)) return output;
|
|
let children;
|
|
try {
|
|
children = fs.readDir(output).filter((entry2) => entry2.isDirectory && !entry2.name.startsWith(".ps5-react"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (children.length !== 1) return null;
|
|
output = `${output}/${children[0].name}`;
|
|
}
|
|
return null;
|
|
}
|
|
// The extractor writes files 0666, without execute: ShadowMount mounts the folder, but its
|
|
// eboot.bin won't launch (CE-107750-0, issue #11) until everything is opened up, as the updater
|
|
// does for Overdrive's own folder. A frame every 256 entries keeps a large game from stalling the UI.
|
|
async function openUp(path) {
|
|
let count = 0;
|
|
const visit = async (dir2) => {
|
|
fs.chmod(dir2, 511);
|
|
for (const entry2 of fs.readDir(dir2)) {
|
|
if (++count % 256 === 0) await wait(0);
|
|
if (entry2.isDirectory) await visit(`${dir2}/${entry2.name}`);
|
|
else fs.chmod(`${dir2}/${entry2.name}`, 511);
|
|
}
|
|
};
|
|
try {
|
|
await visit(path);
|
|
} catch (error2) {
|
|
console.log("[download-queue] permissions not opened up:", error2.message);
|
|
}
|
|
}
|
|
function extraction(job2, files) {
|
|
if (!archives) throw new Error("Archive extraction requires the updated PS5 React runtime.");
|
|
if (releasesOf) {
|
|
const stored2 = job2.release.parts ? job2.release.parts.flatMap((part) => part.sources) : [job2.release];
|
|
const current = releasesOf(job2.gameId).flatMap((release) => release.parts ? release.parts.flatMap((part) => part.sources) : [release]);
|
|
for (const source2 of stored2) {
|
|
const match = current.find((item) => item.url && item.url === source2.url && item.browserUrl === source2.browserUrl);
|
|
if (source2.archivePassword == null && match?.archivePassword) source2.archivePassword = match.archivePassword;
|
|
}
|
|
}
|
|
const password = archivePassword(job2.release);
|
|
storageIdentity(job2);
|
|
const directory2 = `${job2.root}/downloads/extracted`;
|
|
fs.mkdir(directory2, { recursive: true });
|
|
job2.output || (job2.output = `${directory2}/${job2.release.filename.replace(/[^a-z0-9_.-]/gi, "_")}`);
|
|
if (job2.publishTo && fs.exists(job2.publishTo)) job2.output = job2.publishTo;
|
|
return {
|
|
sources: files,
|
|
destination: job2.output,
|
|
...password !== void 0 ? { password } : {},
|
|
maxBytes: fs.exists(job2.output) ? 1024 ** 4 : Math.min(1024 ** 4, Math.max(1, fs.diskUsage(job2.root).free - MARGIN2))
|
|
};
|
|
}
|
|
const STREAM_FAILURE = /RAR worker|Only a RAR set|extraction thread|archive task is already running|^RAR extraction failed|^Extraction cancelled/;
|
|
async function forgetPart(job2, error2) {
|
|
const part = job2.parts?.[Number(INCOMPLETE_PART.exec(error2.message)?.[1]) - 1];
|
|
if (!part) return false;
|
|
try {
|
|
await Promise.all(["", ".complete"].map((suffix) => fs.removeAsync(part.destination + suffix)));
|
|
} catch (failure) {
|
|
console.log("[download-queue] incomplete volume not deleted:", failure.message);
|
|
return false;
|
|
}
|
|
part.completed = false;
|
|
part.written = 0;
|
|
delete part.options;
|
|
return true;
|
|
}
|
|
async function extract(job2, files, streamed) {
|
|
const options = extraction(job2, files);
|
|
const downloaded = { written: job2.written, total: job2.total };
|
|
let estimate = files.reduce((sum, path) => sum + (fs.stat(path)?.size || 0), 0) || null;
|
|
job2.state = "extracting";
|
|
job2.written = 0;
|
|
job2.total = estimate;
|
|
job2.bytesPerSecond = 0;
|
|
change();
|
|
let sample = { at: Date.now(), bytes: 0 };
|
|
const report = (bytes) => {
|
|
const now = Date.now();
|
|
job2.written = estimate ? Math.min(bytes, estimate * 0.99) : bytes;
|
|
if (now - sample.at >= 1e3) {
|
|
const speed = (bytes - sample.bytes) * 1e3 / (now - sample.at);
|
|
job2.bytesPerSecond = job2.bytesPerSecond ? job2.bytesPerSecond * 0.7 + speed * 0.3 : speed;
|
|
sample = { at: now, bytes };
|
|
}
|
|
emit2();
|
|
};
|
|
const follow = (progress) => {
|
|
if (progress.total && progress.total !== estimate) job2.total = estimate = progress.total;
|
|
if (progress.phase !== "finishing") {
|
|
report(progress.written || 0);
|
|
return;
|
|
}
|
|
if (job2.phase === "finishing") return;
|
|
job2.phase = "finishing";
|
|
job2.bytesPerSecond = 0;
|
|
if (job2.total) job2.written = job2.total;
|
|
emit2();
|
|
};
|
|
let result;
|
|
try {
|
|
if (streamed) {
|
|
active2.task = streamed.task;
|
|
sample = { at: Date.now(), bytes: streamed.written };
|
|
report(streamed.written);
|
|
const off = streamed.task.subscribe(follow);
|
|
const outcome = await streamed.done;
|
|
off();
|
|
if (outcome.error && (job2.state === "pausing" || job2.cancelling)) throw outcome.error;
|
|
// A password the stream couldn't use gets the known ones below, once every volume is here.
|
|
if (outcome.error && !STREAM_FAILURE.test(cause(outcome.error.message)) && !PASSWORD_FAILURE.test(cause(outcome.error.message))) throw outcome.error;
|
|
if (outcome.error) {
|
|
console.log("[download-queue] extraction during download stopped, extracting again:", outcome.error.message);
|
|
job2.written = 0;
|
|
delete job2.phase;
|
|
sample = { at: Date.now(), bytes: 0 };
|
|
emit2();
|
|
}
|
|
result = outcome.result;
|
|
}
|
|
if (!result) {
|
|
if (!fs.exists(job2.output) && fs.diskUsage(job2.root).free < (estimate || 0) + MARGIN2)
|
|
throw new Error("Not enough space to extract this download. Free space and retry.");
|
|
const { password: listed, ...unlocked } = options;
|
|
const passwords = [listed, ...KNOWN_PASSWORDS.filter((password) => password !== listed)];
|
|
for (let index = 0; !result; index++) {
|
|
const task = archives.extract(passwords[index] === void 0 ? unlocked : { ...unlocked, password: passwords[index] });
|
|
active2.task = task;
|
|
const off = task.subscribe(follow);
|
|
try {
|
|
result = await task.done;
|
|
} catch (error2) {
|
|
const last = index + 1 >= passwords.length;
|
|
if (last || job2.state === "pausing" || job2.cancelling || !PASSWORD_FAILURE.test(cause(error2.message))) throw error2;
|
|
console.log(`[download-queue] archive password ${index + 1} of ${passwords.length} did not open it, trying the next`);
|
|
job2.written = 0;
|
|
sample = { at: Date.now(), bytes: 0 };
|
|
emit2();
|
|
} finally {
|
|
off();
|
|
}
|
|
}
|
|
}
|
|
} catch (error2) {
|
|
if (await forgetPart(job2, error2)) downloaded.written = job2.parts.reduce((sum, part) => sum + part.written, 0);
|
|
throw error2;
|
|
} finally {
|
|
Object.assign(job2, downloaded, { bytesPerSecond: 0 });
|
|
delete job2.phase;
|
|
}
|
|
const images = (result.artifacts || []).filter((path) => /\.(ffpfsc|ffpfs|ffpkg|exfat)$/i.test(path));
|
|
if (images.length) {
|
|
const destination = `${job2.root}/homebrew/${job2.release.filename.replace(/[^a-z0-9_.-]/gi, "_")}-extracted`;
|
|
fs.mkdir(`${job2.root}/homebrew`, { recursive: true });
|
|
job2.publishTo = destination;
|
|
change();
|
|
if (job2.output !== destination) {
|
|
if (fs.exists(destination)) throw new Error("Extracted destination already exists; preserve it and inspect before retrying.");
|
|
storageIdentity(job2);
|
|
fs.rename(job2.output, destination);
|
|
job2.output = destination;
|
|
}
|
|
job2.artifact.nextAction = "shadowmount";
|
|
job2.artifact.destination = `${job2.output}/${images[0]}`;
|
|
job2.state = "downloaded";
|
|
job2.message = t("downloads.extractedReady", { parts: job2.parts ? "all" : "one" });
|
|
} else {
|
|
job2.state = "extracted";
|
|
job2.message = t("downloads.extractedTo", { path: job2.output });
|
|
const packaged = (result.artifacts || []).filter((path) => /\.(pkg|fpkg)$/i.test(path));
|
|
const folder = packaged.length ? null : gameFolder(job2.output);
|
|
if (packaged.length) {
|
|
job2.packages = packaged.map((path) => `${job2.output}/${path}`);
|
|
job2.state = "installer-required";
|
|
job2.message = t("downloads.packagesExtracted", { path: job2.output });
|
|
} else if (folder) {
|
|
const destination = `${job2.root}/homebrew/${job2.release.filename.replace(/[^a-z0-9_.-]/gi, "_")}-app`;
|
|
fs.mkdir(`${job2.root}/homebrew`, { recursive: true });
|
|
if (fs.exists(destination)) throw new Error("Extracted destination already exists; preserve it and inspect before retrying.");
|
|
storageIdentity(job2);
|
|
fs.rename(folder, destination);
|
|
if (folder !== job2.output) await fs.removeAsync(job2.output);
|
|
await openUp(destination);
|
|
job2.output = destination;
|
|
job2.publishTo = destination;
|
|
job2.artifact.nextAction = "shadowmount";
|
|
job2.artifact.destination = destination;
|
|
job2.state = "downloaded";
|
|
job2.message = t("downloads.extractedReady", { parts: job2.parts ? "all" : "one" });
|
|
}
|
|
}
|
|
change();
|
|
try {
|
|
await Promise.all(files.flatMap((path) => [path, `${path}.complete`]).map((path) => fs.removeAsync(path)));
|
|
if (job2.release.parts) await fs.removeAsync(setDirectory(job2));
|
|
} catch (error2) {
|
|
console.log("[download-queue] archive not deleted:", error2.message);
|
|
job2.message = `${job2.message} ${t("downloads.archiveNotDeleted", { error: error2.message })}`;
|
|
}
|
|
}
|
|
function streamSet(job2) {
|
|
const first = job2.parts[0].destination;
|
|
if (!archives?.streams || job2.parts.length < 2 || archives.inspect([first]).kind !== "rar") return null;
|
|
let task;
|
|
try {
|
|
const options = extraction(job2, job2.parts.map((part) => part.destination));
|
|
if (fs.exists(options.destination)) return null;
|
|
change();
|
|
task = archives.extract({ ...options, stream: true });
|
|
} catch (error2) {
|
|
console.log("[download-queue] extraction during download not started:", error2.message);
|
|
return null;
|
|
}
|
|
const streamed = { task, written: 0 };
|
|
const off = task.subscribe((progress) => {
|
|
streamed.written = progress.written || 0;
|
|
});
|
|
streamed.done = task.done.then((result) => ({ result }), (error2) => ({ error: error2 })).finally(off);
|
|
return streamed;
|
|
}
|
|
const setDirectory = (job2) => `${job2.root}/downloads/sets/${job2.release.filename}`;
|
|
function setNeed(job2, extracted = 0) {
|
|
const sizes = job2.parts.map((item) => item.total ?? item.options?.expectedBytes ?? null);
|
|
const largest = Math.max(0, ...sizes.filter(Boolean));
|
|
const size = (index) => sizes[index] ?? largest;
|
|
return Math.max(0, job2.parts.reduce((sum, item, index) => sum + size(index) + (item.completed ? 0 : Math.max(0, size(index) - item.written)), 0) - extracted);
|
|
}
|
|
function ownedPaths(job2) {
|
|
const managed = (path) => {
|
|
const parts = path.startsWith(`${job2.root}/`) ? path.slice(job2.root.length + 1).split("/") : [];
|
|
return !parts.some((part) => ["", ".", ".."].includes(part)) && (parts[0] === "homebrew" && parts.length >= 2 || parts[0] === "downloads" && parts.length >= 3);
|
|
};
|
|
const extracted = job2.output && `${job2.root}/downloads/extracted/${job2.release.filename.replace(/[^a-z0-9_.-]/gi, "_")}`;
|
|
const paths = [
|
|
job2.artifact.destination,
|
|
job2.downloadDestination,
|
|
job2.output,
|
|
job2.publishTo,
|
|
extracted,
|
|
job2.release.parts && setDirectory(job2)
|
|
].filter(Boolean).flatMap((path) => [path, ...[".complete", ".complete.tmp", ".part", ".resume", ".resume.tmp"].map((suffix) => path + suffix)]);
|
|
return [...new Set(paths)].filter((path) => managed(path) && !jobs.some((other) => other !== job2 && [other.artifact.destination, other.downloadDestination, other.output, other.publishTo].some((owned2) => owned2 && (owned2 === path || owned2.startsWith(`${path}/`)))));
|
|
}
|
|
async function executeSet(job2, signal) {
|
|
if (job2.release.unavailableReason) throw new Error(job2.release.unavailableReason);
|
|
const root2 = storageIdentity(job2);
|
|
if (!job2.rootIdentity) job2.rootIdentity = root2;
|
|
fs.mkdir(job2.artifact.directory, { recursive: true });
|
|
job2.parts || (job2.parts = job2.release.parts.map((part) => ({
|
|
number: part.number,
|
|
destination: `${job2.artifact.directory}/part-${part.number}.bin`,
|
|
written: 0,
|
|
completed: false
|
|
})));
|
|
let streamed = null;
|
|
try {
|
|
for (let index = 0; index < job2.parts.length; index++) {
|
|
if (!streamed && index > 0 && job2.parts[0].completed && fs.exists(job2.parts[0].destination)) streamed = active2.streamed = streamSet(job2);
|
|
job2.partIndex = index;
|
|
const part = job2.parts[index];
|
|
if (job2.state === "pausing") return;
|
|
if (part.completed && fs.exists(part.destination)) continue;
|
|
if (part.completed) {
|
|
part.completed = false;
|
|
part.written = 0;
|
|
delete part.options;
|
|
}
|
|
job2.state = "resolving";
|
|
job2.message = t("downloads.part", { part: index + 1, parts: job2.parts.length });
|
|
active2.transferring = false;
|
|
change();
|
|
const source2 = job2.release.parts[index].sources[part.sourceIndex || 0];
|
|
job2.currentSource = source2;
|
|
if (!part.options) {
|
|
const resolved = await sources.resolve(source2, { signal, directURL: job2.directURL });
|
|
part.options = { ...resolved, expectedBytes: source2.sizeBytes || void 0, sha256: source2.sha256 || void 0 };
|
|
delete job2.directURL;
|
|
change();
|
|
}
|
|
if (job2.state === "pausing") return;
|
|
storageIdentity(job2);
|
|
if (!fs.exists(part.destination) && fs.diskUsage(job2.root).free < setNeed(job2, streamed?.written) + MARGIN2)
|
|
throw new Error("Not enough space for the remaining volumes and their extraction. Free space and resume.");
|
|
active2.transferring = true;
|
|
const task = downloads.enqueue({
|
|
...part.options,
|
|
rejectHtml: true,
|
|
connections: 64,
|
|
adaptive: true,
|
|
destination: part.destination,
|
|
storageRoot: job2.root,
|
|
recoverCompleted: true
|
|
});
|
|
active2.task = task;
|
|
const off = task.subscribe((progress) => {
|
|
if (job2.state === "pausing") return;
|
|
if (ACTIVE.includes(progress.state)) job2.state = progress.state;
|
|
part.written = progress.written || 0;
|
|
part.total = progress.total;
|
|
job2.written = job2.parts.reduce((sum, item) => sum + item.written, 0);
|
|
job2.total = job2.parts.every((item) => item.total != null) ? job2.parts.reduce((sum, item) => sum + item.total, 0) : null;
|
|
job2.received = progress.received || 0;
|
|
job2.connections = progress.connections || 0;
|
|
job2.bytesPerSecond = progress.bytesPerSecond || 0;
|
|
if (++ticks % 20 === 0) {
|
|
delete job2.launches;
|
|
try {
|
|
save({ sync: false });
|
|
} catch (error2) {
|
|
job2.error = `Queue save failed: ${error2.message}`;
|
|
task.cancel();
|
|
}
|
|
if (index === 0) checkHeaders(job2, `${part.destination}.part`, task);
|
|
}
|
|
emit2();
|
|
});
|
|
let result;
|
|
try {
|
|
result = await task.done;
|
|
} finally {
|
|
off();
|
|
}
|
|
part.written = result.written ?? part.total ?? part.written;
|
|
job2.written = job2.parts.reduce((sum, item) => sum + item.written, 0);
|
|
part.completed = true;
|
|
change();
|
|
const refusal = index === 0 && archives?.inspect([part.destination]).refusal;
|
|
if (refusal) throw new Error(refusal);
|
|
}
|
|
} finally {
|
|
delete active2.streamed;
|
|
if (streamed && (job2.state === "pausing" || job2.parts.some((part) => !part.completed))) {
|
|
streamed.task.cancel();
|
|
await streamed.done;
|
|
streamed = null;
|
|
}
|
|
}
|
|
if (job2.state === "pausing") return;
|
|
await extract(job2, job2.parts.map((part) => part.destination), streamed);
|
|
notifications.show(t("downloads.notice.complete"), job2.title);
|
|
if (job2.state === "installer-required" && canInstall()) await install(job2);
|
|
}
|
|
function lostPartial(job2) {
|
|
const kept = (path) => fs.exists(path) || fs.exists(`${path}.part`);
|
|
if (job2.parts) return job2.parts.some((part) => (part.written || part.completed) && !kept(part.destination));
|
|
return job2.written > 0 && !kept(job2.downloadDestination || job2.artifact.destination);
|
|
}
|
|
function holding(root2) {
|
|
const lost = jobs.filter((job2) => job2.root === root2 && ["paused", "failed"].includes(job2.state) && lostPartial(job2));
|
|
const unmarked = lost.filter((job2) => job2.error !== DELETED);
|
|
for (const job2 of unmarked) job2.error = DELETED;
|
|
if (unmarked.length) change();
|
|
return jobs.filter((job2) => job2.root === root2 && (BUSY.includes(job2.state) || job2.state === "paused") && !lost.includes(job2));
|
|
}
|
|
function pump(first) {
|
|
if (active2 || captures.size) return;
|
|
const job2 = first?.state === "queued" ? first : jobs.find((item) => item.state === "queued");
|
|
if (!job2) return;
|
|
active2 = { job: job2, task: null };
|
|
void execute(job2);
|
|
}
|
|
function storageIdentity(job2) {
|
|
const info = fs.stat(job2.root);
|
|
if (!info?.isDirectory) throw new Error("Storage disconnected. Reconnect the selected drive.");
|
|
if (typeof info.device !== "string" || typeof info.inode !== "string") throw new Error("Storage identity is unavailable. Update the runtime.");
|
|
const identity = `${info.device}:${info.inode}`;
|
|
if (job2.rootIdentity && job2.rootIdentity !== identity) {
|
|
if (job2.root !== "/data") throw new Error("Storage changed. Reconnect the original drive.");
|
|
job2.rootIdentity = identity;
|
|
}
|
|
return identity;
|
|
}
|
|
function partialFiles(job2) {
|
|
storageIdentity(job2);
|
|
if (!job2.rootIdentity) throw new Error("This older task has no storage identity. Preserve and inspect its partial files.");
|
|
if (fs.exists(job2.parts?.[job2.partIndex]?.destination || job2.downloadDestination || job2.artifact.destination)) throw new Error("A final file exists. Retry to verify and recover it.");
|
|
const destination = job2.parts?.[job2.partIndex]?.destination || job2.downloadDestination || job2.artifact.destination;
|
|
const paths = [".part", ".resume", ".resume.tmp", ".complete", ".complete.tmp"].map((suffix) => destination + suffix);
|
|
for (const path of paths) {
|
|
const info = fs.stat(path);
|
|
if (info && !info.isFile) throw new Error(`Cannot discard a non-file: ${path}`);
|
|
}
|
|
return paths;
|
|
}
|
|
function setFiles(job2) {
|
|
storageIdentity(job2);
|
|
if (!job2.rootIdentity) throw new Error("This older task has no storage identity. Preserve and inspect its partial files.");
|
|
const extracted = job2.output?.startsWith(`${job2.root}/downloads/extracted/`) && !job2.publishTo ? [job2.output] : [];
|
|
return [setDirectory(job2), ...extracted].filter((path) => !jobs.some((other) => other !== job2 && [other.release.parts && setDirectory(other), other.output, other.publishTo, other.artifact.destination, other.downloadDestination].some((owned2) => owned2 && (owned2 === path || owned2.startsWith(`${path}/`)))));
|
|
}
|
|
const removePath = (path) => fs.removeAsync(path).catch((error2) => {
|
|
throw new Error(`Its files were not deleted: ${error2.message}`);
|
|
});
|
|
const removeAll = (paths) => Promise.all(paths.map(removePath));
|
|
async function removing(job2, work) {
|
|
const state = job2.state;
|
|
job2.state = "removing";
|
|
job2.removingFrom = state;
|
|
try {
|
|
change();
|
|
} catch {
|
|
emit2();
|
|
}
|
|
let result;
|
|
try {
|
|
result = await work();
|
|
} catch (error2) {
|
|
console.log(`[download-queue] ${job2.id} not deleted:`, error2.message);
|
|
job2.state = state;
|
|
delete job2.removingFrom;
|
|
job2.error = error2.message;
|
|
try {
|
|
change();
|
|
} catch {
|
|
emit2();
|
|
}
|
|
throw error2;
|
|
}
|
|
job2.state = state;
|
|
delete job2.removingFrom;
|
|
return result;
|
|
}
|
|
const onOriginalStorage = (job2) => {
|
|
try {
|
|
storageIdentity(job2);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
async function forget(job2) {
|
|
if ((job2.rootIdentity || job2.written) && !job2.adopted && onOriginalStorage(job2)) {
|
|
const paths = job2.release.parts ? setFiles(job2) : partialFiles(job2);
|
|
if (job2.state === "queued") job2.state = "paused";
|
|
await removing(job2, () => removeAll(paths));
|
|
}
|
|
const previous = jobs;
|
|
jobs = jobs.filter((item) => item !== job2);
|
|
try {
|
|
save();
|
|
} catch (error2) {
|
|
jobs = previous;
|
|
throw error2;
|
|
}
|
|
}
|
|
async function shadowRequest2(route2, body = {}) {
|
|
if (platform !== "ps5") throw new Error("ShadowMount registration requires a PS5.");
|
|
return shadowRequest(http, route2, body);
|
|
}
|
|
async function registered3(job2) {
|
|
const paths = [job2.artifact.destination, job2.output, job2.publishTo].filter(Boolean);
|
|
const info = (title) => shadowRequest2("games/info", { title_id: title }).catch((error2) => {
|
|
if (/HTTP 404/.test(error2.message)) return null;
|
|
throw error2;
|
|
});
|
|
const listed2 = await info(job2.consoleTitleId ?? job2.titleId);
|
|
if (within2(listed2?.path, paths, job2.root)) return listed2;
|
|
for (const image of await mountedImages(shadowRequest2, paths, job2.root).catch(() => null) ?? []) {
|
|
const title = mountedTitle(image);
|
|
if (!title || title === listed2?.title_id) continue;
|
|
const game = await info(title);
|
|
if (within2(game?.path, paths, job2.root)) {
|
|
job2.consoleTitleId = title;
|
|
return game;
|
|
}
|
|
}
|
|
return !fs.exists(job2.artifact.destination) && listed2?.managed ? listed2 : null;
|
|
}
|
|
function mountedTitle(image) {
|
|
const titleIn = (folder) => {
|
|
try {
|
|
return JSON.parse(fs.readFile(`${folder}/sce_sys/param.json`)).titleId || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
let folders = [];
|
|
try {
|
|
folders = fs.readDir(image.mount_point).filter((entry2) => entry2.isDirectory).map((entry2) => `${image.mount_point}/${entry2.name}`);
|
|
} catch {
|
|
}
|
|
for (const folder of [image.mount_point, ...folders]) {
|
|
const title = titleIn(folder);
|
|
if (title) return title;
|
|
}
|
|
return null;
|
|
}
|
|
async function logStorage(log2) {
|
|
try {
|
|
const { mounts = [] } = await shadowRequest2("storage");
|
|
for (const mount of mounts) {
|
|
log2(`statfs ${mount.mount_point} (${mount.source}, ${mount.filesystem}): total ${mount.total_bytes}, free ${mount.free_bytes}, available ${mount.available_bytes}, used ${mount.used_bytes}`);
|
|
}
|
|
} catch (error2) {
|
|
log2(`storage not read: ${error2.message}`);
|
|
}
|
|
}
|
|
const deletions = /* @__PURE__ */ new WeakMap();
|
|
async function deleteJob(job2) {
|
|
const log2 = (message) => console.log(`[download-queue] delete ${job2.id}: ${message}`);
|
|
const paths = ownedPaths(job2);
|
|
let uninstall = null;
|
|
if (job2.artifact.nextAction === "shadowmount" && platform === "ps5") {
|
|
const check = () => mountedImages(shadowRequest2, paths, job2.root).catch((error2) => {
|
|
throw new Error(`ShadowMount could not say whether the image is mounted, so its files were kept: ${error2.message}`);
|
|
});
|
|
let mounted = await check();
|
|
if (mounted === null) log2("ShadowMount is not running, so none of its files is mounted");
|
|
else {
|
|
let released = false;
|
|
const game = await registered3(job2).catch((error2) => {
|
|
log2(`game info failed: ${error2.message}`);
|
|
return void 0;
|
|
});
|
|
log2(game ? `ShadowMount game ${game.title_id} at ${game.path}, runtime ${game.runtime_path || "none"}, installed ${game.installed}, managed ${game.managed}, mounted ${game.mounted}` : `ShadowMount ${game === null ? "has no game" : "was not asked about the game"} of ${job2.titleId}`);
|
|
log2(mounted.length ? `mounted: ${mounted.map((image) => image.path).join(", ")}` : "none of its files is mounted");
|
|
if (game !== null) {
|
|
try {
|
|
await shadowRequest2("games/uninstall", { title_id: game?.title_id ?? job2.installedTitleId ?? job2.titleId });
|
|
log2("uninstalled");
|
|
released = true;
|
|
} catch (error2) {
|
|
log2(`uninstall failed: ${error2.message}`);
|
|
const unknown = /HTTP 404/.test(error2.message);
|
|
if (game?.installed || game === void 0 && !unknown) throw new Error(`ShadowMount did not uninstall it, so its files were kept: ${error2.message}`);
|
|
if (!unknown) uninstall = error2;
|
|
}
|
|
}
|
|
const titles = new Set(mounted.map((image) => game && within2(game.path, [image.path], job2.root) ? game.title_id : mountedTitle(image)));
|
|
if (game?.mounted) titles.add(game.title_id);
|
|
titles.delete(null);
|
|
for (const title of titles) {
|
|
await shadowRequest2("games/unmount", { title_id: title }).then(() => {
|
|
log2(`unmounted ${title}`);
|
|
released = true;
|
|
}, (error2) => log2(`unmount of ${title} failed: ${error2.message}`));
|
|
}
|
|
for (let attempt = 0; mounted.length && released && attempt < 5; attempt++) {
|
|
if (attempt) await wait(1e3);
|
|
mounted = await check() ?? [];
|
|
}
|
|
if (mounted.length) {
|
|
log2(`still mounted: ${mounted.map((image) => image.path).join(", ")}`);
|
|
throw new Error(`ShadowMount still has ${mounted[0].path} mounted, so its files were kept. Close any game and try again; if it stays mounted, delete the game from ShadowMount's web page (port 10101), which unmounts it first.`);
|
|
}
|
|
}
|
|
}
|
|
const before = fs.diskUsage(job2.root).free;
|
|
for (const path of paths) {
|
|
if (!fs.exists(path)) continue;
|
|
const info = fs.stat(path);
|
|
await removePath(path);
|
|
log2(`deleted ${path}${info?.isDirectory ? " (folder)" : `, ${info?.size || 0} bytes`}`);
|
|
}
|
|
const after = fs.diskUsage(job2.root).free;
|
|
log2(`${job2.root} free went from ${before} to ${after} bytes (${after - before} freed)`);
|
|
if (platform === "ps5") void logStorage(log2);
|
|
const previous = jobs;
|
|
jobs = jobs.filter((item) => item !== job2);
|
|
try {
|
|
save();
|
|
} catch (error2) {
|
|
jobs = previous;
|
|
throw error2;
|
|
}
|
|
emit2();
|
|
return uninstall;
|
|
}
|
|
async function markInstalled(candidates2) {
|
|
let found = false;
|
|
let learned = false;
|
|
for (const job2 of candidates2) {
|
|
const known = job2.consoleTitleId;
|
|
const game = await registered3(job2);
|
|
learned || (learned = job2.consoleTitleId !== known);
|
|
if (!game?.installed || !jobs.includes(job2) || job2.state !== "downloaded") continue;
|
|
job2.state = "installed";
|
|
job2.message = t("downloads.installed");
|
|
job2.error = "";
|
|
job2.installedTitleId = game.title_id;
|
|
found = true;
|
|
}
|
|
if (found || learned) change();
|
|
return found;
|
|
}
|
|
async function checkInstalls(id) {
|
|
const candidates2 = jobs.filter((job2) => job2.state === "downloaded" && job2.artifact.nextAction === "shadowmount" && (id === void 0 || job2.id === id));
|
|
if (platform !== "ps5" || !candidates2.length) return false;
|
|
try {
|
|
return await markInstalled(candidates2);
|
|
} catch (error2) {
|
|
console.log("[download-queue] ShadowMount install check failed:", error2.message);
|
|
return false;
|
|
}
|
|
}
|
|
let syncing = null;
|
|
const syncInstalls = (id) => syncing ?? (syncing = checkInstalls(id).finally(() => {
|
|
syncing = null;
|
|
}));
|
|
let running;
|
|
for (const job2 of jobs.filter((item) => item.restore)) {
|
|
const was = job2.restore;
|
|
delete job2.restore;
|
|
try {
|
|
storageIdentity(job2);
|
|
fs.diskUsage(job2.root);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (was === "running" && (job2.launches || 0) >= 2) continue;
|
|
if (was === "running") {
|
|
job2.launches = (job2.launches || 0) + 1;
|
|
running = job2;
|
|
}
|
|
job2.state = "queued";
|
|
job2.error = "";
|
|
}
|
|
if (jobs.some((job2) => job2.state === "queued")) {
|
|
change();
|
|
pump(running);
|
|
}
|
|
function stopCapture(job2) {
|
|
const session = captures.get(job2.id);
|
|
if (session) {
|
|
captures.delete(job2.id);
|
|
session.cancel.cancel();
|
|
session.task?.cancel();
|
|
}
|
|
delete job2.captureState;
|
|
delete job2.captureError;
|
|
delete job2.captureMode;
|
|
}
|
|
function continueJob(job2, url, verifiedOptions) {
|
|
if (job2.state !== "awaiting-browser" || active2?.job === job2) return;
|
|
webURL(url);
|
|
if (!(job2.parts?.[job2.partIndex]?.written ?? job2.written) && job2.rootIdentity && !job2.adopted) {
|
|
for (const path of partialFiles(job2)) if (fs.exists(path)) fs.remove(path);
|
|
}
|
|
if (job2.parts) job2.parts[job2.partIndex].options = verifiedOptions;
|
|
job2.options = job2.parts ? void 0 : verifiedOptions;
|
|
job2.directURL = verifiedOptions ? void 0 : url;
|
|
stopCapture(job2);
|
|
delete job2.reresolved;
|
|
job2.message = "";
|
|
job2.error = "";
|
|
job2.state = "queued";
|
|
change();
|
|
pump(job2);
|
|
}
|
|
async function captureBrowser(job2, source2, session) {
|
|
const current = () => captures.get(job2.id) === session && jobs.includes(job2) && job2.state === "awaiting-browser";
|
|
let handingOff = false;
|
|
try {
|
|
const linkPage = vikingLinkPage(source2);
|
|
console.log(linkPage ? "[browser-capture] link page; any Vikingfile file" : "[browser-capture] reading public file metadata");
|
|
const metadata = linkPage ? linkPageMetadata(source2) : await describeVikingCapture(http, source2, session.cancel.signal);
|
|
if (!current()) return;
|
|
session.task = browserCapture.capture({ prefix: VIKING_PREFIX, suffix: `/${metadata.filename}`, timeoutSeconds: 180 });
|
|
job2.captureState = "capturing";
|
|
change();
|
|
console.log("[browser-capture] capture submitted; opening browser");
|
|
if (!linking?.openURL(webURL(job2.browserUrl).url)) throw new Error("The browser could not open. Retry or enter the final file link.");
|
|
const result = await session.task.done;
|
|
if (!current()) return;
|
|
job2.captureState = "validating";
|
|
change();
|
|
console.log("[browser-capture] link captured; validating one-byte range");
|
|
const validated = await validateVikingCapture(http, result.url, linkPage ? capturedVikingFile(result.url, metadata) : metadata, session.cancel.signal);
|
|
if (!current()) return;
|
|
source2.sizeBytes = validated.expectedBytes;
|
|
if (!job2.parts) job2.total = validated.expectedBytes;
|
|
notifications.show(t("downloads.notice.captured"), job2.title);
|
|
console.log("[browser-capture] file validated; handing off to queue");
|
|
handingOff = true;
|
|
continueJob(job2, validated.url, {
|
|
url: validated.url,
|
|
expectedBytes: validated.expectedBytes,
|
|
sha256: source2.sha256 || void 0,
|
|
rejectHtml: true
|
|
});
|
|
console.log("[browser-capture] handoff finished; state:", job2.state);
|
|
} catch (error2) {
|
|
if (!current() && !(handingOff && jobs.includes(job2) && job2.state === "queued" && active2?.job !== job2)) return;
|
|
console.log("[browser-capture] failed:", error2.message);
|
|
session.cancel.cancel();
|
|
session.task?.cancel();
|
|
captures.delete(job2.id);
|
|
job2.state = "awaiting-browser";
|
|
job2.captureState = "failed";
|
|
job2.captureError = error2.message;
|
|
change();
|
|
pump();
|
|
}
|
|
}
|
|
/** The same capture for a host whose check only the browser can pass: the vault's mirrors,
|
|
* Akirabox, Datanodes. The file link appears on the page, or when its download starts, and the
|
|
* browser's memory is read for it: the human never types it. */
|
|
async function captureBrowserLink(job2, source2, session) {
|
|
const current = () => captures.get(job2.id) === session && jobs.includes(job2) && job2.state === "awaiting-browser";
|
|
let handingOff = false;
|
|
try {
|
|
const anchor = captureAnchor(job2.titleId);
|
|
console.log("[browser-capture] watching any file link carrying", anchor);
|
|
session.task = browserCapture.capture({ prefix: LINK_PREFIX, suffix: `/${anchor}`, timeoutSeconds: 180 });
|
|
job2.captureState = "capturing";
|
|
change();
|
|
if (!linking?.openURL(webURL(job2.browserUrl).url)) throw new Error("The browser could not open. Retry or enter the final file link.");
|
|
const result = await session.task.done;
|
|
if (!current()) return;
|
|
job2.captureState = "validating";
|
|
change();
|
|
console.log("[browser-capture] link captured; validating one-byte range");
|
|
const validated = await validateCapturedLink(http, result.url, { anchor, sizeBytes: source2.sizeBytes || null }, session.cancel.signal);
|
|
if (!current()) return;
|
|
// The file's real length replaces the one the catalog could not know, as for Vikingfile.
|
|
source2.sizeBytes = validated.expectedBytes;
|
|
if (!job2.parts) job2.total = validated.expectedBytes;
|
|
notifications.show(t("downloads.notice.captured"), job2.title);
|
|
console.log("[browser-capture] file validated; handing off to queue");
|
|
handingOff = true;
|
|
continueJob(job2, validated.url, {
|
|
url: validated.url,
|
|
expectedBytes: validated.expectedBytes,
|
|
sha256: source2.sha256 || void 0,
|
|
rejectHtml: true
|
|
});
|
|
console.log("[browser-capture] handoff finished; state:", job2.state);
|
|
} catch (error2) {
|
|
if (!current() && !(handingOff && jobs.includes(job2) && job2.state === "queued" && active2?.job !== job2)) return;
|
|
console.log("[browser-capture] failed:", error2.message);
|
|
session.cancel.cancel();
|
|
session.task?.cancel();
|
|
captures.delete(job2.id);
|
|
job2.state = "awaiting-browser";
|
|
job2.captureState = "failed";
|
|
job2.captureError = error2.message;
|
|
change();
|
|
pump();
|
|
}
|
|
}
|
|
return {
|
|
snapshot,
|
|
subscribe(listener) {
|
|
listeners4.add(listener);
|
|
listener(snapshot());
|
|
return () => listeners4.delete(listener);
|
|
},
|
|
/** `alternatives`: other sources of the game, best first, tried in order if this one can't start. */
|
|
enqueue(game, release, root2, alternatives = []) {
|
|
const id = `${game.id}/${release.id}`;
|
|
const existing = jobs.find((job2) => job2.id === id);
|
|
if (existing) return existing.id;
|
|
if (release.unavailableReason) throw new Error(release.unavailableReason);
|
|
const artifact2 = artifactFor(release, root2);
|
|
if (fs.exists(artifact2.destination)) throw new Error("Destination already exists. Choose another release or inspect the existing file.");
|
|
if (jobs.some((job2) => job2.artifact.destination === artifact2.destination)) throw new Error("Another queued release owns this filename.");
|
|
const orphan = fs.exists(`${artifact2.destination}.part`);
|
|
if (jobs.length === 32) jobs = jobs.filter((job2) => !COMPLETE.includes(job2.state));
|
|
if (jobs.length === 32) throw new Error("Queue has 32 unfinished downloads. Finish or remove one first.");
|
|
if (release.sizeBytes) {
|
|
const pending2 = holding(root2);
|
|
const held = orphan ? Math.min(fs.stat(`${artifact2.destination}.part`)?.size || 0, release.sizeBytes) : 0;
|
|
const need = stillNeeded({ release, artifact: artifact2, total: release.sizeBytes, written: held });
|
|
const shortfall = spaceShortfall(root2, need, fs.diskUsage(root2).free, pending2);
|
|
if (shortfall) throw new Error(shortfall);
|
|
}
|
|
jobs.push({
|
|
id,
|
|
gameId: game.id,
|
|
titleId: game.titleId || game.id,
|
|
title: game.title,
|
|
release: { ...release },
|
|
requestedId: release.id,
|
|
fallbacks: alternatives.slice(0, 4).map((item) => ({ ...item })),
|
|
root: root2,
|
|
artifact: artifact2,
|
|
state: "queued",
|
|
written: 0,
|
|
total: release.sizeBytes || null,
|
|
error: ""
|
|
});
|
|
try {
|
|
if (orphan) Object.assign(jobs.at(-1), { rootIdentity: storageIdentity(jobs.at(-1)), adopted: true });
|
|
change();
|
|
} catch (error2) {
|
|
jobs.pop();
|
|
throw error2;
|
|
}
|
|
pump();
|
|
return id;
|
|
},
|
|
pause(id) {
|
|
const job2 = find(id);
|
|
if (job2.state === "queued") job2.state = "paused";
|
|
else if (active2?.job === job2 && ACTIVE.includes(job2.state)) {
|
|
job2.state = "pausing";
|
|
active2.cancel?.cancel();
|
|
active2.task?.cancel();
|
|
}
|
|
change();
|
|
},
|
|
/** Installs a downloaded PKG again, after the console refused it or the app closed while installing. */
|
|
install(id) {
|
|
const job2 = find(id);
|
|
if (job2.state !== "installer-required" || !canInstall()) return;
|
|
job2.install = true;
|
|
job2.state = "queued";
|
|
job2.error = "";
|
|
change();
|
|
pump();
|
|
},
|
|
get installs() {
|
|
return canInstall();
|
|
},
|
|
resume(id) {
|
|
const job2 = find(id);
|
|
if (job2.state !== "paused" && job2.state !== "failed") return;
|
|
delete job2.reresolved;
|
|
job2.state = "queued";
|
|
job2.error = "";
|
|
change();
|
|
pump();
|
|
},
|
|
async restart(id) {
|
|
const job2 = find(id);
|
|
if (!["paused", "failed", "awaiting-browser"].includes(job2.state) || active2?.job === job2) return;
|
|
if (job2.adopted) throw new Error(ADOPTED);
|
|
const paths = !job2.parts?.[job2.partIndex]?.completed && (job2.options || job2.rootIdentity || job2.written || fs.exists(job2.artifact.destination + ".part") || fs.exists(job2.artifact.destination + ".resume")) ? partialFiles(job2) : [];
|
|
stopCapture(job2);
|
|
if (paths.length) await removing(job2, () => removeAll(paths));
|
|
delete job2.directURL;
|
|
delete job2.reresolved;
|
|
if (job2.parts?.[job2.partIndex] && !job2.parts[job2.partIndex].completed) {
|
|
job2.parts[job2.partIndex].written = 0;
|
|
delete job2.parts[job2.partIndex].options;
|
|
}
|
|
job2.written = job2.parts ? job2.parts.reduce((sum, part) => sum + part.written, 0) : 0;
|
|
job2.bytesPerSecond = 0;
|
|
delete job2.options;
|
|
delete job2.inspected;
|
|
job2.state = "queued";
|
|
job2.error = "";
|
|
change();
|
|
pump();
|
|
},
|
|
remove(id) {
|
|
const job2 = find(id);
|
|
if (!["paused", "failed", "awaiting-browser", ...COMPLETE].includes(job2.state) || active2?.job === job2) return;
|
|
stopCapture(job2);
|
|
const previous = jobs;
|
|
jobs = jobs.filter((item) => item !== job2);
|
|
try {
|
|
save();
|
|
} catch (error2) {
|
|
jobs = previous;
|
|
throw error2;
|
|
}
|
|
emit2();
|
|
pump();
|
|
},
|
|
/**
|
|
* Uninstalls a finished game from ShadowMount and, once ShadowMount's image list shows none of the
|
|
* task's paths mounted, deletes everything the task put on its drive and forgets it. A ShadowMount
|
|
* that does not answer mounts nothing, so the files go. Each step is logged.
|
|
*/
|
|
async deleteFromDisk(id) {
|
|
const job2 = find(id);
|
|
if (deletions.has(job2)) return deletions.get(job2);
|
|
if (!COMPLETE.includes(job2.state)) return;
|
|
const deletion = (async () => {
|
|
storageIdentity(job2);
|
|
const uninstall = await removing(job2, () => deleteJob(job2));
|
|
if (uninstall) throw new Error(`Deleted from disk, but ShadowMount did not uninstall it: ${uninstall.message}`);
|
|
})().finally(() => deletions.delete(job2));
|
|
deletions.set(job2, deletion);
|
|
return deletion;
|
|
},
|
|
openBrowser(id) {
|
|
const job2 = find(id);
|
|
if (job2.state !== "awaiting-browser") return;
|
|
const source2 = job2.parts ? job2.currentSource : job2.release;
|
|
const viking = sourceKind(source2) === "vikingfile" || vikingLinkPage(source2);
|
|
const anchor = viking ? "" : captureAnchor(job2.titleId);
|
|
if (browserCapture && (viking || anchor)) {
|
|
if (captures.has(id)) return;
|
|
if (captures.size || active2) throw new Error("Wait for the current queue task before opening the download page, so its captured link can start immediately.");
|
|
const session = { cancel: cancellation(), task: null };
|
|
captures.set(id, session);
|
|
job2.captureState = "preparing";
|
|
delete job2.captureError;
|
|
if (viking) delete job2.captureMode;
|
|
else job2.captureMode = "link";
|
|
change();
|
|
if (viking) void captureBrowser(job2, source2, session);
|
|
else void captureBrowserLink(job2, source2, session);
|
|
return;
|
|
}
|
|
if (!linking?.openURL(webURL(job2.browserUrl).url)) throw new Error("The browser could not open. Open this source on another device.");
|
|
job2.message = t("downloads.afterVerification");
|
|
change();
|
|
},
|
|
cancelBrowserCapture(id, { manual = false } = {}) {
|
|
const job2 = find(id);
|
|
stopCapture(job2);
|
|
if (manual) job2.captureState = "failed";
|
|
change();
|
|
pump();
|
|
},
|
|
continueWithURL(id, url) {
|
|
continueJob(find(id), url);
|
|
},
|
|
/** Marks finished images (all, or job `id`'s) ShadowMount has installed since as installed; false when none or it can't be asked. */
|
|
syncInstalls,
|
|
/** Frees the engine's one archive task, for the updater: a set extracting while it downloads extracts after its download instead. */
|
|
async yieldArchives() {
|
|
const streamed = active2?.streamed;
|
|
if (streamed) {
|
|
streamed.task.cancel();
|
|
await streamed.done;
|
|
}
|
|
},
|
|
/** Whether the queue keeps the console awake: some download is queued or running. */
|
|
get awake() {
|
|
return awake2;
|
|
},
|
|
/** Discards the partial file and forgets the task; an active one is paused first. */
|
|
async cancel(id) {
|
|
const job2 = find(id);
|
|
if (COMPLETE.includes(job2.state) || job2.state === "removing") return;
|
|
if (active2?.job === job2) {
|
|
job2.cancelling = true;
|
|
this.pause(id);
|
|
return;
|
|
}
|
|
stopCapture(job2);
|
|
await forget(job2);
|
|
emit2();
|
|
pump();
|
|
},
|
|
async register(id) {
|
|
const job2 = find(id);
|
|
if (job2.artifact.nextAction !== "shadowmount" || !COMPLETE.includes(job2.state)) return;
|
|
try {
|
|
if (await markInstalled([job2]).catch(() => false)) return;
|
|
const version2 = await shadowRequest2("version");
|
|
if (!version2.capabilities?.includes("rescan")) throw new Error("This ShadowMount version has no rescan API. Use its automatic scan.");
|
|
await shadowRequest2("scan", { reset_attempts: false });
|
|
job2.message = t("downloads.installRequested");
|
|
job2.error = "";
|
|
} catch (error2) {
|
|
job2.error = error2.message;
|
|
}
|
|
change();
|
|
}
|
|
};
|
|
}
|
|
|
|
export { BUSY, COMPLETE, createDownloadQueue };
|