Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ queue_will_mount: "mount after upload",
"pkglib.footer.count": "{n} packages",
"pkglib.footer.size": "{size} on PS5",
"pkglib.clearFinished": "Clear finished ({n})",
"pkglib.installAll": "Install all",
"pkglib.installAll.hint": "Install every staged package, base games before updates and DLC",
"pkglib.clearAll": "Clear all",
"pkglib.clearAll.confirmTitle": "Delete all staged packages?",
"pkglib.clearAll.confirmBody": "This permanently deletes all {n} staged .pkg file(s) from the PS5. Installed games are not affected.",
Expand Down
44 changes: 43 additions & 1 deletion client/src/screens/InstallPackage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,13 @@ export default function InstallPackageScreen() {
const loading = usePkgLibrary(host, (s) => s.loading);
const error = usePkgLibrary(host, (s) => s.error);
const installing = usePkgLibrary(host, (s) => s.installing);
const installingAll = usePkgLibrary(host, (s) => s.installingAll);
const busyNotice = usePkgLibrary(host, (s) => s.busyNotice);
const installPending = usePkgLibrary(host, (s) => s.installPending);
const refresh = usePkgLibrary(host, (s) => s.refresh);
const addAndUpload = usePkgLibrary(host, (s) => s.addAndUpload);
const install = usePkgLibrary(host, (s) => s.install);
const installAll = usePkgLibrary(host, (s) => s.installAll);
const cancelPendingInstall = usePkgLibrary(
host,
(s) => s.cancelPendingInstall,
Expand Down Expand Up @@ -530,6 +532,13 @@ export default function InstallPackageScreen() {
() => entries.filter(isFinishedPkg).length,
[entries],
);
// Count of not-yet-installed, idle rows — drives the "Install all (N)"
// button. Mirrors installAll's own target filter in the store so the count
// and the action agree. >1 so the button only appears when it saves taps.
const installableCount = useMemo(
() => entries.filter((e) => e.status === "idle" && !e.installedHere).length,
[entries],
);
const sorted = entries; // store already sorts by title
// Installing swaps the payload out (it owns the transfer port :9113), so it
// can't run concurrently with an upload. Rather than block the button, the
Expand All @@ -556,13 +565,46 @@ export default function InstallPackageScreen() {
single-PS5 users; color-matches the console's tab so it's
unambiguous with multiple consoles. */}
<ConsoleChip addr={host} />
{/* Install every staged, not-yet-installed package in one tap,
base → update → DLC order. Only shown when it saves taps
(>1 installable row — a single row has its own Install button).
Disabled while any install runs; the store queues behind an
active upload rather than failing. */}
{installableCount > 1 && (
<Button
variant="secondary"
size="sm"
leftIcon={<Download size={14} />}
onClick={() => void installAll(host)}
loading={installingAll}
disabled={!hostReady || installing || installingAll}
title={
!hostReady
? tr(
"install.add.disabledHint",
"Set a PS5 host on the Connection tab first",
)
: installing
? tr(
"pkglib.add.installingHint",
"Wait for the current install to finish",
)
: tr(
"pkglib.installAll.hint",
"Install every staged package, base games before updates and DLC",
)
}
>
{tr("pkglib.installAll", undefined, "Install all")} ({installableCount})
</Button>
)}
<Button
variant="primary"
size="sm"
leftIcon={<Plus size={14} />}
onClick={handlePick}
loading={picking}
disabled={!hostReady || installing}
disabled={!hostReady || installing || installingAll}
title={
!hostReady
? tr(
Expand Down
40 changes: 40 additions & 0 deletions client/src/state/pkgLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import {
titleIdFromContentId,
platformFromTitleId,
pkgEntryInstallOrder,
pkgLibraryStore,
evictPkgLibraryStore,
isFinishedPkg,
Expand Down Expand Up @@ -71,6 +72,45 @@ describe("platformFromTitleId", () => {
});
});

describe("pkgEntryInstallOrder", () => {
it("orders by PARAM.SFO category: base(0) < update(1) < DLC(2)", () => {
expect(pkgEntryInstallOrder({ category: "gd", path: "/x.pkg" })).toBe(0);
expect(pkgEntryInstallOrder({ category: "gp", path: "/x.pkg" })).toBe(1);
expect(pkgEntryInstallOrder({ category: "ac", path: "/x.pkg" })).toBe(2);
});
it("falls back to a path hint when category is absent (headerless rows)", () => {
expect(
pkgEntryInstallOrder({ category: undefined, path: "/data/updates/p.pkg" }),
).toBe(1);
expect(
pkgEntryInstallOrder({ category: undefined, path: "/data/dlc/p.pkg" }),
).toBe(2);
});
it("defaults an unknown/base-shaped row to 0", () => {
expect(pkgEntryInstallOrder({ category: undefined, path: "/data/game.pkg" })).toBe(0);
expect(pkgEntryInstallOrder({ category: "xx", path: "/data/game.pkg" })).toBe(0);
});
it("prefers the category over a misleading path hint", () => {
// A base game that happens to live under an /updates/ dir must still be 0.
expect(
pkgEntryInstallOrder({ category: "gd", path: "/data/updates/base.pkg" }),
).toBe(0);
});
it("sorts a mixed batch base → update → DLC (stable within a tier)", () => {
const rows = [
{ category: "ac", path: "/dlc1.pkg" },
{ category: "gp", path: "/upd.pkg" },
{ category: "gd", path: "/base2.pkg" },
{ category: "gd", path: "/base1.pkg" },
];
const sorted = rows
.slice()
.sort((a, b) => pkgEntryInstallOrder(a) - pkgEntryInstallOrder(b))
.map((r) => r.path);
expect(sorted).toEqual(["/base2.pkg", "/base1.pkg", "/upd.pkg", "/dlc1.pkg"]);
});
});

describe("per-console store registry (eviction)", () => {
it("returns the SAME store instance for a host until evicted", () => {
const a1 = pkgLibraryStore("192.168.50.1");
Expand Down
95 changes: 95 additions & 0 deletions client/src/state/pkgLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ export function platformFromTitleId(titleId: string | null | undefined): string
return "";
}

/** Install ordering for a staged library row: base (0) → update (1) → DLC (2).
* Mirrors `uploadQueue.installOrderPriority` but reads a `PkgEntry` (PARAM.SFO
* category `gd`/`gp`/`ac`, falling back to a `/updates/` or `/dlc/` path hint
* for headerless rows). `installAll` sorts by this so an add-on never installs
* before its base — Sony's installer accepts an update/DLC whose base isn't
* present yet but then lands nothing. Exported for unit testing. */
export function pkgEntryInstallOrder(e: Pick<PkgEntry, "category" | "path">): number {
const cat = e.category;
if (cat === "gp") return 1;
if (cat === "ac") return 2;
if (cat === "gd") return 0;
if (/\/updates\/[^/]*$/.test(e.path)) return 1;
if (/\/dlc\/[^/]*$/.test(e.path)) return 2;
return 0;
}

/**
* Whether a library row should get the "installed"/"Reinstall" treatment
* (badge + secondary button) for a given set of installed title ids.
Expand Down Expand Up @@ -418,9 +434,20 @@ interface PkgLibraryState {
* mid-swap and leave a half-loaded payload. */
installPending: boolean;

/** True while `installAll` is driving a sequential batch. Cosmetic — it
* disables the Install-all button and shows batch progress; it does NOT
* gate the inner per-pkg `install()`, which self-serializes on `installing`. */
installingAll: boolean;

refresh: (host: string) => Promise<void>;
addAndUpload: (localPath: string, host: string) => Promise<void>;
install: (path: string, host: string) => Promise<void>;
/** Install every staged, not-yet-installed, idle row sequentially, in
* base → update → DLC order (`pkgEntryInstallOrder`). Each item runs the
* full readiness-gated `install()` cascade; one failure doesn't abort the
* rest. Ends with a single summary bell. No-op if a single install is
* already running. */
installAll: (host: string) => Promise<void>;
/** Abandon an install that's still waiting its turn behind an upload. */
cancelPendingInstall: () => void;
remove: (path: string, host: string) => Promise<void>;
Expand Down Expand Up @@ -1002,6 +1029,7 @@ const makePkgLibraryStore = () =>
installing: false,
busyNotice: null,
installPending: false,
installingAll: false,

async refresh(host) {
if (!host?.trim() || get().installing) return;
Expand Down Expand Up @@ -1536,6 +1564,73 @@ const makePkgLibraryStore = () =>
}
},

async installAll(host) {
if (!host?.trim()) return;
// Don't start a batch on top of a single in-flight install (or another
// batch) — the inner install() would early-return and we'd silently skip
// rows. The button is disabled in this state too; this is the guard.
if (get().installing || get().installingAll) return;

// Snapshot the not-yet-installed, idle rows and order them base → update →
// DLC so an add-on never installs before its base. `installedHere` is the
// authoritative per-package signal (see pkgRowInstalled); a row mid-upload
// or queued is skipped — installAll only drives ready staged packages.
const targets = get()
.entries.filter((e) => e.status === "idle" && !e.installedHere)
.slice()
.sort((a, b) => {
const d = pkgEntryInstallOrder(a) - pkgEntryInstallOrder(b);
// Stable within a tier: preserve listing order (base games in the
// order they were staged), matching uploadQueue's install ordering.
return d;
});

if (targets.length === 0) {
pushNotification("info", "Nothing to install", {
body: "Every staged package is already installed on this console.",
});
return;
}

set({ installingAll: true });
let ok = 0;
let failed = 0;
try {
for (const target of targets) {
// Re-read the row: an earlier item in the batch (or an outside action)
// may have changed its state. Skip if it's no longer an idle, not-yet-
// installed row.
const cur = get().entries.find((e) => e.path === target.path);
if (!cur || cur.status !== "idle" || cur.installedHere) continue;
// install() self-serializes on `installing` and awaits the full
// readiness-gated cascade; it sets the row's lastResult. It never
// throws (it catches internally), so a single failure can't abort the
// batch — we just count the outcome and move on.
await get().install(target.path, host);
const after = get().entries.find((e) => e.path === target.path);
if (after?.lastResult?.ok) ok++;
else failed++;
}
} finally {
set({ installingAll: false });
}

// One summary bell for the whole batch (each item's own success/failure
// bell still fires inside install(), matching the single-install UX).
pushNotification(
failed === 0 ? "success" : "warning",
failed === 0
? `Installed ${ok} package${ok === 1 ? "" : "s"}`
: `Installed ${ok} of ${ok + failed}; ${failed} failed`,
{
body:
failed === 0
? "All staged packages installed."
: "Some packages didn't install — check the rows marked failed and retry them.",
},
);
},

async installExternal(pkg, host) {
if (!host?.trim() || get().installing) {
return { ok: false, message: "Another install is in progress." };
Expand Down
Loading