diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts
index e89ad07e..7df5e449 100644
--- a/client/src/i18n/locales/en.ts
+++ b/client/src/i18n/locales/en.ts
@@ -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.",
diff --git a/client/src/screens/InstallPackage/index.tsx b/client/src/screens/InstallPackage/index.tsx
index dc41d3e4..0294cadf 100644
--- a/client/src/screens/InstallPackage/index.tsx
+++ b/client/src/screens/InstallPackage/index.tsx
@@ -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,
@@ -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
@@ -556,13 +565,46 @@ export default function InstallPackageScreen() {
single-PS5 users; color-matches the console's tab so it's
unambiguous with multiple consoles. */}
+ {/* 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 && (
+ }
+ 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})
+
+ )}
}
onClick={handlePick}
loading={picking}
- disabled={!hostReady || installing}
+ disabled={!hostReady || installing || installingAll}
title={
!hostReady
? tr(
diff --git a/client/src/state/pkgLibrary.test.ts b/client/src/state/pkgLibrary.test.ts
index e361b256..d4879e0f 100644
--- a/client/src/state/pkgLibrary.test.ts
+++ b/client/src/state/pkgLibrary.test.ts
@@ -41,6 +41,7 @@ import {
import {
titleIdFromContentId,
platformFromTitleId,
+ pkgEntryInstallOrder,
pkgLibraryStore,
evictPkgLibraryStore,
isFinishedPkg,
@@ -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");
diff --git a/client/src/state/pkgLibrary.ts b/client/src/state/pkgLibrary.ts
index da7f66e9..fa531108 100644
--- a/client/src/state/pkgLibrary.ts
+++ b/client/src/state/pkgLibrary.ts
@@ -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): 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.
@@ -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;
addAndUpload: (localPath: string, host: string) => Promise;
install: (path: string, host: string) => Promise;
+ /** 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;
/** Abandon an install that's still waiting its turn behind an upload. */
cancelPendingInstall: () => void;
remove: (path: string, host: string) => Promise;
@@ -1002,6 +1029,7 @@ const makePkgLibraryStore = () =>
installing: false,
busyNotice: null,
installPending: false,
+ installingAll: false,
async refresh(host) {
if (!host?.trim() || get().installing) return;
@@ -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." };
diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json
index e2ff73d4..d4986183 100644
--- a/scripts/i18n-known-missing.json
+++ b/scripts/i18n-known-missing.json
@@ -75,6 +75,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -294,6 +296,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -513,6 +517,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -732,6 +738,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -951,6 +959,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -1170,6 +1180,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -1389,6 +1401,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -1608,6 +1622,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -1827,6 +1843,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -2046,6 +2064,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -2265,6 +2285,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -2484,6 +2506,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -2703,6 +2727,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -2922,6 +2948,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -3141,6 +3169,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -3360,6 +3390,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",
@@ -3579,6 +3611,8 @@
"pkglib.external.scanning",
"pkglib.external.scanningDrives",
"pkglib.external.title",
+ "pkglib.installAll",
+ "pkglib.installAll.hint",
"pkglib.installnote.body",
"pkglib.installnote.title",
"pkglib.options.heading",