mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Save data: per-user names, user filter and per-user USB backups
On a shared console every user's saves were listed together under raw user ids, and USB backups of the same game shared one folder, so Restore all from USB could restore one user's backup into another's save. Label saves with the owner's name (profile info), start on the signed-in user with a Whose saves picker, name local backup zips after the game and user, and keep USB backups in a per-user folder (legacy backups used only when one user owns the title). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
1 parent
d7169cc7e1
commit
11d12102c0
25 files changed
+342
-26
No files matched your search
@@ -4,6 +4,21 @@ What's new in ps5upload, written for humans.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.2
|
||||
|
||||
- **Save data on a shared PS5 shows whose save is whose.** Each save is labelled with its
|
||||
owner's name instead of a number, and the screen starts on the user who is signed in at the
|
||||
console; "Whose saves" switches to another user or to everyone. Back up all and Restore all
|
||||
act on the saves shown. A backup saved on your computer is named after the game and the user
|
||||
(`PPSA01234-Alice.zip`).
|
||||
- **USB backups are kept per user.** Backups to a USB drive on the console now go into a folder
|
||||
per user (`<game>/<name>-<user id>/<date>/`). Before, every user's backups of a game shared one
|
||||
folder, so on a shared console "Restore all from USB" could put one user's latest backup into
|
||||
another user's save. Backups made before this update are still used when only one user on the
|
||||
console has a save of that game.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.1
|
||||
|
||||
- **The app scrolls again on macOS Big Sur.** On Big Sur's older built-in web engine the app
|
||||
|
||||
@@ -1001,6 +1001,9 @@ usbmodal_free_slash: "مجاني",
|
||||
usbmodal_priority: "الأولوية",
|
||||
saves_folder: "مجلد",
|
||||
saves_user: "مستخدم",
|
||||
saves_user_filter: "حفظ من",
|
||||
saves_user_all: "كل المستخدمين",
|
||||
saves_user_other: "مستخدم آخر ({id})",
|
||||
search_searching: "البحث",
|
||||
search_scanned_progress: "مدقق",
|
||||
search_match_progress: "المعدات",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "র এরর",
|
||||
roster_edit_name_placeholder: "নাম",
|
||||
saves_folder: "ফোল্ডার",
|
||||
saves_user: "ব্যবহারকারী",
|
||||
saves_user_filter: "কার সেভ",
|
||||
saves_user_all: "সব ব্যবহারকারী",
|
||||
saves_user_other: "অন্য ব্যবহারকারী ({id})",
|
||||
search_entries_result: "এন্ট্রি",
|
||||
search_in: "এ",
|
||||
search_match_progress: "ম্যাচ",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ queue_raw_error: "Rohfehler",
|
||||
roster_edit_name_placeholder: "Name",
|
||||
saves_folder: "Ordner",
|
||||
saves_user: "Benutzer",
|
||||
saves_user_filter: "Wessen Spielstände",
|
||||
saves_user_all: "Alle Benutzer",
|
||||
saves_user_other: "Anderer Benutzer ({id})",
|
||||
search_entries_result: "Einträge",
|
||||
search_in: "in",
|
||||
search_match_progress: "Treffer",
|
||||
|
||||
@@ -1754,6 +1754,9 @@ usbmodal_free_slash: "free /",
|
||||
usbmodal_priority: "· priority",
|
||||
saves_folder: "folder",
|
||||
saves_user: "user",
|
||||
saves_user_filter: "Whose saves",
|
||||
saves_user_all: "All users",
|
||||
saves_user_other: "Other user ({id})",
|
||||
search_searching: "Searching",
|
||||
search_scanned_progress: "scanned ·",
|
||||
search_match_progress: "matches",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "error sin procesar",
|
||||
roster_edit_name_placeholder: "Nombre",
|
||||
saves_folder: "carpeta",
|
||||
saves_user: "usuario",
|
||||
saves_user_filter: "Partidas de",
|
||||
saves_user_all: "Todos los usuarios",
|
||||
saves_user_other: "Otro usuario ({id})",
|
||||
search_entries_result: "entradas",
|
||||
search_in: "en",
|
||||
search_match_progress: "coincidencia",
|
||||
|
||||
@@ -1620,6 +1620,9 @@ usbmodal_free_slash: "خالی /",
|
||||
usbmodal_priority: "· اولویت",
|
||||
saves_folder: "پوشه",
|
||||
saves_user: "کاربر",
|
||||
saves_user_filter: "ذخیرههای چه کسی",
|
||||
saves_user_all: "همه کاربران",
|
||||
saves_user_other: "کاربر دیگر ({id})",
|
||||
search_searching: "در حال جستجو",
|
||||
search_scanned_progress: "اسکنشده ·",
|
||||
search_match_progress: "تطابق",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "erreur brute",
|
||||
roster_edit_name_placeholder: "Nom",
|
||||
saves_folder: "dossier",
|
||||
saves_user: "utilisateur",
|
||||
saves_user_filter: "Sauvegardes de",
|
||||
saves_user_all: "Tous les utilisateurs",
|
||||
saves_user_other: "Autre utilisateur ({id})",
|
||||
search_entries_result: "entrées",
|
||||
search_in: "dans",
|
||||
search_match_progress: "correspondance",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ queue_raw_error: "रॉ त्रुटि",
|
||||
roster_edit_name_placeholder: "नाम",
|
||||
saves_folder: "फ़ोल्डर",
|
||||
saves_user: "उपयोगकर्ता",
|
||||
saves_user_filter: "किसके सेव",
|
||||
saves_user_all: "सभी उपयोगकर्ता",
|
||||
saves_user_other: "अन्य उपयोगकर्ता ({id})",
|
||||
search_entries_result: "एंट्रियाँ",
|
||||
search_in: "में",
|
||||
search_match_progress: "मैच",
|
||||
|
||||
@@ -1556,6 +1556,9 @@ usbmodal_free_slash: "szabad /",
|
||||
usbmodal_priority: "· prioritás",
|
||||
saves_folder: "mappa",
|
||||
saves_user: "felhasználó",
|
||||
saves_user_filter: "Kinek a mentései",
|
||||
saves_user_all: "Minden felhasználó",
|
||||
saves_user_other: "Másik felhasználó ({id})",
|
||||
search_searching: "Keresés",
|
||||
search_scanned_progress: "átnézve ·",
|
||||
search_match_progress: "találat",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "kesalahan mentah",
|
||||
roster_edit_name_placeholder: "Nama",
|
||||
saves_folder: "folder",
|
||||
saves_user: "pengguna",
|
||||
saves_user_filter: "Simpanan milik",
|
||||
saves_user_all: "Semua pengguna",
|
||||
saves_user_other: "Pengguna lain ({id})",
|
||||
search_entries_result: "entri",
|
||||
search_in: "di",
|
||||
search_match_progress: "kecocokan",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "errore grezzo",
|
||||
roster_edit_name_placeholder: "Nome",
|
||||
saves_folder: "cartella",
|
||||
saves_user: "utente",
|
||||
saves_user_filter: "Salvataggi di",
|
||||
saves_user_all: "Tutti gli utenti",
|
||||
saves_user_other: "Altro utente ({id})",
|
||||
search_entries_result: "voci",
|
||||
search_in: "in",
|
||||
search_match_progress: "corrispondenza",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ queue_raw_error: "生のエラー",
|
||||
roster_edit_name_placeholder: "名前",
|
||||
saves_folder: "フォルダー",
|
||||
saves_user: "ユーザー",
|
||||
saves_user_filter: "表示するユーザー",
|
||||
saves_user_all: "すべてのユーザー",
|
||||
saves_user_other: "その他のユーザー({id})",
|
||||
search_entries_result: "件",
|
||||
search_in: "場所:",
|
||||
search_match_progress: "一致",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ queue_raw_error: "원시 오류",
|
||||
roster_edit_name_placeholder: "이름",
|
||||
saves_folder: "폴더",
|
||||
saves_user: "사용자",
|
||||
saves_user_filter: "누구의 저장 데이터",
|
||||
saves_user_all: "모든 사용자",
|
||||
saves_user_other: "다른 사용자 ({id})",
|
||||
search_entries_result: "개",
|
||||
search_in: "위치:",
|
||||
search_match_progress: "일치",
|
||||
|
||||
@@ -1363,6 +1363,9 @@ usbmodal_free_slash: "wolne /",
|
||||
usbmodal_priority: "· priorytet",
|
||||
saves_folder: "folder",
|
||||
saves_user: "użytkownik",
|
||||
saves_user_filter: "Czyje zapisy",
|
||||
saves_user_all: "Wszyscy użytkownicy",
|
||||
saves_user_other: "Inny użytkownik ({id})",
|
||||
search_searching: "Wyszukiwanie",
|
||||
search_scanned_progress: "zeskanowano ·",
|
||||
search_match_progress: "dopasowanie",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "erro bruto",
|
||||
roster_edit_name_placeholder: "Nome",
|
||||
saves_folder: "pasta",
|
||||
saves_user: "usuário",
|
||||
saves_user_filter: "Saves de quem",
|
||||
saves_user_all: "Todos os usuários",
|
||||
saves_user_other: "Outro usuário ({id})",
|
||||
search_entries_result: "entradas",
|
||||
search_in: "em",
|
||||
search_match_progress: "correspondência",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "необработанная ошибка",
|
||||
roster_edit_name_placeholder: "Имя",
|
||||
saves_folder: "папка",
|
||||
saves_user: "пользователь",
|
||||
saves_user_filter: "Чьи сохранения",
|
||||
saves_user_all: "Все пользователи",
|
||||
saves_user_other: "Другой пользователь ({id})",
|
||||
search_entries_result: "записей",
|
||||
search_in: "в",
|
||||
search_match_progress: "совпадение",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "ข้อผิดพลาดดิบ",
|
||||
roster_edit_name_placeholder: "ชื่อ",
|
||||
saves_folder: "โฟลเดอร์",
|
||||
saves_user: "ผู้ใช้",
|
||||
saves_user_filter: "เซฟของใคร",
|
||||
saves_user_all: "ผู้ใช้ทั้งหมด",
|
||||
saves_user_other: "ผู้ใช้อื่น ({id})",
|
||||
search_entries_result: "รายการ",
|
||||
search_in: "ใน",
|
||||
search_match_progress: "ตรงกัน",
|
||||
|
||||
@@ -1013,6 +1013,9 @@ queue_raw_error: "ham hata",
|
||||
roster_edit_name_placeholder: "Ad",
|
||||
saves_folder: "klasör",
|
||||
saves_user: "kullanıcı",
|
||||
saves_user_filter: "Kimin kayıtları",
|
||||
saves_user_all: "Tüm kullanıcılar",
|
||||
saves_user_other: "Başka kullanıcı ({id})",
|
||||
search_entries_result: "kayıt",
|
||||
search_in: "şurada:",
|
||||
search_match_progress: "eşleşme",
|
||||
|
||||
@@ -1015,6 +1015,9 @@ queue_raw_error: "lỗi thô",
|
||||
roster_edit_name_placeholder: "Tên",
|
||||
saves_folder: "thư mục",
|
||||
saves_user: "người dùng",
|
||||
saves_user_filter: "Dữ liệu lưu của ai",
|
||||
saves_user_all: "Tất cả người dùng",
|
||||
saves_user_other: "Người dùng khác ({id})",
|
||||
search_entries_result: "mục",
|
||||
search_in: "trong",
|
||||
search_match_progress: "khớp",
|
||||
|
||||
@@ -1020,6 +1020,9 @@ queue_raw_error: "原始错误",
|
||||
roster_edit_name_placeholder: "名称",
|
||||
saves_folder: "文件夹",
|
||||
saves_user: "用户",
|
||||
saves_user_filter: "谁的存档",
|
||||
saves_user_all: "所有用户",
|
||||
saves_user_other: "其他用户({id})",
|
||||
search_entries_result: "条",
|
||||
search_in: "位于",
|
||||
search_match_progress: "匹配",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ queue_raw_error: "原始錯誤",
|
||||
roster_edit_name_placeholder: "名稱",
|
||||
saves_folder: "資料夾",
|
||||
saves_user: "使用者",
|
||||
saves_user_filter: "誰的存檔",
|
||||
saves_user_all: "所有使用者",
|
||||
saves_user_other: "其他使用者({id})",
|
||||
search_entries_result: "筆",
|
||||
search_in: "位於",
|
||||
search_match_progress: "相符",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { consoleAddr } from "../../lib/addr";
|
||||
import { isAndroid } from "../../lib/platform";
|
||||
import { backupStamp } from "./backupStamp";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// useEffect is consumed inside SaveThumbnail below.
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
@@ -33,7 +33,20 @@ import {
|
||||
savesOpen,
|
||||
savesClose,
|
||||
type OpenSave,
|
||||
profileInfo,
|
||||
type ProfileUser,
|
||||
} from "../../api/ps5";
|
||||
import {
|
||||
backupZipName,
|
||||
defaultUserFilter,
|
||||
filterSaves,
|
||||
latestUsbBackup,
|
||||
saveOwners,
|
||||
usbUserDir,
|
||||
userHex,
|
||||
userName,
|
||||
type UserFilter,
|
||||
} from "./saveUsers";
|
||||
import { beginTask, type TaskHandle } from "../../state/trackTask";
|
||||
import { localFs } from "../../api/localFs";
|
||||
import { useConnectionStore } from "../../state/connection";
|
||||
@@ -49,6 +62,7 @@ import {
|
||||
OverflowMenu,
|
||||
Spinner,
|
||||
Modal,
|
||||
Select,
|
||||
} from "../../components";
|
||||
// Direct import to avoid the barrel's circular-dep warning at build.
|
||||
import { useConfirm } from "../../components/ConfirmDialog";
|
||||
@@ -158,7 +172,19 @@ export default function SavesScreen() {
|
||||
}
|
||||
};
|
||||
const guard = useStaleHostGuard();
|
||||
const [saves, setSaves] = useState<SaveEntry[] | null>(null);
|
||||
const [allSaves, setSaves] = useState<SaveEntry[] | null>(null);
|
||||
// The console's local users (names) and whose saves are shown. On a shared console the
|
||||
// screen starts on the user at the console, so backups and restores can't pick up someone
|
||||
// else's save of the same game by mistake.
|
||||
const [users, setUsers] = useState<ProfileUser[]>([]);
|
||||
const [userFilter, setUserFilter] = useState<UserFilter>("all");
|
||||
const filterChosen = useRef(false);
|
||||
const saves = useMemo(
|
||||
() => (allSaves ? filterSaves(allSaves, userFilter) : null),
|
||||
[allSaves, userFilter],
|
||||
);
|
||||
const owners = useMemo(() => saveOwners(allSaves ?? [], users), [allSaves, users]);
|
||||
const ownerName = useCallback((e: SaveEntry) => userName(e.user_id, users), [users]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Set of entry paths currently being backed up / restored. Used to
|
||||
@@ -196,8 +222,17 @@ export default function SavesScreen() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await savesList(mgmtAddr(probe.host));
|
||||
const [r, profile] = await Promise.all([
|
||||
savesList(mgmtAddr(probe.host)),
|
||||
// Names are a nicety: an older helper or a failed read just shows ids.
|
||||
profileInfo(mgmtAddr(probe.host)).catch(() => null),
|
||||
]);
|
||||
if (probe.isStale()) return;
|
||||
setUsers(profile?.users ?? []);
|
||||
if (!filterChosen.current) {
|
||||
filterChosen.current = true;
|
||||
setUserFilter(defaultUserFilter(r.saves, profile?.uid ?? 0));
|
||||
}
|
||||
setSaves(r.saves);
|
||||
} catch (e) {
|
||||
if (probe.isStale()) return;
|
||||
@@ -219,6 +254,9 @@ export default function SavesScreen() {
|
||||
useEffect(() => {
|
||||
setSaves(null);
|
||||
setError(null);
|
||||
setUsers([]);
|
||||
setUserFilter("all");
|
||||
filterChosen.current = false;
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -259,9 +297,11 @@ export default function SavesScreen() {
|
||||
// it stayed empty while the zip went to Downloads (#451, "0 byte zip"). There the backup
|
||||
// goes straight to Downloads, dated so a second backup does not replace the first.
|
||||
const android = isAndroid();
|
||||
const destZipName = android
|
||||
? `${entry.title_id}-${backupStamp(new Date())}.zip`
|
||||
: `${entry.title_id}.zip`;
|
||||
const destZipName = backupZipName(
|
||||
entry.title_id,
|
||||
ownerName(entry),
|
||||
android ? backupStamp(new Date()) : undefined,
|
||||
);
|
||||
const destZip = android
|
||||
? ""
|
||||
: await saveDialog({
|
||||
@@ -494,7 +534,9 @@ export default function SavesScreen() {
|
||||
// 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()}`;
|
||||
// One folder per user (`<name>-<uid hex>`), so a shared console's users never restore
|
||||
// each other's backups of the same game (saveUsers.ts).
|
||||
const remoteDir = `${base}/${entry.title_id}/${usbUserDir(entry.user_id, ownerName(entry))}/${backupTimestamp()}`;
|
||||
const remoteZip = `${remoteDir}/${zipName}`;
|
||||
const tempEntries = await localFs.listDir(tempDir).catch(() => []);
|
||||
const zipSize = tempEntries.find((e) => e.name === zipName)?.size ?? 0;
|
||||
@@ -555,27 +597,27 @@ export default function SavesScreen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the lexically-latest timestamped backup directory for a given
|
||||
* title under `<base>/<title_id>/`. The backupTimestamp() format
|
||||
* (YYYY-MM-DD_HHMMSS) is documented as lexically sortable, so the
|
||||
* greatest string name == the newest backup — no mtime parsing needed.
|
||||
*
|
||||
* Returns null if the title dir is absent, unreadable, or has never
|
||||
* been backed up (no subdirs).
|
||||
* The newest USB backup of `entry`'s title for `entry`'s user, relative to
|
||||
* `<base>/<title_id>/` (saveUsers.ts latestUsbBackup). Null when there is none.
|
||||
*/
|
||||
async function resolveLatestUsbBackup(
|
||||
addr: string,
|
||||
base: string,
|
||||
title_id: string,
|
||||
entry: SaveEntry,
|
||||
): Promise<string | null> {
|
||||
const titleDir = `${base}/${entry.title_id}`;
|
||||
const onlyUser = (allSaves ?? [])
|
||||
.filter((s) => s.title_id === entry.title_id)
|
||||
.every((s) => userHex(s.user_id) === userHex(entry.user_id));
|
||||
try {
|
||||
const entries = await fsListDir(addr, `${base}/${title_id}`);
|
||||
const dirs = entries
|
||||
.filter((e) => e.kind === "dir")
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
return dirs[0] ?? null;
|
||||
return await latestUsbBackup(
|
||||
async (rel) => {
|
||||
const list = await fsListDir(addr, rel ? `${titleDir}/${rel}` : titleDir).catch(() => []);
|
||||
return list.filter((e) => e.kind === "dir").map((e) => e.name);
|
||||
},
|
||||
entry.user_id,
|
||||
onlyUser,
|
||||
);
|
||||
} catch {
|
||||
// Dir absent or unreadable — no backup has ever been written.
|
||||
return null;
|
||||
@@ -627,10 +669,11 @@ export default function SavesScreen() {
|
||||
// Resolve the newest backup. Return "no-backup" rather than
|
||||
// throwing so the bulk handler can skip quietly without incrementing
|
||||
// the failure count.
|
||||
const latest = await resolveLatestUsbBackup(addr, base, entry.title_id);
|
||||
if (!latest) return "no-backup";
|
||||
const latestRel = await resolveLatestUsbBackup(addr, base, entry);
|
||||
if (!latestRel) return "no-backup";
|
||||
const latest = latestRel.split("/").pop() ?? latestRel;
|
||||
const zipName = `${entry.title_id}.zip`;
|
||||
const remoteZip = `${base}/${entry.title_id}/${latest}/${zipName}`;
|
||||
const remoteZip = `${base}/${entry.title_id}/${latestRel}/${zipName}`;
|
||||
if (!opts?.skipConfirm) {
|
||||
const ok = await confirmDialog({
|
||||
title: tr(
|
||||
@@ -1028,6 +1071,22 @@ export default function SavesScreen() {
|
||||
</ul>
|
||||
</Modal>
|
||||
)}
|
||||
{owners.length > 1 && (
|
||||
<div className="max-w-xs" data-testid="saves-user-filter">
|
||||
<Select
|
||||
label={tr("saves_user_filter", undefined, "Whose saves")}
|
||||
value={userFilter}
|
||||
onChange={(ev) => setUserFilter(ev.target.value)}
|
||||
options={[
|
||||
{ value: "all", label: tr("saves_user_all", undefined, "All users") },
|
||||
...owners.map((o) => ({
|
||||
value: userHex(o.id),
|
||||
label: o.name ?? tr("saves_user_other", { id: userHex(o.id) }, "Other user ({id})"),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{grouped.map(({ title_id, entries }) => (
|
||||
<section
|
||||
key={title_id}
|
||||
@@ -1053,7 +1112,9 @@ export default function SavesScreen() {
|
||||
{e.path}
|
||||
</code>
|
||||
<div className="text-xs text-[var(--color-muted)]">
|
||||
{tr("saves_user", undefined, "user")} {e.user_id} ·{" "}
|
||||
{ownerName(e) ??
|
||||
tr("saves_user_other", { id: userHex(e.user_id) }, "Other user ({id})")}{" "}
|
||||
·{" "}
|
||||
{formatBytes(e.size)} ·{" "}
|
||||
{formatDate(e.mtime * 1000, "date")}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProfileUser, SaveEntry } from "../../api/ps5";
|
||||
import {
|
||||
backupZipName,
|
||||
defaultUserFilter,
|
||||
filterSaves,
|
||||
latestUsbBackup,
|
||||
saveOwners,
|
||||
safeName,
|
||||
usbUserDir,
|
||||
userHex,
|
||||
userName,
|
||||
} from "./saveUsers";
|
||||
|
||||
const users: ProfileUser[] = [
|
||||
{ uid: 0x150c71a1, uid_hex: "0x150C71A1", username: "Alice" },
|
||||
{ uid: 0x8000_0001 | 0, uid_hex: "0x80000001", username: "Bob" },
|
||||
];
|
||||
const save = (user_id: number, title_id: string): SaveEntry => ({
|
||||
title_id,
|
||||
user_id,
|
||||
path: `/user/home/${userHex(user_id)}/savedata_prospero/${title_id}`,
|
||||
size: 1,
|
||||
mtime: 1,
|
||||
kind: "ps5",
|
||||
});
|
||||
const saves = [save(0x150c71a1, "PPSA1"), save(0x80000001 | 0, "PPSA1"), save(0x150c71a1, "PPSA2"), save(0x22, "PPSA3")];
|
||||
|
||||
describe("save owners", () => {
|
||||
it("matches ids by their hex, signed or not", () => {
|
||||
expect(userHex(0x80000001 | 0)).toBe("80000001");
|
||||
expect(userName(0x80000001 | 0, users)).toBe("Bob");
|
||||
expect(userName(0x22, users)).toBeNull();
|
||||
expect(saveOwners(saves, users)).toEqual([
|
||||
{ id: 0x150c71a1, name: "Alice" },
|
||||
{ id: 0x80000001 | 0, name: "Bob" },
|
||||
{ id: 0x22, name: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts on the user at the console when they have saves, else on everyone", () => {
|
||||
expect(defaultUserFilter(saves, 0x150c71a1)).toBe("150c71a1");
|
||||
expect(defaultUserFilter(saves, 0)).toBe("all");
|
||||
expect(defaultUserFilter(saves, 0x999)).toBe("all");
|
||||
expect(filterSaves(saves, "150c71a1").map((s) => s.title_id)).toEqual(["PPSA1", "PPSA2"]);
|
||||
expect(filterSaves(saves, "all")).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup names", () => {
|
||||
it("labels folders and zips with a file-safe name", () => {
|
||||
expect(safeName(' A/b:c* "d" ')).toBe("A_b_c_ _d_");
|
||||
expect(usbUserDir(0x150c71a1, "Alice")).toBe("Alice-150c71a1");
|
||||
expect(usbUserDir(0x22, null)).toBe("00000022");
|
||||
expect(backupZipName("PPSA1", "Alice")).toBe("PPSA1-Alice.zip");
|
||||
expect(backupZipName("PPSA1", null, "20261018-1200")).toBe("PPSA1-20261018-1200.zip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestUsbBackup", () => {
|
||||
const tree: Record<string, string[]> = {
|
||||
"": ["2026-01-01_120000", "2026-02-01_120000", "Alice-150c71a1", "Bob-80000001"],
|
||||
"Alice-150c71a1": ["2026-03-01_120000", "2026-04-01_090000", "notes"],
|
||||
"Bob-80000001": [],
|
||||
};
|
||||
const list = async (rel: string) => tree[rel] ?? [];
|
||||
|
||||
it("takes the user's own newest backup, found by id even after a rename", async () => {
|
||||
expect(await latestUsbBackup(list, 0x150c71a1, false)).toBe("Alice-150c71a1/2026-04-01_090000");
|
||||
});
|
||||
|
||||
it("uses an old shared backup only when the user is the title's only user", async () => {
|
||||
expect(await latestUsbBackup(list, 0x80000001 | 0, false)).toBeNull();
|
||||
expect(await latestUsbBackup(list, 0x80000001 | 0, true)).toBe("2026-02-01_120000");
|
||||
expect(await latestUsbBackup(async () => [], 0x22, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Which console user a save belongs to, by name, and where that user's USB backups live.
|
||||
//
|
||||
// Saves sit under /user/home/<uid hex>/…; the helper reports `user_id` as a signed 32-bit
|
||||
// number and the profile list (`profileInfo`) names every local user. On a shared console the
|
||||
// Save data screen shows one user's saves at a time and labels them by name.
|
||||
//
|
||||
// USB backups used to be `<base>/<title_id>/<timestamp>/<title_id>.zip` for every user, so
|
||||
// "Restore all from USB" on a shared console could put one user's latest backup into another
|
||||
// user's save of the same game. New backups go to `<base>/<title_id>/<name>-<uid hex>/<timestamp>/`.
|
||||
import type { ProfileUser, SaveEntry } from "../../api/ps5";
|
||||
|
||||
/** The 8-digit lowercase hex of a user id, as in /user/home/<hex>. */
|
||||
export function userHex(id: number): string {
|
||||
return (id >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** The local user's name for `id`, or null when the console didn't name it. */
|
||||
export function userName(id: number, users: readonly ProfileUser[]): string | null {
|
||||
const hex = userHex(id);
|
||||
const u = users.find((x) => userHex(x.uid) === hex);
|
||||
const name = u?.username.trim();
|
||||
return name ? name : null;
|
||||
}
|
||||
|
||||
/** The users that own at least one save, named where possible, in first-seen order. */
|
||||
export function saveOwners(
|
||||
saves: readonly SaveEntry[],
|
||||
users: readonly ProfileUser[],
|
||||
): Array<{ id: number; name: string | null }> {
|
||||
const seen = new Map<string, { id: number; name: string | null }>();
|
||||
for (const s of saves) {
|
||||
const hex = userHex(s.user_id);
|
||||
if (!seen.has(hex)) seen.set(hex, { id: s.user_id, name: userName(s.user_id, users) });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/** "all", or the hex of the user whose saves are shown. */
|
||||
export type UserFilter = "all" | string;
|
||||
|
||||
/** The starting filter: the user at the console when they own saves, else every user. */
|
||||
export function defaultUserFilter(saves: readonly SaveEntry[], foregroundUid: number): UserFilter {
|
||||
if (!foregroundUid) return "all";
|
||||
const hex = userHex(foregroundUid);
|
||||
return saves.some((s) => userHex(s.user_id) === hex) ? hex : "all";
|
||||
}
|
||||
|
||||
export function filterSaves(saves: readonly SaveEntry[], filter: UserFilter): SaveEntry[] {
|
||||
return filter === "all" ? [...saves] : saves.filter((s) => userHex(s.user_id) === filter);
|
||||
}
|
||||
|
||||
/** A name made safe for a file or folder name on exFAT/FAT32 and every host OS. */
|
||||
export function safeName(name: string): string {
|
||||
const visible = Array.from(name, (c) => (c.charCodeAt(0) < 32 ? "_" : c)).join("");
|
||||
return visible
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.replace(/^[.\s]+|[.\s]+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/** This user's backup folder name under `<base>/<title_id>/`. */
|
||||
export function usbUserDir(id: number, name: string | null): string {
|
||||
const safe = name ? safeName(name) : "";
|
||||
return safe ? `${safe}-${userHex(id)}` : userHex(id);
|
||||
}
|
||||
|
||||
/** The default .zip name for a backup on this computer. */
|
||||
export function backupZipName(titleId: string, name: string | null, stamp?: string): string {
|
||||
const who = name ? safeName(name) : "";
|
||||
return [titleId, who, stamp].filter(Boolean).join("-") + ".zip";
|
||||
}
|
||||
|
||||
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
/**
|
||||
* The newest USB backup of a title for one user, as a path relative to `<base>/<title_id>/`.
|
||||
*
|
||||
* `listDir(rel)` lists folder names under `<base>/<title_id>/<rel>` ("" = the title folder).
|
||||
* The user's own folder wins (matched by uid, so a renamed user still finds it). Backups from
|
||||
* before per-user folders (timestamps straight under the title) are used only when
|
||||
* `onlyUserOfTitle` — this user is the only one with a save of the title on the console — since
|
||||
* nothing says whose they are.
|
||||
*/
|
||||
export async function latestUsbBackup(
|
||||
listDir: (rel: string) => Promise<string[]>,
|
||||
id: number,
|
||||
onlyUserOfTitle: boolean,
|
||||
): Promise<string | null> {
|
||||
const hex = userHex(id);
|
||||
const top = await listDir("");
|
||||
const mine = top.find((n) => n.toLowerCase() === hex || n.toLowerCase().endsWith(`-${hex}`));
|
||||
if (mine) {
|
||||
const stamps = (await listDir(mine)).filter((n) => TIMESTAMP.test(n)).sort();
|
||||
if (stamps.length) return `${mine}/${stamps[stamps.length - 1]}`;
|
||||
}
|
||||
if (!onlyUserOfTitle) return null;
|
||||
const legacy = top.filter((n) => TIMESTAMP.test(n)).sort();
|
||||
return legacy.length ? legacy[legacy.length - 1] : null;
|
||||
}
|
||||
Reference in new issue
Block a user