mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-22 03:42:55 -05:00
Engine: POST /api/folder/build unpacks a game image (or, once the pkg reader lands, a debug-key .pkg) into <out>/<name>. It runs as a job with Write and Verify stages; every path is checked before anything is written (.., absolute, empty components, names this OS cannot hold, file/folder clashes, case clashes on a case-insensitive drive); data streams in 8 MiB chunks with cancel per chunk; the folder is written as <name>.partial/ (marked so the output-folder sweep can tell it from a user's own) and renamed only after the read-back: every size and empty folder, and the SHA-256 of a seeded sample (or every file) re-read from a freshly opened source. An existing target, a plain folder source and a shortfall of free space (files + 64 MiB) are refused before the job starts. Client: a third "What to make" tile, A game folder. A folder source disables Make with "This is already a folder"; .pkg is accepted as a source; then is keep only; the finished card shows the folder and Show in folder. The queue, prefs, pipeline and stage list know the folder kind. Strings in all 21 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
838 lines
30 KiB
TypeScript
838 lines
30 KiB
TypeScript
// Convert to FPKG as one pipeline: an engine build job, then (for Convert & install) the install
|
|
// of the package it wrote — streamed from this computer, or uploaded to the PS5 first. The screen renders `pipeline`; every action here checks the
|
|
// phase it is allowed from, so a stray click cannot act on the wrong package or reset a run.
|
|
|
|
import { installErrorLink } from "../lib/installErrorDoc";
|
|
import { create } from "zustand";
|
|
|
|
import { useConvertPrefs } from "./convertPrefs";
|
|
|
|
import { imageUploadItem, type ImageUploadPlan } from "../lib/imageUpload";
|
|
import { useUploadQueueStore } from "./uploadQueue";
|
|
|
|
import { fpkg, type AmprLz4Options, type FpkgBuildRequest, type ImageFormat } from "../api/fpkg";
|
|
import { jobCancel, jobStatus } from "../api/ps5";
|
|
import { useConnectionStore } from "./connection";
|
|
import { useRosterStore } from "./roster";
|
|
import { hostOf } from "../lib/addr";
|
|
import { pushNotification } from "./notifications";
|
|
import { pkgLibraryStore, type PkgLibraryStore } from "./pkgLibrary";
|
|
import { useTaskStore } from "./tasks";
|
|
import { remoteApi } from "../api/remote";
|
|
import { fetchRemote } from "../lib/materialize";
|
|
import { isRemotePath } from "../lib/remotePath";
|
|
import { finishSwap, runSwap, type SwapJournal } from "../lib/dumpSwap";
|
|
import { consoleSwapDeps } from "../lib/dumpSwapConsole";
|
|
|
|
export type PipelineStage =
|
|
| "copy"
|
|
| "extract"
|
|
| "check"
|
|
| "pack"
|
|
| "plan"
|
|
| "compress"
|
|
| "write"
|
|
| "verify"
|
|
| "park"
|
|
| "send"
|
|
| "install";
|
|
|
|
/** What a run does: a package, a package then its install, an install of a kept package, a
|
|
* compressed .ffpfsc image from an image, a game image (.exfat) from a folder, or a plain
|
|
* folder from an image or a debug-key package. */
|
|
export type PipelineMode = "convert" | "convert-install" | "install" | "ffpfsc" | "image" | "folder";
|
|
|
|
/** A run whose result is a game image to mount, not a package to install. */
|
|
export const makesImage = (mode: PipelineMode) => mode === "ffpfsc" || mode === "image";
|
|
|
|
/** A run whose result is a plain game folder on this computer. */
|
|
export const makesFolder = (mode: PipelineMode) => mode === "folder";
|
|
|
|
/** How a built package reaches the console: streamed from this computer over HTTP (nothing
|
|
* staged), or uploaded to PS5 staging and installed from there (never needs the console to
|
|
* reach this computer). */
|
|
export type InstallMethod = "stream" | "upload";
|
|
|
|
type StageMs = Partial<Record<PipelineStage, number>>;
|
|
|
|
export type Pipeline =
|
|
| { phase: "idle" }
|
|
| {
|
|
phase: "running";
|
|
mode: PipelineMode;
|
|
source: string;
|
|
host: string | null;
|
|
stage: PipelineStage;
|
|
stageDone: number;
|
|
stageTotal: number;
|
|
startedMs: number;
|
|
stageStartedMs: number;
|
|
stageMs: StageMs;
|
|
jobId: string | null;
|
|
installTaskId: string | null;
|
|
/** This run's row in the activity bar; null for a re-install, which the install's own
|
|
* task already shows. */
|
|
taskId: string | null;
|
|
packagePath: string | null;
|
|
/** The package's title id, from the build's content id (what Launch starts). */
|
|
titleId: string | null;
|
|
/** A server source copied to this computer for the build; removed once it succeeds. */
|
|
copiedSource: string | null;
|
|
/** The game unpacked from an archive (inside an engine unpack folder); removed with it
|
|
* once the run is over. */
|
|
extractedSource: string | null;
|
|
}
|
|
| {
|
|
phase: "done";
|
|
mode: PipelineMode;
|
|
source: string;
|
|
host: string | null;
|
|
packagePath: string;
|
|
packageBytes: number;
|
|
convertMs: number;
|
|
installMs: number;
|
|
stageMs: StageMs;
|
|
deleted: boolean;
|
|
titleId: string | null;
|
|
/** A console dump swapped for this package, awaiting Delete the old dump / Keep it
|
|
* parked; null once that is chosen (or when nothing was swapped). */
|
|
swap?: SwapJournal | null;
|
|
/** An image put in the Upload queue (by the run's own plan, or Upload to PS5). */
|
|
uploadQueued?: boolean;
|
|
}
|
|
| {
|
|
phase: "failed";
|
|
mode: PipelineMode;
|
|
source: string;
|
|
host: string | null;
|
|
stage: PipelineStage;
|
|
message: string;
|
|
packagePath: string | null;
|
|
stageMs: StageMs;
|
|
titleId: string | null;
|
|
};
|
|
|
|
export interface ConversionState {
|
|
pipeline: Pipeline;
|
|
start: (
|
|
req: FpkgBuildRequest,
|
|
opts: {
|
|
install: boolean;
|
|
host: string | null;
|
|
method?: InstallMethod;
|
|
/** An archive's password (RAR); never stored. */
|
|
password?: string;
|
|
/** Delete the package once its install is verified (Convert's "delete after"). */
|
|
deleteAfter?: boolean;
|
|
},
|
|
) => Promise<void>;
|
|
/** Compress an .exfat / .ffpkg image into a .ffpfsc. */
|
|
compress: (source: string, outputDir?: string) => Promise<void>;
|
|
/** Write a game folder as one game image (`format`, .exfat by default); with
|
|
* `thenCompress`, straight into a .ffpfsc (no uncompressed copy is written). */
|
|
buildImage: (
|
|
source: string,
|
|
outputDir: string | undefined,
|
|
thenCompress: boolean,
|
|
format?: ImageFormat,
|
|
/** Once the image is built and checked, put it in the Upload queue. */
|
|
thenUpload?: ImageUploadPlan,
|
|
/** Pack the game's files into AMPR LZ4 asset packs first (an .exfat image). */
|
|
lz4?: AmprLz4Options,
|
|
) => Promise<void>;
|
|
/** Unpack a game image or a debug-key package into a plain folder in `outputDir`. */
|
|
buildFolder: (source: string, outputDir: string | undefined) => Promise<void>;
|
|
/** Put the finished image in the Upload queue. */
|
|
uploadImage: (plan: ImageUploadPlan) => void;
|
|
/** Install the kept package again: after a failed install, or Install again on a result. */
|
|
retryInstall: (host: string, method?: InstallMethod) => Promise<void>;
|
|
cancel: () => Promise<void>;
|
|
/** A new source: clears a finished result; ignored while running. */
|
|
reset: () => void;
|
|
deletePackage: () => Promise<void>;
|
|
/** After a swap: delete the parked dump, or keep it parked outside the scan roots. */
|
|
finishReplace: (choice: "delete" | "keep") => Promise<void>;
|
|
}
|
|
|
|
/** How often a build job is polled (ms). */
|
|
export const POLL_MS = 500;
|
|
/** Consecutive failed polls before the run is declared lost. */
|
|
const MAX_POLL_FAILURES = 5;
|
|
const BUILD_STAGES: readonly PipelineStage[] = ["check", "pack", "plan", "compress", "write", "verify"];
|
|
|
|
type Running = Extract<Pipeline, { phase: "running" }>;
|
|
|
|
/** The stage names Convert's progress card shows. */
|
|
const STAGE_LABEL: Record<PipelineStage, string> = {
|
|
copy: "Copy from server",
|
|
extract: "Unpack archive",
|
|
check: "Check source",
|
|
pack: "Pack LZ4 assets",
|
|
plan: "Plan package",
|
|
compress: "Compress",
|
|
write: "Write package",
|
|
verify: "Verify",
|
|
park: "Set the dump aside",
|
|
send: "Send to PS5",
|
|
install: "Install on PS5",
|
|
};
|
|
|
|
function baseName(path: string): string {
|
|
return path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
|
|
}
|
|
|
|
/** A cancelled build ends as an error saying so. */
|
|
function isCancelMessage(message: string): boolean {
|
|
return /\bcancel(l)?ed\b/i.test(message);
|
|
}
|
|
|
|
function running(): Running | null {
|
|
const p = useFpkgConversion.getState().pipeline;
|
|
return p.phase === "running" ? p : null;
|
|
}
|
|
|
|
function update(patch: Partial<Running>) {
|
|
const p = running();
|
|
if (p) useFpkgConversion.setState({ pipeline: { ...p, ...patch } });
|
|
}
|
|
|
|
function reportStage(taskId: string | null, stage: PipelineStage, done: number, total: number) {
|
|
if (!taskId) return;
|
|
useTaskStore.getState().updateTask(taskId, {
|
|
stage: STAGE_LABEL[stage],
|
|
progress: total > 0 ? { current: done, total, unit: "bytes" } : undefined,
|
|
});
|
|
}
|
|
|
|
/** Move to `stage`, recording how long the previous one took. */
|
|
function enterStage(stage: PipelineStage, done = 0, total = 0) {
|
|
const p = running();
|
|
if (!p) return;
|
|
reportStage(p.taskId, stage, done, total);
|
|
if (p.stage === stage) {
|
|
update({ stageDone: done, stageTotal: total });
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
update({
|
|
stage,
|
|
stageDone: done,
|
|
stageTotal: total,
|
|
stageStartedMs: now,
|
|
stageMs: { ...p.stageMs, [p.stage]: now - p.stageStartedMs },
|
|
});
|
|
}
|
|
|
|
function fail(stage: PipelineStage, message: string, packagePath: string | null) {
|
|
const p = running();
|
|
if (!p) return;
|
|
dropCopy(p);
|
|
if (p.taskId) {
|
|
if (isCancelMessage(message)) useTaskStore.getState().finishTask(p.taskId, "cancelled");
|
|
else
|
|
useTaskStore.getState().finishTask(p.taskId, "failed", {
|
|
lastError: { code: "FPKG_FAILED", message, recoverable: false },
|
|
});
|
|
}
|
|
const what =
|
|
p.mode === "ffpfsc"
|
|
? "Compression"
|
|
: p.mode === "image"
|
|
? "Making the game image"
|
|
: p.mode === "folder"
|
|
? "Making the game folder"
|
|
: p.mode === "convert"
|
|
? "FPKG conversion"
|
|
: "Convert & install";
|
|
useFpkgConversion.setState({
|
|
pipeline: {
|
|
phase: "failed",
|
|
mode: p.mode,
|
|
source: p.source,
|
|
host: p.host,
|
|
stage,
|
|
message,
|
|
packagePath,
|
|
stageMs: { ...p.stageMs, [p.stage]: Date.now() - p.stageStartedMs },
|
|
titleId: p.titleId,
|
|
},
|
|
});
|
|
// An install-stage failure links to the install-route matrix entry for its
|
|
// error; a build failure has no such page, so it goes back to Convert.
|
|
pushNotification("error", `${what} failed`, {
|
|
// The install stage's failure is an engine job's ending, which the host announces.
|
|
jobOutcome: stage === "install",
|
|
body: message,
|
|
link: stage === "install" ? installErrorLink(message) : "/convert",
|
|
});
|
|
}
|
|
|
|
/** The Upload plan of the image being built ("send this game folder as an image"). */
|
|
let pendingImageUpload: ImageUploadPlan | null = null;
|
|
/** The running Convert & install asked for its package to go once the install verified. */
|
|
let deleteAfterInstall = false;
|
|
|
|
/** Puts a finished image in the Upload queue and starts that console's queue. */
|
|
function queueImage(imagePath: string, bytes: number, plan: ImageUploadPlan) {
|
|
const q = useUploadQueueStore.getState();
|
|
const id = q.add(imageUploadItem(imagePath, plan, bytes));
|
|
// Run just this image: uploads someone queued for later still wait for Start.
|
|
void q.startHost(plan.host, { onlyIds: [id] });
|
|
}
|
|
|
|
/** A build that ends the run (Convert only, or a .ffpfsc image). */
|
|
function finish(packagePath: string, packageBytes: number, convertMs: number) {
|
|
const p = running();
|
|
if (!p) return;
|
|
const plan = makesImage(p.mode) ? pendingImageUpload : null;
|
|
pendingImageUpload = null;
|
|
if (plan) queueImage(packagePath, packageBytes, plan);
|
|
if (p.taskId) useTaskStore.getState().finishTask(p.taskId, "done");
|
|
dropCopy(p);
|
|
const now = Date.now();
|
|
useFpkgConversion.setState({
|
|
pipeline: {
|
|
phase: "done",
|
|
mode: p.mode,
|
|
source: p.source,
|
|
host: p.host,
|
|
packagePath,
|
|
packageBytes,
|
|
convertMs,
|
|
installMs: 0,
|
|
stageMs: { ...p.stageMs, [p.stage]: now - p.stageStartedMs },
|
|
deleted: false,
|
|
titleId: p.titleId,
|
|
uploadQueued: !!plan,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Upload to staging, then install; the staged row's bytes and status drive Send and Install. */
|
|
async function uploadThenInstall(store: PkgLibraryStore, packagePath: string, host: string) {
|
|
let dest: string | null = null;
|
|
const unsubscribe = store.subscribe((s) => {
|
|
const row = dest ? s.entries.find((e) => e.path === dest) : undefined;
|
|
if (row?.status === "uploading") enterStage("send", row.bytes ?? 0, row.totalBytes ?? 0);
|
|
else if (row?.status === "installing") enterStage("install");
|
|
});
|
|
try {
|
|
return await store.getState().uploadInstall(packagePath, host, { onDest: (d) => (dest = d) });
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
}
|
|
|
|
/** `ps5://10.0.0.2/data/homebrew/G.exfat` → `/data/homebrew/G.exfat`. */
|
|
export function consoleDumpPath(source: string): string | null {
|
|
const m = /^ps5:\/\/[^/]+(\/.*)$/.exec(source);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
/** A dump on the console is swapped for its package, never installed beside it: both share a
|
|
* title id, and ShadowMountPlus would register the dump again over the install. */
|
|
async function runReplace(packagePath: string, host: string, dump: string) {
|
|
const p = running();
|
|
if (!p) return;
|
|
if (!p.titleId) {
|
|
fail("park", "The package has no title id to match the dump with.", packagePath);
|
|
return;
|
|
}
|
|
enterStage("park");
|
|
const startedMs = Date.now();
|
|
const r = await runSwap(
|
|
{ titleId: p.titleId, dump, packagePath },
|
|
consoleSwapDeps(host),
|
|
(step) => enterStage(step === "install" ? "install" : "park"),
|
|
);
|
|
if (!running()) return;
|
|
if (!r.ok) {
|
|
fail(running()!.stage, r.message ?? "The swap did not finish.", packagePath);
|
|
return;
|
|
}
|
|
const cur = running()!;
|
|
installDone(packagePath, startedMs - cur.startedMs, Date.now() - startedMs, r.journal ?? null);
|
|
pushNotification("success", "Installed on the PS5", {
|
|
body: "The old dump is set aside; delete it or keep it on the Convert screen.",
|
|
link: "/convert",
|
|
});
|
|
}
|
|
|
|
async function runInstall(packagePath: string, host: string | null, method: InstallMethod) {
|
|
const p = running();
|
|
if (!p) return;
|
|
if (!host) {
|
|
fail("send", "Connect to a PS5 to install.", packagePath);
|
|
return;
|
|
}
|
|
const dump = consoleDumpPath(p.source);
|
|
if (dump) {
|
|
await runReplace(packagePath, host, dump);
|
|
return;
|
|
}
|
|
enterStage("send");
|
|
const startedMs = Date.now();
|
|
const store = pkgLibraryStore(host);
|
|
const r =
|
|
method === "upload"
|
|
? await uploadThenInstall(store, packagePath, host)
|
|
: await store
|
|
.getState()
|
|
.installStream(packagePath, host, { onTask: (id) => update({ installTaskId: id }) });
|
|
if (!running()) return;
|
|
if (r.ok) {
|
|
const cur = running()!;
|
|
const convertMs = cur.mode === "install" ? 0 : startedMs - cur.startedMs;
|
|
installDone(packagePath, convertMs, Date.now() - startedMs);
|
|
pushNotification("success", "Installed on the PS5", {
|
|
body: packagePath,
|
|
link: "/convert",
|
|
jobOutcome: true,
|
|
});
|
|
} else {
|
|
fail("install", r.message ?? "The install did not finish.", packagePath);
|
|
}
|
|
}
|
|
|
|
function installDone(
|
|
packagePath: string,
|
|
convertMs: number,
|
|
installMs: number,
|
|
swap: SwapJournal | null = null,
|
|
) {
|
|
const p = running();
|
|
if (!p) return;
|
|
if (p.taskId) useTaskStore.getState().finishTask(p.taskId, "done");
|
|
dropCopy(p);
|
|
const now = Date.now();
|
|
useFpkgConversion.setState({
|
|
pipeline: {
|
|
phase: "done",
|
|
mode: p.mode,
|
|
source: p.source,
|
|
host: p.host,
|
|
packagePath,
|
|
packageBytes: packageBytesOf(p),
|
|
convertMs,
|
|
installMs,
|
|
stageMs: { ...p.stageMs, [p.stage]: now - p.stageStartedMs },
|
|
deleted: false,
|
|
titleId: p.titleId,
|
|
swap,
|
|
},
|
|
});
|
|
if (deleteAfterInstall) {
|
|
deleteAfterInstall = false;
|
|
void fpkg
|
|
.deletePackage(packagePath)
|
|
.then(() => {
|
|
const cur = useFpkgConversion.getState().pipeline;
|
|
if (cur.phase === "done" && cur.packagePath === packagePath)
|
|
useFpkgConversion.setState({ pipeline: { ...cur, deleted: true } });
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Bytes of the package being installed, remembered from the build (or the earlier result). */
|
|
const packageSizes = new Map<string, number>();
|
|
function packageBytesOf(p: Running): number {
|
|
return (p.packagePath && packageSizes.get(p.packagePath)) || 0;
|
|
}
|
|
|
|
/** `install`: how to install the package once it is built, or null to stop at the package. */
|
|
function poll(jobId: string, install: InstallMethod | null, failures = 0) {
|
|
setTimeout(async () => {
|
|
const p = running();
|
|
if (!p || p.jobId !== jobId) return;
|
|
let snapshot;
|
|
try {
|
|
snapshot = await jobStatus(jobId);
|
|
} catch {
|
|
if (failures + 1 >= MAX_POLL_FAILURES) {
|
|
// Giving up on watching must not leave the build running unseen: stop it, and say
|
|
// so honestly when even the stop could not reach the engine.
|
|
let stopped = true;
|
|
try {
|
|
await jobCancel(jobId);
|
|
} catch {
|
|
stopped = false;
|
|
}
|
|
if (running()?.jobId !== jobId) return;
|
|
fail(
|
|
p.stage,
|
|
stopped
|
|
? "The engine stopped responding, so the conversion was stopped."
|
|
: "The engine stopped responding; the conversion may still be running there. Check Tasks before starting it again.",
|
|
null,
|
|
);
|
|
} else {
|
|
poll(jobId, install, failures + 1);
|
|
}
|
|
return;
|
|
}
|
|
if (running()?.jobId !== jobId) return;
|
|
if (snapshot.status === "running") {
|
|
const s = snapshot.stage;
|
|
if (s && BUILD_STAGES.includes(s.id as PipelineStage)) {
|
|
enterStage(s.id as PipelineStage, s.done, s.total);
|
|
} else if (!s && p.mode === "ffpfsc") {
|
|
// A compression job reports no stages, only its overall bytes.
|
|
enterStage("compress", snapshot.bytes_sent ?? 0, snapshot.total_bytes ?? 0);
|
|
}
|
|
poll(jobId, install);
|
|
return;
|
|
}
|
|
if (snapshot.status === "done") {
|
|
const path = snapshot.dest ?? "";
|
|
const bytes = snapshot.bytes_sent ?? 0;
|
|
packageSizes.set(path, bytes);
|
|
const cur = running()!;
|
|
update({ packagePath: path, jobId: null, titleId: titleIdOf(snapshot.tx_id_hex) });
|
|
if (install) {
|
|
// The console chosen when Convert & install was pressed, even if the user switched
|
|
// to another one during an hour-long build. When it has left the roster since, stop
|
|
// with the package kept so the user picks where it goes.
|
|
const target = installTarget(cur.host);
|
|
if (target.gone) {
|
|
fail(
|
|
"send",
|
|
`${target.gone} is no longer in your consoles. The package is kept; choose a console and install it from here.`,
|
|
path,
|
|
);
|
|
return;
|
|
}
|
|
update({ host: target.host });
|
|
await runInstall(path, target.host, install);
|
|
} else {
|
|
finish(path, bytes, Date.now() - cur.startedMs);
|
|
pushNotification(
|
|
"success",
|
|
cur.mode === "ffpfsc"
|
|
? "Compression complete"
|
|
: cur.mode === "image"
|
|
? "Game image ready"
|
|
: cur.mode === "folder"
|
|
? "Game folder ready"
|
|
: "FPKG conversion complete",
|
|
{ body: path, link: "/convert" },
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
fail(p.stage, snapshot.error ?? "The build failed.", null);
|
|
}, POLL_MS);
|
|
}
|
|
|
|
|
|
function beginRun(mode: PipelineMode, source: string, host: string | null, stage: PipelineStage) {
|
|
const now = Date.now();
|
|
const taskId =
|
|
mode === "install"
|
|
? null
|
|
: useTaskStore.getState().registerTask({
|
|
kind: mode === "ffpfsc" ? "ffpfsc-compress" : "fpkg-convert",
|
|
origin: "convert",
|
|
label: `${mode === "ffpfsc" ? "Compress" : mode === "image" ? "Make image of" : mode === "folder" ? "Make folder of" : "Convert"} ${baseName(source)}`,
|
|
detail: source,
|
|
consoleId: host ?? "",
|
|
control: { owner: "fpkg-convert" },
|
|
});
|
|
reportStage(taskId, stage, 0, 0);
|
|
useFpkgConversion.setState({
|
|
pipeline: {
|
|
phase: "running",
|
|
mode,
|
|
source,
|
|
host,
|
|
stage,
|
|
stageDone: 0,
|
|
stageTotal: 0,
|
|
startedMs: now,
|
|
stageStartedMs: now,
|
|
stageMs: {},
|
|
jobId: null,
|
|
installTaskId: null,
|
|
taskId,
|
|
packagePath: null,
|
|
titleId: null,
|
|
copiedSource: null,
|
|
extractedSource: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** `.zip`, `.7z` or `.rar` (a multi-part set starts at its first `.partN.rar`). */
|
|
export function isArchiveSource(path: string): boolean {
|
|
return /\.(zip|7z|rar)$/i.test(path.trim());
|
|
}
|
|
|
|
/** A game image (`.exfat`, `.ffpkg`/`.ufs2`, `.ffpfs`, `.ffpfsc`) or a package (`.pkg`): what
|
|
* can be unpacked into a plain folder. A package has to be made with the debug key; the
|
|
* engine says so when it is not. */
|
|
export function isFolderableSource(path: string): boolean {
|
|
return /\.(exfat|ffpkg|ufs2|ffpfs|ffpfsc|pkg)$/i.test(path.trim());
|
|
}
|
|
|
|
/** The engine's archive errors, said for a person. */
|
|
export function archiveErrorText(message: string): string {
|
|
if (/rar_password_required/.test(message))
|
|
return "This archive is password-protected. Enter its password and start again.";
|
|
if (/rar_password_wrong/.test(message))
|
|
return "The archive's password is wrong. Check it and start again.";
|
|
const missing = /rar_missing_volume:\s*(.+)/.exec(message);
|
|
if (missing)
|
|
return `A part of this archive is missing: ${missing[1].trim()}. Keep every part in the same folder.`;
|
|
return message;
|
|
}
|
|
|
|
/** Unpack an archive with the engine, following its job; the game path found inside. */
|
|
async function extractArchive(archive: string, outputDir: string | undefined, password?: string) {
|
|
const { job_id } = await fpkg.extract(archive, outputDir, password);
|
|
update({ jobId: job_id });
|
|
for (;;) {
|
|
const s = await jobStatus(job_id);
|
|
if (s.status === "done") return s.dest ?? "";
|
|
if (s.status === "failed") throw new Error(s.error ?? "The archive did not unpack.");
|
|
if (running()?.jobId === job_id) enterStage("extract", s.bytes_sent ?? 0, s.total_bytes ?? 0);
|
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
}
|
|
}
|
|
|
|
/** A source that needs this run's own stages before the build: a server archive is copied
|
|
* here (unpacking needs a local file), and any archive is unpacked. A server folder or image
|
|
* needs neither: the engine reads it in place. */
|
|
function needsPrep(source: string): boolean {
|
|
return isArchiveSource(source);
|
|
}
|
|
|
|
/** Get a source ready for the build, as stages of this run (each with progress and Cancel):
|
|
* a server archive is copied into the output folder, an archive is unpacked there. Then start
|
|
* the job `startJob` makes on what that left. */
|
|
async function prepareThenStart(
|
|
source: string,
|
|
outputDir: string | undefined,
|
|
startJob: (local: string) => Promise<{ job_id: string }>,
|
|
install: InstallMethod | null,
|
|
password?: string,
|
|
) {
|
|
let local = source;
|
|
if (isRemotePath(source)) {
|
|
if (!(await copyFromServer(source, outputDir))) return;
|
|
local = running()?.copiedSource ?? "";
|
|
}
|
|
if (isArchiveSource(local)) {
|
|
enterStage("extract");
|
|
try {
|
|
local = await extractArchive(local, outputDir, password);
|
|
} catch (error) {
|
|
fail("extract", archiveErrorText(error instanceof Error ? error.message : String(error)), null);
|
|
return;
|
|
}
|
|
if (!running()) {
|
|
void fpkg.cleanupExtract(local).catch(() => {});
|
|
return;
|
|
}
|
|
update({ extractedSource: local, jobId: null });
|
|
}
|
|
enterStage("check");
|
|
try {
|
|
const { job_id } = await startJob(local);
|
|
update({ jobId: job_id });
|
|
poll(job_id, install);
|
|
} catch (error) {
|
|
fail("check", error instanceof Error ? error.message : String(error), null);
|
|
}
|
|
}
|
|
|
|
/** Copy a server source into the output folder; false (the run already failed) if it didn't. */
|
|
async function copyFromServer(source: string, outputDir: string | undefined): Promise<boolean> {
|
|
let local: string;
|
|
try {
|
|
local = await fetchRemote(source, {
|
|
// A folder per run, so a copy left by one run never blocks the next.
|
|
destDir: outputDir
|
|
? `${outputDir.replace(/[\\/]+$/, "")}/.ps5upload-source/${runFolder()}`
|
|
: undefined,
|
|
pollMs: POLL_MS,
|
|
onJob: (id) => update({ jobId: id }),
|
|
onProgress: (done, total) => enterStage("copy", done, total),
|
|
});
|
|
} catch (error) {
|
|
fail("copy", error instanceof Error ? error.message : String(error), null);
|
|
return false;
|
|
}
|
|
if (!running()) return false;
|
|
update({ copiedSource: local, jobId: null });
|
|
return true;
|
|
}
|
|
|
|
function runFolder(): string {
|
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
}
|
|
|
|
/** The copy of a server source and an archive's unpack are only scaffolding: drop them once
|
|
* the build is over, whether it finished or not (the next run makes its own). */
|
|
function dropCopy(p: Running) {
|
|
if (p.copiedSource) void remoteApi.cleanupFetched(p.copiedSource).catch(() => {});
|
|
if (p.extractedSource) void fpkg.cleanupExtract(p.extractedSource).catch(() => {});
|
|
}
|
|
|
|
/** "UP4433-PPSA17221_00-MINECRAFTPS50000" → "PPSA17221". */
|
|
function titleIdOf(contentId: string | undefined | null): string | null {
|
|
return contentId && contentId.length >= 16 ? contentId.slice(7, 16) : null;
|
|
}
|
|
|
|
/** Where a finished build installs: the console recorded when the run started, or (when none
|
|
* was) the one connected now. `gone` names a recorded console that left the roster. */
|
|
export function installTarget(recorded: string | null): { host: string | null; gone?: string } {
|
|
if (!recorded) return { host: currentHost() };
|
|
const profiles = useRosterStore.getState().profiles ?? [];
|
|
if (profiles.length > 0 && !profiles.some((p) => hostOf(p.host) === hostOf(recorded)))
|
|
return { host: null, gone: recorded };
|
|
return { host: recorded };
|
|
}
|
|
|
|
/** The console the connection bar has now, when a payload answers there. */
|
|
function currentHost(): string | null {
|
|
const c = useConnectionStore.getState();
|
|
return c.payloadStatus === "up" && c.host?.trim() ? c.host : null;
|
|
}
|
|
|
|
export const useFpkgConversion = create<ConversionState>((set, get) => ({
|
|
pipeline: { phase: "idle" },
|
|
|
|
start: async (req, { install, host, method = "stream", password, deleteAfter }) => {
|
|
const then = install ? method : null;
|
|
if (get().pipeline.phase === "running") return;
|
|
const fullVerify = useConvertPrefs.getState().fullVerify;
|
|
deleteAfterInstall = install && !!deleteAfter;
|
|
if (needsPrep(req.source)) {
|
|
beginRun(
|
|
install ? "convert-install" : "convert",
|
|
req.source,
|
|
host,
|
|
isRemotePath(req.source) ? "copy" : "extract",
|
|
);
|
|
void prepareThenStart(
|
|
req.source,
|
|
req.outputDir,
|
|
(local) => fpkg.build({ ...req, source: local, fullVerify }),
|
|
then,
|
|
password,
|
|
);
|
|
return;
|
|
}
|
|
beginRun(install ? "convert-install" : "convert", req.source, host, "check");
|
|
try {
|
|
const { job_id } = await fpkg.build({ ...req, fullVerify });
|
|
update({ jobId: job_id });
|
|
poll(job_id, then);
|
|
} catch (error) {
|
|
fail("check", error instanceof Error ? error.message : String(error), null);
|
|
}
|
|
},
|
|
|
|
compress: async (source, outputDir) => {
|
|
if (get().pipeline.phase === "running") return;
|
|
const fullVerify = useConvertPrefs.getState().fullVerify;
|
|
// Compressing reads the image from this machine's disk, so a server image is copied
|
|
// first (unlike a build, which reads it in place).
|
|
if (isRemotePath(source) || isArchiveSource(source)) {
|
|
beginRun("ffpfsc", source, null, isRemotePath(source) ? "copy" : "extract");
|
|
void prepareThenStart(source, outputDir, (local) => fpkg.compress(local, outputDir, fullVerify), null);
|
|
return;
|
|
}
|
|
beginRun("ffpfsc", source, null, "compress");
|
|
try {
|
|
const { job_id } = await fpkg.compress(source, outputDir, fullVerify);
|
|
update({ jobId: job_id });
|
|
poll(job_id, null);
|
|
} catch (error) {
|
|
fail("compress", error instanceof Error ? error.message : String(error), null);
|
|
}
|
|
},
|
|
|
|
buildImage: async (source, outputDir, thenCompress, format = "exfat", thenUpload, lz4) => {
|
|
if (get().pipeline.phase === "running") return;
|
|
pendingImageUpload = thenUpload ?? null;
|
|
// One engine job: compressing happens as the image is written, so no uncompressed copy
|
|
// is ever on disk.
|
|
// The console it goes to, when it is sent once built: another console's view of this
|
|
// game is not busy with it.
|
|
beginRun("image", source, thenUpload?.host ?? null, lz4 ? "pack" : "plan");
|
|
try {
|
|
const { job_id } = await fpkg.buildImage(
|
|
source,
|
|
outputDir,
|
|
format,
|
|
thenCompress,
|
|
lz4,
|
|
useConvertPrefs.getState().verifyChunkKib,
|
|
useConvertPrefs.getState().fullVerify,
|
|
);
|
|
update({ jobId: job_id });
|
|
poll(job_id, null);
|
|
} catch (error) {
|
|
fail(lz4 ? "pack" : "write", error instanceof Error ? error.message : String(error), null);
|
|
}
|
|
},
|
|
|
|
buildFolder: async (source, outputDir) => {
|
|
if (get().pipeline.phase === "running") return;
|
|
pendingImageUpload = null;
|
|
beginRun("folder", source, null, "write");
|
|
try {
|
|
const { job_id } = await fpkg.buildFolder(source, outputDir, undefined, useConvertPrefs.getState().fullVerify);
|
|
update({ jobId: job_id });
|
|
poll(job_id, null);
|
|
} catch (error) {
|
|
fail("write", error instanceof Error ? error.message : String(error), null);
|
|
}
|
|
},
|
|
|
|
retryInstall: async (host, method = "stream") => {
|
|
const p = get().pipeline;
|
|
const path =
|
|
p.phase === "failed" ? p.packagePath : p.phase === "done" && !p.deleted ? p.packagePath : null;
|
|
if (!path || (p.phase !== "failed" && p.phase !== "done")) return;
|
|
if (makesFolder(p.mode) || (p.phase === "done" && makesImage(p.mode))) return;
|
|
deleteAfterInstall = false;
|
|
beginRun("install", p.source, host, "send");
|
|
update({ packagePath: path, titleId: p.titleId });
|
|
await runInstall(path, host, method);
|
|
},
|
|
|
|
cancel: async () => {
|
|
const p = running();
|
|
if (p?.jobId) await jobCancel(p.jobId);
|
|
},
|
|
|
|
reset: () => {
|
|
const phase = get().pipeline.phase;
|
|
if (phase === "done" || phase === "failed") set({ pipeline: { phase: "idle" } });
|
|
},
|
|
|
|
finishReplace: async (choice) => {
|
|
const p = get().pipeline;
|
|
if (p.phase !== "done" || !p.swap || !p.host) return;
|
|
await finishSwap(p.swap, choice, consoleSwapDeps(p.host));
|
|
const now = get().pipeline;
|
|
if (now.phase === "done") set({ pipeline: { ...now, swap: null } });
|
|
},
|
|
|
|
uploadImage: (plan) => {
|
|
const p = get().pipeline;
|
|
if (p.phase !== "done" || p.deleted || !makesImage(p.mode) || p.uploadQueued) return;
|
|
queueImage(p.packagePath, p.packageBytes, plan);
|
|
set({ pipeline: { ...p, uploadQueued: true } });
|
|
},
|
|
|
|
deletePackage: async () => {
|
|
const p = get().pipeline;
|
|
if (p.phase !== "done" || p.deleted) return;
|
|
await fpkg.deletePackage(p.packagePath);
|
|
set({ pipeline: { ...p, deleted: true } });
|
|
},
|
|
}));
|