From 818b87468a5e2f76dbf89f54d3df71dd608d1378 Mon Sep 17 00:00:00 2001 From: Twice6804 <260297042+Twice6804@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:20:48 -0700 Subject: [PATCH] feat(saves): back up a save directly to a USB drive on the PS5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a configurable PS5-side save path (Settings, default /mnt/usb0/savedata) and a "Save to USB" action (per-row + bulk) on the Saves screen that pulls a save off the console, zips it, then pushes it back to ///.zip on a USB/extended-storage drive plugged into the PS5 itself — no host PC round-trip needed. Reuses the existing download/finalize/zip pipeline and single-file transfer (transfer_file); no payload/engine changes required. Pre-flights the USB mount via the existing checkDestinationFreeSpace helper and re-checks for a stale host before uploading, mirroring the Restore flow's safety guards. Co-Authored-By: Claude Sonnet 4.6 --- client/src/i18n/locales/en.ts | 12 ++ client/src/lib/backupTimestamp.test.ts | 24 +++ client/src/lib/backupTimestamp.ts | 19 +++ client/src/screens/Saves/index.tsx | 220 ++++++++++++++++++++++++- client/src/screens/Settings/index.tsx | 55 +++++++ client/src/state/saveSettings.test.ts | 91 ++++++++++ client/src/state/saveSettings.ts | 48 ++++++ client/src/state/userConfig.ts | 14 ++ scripts/i18n-known-missing.json | 187 +++++++++++++++++++++ 9 files changed, 661 insertions(+), 9 deletions(-) create mode 100644 client/src/lib/backupTimestamp.test.ts create mode 100644 client/src/lib/backupTimestamp.ts create mode 100644 client/src/state/saveSettings.test.ts create mode 100644 client/src/state/saveSettings.ts diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 2e92e24c..13df2ac6 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -1017,6 +1017,13 @@ saves_restore_confirm_title: "Restore {title} from a .zip backup?", saves_restore_picker: "Pick the .zip backup to restore from", saves_restore_tooltip: "Pick a .zip backup and upload its contents back to this save's PS5 path. Overwrites the live save.", saves_title: "Save data", +saves_backup_usb: "Save to USB", +saves_backup_usb_tooltip: "Back this save up to the USB save path configured in Settings, without leaving the PS5.", +saves_backup_usb_all: "Back up all to USB", +saves_backup_usb_no_volume: "No writable USB/external drive found at {path}. Plug it into the PS5 and try again.", +saves_backup_usb_low_space: "Not enough free space at {path} for this backup.", +saves_backup_usb_summary: "Backed up {ok}/{total} to USB", +saves_backup_usb_summary_failed: "Backed up {ok}/{total} to USB; {failed} failed", schedules_add: "Add daily", schedules_caveat: "Schedules fire only while ps5upload is open. For true overnight automation, set a system cron job that hits the engine HTTP API instead.", schedules_remove: "remove", @@ -1057,6 +1064,11 @@ engine_url_label: "Engine URL", engine_url_reset: "Reset", engine_url_hint: "Where the app reaches the ps5upload-engine. Leave as the default local sidecar, or point at a remote/self-hosted engine. Restart the app after switching between local and remote.", +settings_card_save_path: "Save backups", +save_path_label: "USB save path", +save_path_reset: "Reset", +save_path_hint: + "PS5-side base folder for \"Save to USB storage\" backups (e.g. a USB stick plugged into the console). Each backup lands at //<timestamp>/<title id>.zip.", settings_group_updates: "Updates", settings_group_data: "Data & reset", settings_group_automation: "Automation", diff --git a/client/src/lib/backupTimestamp.test.ts b/client/src/lib/backupTimestamp.test.ts new file mode 100644 index 00000000..243d0d8f --- /dev/null +++ b/client/src/lib/backupTimestamp.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { backupTimestamp } from "./backupTimestamp"; + +describe("backupTimestamp", () => { + it("formats as YYYY-MM-DD_HHMMSS in local time", () => { + const d = new Date(2026, 5, 17, 14, 25, 30); // June 17 2026, 14:25:30 local + expect(backupTimestamp(d)).toBe("2026-06-17_142530"); + }); + + it("zero-pads single-digit month/day/hour/minute/second", () => { + const d = new Date(2026, 0, 2, 3, 4, 5); // Jan 2 2026, 03:04:05 local + expect(backupTimestamp(d)).toBe("2026-01-02_030405"); + }); + + it("defaults to the current time when no argument is given", () => { + const before = new Date(); + const ts = backupTimestamp(); + const after = new Date(); + // Sanity check the shape and that it falls within [before, after]. + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}_\d{6}$/); + expect(backupTimestamp(before) <= ts).toBe(true); + expect(ts <= backupTimestamp(after)).toBe(true); + }); +}); diff --git a/client/src/lib/backupTimestamp.ts b/client/src/lib/backupTimestamp.ts new file mode 100644 index 00000000..2e9e302a --- /dev/null +++ b/client/src/lib/backupTimestamp.ts @@ -0,0 +1,19 @@ +// Timestamp string used to name a "Save to USB storage" backup folder: +// `<savePath>/<title_id>/<timestamp>/<title_id>.zip`. Sortable lexically +// and filesystem-safe on every target (PS5 exFAT/FAT32/UFS, host OSes). +// +// Pure function — `d` is passed in so tests don't depend on the wall +// clock (mirrors the `nowMs`-injection convention in pkgStagingPath.ts). + +function pad(n: number, width = 2): string { + return String(n).padStart(width, "0"); +} + +/** Local-time `YYYY-MM-DD_HHMMSS`, e.g. `2026-06-17_142530`. Local time + * (not UTC) so the folder name matches what the user would expect to + * see if they browse the USB drive themselves. */ +export function backupTimestamp(d: Date = new Date()): string { + const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const time = `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + return `${date}_${time}`; +} diff --git a/client/src/screens/Saves/index.tsx b/client/src/screens/Saves/index.tsx index 0ca8e4d8..9cfb5baa 100644 --- a/client/src/screens/Saves/index.tsx +++ b/client/src/screens/Saves/index.tsx @@ -8,11 +8,14 @@ import { Download, Upload as UploadIcon, FolderOpen, + HardDrive, } from "lucide-react"; import { openInFileSystem } from "../../state/fsNavigation"; import { savesList, startTransferDir, + startTransferFile, + checkDestinationFreeSpace, fsDelete, fsListDir, waitForJob, @@ -24,7 +27,10 @@ import { saveArchiveRestorePrepare, type SaveEntry, } from "../../api/ps5"; +import { localFs } from "../../api/localFs"; import { useConnectionStore, PS5_PAYLOAD_PORT } from "../../state/connection"; +import { getSavePath } from "../../state/saveSettings"; +import { backupTimestamp } from "../../lib/backupTimestamp"; import { PageHeader, Button, @@ -73,6 +79,9 @@ export default function SavesScreen() { // two ops over the same PS5 save path (which would deletes-and- // uploads in undefined order and leave the live save corrupt). const [busy, setBusy] = useState<Set<string>>(() => new Set()); + // "Back up all to USB" is a single sequential run across every save, so + // its busy flag is global rather than per-path like `busy` above. + const [bulkBackupBusy, setBulkBackupBusy] = useState(false); const isBusy = useCallback((path: string) => busy.has(path), [busy]); const markBusy = useCallback((path: string, on: boolean) => { setBusy((prev) => { @@ -322,6 +331,161 @@ export default function SavesScreen() { } } + /** + * Core "Save to USB storage" flow: pull the save off the PS5 (same + * download/finalize/zip steps as handleDownload), but instead of a + * host file-save dialog, push the resulting zip BACK onto the PS5 at + * `<savePath>/<title_id>/<timestamp>/<title_id>.zip` — typically a USB + * stick or extended-storage drive plugged into the console itself. + * + * `skipPreflight` lets the bulk "Back up all to USB" button validate + * the USB mount once up front instead of once per title. + */ + async function backupOneToUsb( + entry: SaveEntry, + opts?: { skipPreflight?: boolean }, + ) { + if (!host?.trim()) return; + if (isBusy(entry.path)) return; + const backupHost = host.trim(); + const addr = `${backupHost}:${PS5_PAYLOAD_PORT}`; + const base = getSavePath(); + markBusy(entry.path, true); + let tempDir: string | null = null; + try { + if (!opts?.skipPreflight) { + const preflight = await checkDestinationFreeSpace(addr, base, 0); + if (!preflight.volume || !preflight.volume.writable || preflight.volume.is_placeholder) { + throw new Error( + tr( + "saves_backup_usb_no_volume", + { path: base }, + `No writable USB/external drive found at ${base}. Plug it into the PS5 and try again.`, + ), + ); + } + } + // 1) Scratch dir + pull the PS5 save folder, same as handleDownload. + tempDir = await saveArchiveMakeTemp(entry.title_id); + const jobId = await startTransferDownload(entry.path, tempDir, addr, "folder"); + await waitForJob(jobId); + // 2) Format-aware cleanup (strip sdimg_ prefix, drop emulator + // bookkeeping subdirs) — identical to handleDownload. + await saveArchiveBackupFinalize(tempDir, entry.title_id); + // 3) Zip into the scratch dir itself (no host file-save dialog — + // the zip never needs to leave the temp dir before it's uploaded). + const zipName = `${entry.title_id}.zip`; + const hostZip = `${tempDir}/${zipName}`; + await saveArchiveZip(tempDir, entry.title_id, hostZip, zipName); + // 4) Size the zip (via the scratch dir listing) and confirm the USB + // target has room for it. Best-effort: an unreadable size or an + // unmatched volume just skips the check rather than blocking. + const remoteDir = `${base}/${entry.title_id}/${backupTimestamp()}`; + const remoteZip = `${remoteDir}/${zipName}`; + const tempEntries = await localFs.listDir(tempDir).catch(() => []); + const zipSize = tempEntries.find((e) => e.name === zipName)?.size ?? 0; + if (zipSize > 0) { + const spaceCheck = await checkDestinationFreeSpace(addr, remoteZip, zipSize); + if (spaceCheck.insufficient) { + throw new Error( + tr( + "saves_backup_usb_low_space", + { path: base }, + `Not enough free space at ${base} for this backup.`, + ), + ); + } + } + // 5) Stale-host re-check, same reasoning as handleRestore: the + // download+zip steps above can run for many seconds, and the user + // may have switched PS5 in the roster sidebar meanwhile. Refuse to + // upload to the wrong console. + const currentHost = useConnectionStore.getState().host?.trim(); + if (currentHost !== backupHost) { + throw new Error( + `Host changed during backup (was ${backupHost}, now ${currentHost || "(none)"}). ` + + "Aborted before upload — your other console's USB drive is untouched.", + ); + } + // 6) Upload the zip to the PS5's USB path. The payload's + // ensure_parent_dir auto-creates <title_id>/<timestamp>/ — no + // manual mkdir needed. + const jobId2 = await startTransferFile(hostZip, remoteZip, addr, null); + await waitForJob(jobId2); + pushNotification( + "success", + withConsolePrefix(backupHost, `Backed up ${entry.title_id} to USB`), + { body: `Saved to ${remoteZip}` }, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + pushNotification( + "error", + withConsolePrefix(backupHost, `USB backup failed: ${entry.title_id}`), + { body: msg }, + ); + throw e; // let the bulk handler count this as a failure + } finally { + if (tempDir) await saveArchiveCleanupTemp(tempDir).catch(() => {}); + markBusy(entry.path, false); + } + } + + function handleBackupToUsb(entry: SaveEntry) { + backupOneToUsb(entry).catch(() => { + // Already surfaced via setError + pushNotification above. + }); + } + + async function handleBackupAllToUsb() { + if (!host?.trim() || !saves || saves.length === 0 || bulkBackupBusy) return; + const backupHost = host.trim(); + const addr = `${backupHost}:${PS5_PAYLOAD_PORT}`; + const base = getSavePath(); + setBulkBackupBusy(true); + try { + const preflight = await checkDestinationFreeSpace(addr, base, 0); + if (!preflight.volume || !preflight.volume.writable || preflight.volume.is_placeholder) { + const msg = tr( + "saves_backup_usb_no_volume", + { path: base }, + `No writable USB/external drive found at ${base}. Plug it into the PS5 and try again.`, + ); + setError(msg); + pushNotification("error", withConsolePrefix(backupHost, "USB backup failed"), { + body: msg, + }); + return; + } + let ok = 0; + let failed = 0; + for (const entry of saves) { + try { + await backupOneToUsb(entry, { skipPreflight: true }); + ok++; + } catch { + failed++; + } + } + pushNotification( + failed === 0 ? "success" : "error", + withConsolePrefix( + backupHost, + failed === 0 + ? tr("saves_backup_usb_summary", { ok, total: saves.length }, `Backed up ${ok}/${saves.length} to USB`) + : tr( + "saves_backup_usb_summary_failed", + { ok, total: saves.length, failed }, + `Backed up ${ok}/${saves.length} to USB; ${failed} failed`, + ), + ), + ); + } finally { + setBulkBackupBusy(false); + } + } + return ( <div className="p-6"> {confirmDialogNode} @@ -336,15 +500,39 @@ export default function SavesScreen() { "Per-game save folders on the PS5. PS5 saves under savedata_prospero/, PS4 legacy saves under savedata/. Backup writes a portable <title-id>.zip; restore expects the same shape.", )} right={ - <Button - variant="secondary" - size="sm" - leftIcon={<RefreshCw size={12} />} - onClick={refresh} - disabled={loading || !host?.trim() || payloadStatus !== "up"} - > - {tr("refresh", undefined, "Refresh")} - </Button> + <div className="flex items-center gap-2"> + <Button + variant="secondary" + size="sm" + leftIcon={ + bulkBackupBusy ? ( + <Loader2 size={12} className="animate-spin" /> + ) : ( + <HardDrive size={12} /> + ) + } + onClick={handleBackupAllToUsb} + disabled={ + bulkBackupBusy || + loading || + !host?.trim() || + payloadStatus !== "up" || + !saves || + saves.length === 0 + } + > + {tr("saves_backup_usb_all", undefined, "Back up all to USB")} + </Button> + <Button + variant="secondary" + size="sm" + leftIcon={<RefreshCw size={12} />} + onClick={refresh} + disabled={loading || !host?.trim() || payloadStatus !== "up"} + > + {tr("refresh", undefined, "Refresh")} + </Button> + </div> } /> @@ -414,6 +602,20 @@ export default function SavesScreen() { > {tr("saves_download", undefined, "Backup")} </Button> + <Button + variant="ghost" + size="sm" + leftIcon={<HardDrive size={11} />} + onClick={() => handleBackupToUsb(e)} + disabled={isBusy(e.path) || bulkBackupBusy} + title={tr( + "saves_backup_usb_tooltip", + undefined, + "Back this save up to the USB save path configured in Settings, without leaving the PS5.", + )} + > + {tr("saves_backup_usb", undefined, "Save to USB")} + </Button> {/* danger (red-bordered), NOT ghost like Backup: Restore overwrites — wipes — the live PS5 save. It sat visually identical to the harmless Backup button next to it, diff --git a/client/src/screens/Settings/index.tsx b/client/src/screens/Settings/index.tsx index b13b5b2a..7fc4f8b1 100644 --- a/client/src/screens/Settings/index.tsx +++ b/client/src/screens/Settings/index.tsx @@ -42,6 +42,7 @@ import { } from "../../state/uploadSettings"; import { useConnectionStore } from "../../state/connection"; import { useEngineStore, DEFAULT_ENGINE_URL } from "../../state/engine"; +import { useSaveSettingsStore, DEFAULT_SAVE_PATH } from "../../state/saveSettings"; import { userConfigPath, resetAllAppData } from "../../state/userConfig"; import { useUpdateStore, type UpdatePhase } from "../../state/update"; import { isMobile } from "../../lib/platform"; @@ -149,6 +150,58 @@ function EngineUrlSection() { ); } +/** Save-to-USB base path field. Where the Saves screen's "Save to USB + * storage" button writes backups on the PS5 itself (e.g. a USB stick + * plugged into the console) — full layout is `<path>/<title_id>/ + * <timestamp>/<title_id>.zip`. Edits commit on blur, same as the + * Engine URL field above. */ +function SavePathSection() { + const tr = useTr(); + const savePath = useSaveSettingsStore((s) => s.savePath); + const setSavePath = useSaveSettingsStore((s) => s.setSavePath); + const [draft, setDraft] = useState(savePath); + + return ( + <Section title={tr("settings_card_save_path", undefined, "Save backups")}> + <label className="grid gap-1.5 text-sm"> + <span className="font-medium"> + {tr("save_path_label", undefined, "USB save path")} + </span> + <div className="flex items-center gap-2"> + <input + type="text" + value={draft} + onChange={(e) => setDraft(e.target.value)} + onBlur={() => setSavePath(draft)} + placeholder={DEFAULT_SAVE_PATH} + spellCheck={false} + className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm" + /> + {savePath !== DEFAULT_SAVE_PATH && ( + <button + type="button" + onClick={() => { + setDraft(DEFAULT_SAVE_PATH); + setSavePath(DEFAULT_SAVE_PATH); + }} + className="text-xs text-[var(--color-muted)] hover:text-[var(--color-text)]" + > + {tr("save_path_reset", undefined, "Reset")} + </button> + )} + </div> + <span className="text-xs text-[var(--color-muted)]"> + {tr( + "save_path_hint", + undefined, + "PS5-side base folder for \"Save to USB storage\" backups (e.g. a USB stick plugged into the console). Each backup lands at <path>/<title id>/<timestamp>/<title id>.zip.", + )} + </span> + </label> + </Section> + ); +} + export default function SettingsScreen() { const { enabled, supported, lastError, setEnabled, syncFromBackend } = useKeepAwakeStore(); @@ -312,6 +365,8 @@ export default function SettingsScreen() { <EngineUrlSection /> + <SavePathSection /> + <GroupHeading> {tr("settings_group_uploads", undefined, "Uploads")} </GroupHeading> diff --git a/client/src/state/saveSettings.test.ts b/client/src/state/saveSettings.test.ts new file mode 100644 index 00000000..9c62eaa2 --- /dev/null +++ b/client/src/state/saveSettings.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { useSaveSettingsStore, normalizeSavePath, DEFAULT_SAVE_PATH } from "./saveSettings"; + +// vitest's default node env has no `window`; the store reads +// `window.localStorage`. Install a tiny in-memory stub (same pattern as +// installSettings.test.ts) so the real persist branches run. +function installWindowStub(seed?: Record<string, string>) { + const store = new globalThis.Map<string, string>( + seed ? Object.entries(seed) : [], + ); + const localStorage = { + getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null), + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }; + (globalThis as { window?: unknown }).window = { localStorage }; +} + +const KEY = "ps5upload.save_path"; + +describe("normalizeSavePath", () => { + it("trims whitespace", () => { + expect(normalizeSavePath(" /mnt/usb0/savedata ")).toBe( + "/mnt/usb0/savedata", + ); + }); + + it("strips trailing slashes", () => { + expect(normalizeSavePath("/mnt/usb0/savedata/")).toBe( + "/mnt/usb0/savedata", + ); + expect(normalizeSavePath("/mnt/usb0/savedata///")).toBe( + "/mnt/usb0/savedata", + ); + }); + + it("falls back to the default when empty or whitespace-only", () => { + expect(normalizeSavePath("")).toBe(DEFAULT_SAVE_PATH); + expect(normalizeSavePath(" ")).toBe(DEFAULT_SAVE_PATH); + }); + + it("leaves an already-normalized custom path untouched", () => { + expect(normalizeSavePath("/mnt/ext0/backups")).toBe("/mnt/ext0/backups"); + }); +}); + +describe("saveSettings — savePath", () => { + beforeEach(() => { + installWindowStub(); + }); + + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + vi.resetModules(); + }); + + it("defaults to DEFAULT_SAVE_PATH when the key is absent", async () => { + installWindowStub(); + vi.resetModules(); + const mod = await import("./saveSettings"); + expect(mod.useSaveSettingsStore.getState().savePath).toBe( + DEFAULT_SAVE_PATH, + ); + }); + + it("loads and normalizes a previously stored value", async () => { + installWindowStub({ [KEY]: "/mnt/usb1/savedata/" }); + vi.resetModules(); + const mod = await import("./saveSettings"); + expect(mod.useSaveSettingsStore.getState().savePath).toBe( + "/mnt/usb1/savedata", + ); + }); + + it("setter persists the normalized value and mirrors state", () => { + const s = useSaveSettingsStore.getState(); + s.setSavePath("/mnt/ext0/backups/"); + expect(window.localStorage.getItem(KEY)).toBe("/mnt/ext0/backups"); + expect(useSaveSettingsStore.getState().savePath).toBe( + "/mnt/ext0/backups", + ); + }); + + it("setter falls back to the default for an empty value", () => { + const s = useSaveSettingsStore.getState(); + s.setSavePath(" "); + expect(window.localStorage.getItem(KEY)).toBe(DEFAULT_SAVE_PATH); + expect(useSaveSettingsStore.getState().savePath).toBe(DEFAULT_SAVE_PATH); + }); +}); diff --git a/client/src/state/saveSettings.ts b/client/src/state/saveSettings.ts new file mode 100644 index 00000000..4fe22202 --- /dev/null +++ b/client/src/state/saveSettings.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; + +/** + * Where "Save to USB storage" writes save backups on the PS5 itself — + * e.g. a USB stick or extended-storage drive plugged into the console, + * NOT the host PC (that's the existing Saves screen's Backup-to-file + * flow). Defaults to the first USB mount slot the payload exposes. + * + * Persisted to localStorage and mirrored to ~/.ps5upload/settings.json, + * same as engineUrl (state/engine.ts). + */ + +export const DEFAULT_SAVE_PATH = "/mnt/usb0/savedata"; +const KEY_SAVE_PATH = "ps5upload.save_path"; + +/** Trim and drop trailing slashes so callers can `${savePath}/<title_id>/...` + * without doubling up. Falls back to the default when empty. */ +export function normalizeSavePath(raw: string): string { + const v = raw.trim().replace(/\/+$/, ""); + return v || DEFAULT_SAVE_PATH; +} + +function loadSavePath(): string { + if (typeof window === "undefined") return DEFAULT_SAVE_PATH; + const v = window.localStorage.getItem(KEY_SAVE_PATH); + return v ? normalizeSavePath(v) : DEFAULT_SAVE_PATH; +} + +interface SaveSettingsState { + savePath: string; + setSavePath: (path: string) => void; +} + +export const useSaveSettingsStore = create<SaveSettingsState>((set) => ({ + savePath: loadSavePath(), + setSavePath: (path) => { + const savePath = normalizeSavePath(path); + if (typeof window !== "undefined") { + window.localStorage.setItem(KEY_SAVE_PATH, savePath); + } + set({ savePath }); + }, +})); + +/** Non-hook accessor for module-scope callers (Saves screen handlers). */ +export function getSavePath(): string { + return useSaveSettingsStore.getState().savePath; +} diff --git a/client/src/state/userConfig.ts b/client/src/state/userConfig.ts index 38a61d33..25a0702f 100644 --- a/client/src/state/userConfig.ts +++ b/client/src/state/userConfig.ts @@ -7,6 +7,7 @@ import { useKeepAwakeStore } from "./keepAwake"; import { useUploadSettingsStore } from "./uploadSettings"; import { useConnectionStore } from "./connection"; import { useEngineStore, normalizeEngineUrl } from "./engine"; +import { useSaveSettingsStore, normalizeSavePath } from "./saveSettings"; /** * Mirror all persisted user settings to `~/.ps5upload/settings.json`. @@ -36,6 +37,9 @@ interface SettingsSnapshot { /** Base URL of the engine the UI talks to. Default is the local * sidecar; can point at a remote/self-hosted engine. */ engine_url?: string; + /** PS5-side base directory "Save to USB storage" backs up into — + * e.g. a USB stick plugged into the console. Default /mnt/usb0/savedata. */ + save_path?: string; keep_awake?: boolean; upload?: { always_overwrite?: boolean; @@ -51,6 +55,7 @@ function snapshotCurrent(): SettingsSnapshot { lang: useLangStore.getState().lang, ps5_host: useConnectionStore.getState().host, engine_url: useEngineStore.getState().engineUrl, + save_path: useSaveSettingsStore.getState().savePath, keep_awake: useKeepAwakeStore.getState().enabled, upload: { always_overwrite: useUploadSettingsStore.getState().alwaysOverwrite, @@ -111,6 +116,7 @@ export function installUserConfigMirror() { schedulePersist(); pushEngineUrl(s.engineUrl); }); + useSaveSettingsStore.subscribe(schedulePersist); } /** Tell the Rust shell which engine URL its proxies should hit. */ @@ -198,6 +204,14 @@ export async function hydrateFromUserConfig(): Promise<void> { // it changed here — the shell read settings.json at startup, but a // localStorage-only value (no file) wouldn't have reached it. pushEngineUrl(useEngineStore.getState().engineUrl); + const liveSavePath = useSaveSettingsStore.getState().savePath; + if ( + typeof data.save_path === "string" && + data.save_path.trim() && + normalizeSavePath(data.save_path) !== liveSavePath + ) { + useSaveSettingsStore.getState().setSavePath(data.save_path); + } const liveKeepAwake = useKeepAwakeStore.getState().enabled; if (typeof data.keep_awake === "boolean" && data.keep_awake !== liveKeepAwake) { await useKeepAwakeStore.getState().setEnabled(data.keep_awake); diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json index 92cdc455..b797cbc1 100644 --- a/scripts/i18n-known-missing.json +++ b/scripts/i18n-known-missing.json @@ -144,12 +144,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -340,12 +351,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -536,12 +558,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -732,12 +765,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -928,12 +972,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -1124,12 +1179,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -1320,12 +1386,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -1516,12 +1593,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -1712,12 +1800,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -1908,12 +2007,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -2104,12 +2214,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -2300,12 +2421,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -2496,12 +2628,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -2692,12 +2835,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -2888,12 +3042,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -3084,12 +3249,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette", @@ -3280,12 +3456,23 @@ "queue_installed", "queue_installed_warn", "queue_will_install", + "save_path_hint", + "save_path_label", + "save_path_reset", + "saves_backup_usb", + "saves_backup_usb_all", + "saves_backup_usb_low_space", + "saves_backup_usb_no_volume", + "saves_backup_usb_summary", + "saves_backup_usb_summary_failed", + "saves_backup_usb_tooltip", "saves_open_folder", "saves_open_folder_hint", "screenshots_bulk_partial", "search_scope_all", "search_scope_tooltip", "settings_card_engine", + "settings_card_save_path", "shortcuts_activate", "shortcuts_close", "shortcuts_cmd_palette",