From 3244ed15e88e434d9dc0ea58188aa7d6e5bd4750 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:30:12 -0400 Subject: [PATCH 1/2] feat: add profile and save manager --- ARCHITECTURE.md | 24 + CHANGELOG.md | 7 + DOCS_INDEX.md | 2 + PROFILES_AND_SAVES.md | 110 +++ PROJECT_STATUS.md | 10 + README.md | 19 + SECURITY.md | 7 + THIRD_PARTY_NOTICES.md | 17 + UnityScraper.spec | 2 + app_paths.py | 2 + assets/references/lefluffie/X360-GPL-3.0.txt | 621 +++++++++++++ backup_manager.py | 10 + database_migrations.py | 110 ++- modern_gui.py | 24 + profile_gui.py | 601 ++++++++++++ profile_manager.py | 915 +++++++++++++++++++ tests.py | 142 ++- 17 files changed, 2620 insertions(+), 3 deletions(-) create mode 100644 PROFILES_AND_SAVES.md create mode 100644 assets/references/lefluffie/X360-GPL-3.0.txt create mode 100644 profile_gui.py create mode 100644 profile_manager.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f35caad..9f40a2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,6 +19,7 @@ for CLI and optional REST automation. SQLite is the durable local store. - `modern_gui.py` builds the dark navigation shell and core pages. - `knowledge_gui.py` renders knowledge search, imports, sources, and conflicts. - `backup_gui.py` renders inventory, package, FTP, and converter workflows. +- `profile_gui.py` renders privacy-aware profile/save inventory and snapshots. - `collection_gui.py` renders collection analysis, matching, reports, and repair previews. - `setup_wizard.py` handles first-run storage setup. @@ -32,6 +33,8 @@ results to Tk's main loop. - `knowledge_service.py` provides search, source status, imports, and details. - `backup_service.py` coordinates scans, installs, exports, verification, FTP, and audit records. +- `profile_manager.py` owns Content-tree discovery, STFS ownership inspection, + profile/save indexing, verified snapshots, and conflict-safe restore. - `knowledge_sync.py` exposes complete source-import workflows to CLI and GUI. - `collection_intelligence.py` coordinates snapshots, exact MediaID matching, health, preservation matching, repair previews, and offline exports. @@ -62,6 +65,8 @@ Main schema groups: - Knowledge: sources, documents, revisions, entities, names, identifiers, facts, citations, relationships, import runs, and conflicts - Backups: targets, scans, inventory, and operations +- Profiles: scan runs, profiles, saves, snapshots, snapshot files, and + auditable operations Schema initialization is idempotent. New migrations should preserve existing data and be covered by tests. @@ -100,6 +105,23 @@ user-selected file or ZIP Game payloads are not stored in SQLite. Inventory records contain paths, identifiers, sizes, statuses, and notes. +### Profile Snapshot + +```text +user-selected Content tree + -> read-only profile and save discovery + -> STFS ownership/header inspection + -> local inventory and mismatch detection + -> selected profile or saves + -> .partial verified copies + -> atomic snapshot manifest + -> conflict-preserving restore +``` + +Profile identifiers are masked in the UI by default. Snapshot payloads remain +on disk under the local application data directory; SQLite stores paths, +hashes, identifiers, and operation history. + ## Storage Normal Windows data: @@ -134,6 +156,8 @@ works in source and PyInstaller one-file builds. expanded size. - Package and export copies use temporary files and verification. - External converters run only through explicit user configuration. +- Profile scans never modify source content, and restores never overwrite a + different existing file. See [SECURITY.md](SECURITY.md) for reporting and operational guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a1485..9fe5750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Notable changes to UnityScraper are documented here. The project follows ### Added +- Profiles & Saves workspace with read-only Content-tree discovery, masked + profile inventory, STFS ownership metadata, save search, duplicate and + mismatch reporting, verified snapshots, manifests, and conflict-safe restore. +- Additive schema migration 6 for profile scans, profiles, saves, snapshots, + snapshot files, and auditable operations. +- Dalavin / DJ SkunkieButt attribution and preserved GPLv3 provenance for the + X360 library and Le Fluffie technical reference. - Bundled Windows XeXTool 6.3 integration with automatic discovery, xorloser attribution, provenance, checksum, and preserved third-party license. - External Tools workspace with XeXTool inspection presets, custom CLI diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 8b47006..631eec0 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -5,6 +5,8 @@ - [README](README.md) - product overview, installation, core workflows, and CLI - [Backup Manager](BACKUP_MANAGER.md) - layouts, installation, exports, FTP, verification, and external conversion +- [Profiles and Saves](PROFILES_AND_SAVES.md) - profile inventory, save + snapshots, privacy, restore behavior, and Le Fluffie attribution - [Collection Intelligence](COLLECTION_INTELLIGENCE.md) - storage discovery, XEX identity, Title Update compatibility, preservation, and repair previews - [Console Sync](CONSOLE_SYNC.md) - persistent transfers, resume, snapshots, diff --git a/PROFILES_AND_SAVES.md b/PROFILES_AND_SAVES.md new file mode 100644 index 0000000..5b8c64c --- /dev/null +++ b/PROFILES_AND_SAVES.md @@ -0,0 +1,110 @@ +# Profiles and Saves + +The **Profiles & Saves** workspace inventories and protects Xbox 360 profile +data from an extracted `Content` directory. It is designed around read-only +discovery and snapshot-before-change workflows. + +## Supported Sources + +Choose any of these: + +- a `Content` directory +- a folder containing `Content` +- an extracted `Hdd1` folder containing `Hdd1/Content` +- a modern FAT32 Xbox 360 USB drive whose content is directly visible + +For FATX-formatted disks or older Xbox 360 USB system drives, mount or extract +the content using a trusted filesystem tool first. UnityScraper does not write +raw disks or mount FATX volumes. + +## Inventory + +UnityScraper recognizes non-public 16-character profile directories and scans: + +```text +Content/ + / + FFFE07D1/00010000/ profile package + /00000001/ saved games +``` + +For supported STFS packages it records: + +- package signature type (`CON`, `LIVE`, or `PIRS`) +- content type +- TitleID and cached game name +- save-game ID +- embedded profile ID +- creator console ID +- device ID +- package display name +- size, modified time, and SHA-256 + +Package fields are compared with their containing profile and TitleID folders. +Mismatches are reported and never rewritten automatically. Identical hashes are +reported as duplicates. + +The UI masks profile identifiers by default. Select **Reveal profile +identifiers** only when needed. Profile and save data stays local and is never +sent to UnityScraper metadata sources. + +## Snapshots + +**Back Up Profile** copies every file in the selected profile directory. +**Back Up Selected Saves** copies only the selected save packages. + +Each snapshot contains: + +- the original relative directory layout +- SHA-256 for every file +- source metadata +- an atomic JSON manifest +- database operation history + +Files are copied through a temporary `.partial` file, verified, and then +published atomically. Snapshots are stored in the application data directory +under `profile_backups`. + +## Restore + +Select a complete snapshot and choose **Restore to Folder**. Every snapshot +file is verified before it is copied. + +UnityScraper never overwrites a different existing file. Matching files are +skipped; conflicts are restored alongside the existing file with a +`.restored-N` suffix. This lets the user compare both copies before deciding +which one belongs on a console. + +Restore currently targets an ordinary folder. Use Backup Manager or your +preferred filesystem tool to transfer verified output to the console or USB +device. + +## Current Safety Boundary + +This release deliberately does not: + +- edit achievements, GPD records, gamertags, or account blocks +- change profile, console, or device ownership fields +- rehash or resign modified CON packages +- authenticate to Xbox Live or Microsoft accounts +- store CPU keys, account credentials, or signing material +- write raw FATX disks + +Those operations can make a profile or save unusable when implemented +incorrectly. Future editing and migration support should only ship with +complete package verification, automatic pre-change snapshots, and +well-tested cross-platform signing support. + +## Le Fluffie Attribution + +The profile/STFS field model is informed by Dalavin, also known as +**DJ SkunkieButt** and **DJ Shepherd**, through the GPLv3 X360 library and Le +Fluffie source archived at +[mtolly/X360](https://github.com/mtolly/X360). + +UnityScraper uses a new Python implementation suited to its existing +cross-platform architecture. It does not bundle Le Fluffie's executable, +updater, embedded key resources, account-modification code, or artwork. The +archived GPL text is preserved with the application and the corresponding +source remains linked from About and the third-party notices. + diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index c8415b6..2cd2eca 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -37,6 +37,11 @@ backup-management, and source-attributed knowledge application. bandwidth limits, verified final sizes, read-only console snapshots, and no stored passwords. - Explicit external converter integration for user-owned ISO images. +- Read-only profile and save discovery with public STFS ownership metadata, + masked identifiers, cached game-name resolution, duplicate and mismatch + reporting, verified snapshots, manifests, and conflict-safe restore. +- Credited GPLv3 technical lineage from Dalavin / DJ SkunkieButt's X360 + library and Le Fluffie without bundling its updater, keys, or executable. - Local-by-default REST API with token-required remote binding, restricted browser origins, validated settings, and current version reporting. - Cross-platform CI, Windows packaging checks, tagged release archives, @@ -72,6 +77,9 @@ backup-management, and source-attributed knowledge application. memory and omitted from database records. - Remote API access requires a token but the built-in server does not provide TLS. Remote deployments need a trusted network or TLS reverse proxy. +- Profile editing, achievement modification, ownership rewriting, and CON + re-signing remain disabled until complete package verification and reliable + cross-platform signing support are available. ## Future Work @@ -79,3 +87,5 @@ backup-management, and source-attributed knowledge application. - Add optional scheduled knowledge refreshes. - Validate console resume behavior against a broader matrix of dashboard FTP servers and add opt-in remote hash verification where servers expose it. +- Add read-only GPD/achievement views, Xenia save mapping, and audited + profile-migration previews before considering package mutation. diff --git a/README.md b/README.md index 592ce8f..60fb5bb 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,24 @@ service exposes. UnityScraper does not silently substitute HTTPS URLs. - Captures read-only console inventories and compares PC and console content. - Runs a user-selected external ISO converter without bundling converter code. +### Profiles and Save Data + +- Discovers profile folders and save packages in extracted Xbox 360 `Content` + trees. +- Reads public STFS ownership, TitleID, package, and display metadata. +- Associates saves with cached game names and flags profile/TitleID mismatches. +- Detects duplicate save packages by SHA-256. +- Masks profile identifiers by default. +- Creates complete-profile or selected-save snapshots with manifests and + verified atomic copies. +- Restores snapshots without overwriting different existing files. +- Exports portable JSON preservation manifests. + +The profile/package model is informed by Dalavin, also known as DJ +SkunkieButt, and the GPLv3 X360 library and Le Fluffie source. See +[PROFILES_AND_SAVES.md](PROFILES_AND_SAVES.md) and +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + ### External Tools - Runs Xbox command-line utilities inside the desktop interface. @@ -149,6 +167,7 @@ Linux source setup: | Add Games | Search cached game names, select TitleIDs, or import lists | | Downloads | Review and manage download activity | | Backup Manager | Scan, install, verify, export, convert, and transfer owned content | +| Profiles & Saves | Inventory profiles, back up saves, and restore snapshots | | External Tools | Run XeXTool and other user-supplied command-line utilities | | Collections | Identify storage, compare Title Updates, verify preservation data, and preview repairs | | Knowledge | Search sources, facts, citations, imports, and conflicts | diff --git a/SECURITY.md b/SECURITY.md index 0b28e6d..85dd9e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,13 @@ filesystem contents in a report. - ZIP imports reject traversal paths, symlinks, excessive entries, and unreasonable expanded sizes. - Package copies use temporary files, verification, and atomic publication. +- Profile and save identifiers are masked in the GUI by default. +- Profile scans are read-only. Snapshot restores preserve different existing + files and write the restored copy alongside them. +- Profiles, saves, gamertags, XUIDs, console IDs, and device IDs are not sent + to metadata sources. - External converters run only when explicitly configured by the user. - UnityScraper does not provide game images, firmware, keys, SDK files, or copy-protection bypass tools. +- UnityScraper does not request Xbox Live credentials or store CPU keys, + account keys, or profile-signing material. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9114149..ad10c58 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,3 +21,20 @@ preserved at UnityScraper's GUI integration is independent code. Users may select a different lawfully obtained XeXTool build or another command-line utility. +## X360 Library and Le Fluffie + +- **Creator:** Dalavin, also known as DJ SkunkieButt and DJ Shepherd +- **Purpose:** Historical Xbox 360 STFS, SVOD, FATX, GPD, profile, and account + research and tooling +- **Archived source:** [mtolly/X360](https://github.com/mtolly/X360) +- **License:** GNU General Public License version 3 + +UnityScraper's profile and save implementation is new Python code informed by +the public package/profile model and field layout documented in the X360 +library and Le Fluffie source. The original GPL text is preserved at +`assets/references/lefluffie/X360-GPL-3.0.txt`. + +UnityScraper does not include Le Fluffie's executable, updater, embedded key +resources, account-modification code, or artwork. The application credits +Dalavin prominently and links to the archived corresponding source. + diff --git a/UnityScraper.spec b/UnityScraper.spec index 00bcadb..650b396 100644 --- a/UnityScraper.spec +++ b/UnityScraper.spec @@ -29,6 +29,8 @@ a = Analysis( 'knowledge_service', 'knowledge_sync', 'plugins', + 'profile_gui', + 'profile_manager', 'updater', 'wiki_adapters', ], diff --git a/app_paths.py b/app_paths.py index e79ccab..8375536 100644 --- a/app_paths.py +++ b/app_paths.py @@ -145,6 +145,7 @@ def xdg_path(variable: str, fallback: Path) -> Path: CACHE_DIR = _PATHS.cache EXPORTS_DIR = _PATHS.exports DIAGNOSTICS_DIR = _PATHS.diagnostics +PROFILE_BACKUPS_DIR = DATA_DIR / "profile_backups" DATABASE_PATH = DATA_DIR / "unityscraper.db" CONFIG_PATH = CONFIG_DIR / "config.json" @@ -170,6 +171,7 @@ def ensure_app_dirs() -> None: CACHE_DIR, EXPORTS_DIR, DIAGNOSTICS_DIR, + PROFILE_BACKUPS_DIR, ): path.mkdir(parents=True, exist_ok=True) diff --git a/assets/references/lefluffie/X360-GPL-3.0.txt b/assets/references/lefluffie/X360-GPL-3.0.txt new file mode 100644 index 0000000..e587591 --- /dev/null +++ b/assets/references/lefluffie/X360-GPL-3.0.txt @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/backup_manager.py b/backup_manager.py index b16df5f..c8a1c2b 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -20,11 +20,13 @@ STFS_MAGICS = {b"CON ", b"LIVE", b"PIRS"} CONTENT_TYPES = { + 0x00000001: ("Saved Game", "00000001"), 0x00000002: ("DLC", "00000002"), 0x00005000: ("Original Xbox Game", "00005000"), 0x00007000: ("Xbox 360 Game", "00007000"), 0x000B0000: ("Title Update", "000B0000"), 0x000D0000: ("Xbox Live Arcade", "000D0000"), + 0x00010000: ("Profile", "00010000"), } GAME_CONTENT_DIRECTORIES = {"00005000", "00007000", "000D0000"} SUPPORT_CONTENT_DIRECTORIES = {"00000002", "000B0000"} @@ -66,6 +68,10 @@ class StfsPackage: display_name: str title_name: str size: int + save_game_id: str + console_id: str + profile_id: str + device_id: str @dataclass(frozen=True) @@ -185,6 +191,10 @@ def inspect_stfs(path: str | Path) -> StfsPackage: display_name=_read_utf16be(header[0x411:0x511]), title_name=_read_utf16be(header[0x1691:0x1791]), size=package_path.stat().st_size, + save_game_id=header[0x368:0x36C].hex().upper(), + console_id=header[0x36C:0x371].hex().upper(), + profile_id=header[0x371:0x379].hex().upper(), + device_id=header[0x3FD:0x411].hex().upper(), ) diff --git a/database_migrations.py b/database_migrations.py index c140d25..902aaf6 100644 --- a/database_migrations.py +++ b/database_migrations.py @@ -8,7 +8,7 @@ from pathlib import Path -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 def _now() -> str: @@ -66,6 +66,7 @@ def ensure_application_schema(connection: sqlite3.Connection) -> int: (3, "console synchronization", _migration_console_sync), (4, "user overrides and recovery", _migration_reliability), (5, "XboxUnity title catalog", _migration_xboxunity_catalog), + (6, "profile and save management", _migration_profiles_and_saves), ) for version, name, migration in migrations: if version in applied: @@ -278,3 +279,110 @@ def _migration_xboxunity_catalog(connection: sqlite3.Connection) -> None: ); """ ) + + +def _migration_profiles_and_saves(connection: sqlite3.Connection) -> None: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS profile_scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_root TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + profile_count INTEGER NOT NULL DEFAULT 0, + save_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + warnings_json TEXT, + error_message TEXT + ); + + CREATE TABLE IF NOT EXISTS xbox_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT NOT NULL, + gamertag TEXT, + source_path TEXT NOT NULL, + package_path TEXT, + package_sha256 TEXT, + console_id TEXT, + device_id TEXT, + profile_kind TEXT NOT NULL DEFAULT 'unknown', + package_status TEXT NOT NULL DEFAULT 'unverified', + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + metadata_json TEXT, + UNIQUE(profile_id, source_path) + ); + CREATE INDEX IF NOT EXISTS idx_xbox_profiles_profile_id + ON xbox_profiles(profile_id); + + CREATE TABLE IF NOT EXISTS profile_saves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT NOT NULL, + titleid TEXT NOT NULL, + name TEXT NOT NULL, + source_path TEXT NOT NULL UNIQUE, + package_magic TEXT, + content_type INTEGER, + save_game_id TEXT, + embedded_profile_id TEXT, + console_id TEXT, + device_id TEXT, + size INTEGER NOT NULL DEFAULT 0, + modified_at TEXT, + sha256 TEXT, + status TEXT NOT NULL DEFAULT 'unverified', + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_profile_saves_owner + ON profile_saves(profile_id, titleid); + CREATE INDEX IF NOT EXISTS idx_profile_saves_sha256 + ON profile_saves(sha256); + + CREATE TABLE IF NOT EXISTS save_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT, + label TEXT, + source_root TEXT NOT NULL, + snapshot_path TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + manifest_sha256 TEXT, + status TEXT NOT NULL DEFAULT 'creating', + notes TEXT + ); + + CREATE TABLE IF NOT EXISTS save_snapshot_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + source_path TEXT NOT NULL, + relative_path TEXT NOT NULL, + sha256 TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at TEXT, + item_kind TEXT NOT NULL, + titleid TEXT, + restore_status TEXT, + UNIQUE(snapshot_id, relative_path), + FOREIGN KEY(snapshot_id) REFERENCES save_snapshots(id) + ); + CREATE INDEX IF NOT EXISTS idx_save_snapshot_files_snapshot + ON save_snapshot_files(snapshot_id); + + CREATE TABLE IF NOT EXISTS profile_save_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + operation_type TEXT NOT NULL, + target_path TEXT, + snapshot_id INTEGER, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + details_json TEXT, + error_message TEXT, + FOREIGN KEY(snapshot_id) REFERENCES save_snapshots(id) + ); + """ + ) diff --git a/modern_gui.py b/modern_gui.py index 3643d71..91e7d40 100644 --- a/modern_gui.py +++ b/modern_gui.py @@ -45,6 +45,8 @@ from knowledge_gui import KnowledgePage from library_service import GameSummary, LibraryService from platform_support import desktop_font_family, open_path +from profile_gui import ProfileSavePage +from profile_manager import ProfileSaveManager from setup_wizard import run_first_run_wizard from title_catalog import TitleSuggestion, XboxUnityTitleCatalog from updater import VersionChecker @@ -53,6 +55,7 @@ APP_VERSION = DISPLAY_VERSION PROJECT_CREATOR = "TrapEmAll" XEXTOOL_CREATOR = "xorloser" +LE_FLUFFIE_CREATOR = "Dalavin (DJ SkunkieButt)" COMMUNITY_MESSAGE = ( "UnityScraper is my way of giving back to the Xbox 360 community that has " "kept this console alive through curiosity, preservation, homebrew, and " @@ -324,6 +327,7 @@ def __init__(self, root: tk.Tk) -> None: self.library = LibraryService() self.knowledge = KnowledgeService() self.backups = BackupService() + self.profiles = ProfileSaveManager() self.collections = CollectionIntelligenceService() self.database = DatabaseManager() self.title_catalog = XboxUnityTitleCatalog() @@ -456,6 +460,7 @@ def _build_shell(self) -> None: ("ADD GAMES", self.show_add_games), ("DOWNLOADS", self.show_downloads), ("BACKUP MANAGER", self.show_backups), + ("PROFILES & SAVES", self.show_profiles), ("EXTERNAL TOOLS", self.show_external_tools), ("COLLECTIONS", self.show_collections), ("KNOWLEDGE", self.show_knowledge), @@ -533,6 +538,16 @@ def show_external_tools(self) -> None: CONFIG_PATH, ) + def show_profiles(self) -> None: + self._clear_content() + self.profile_save_page = ProfileSavePage( + self.root, + self.content, + self.profiles, + self._page_header, + CONFIG_PATH, + ) + def show_collections(self) -> None: self._clear_content() self.collection_page = CollectionPage( @@ -1214,6 +1229,8 @@ def show_about(self) -> None: "backup manager.\n" "Licensed under GPL-3.0-only.\n" "Bundled XeXTool 6.3 created by xorloser.\n\n" + "Profile/STFS design informed by Dalavin (DJ SkunkieButt)'s " + "X360 library and Le Fluffie.\n\n" + describe_storage() ), justify=tk.LEFT, @@ -1279,6 +1296,13 @@ def show_about(self) -> None: "https://github.com/XboxChef/XexToolGUI" ), ).pack(anchor=tk.W, pady=4) + ttk.Button( + panel, + text=f"Le Fluffie credit: {LE_FLUFFIE_CREATOR}", + command=lambda: webbrowser.open( + "https://github.com/mtolly/X360" + ), + ).pack(anchor=tk.W, pady=4) ttk.Button( panel, text="Open GitHub Project", diff --git a/profile_gui.py b/profile_gui.py new file mode 100644 index 0000000..fcf2e5a --- /dev/null +++ b/profile_gui.py @@ -0,0 +1,601 @@ +"""Dark desktop workspace for Xbox 360 profiles and save data.""" + +from __future__ import annotations + +import json +import queue +import threading +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, simpledialog, ttk +from typing import Any, Callable + +from platform_support import open_path +from profile_manager import ProfileSaveManager, mask_identifier + + +def _size(value: int) -> str: + size = float(value) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B" + size /= 1024 + return f"{int(value)} B" + + +class ProfileSavePage: + """Profile inventory, save snapshots, and conflict-safe restore UI.""" + + def __init__( + self, + root: tk.Tk, + parent: ttk.Frame, + manager: ProfileSaveManager, + page_header: Callable[[str, str], None], + config_path: Path, + ) -> None: + self.root = root + self.parent = parent + self.manager = manager + self.config_path = config_path + self.events: queue.Queue[tuple[str, Any]] = queue.Queue() + self.running = False + self.profiles: dict[str, dict[str, Any]] = {} + self.saves: dict[str, dict[str, Any]] = {} + self.snapshots: dict[str, dict[str, Any]] = {} + + page_header( + "Profiles & Saves", + "Inventory, verify, back up, and restore your Xbox 360 profile data.", + ) + self._build() + self.refresh() + + def _build(self) -> None: + body = ttk.Frame(self.parent) + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(0, weight=1) + body.rowconfigure(1, weight=1) + + config = self._read_config() + profile_config = config.get("profiles", {}) + if not isinstance(profile_config, dict): + profile_config = {} + self.source_var = tk.StringVar( + value=str(profile_config.get("source_root", "")) + ) + self.search_var = tk.StringVar() + self.reveal_var = tk.BooleanVar(value=False) + + controls = ttk.Frame(body) + controls.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + controls.columnconfigure(1, weight=1) + ttk.Label(controls, text="Content source").grid(row=0, column=0, sticky=tk.W) + ttk.Entry(controls, textvariable=self.source_var).grid( + row=0, column=1, sticky="ew", padx=8 + ) + ttk.Button(controls, text="Browse", command=self.choose_source).grid( + row=0, column=2, padx=(0, 8) + ) + ttk.Button( + controls, + text="Scan", + style="Accent.TButton", + command=self.scan, + ).grid(row=0, column=3) + + notebook = ttk.Notebook(body) + notebook.grid(row=1, column=0, sticky="nsew") + + inventory = ttk.Frame(notebook, padding=10) + snapshots = ttk.Frame(notebook, padding=10) + notebook.add(inventory, text="Inventory") + notebook.add(snapshots, text="Snapshots") + self._build_inventory(inventory) + self._build_snapshots(snapshots) + + self.status_var = tk.StringVar(value="Choose a Content folder to begin.") + ttk.Label(body, textvariable=self.status_var, style="Subheader.TLabel").grid( + row=2, column=0, sticky="ew", pady=(8, 0) + ) + + def _build_inventory(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + toolbar = ttk.Frame(parent) + toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + ttk.Label(toolbar, text="Find saves").pack(side=tk.LEFT) + search = ttk.Entry(toolbar, textvariable=self.search_var, width=34) + search.pack(side=tk.LEFT, padx=(8, 14)) + search.bind("", lambda _event: self._refresh_saves()) + ttk.Checkbutton( + toolbar, + text="Reveal profile identifiers", + variable=self.reveal_var, + command=self._refresh_profiles, + ).pack(side=tk.LEFT) + ttk.Button(toolbar, text="Refresh", command=self.refresh).pack(side=tk.RIGHT) + + panes = ttk.Panedwindow(parent, orient=tk.HORIZONTAL) + panes.grid(row=1, column=0, sticky="nsew") + + profile_frame = ttk.LabelFrame(panes, text="Profiles", padding=6) + save_frame = ttk.LabelFrame(panes, text="Save Data", padding=6) + panes.add(profile_frame, weight=2) + panes.add(save_frame, weight=3) + profile_frame.columnconfigure(0, weight=1) + profile_frame.rowconfigure(0, weight=1) + save_frame.columnconfigure(0, weight=1) + save_frame.rowconfigure(0, weight=1) + + self.profile_tree = ttk.Treeview( + profile_frame, + columns=("profile", "saves", "size", "status"), + show="headings", + selectmode="browse", + ) + for column, text, width in ( + ("profile", "Profile", 190), + ("saves", "Saves", 60), + ("size", "Size", 80), + ("status", "Status", 110), + ): + self.profile_tree.heading(column, text=text) + self.profile_tree.column(column, width=width, anchor=tk.W) + self.profile_tree.grid(row=0, column=0, sticky="nsew") + self.profile_tree.bind("<>", self._profile_selected) + profile_scroll = ttk.Scrollbar( + profile_frame, + orient=tk.VERTICAL, + command=self.profile_tree.yview, + ) + profile_scroll.grid(row=0, column=1, sticky="ns") + self.profile_tree.configure(yscrollcommand=profile_scroll.set) + + self.save_tree = ttk.Treeview( + save_frame, + columns=("game", "titleid", "size", "status", "modified"), + show="headings", + selectmode="extended", + ) + for column, text, width in ( + ("game", "Game / Save", 230), + ("titleid", "TitleID", 90), + ("size", "Size", 80), + ("status", "Status", 120), + ("modified", "Modified", 145), + ): + self.save_tree.heading(column, text=text) + self.save_tree.column(column, width=width, anchor=tk.W) + self.save_tree.grid(row=0, column=0, sticky="nsew") + self.save_tree.bind("<>", self._save_selected) + save_scroll = ttk.Scrollbar( + save_frame, + orient=tk.VERTICAL, + command=self.save_tree.yview, + ) + save_scroll.grid(row=0, column=1, sticky="ns") + self.save_tree.configure(yscrollcommand=save_scroll.set) + + actions = ttk.Frame(parent) + actions.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + ttk.Button( + actions, + text="Back Up Profile", + command=self.snapshot_profile, + style="Accent.TButton", + ).pack(side=tk.LEFT) + ttk.Button( + actions, + text="Back Up Selected Saves", + command=self.snapshot_saves, + ).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button( + actions, + text="Open Source", + command=self.open_selected_source, + ).pack(side=tk.LEFT, padx=(8, 0)) + self.detail_var = tk.StringVar(value="Select a profile or save for details.") + ttk.Label( + parent, + textvariable=self.detail_var, + style="Subheader.TLabel", + wraplength=900, + justify=tk.LEFT, + ).grid(row=3, column=0, sticky="ew", pady=(8, 0)) + + def _build_snapshots(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + self.snapshot_tree = ttk.Treeview( + parent, + columns=("id", "label", "profile", "created", "files", "size", "status"), + show="headings", + selectmode="browse", + ) + for column, text, width in ( + ("id", "ID", 55), + ("label", "Label", 190), + ("profile", "Profile", 140), + ("created", "Created", 170), + ("files", "Files", 65), + ("size", "Size", 90), + ("status", "Status", 90), + ): + self.snapshot_tree.heading(column, text=text) + self.snapshot_tree.column(column, width=width, anchor=tk.W) + self.snapshot_tree.grid(row=0, column=0, sticky="nsew") + snapshot_scroll = ttk.Scrollbar( + parent, + orient=tk.VERTICAL, + command=self.snapshot_tree.yview, + ) + snapshot_scroll.grid(row=0, column=1, sticky="ns") + self.snapshot_tree.configure(yscrollcommand=snapshot_scroll.set) + + actions = ttk.Frame(parent) + actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + ttk.Button( + actions, + text="Restore to Folder", + command=self.restore_snapshot, + style="Accent.TButton", + ).pack(side=tk.LEFT) + ttk.Button( + actions, + text="Export Manifest", + command=self.export_manifest, + ).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button( + actions, + text="Open Snapshot", + command=self.open_snapshot, + ).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button(actions, text="Refresh", command=self.refresh).pack(side=tk.RIGHT) + + def choose_source(self) -> None: + selected = filedialog.askdirectory( + parent=self.root, + title="Choose Xbox 360 Content folder", + ) + if selected: + self.source_var.set(selected) + self._save_source() + + def scan(self) -> None: + source = self.source_var.get().strip() + if not source: + messagebox.showerror( + "Profiles & Saves", + "Choose an Xbox 360 Content folder first.", + parent=self.root, + ) + return + self._save_source() + self._run( + "Scanning profiles and hashing save packages...", + lambda: self.manager.scan(source), + "scan-complete", + ) + + def refresh(self) -> None: + self._refresh_profiles() + self._refresh_snapshots() + + def _refresh_profiles(self) -> None: + selected = self._selected_profile_id() + self.profiles.clear() + self.profile_tree.delete(*self.profile_tree.get_children()) + for row in self.manager.list_profiles(): + item_id = f"profile-{row['id']}" + self.profiles[item_id] = row + profile_id = str(row["profile_id"]) + gamertag = str(row.get("gamertag") or "") + if self.reveal_var.get(): + label = f"{gamertag or 'Profile'} ({profile_id})" + else: + label = gamertag or f"Profile {mask_identifier(profile_id)}" + if gamertag: + label = f"{mask_identifier(gamertag)} ({mask_identifier(profile_id)})" + self.profile_tree.insert( + "", + tk.END, + iid=item_id, + values=( + label, + int(row.get("save_count") or 0), + _size(int(row.get("total_size") or 0)), + row.get("package_status") or "unknown", + ), + ) + if selected and profile_id == selected: + self.profile_tree.selection_set(item_id) + if not self.profile_tree.selection() and self.profile_tree.get_children(): + first = self.profile_tree.get_children()[0] + self.profile_tree.selection_set(first) + self.profile_tree.focus(first) + self._refresh_saves() + + def _refresh_saves(self) -> None: + profile_id = self._selected_profile_id() + self.saves.clear() + self.save_tree.delete(*self.save_tree.get_children()) + if not profile_id: + return + for row in self.manager.list_saves(profile_id, self.search_var.get()): + item_id = f"save-{row['id']}" + self.saves[item_id] = row + modified = str(row.get("modified_at") or "").replace("T", " ")[:19] + self.save_tree.insert( + "", + tk.END, + iid=item_id, + values=( + row["name"], + row["titleid"], + _size(int(row["size"])), + row["status"], + modified, + ), + ) + + def _refresh_snapshots(self) -> None: + self.snapshots.clear() + self.snapshot_tree.delete(*self.snapshot_tree.get_children()) + for row in self.manager.list_snapshots(): + item_id = f"snapshot-{row['id']}" + self.snapshots[item_id] = row + profile_id = str(row.get("profile_id") or "") + profile_display = ( + profile_id if self.reveal_var.get() else mask_identifier(profile_id) + ) + self.snapshot_tree.insert( + "", + tk.END, + iid=item_id, + values=( + row["id"], + row.get("label") or "Profile snapshot", + profile_display, + str(row["created_at"]).replace("T", " ")[:19], + row["file_count"], + _size(int(row["total_size"])), + row["status"], + ), + ) + + def _profile_selected(self, _event: tk.Event[Any]) -> None: + self._refresh_saves() + profile = self._selected_profile() + if profile: + source = ( + str(profile.get("source_path", "")) + if self.reveal_var.get() + else "Source path hidden" + ) + self.detail_var.set( + f"Profile {mask_identifier(str(profile['profile_id']))} | " + f"{profile.get('save_count', 0)} saves | " + f"{profile.get('package_status', 'unknown')} | " + f"{source}" + ) + + def _save_selected(self, _event: tk.Event[Any]) -> None: + selection = self.save_tree.selection() + if not selection: + return + save = self.saves.get(selection[0]) + if save: + self.detail_var.set( + f"{save['name']} | TitleID {save['titleid']} | " + f"Profile {mask_identifier(str(save['profile_id']))} | " + f"SHA-256 {str(save.get('sha256') or '')[:16]}... | " + f"{save['status']}" + ) + + def snapshot_profile(self) -> None: + profile_id = self._selected_profile_id() + if not profile_id: + messagebox.showinfo( + "Profile backup", "Select a profile first.", parent=self.root + ) + return + label = simpledialog.askstring( + "Profile backup", + "Optional snapshot label:", + parent=self.root, + ) + if label is None: + return + self._run( + "Creating a verified profile snapshot...", + lambda: self.manager.create_snapshot(profile_id, label=label), + "snapshot-complete", + ) + + def snapshot_saves(self) -> None: + profile_id = self._selected_profile_id() + selected = [ + int(item.split("-", 1)[1]) + for item in self.save_tree.selection() + if item in self.saves + ] + if not profile_id or not selected: + messagebox.showinfo( + "Save backup", + "Select one or more saves first.", + parent=self.root, + ) + return + label = simpledialog.askstring( + "Save backup", + "Optional snapshot label:", + parent=self.root, + ) + if label is None: + return + self._run( + "Creating a verified save snapshot...", + lambda: self.manager.create_snapshot( + profile_id, + save_ids=selected, + label=label, + ), + "snapshot-complete", + ) + + def restore_snapshot(self) -> None: + snapshot = self._selected_snapshot() + if not snapshot: + messagebox.showinfo( + "Restore snapshot", "Select a snapshot first.", parent=self.root + ) + return + destination = filedialog.askdirectory( + parent=self.root, + title="Choose restore destination", + ) + if not destination: + return + if not messagebox.askyesno( + "Restore snapshot", + "Restore into the selected folder?\n\n" + "Different existing files will be preserved and the restored copy " + "will be written alongside them.", + parent=self.root, + ): + return + snapshot_id = int(snapshot["id"]) + self._run( + "Verifying and restoring snapshot...", + lambda: self.manager.restore_snapshot(snapshot_id, destination), + "restore-complete", + ) + + def export_manifest(self) -> None: + snapshot = self._selected_snapshot() + if not snapshot: + messagebox.showinfo( + "Export manifest", "Select a snapshot first.", parent=self.root + ) + return + destination = filedialog.asksaveasfilename( + parent=self.root, + title="Export snapshot manifest", + defaultextension=".json", + filetypes=(("JSON manifest", "*.json"),), + ) + if not destination: + return + try: + result = self.manager.export_manifest(int(snapshot["id"]), destination) + except Exception as exc: + messagebox.showerror("Export failed", str(exc), parent=self.root) + return + self.status_var.set(f"Manifest exported to {result}") + + def open_selected_source(self) -> None: + selection = self.save_tree.selection() + if selection and selection[0] in self.saves: + open_path(Path(self.saves[selection[0]]["source_path"]).parent) + return + profile = self._selected_profile() + if profile: + open_path(profile["source_path"]) + + def open_snapshot(self) -> None: + snapshot = self._selected_snapshot() + if snapshot: + open_path(snapshot["snapshot_path"]) + + def _run( + self, + status: str, + operation: Callable[[], Any], + event_name: str, + ) -> None: + if self.running: + messagebox.showinfo( + "Profiles & Saves", + "Another profile operation is still running.", + parent=self.root, + ) + return + self.running = True + self.status_var.set(status) + + def worker() -> None: + try: + self.events.put((event_name, operation())) + except Exception as exc: + self.events.put(("failed", str(exc))) + + threading.Thread(target=worker, name="profile-save-operation", daemon=True).start() + self.root.after(100, self._poll) + + def _poll(self) -> None: + while True: + try: + event, value = self.events.get_nowait() + except queue.Empty: + break + self.running = False + if event == "failed": + self.status_var.set("Profile operation failed") + messagebox.showerror( + "Profiles & Saves", str(value), parent=self.root + ) + elif event == "scan-complete": + self.status_var.set( + f"Found {len(value.profiles)} profiles and " + f"{len(value.saves)} saves; {len(value.warnings)} warnings." + ) + self.refresh() + elif event == "snapshot-complete": + self.status_var.set(f"Verified snapshot {value} created.") + self.refresh() + elif event == "restore-complete": + self.status_var.set( + f"Restored {value.restored}; skipped {value.skipped}; " + f"preserved {value.conflicts} conflicts." + ) + messagebox.showinfo( + "Restore complete", + f"Restored: {value.restored}\n" + f"Already present or skipped: {value.skipped}\n" + f"Conflicts preserved: {value.conflicts}", + parent=self.root, + ) + if self.running: + self.root.after(100, self._poll) + + def _selected_profile(self) -> dict[str, Any] | None: + selection = self.profile_tree.selection() + return self.profiles.get(selection[0]) if selection else None + + def _selected_profile_id(self) -> str: + profile = self._selected_profile() + return str(profile["profile_id"]) if profile else "" + + def _selected_snapshot(self) -> dict[str, Any] | None: + selection = self.snapshot_tree.selection() + return self.snapshots.get(selection[0]) if selection else None + + def _read_config(self) -> dict[str, Any]: + if not self.config_path.exists(): + return {} + try: + return json.loads(self.config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + def _save_source(self) -> None: + config = self._read_config() + profiles = config.setdefault("profiles", {}) + if not isinstance(profiles, dict): + profiles = {} + config["profiles"] = profiles + profiles["source_root"] = self.source_var.get() + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") diff --git a/profile_manager.py b/profile_manager.py new file mode 100644 index 0000000..41d5718 --- /dev/null +++ b/profile_manager.py @@ -0,0 +1,915 @@ +"""Read-first Xbox 360 profile and save inventory, snapshots, and restore.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import tempfile +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from types import TracebackType +from typing import Any, Callable, Iterable, Literal + +from app_paths import DATABASE_PATH, PROFILE_BACKUPS_DIR +from backup_manager import InvalidPackageError, StfsPackage, inspect_stfs +from database_migrations import ensure_application_schema + + +HEX8_RE = re.compile(r"^[0-9A-Fa-f]{8}$") +PROFILE_ID_RE = re.compile(r"^[0-9A-Fa-f]{16}$") +ZERO_PROFILE_ID = "0000000000000000" +PROFILE_TITLE_ID = "FFFE07D1" +SAVE_CONTENT_DIRECTORY = "00000001" +PROFILE_CONTENT_DIRECTORY = "00010000" +COPY_CHUNK = 1024 * 1024 + + +class ProfileSaveError(RuntimeError): + """Base error for profile and save operations.""" + + +class ProfileSaveConflict(ProfileSaveError): + """Raised when a restore would replace a different existing file.""" + + +class _ClosingConnection(sqlite3.Connection): + """Commit or roll back, then release the database file handle.""" + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: + try: + return super().__exit__(exc_type, exc_value, traceback) + finally: + self.close() + + +@dataclass +class ProfileInfo: + profile_id: str + gamertag: str + source_path: Path + package_path: Path | None + package_sha256: str + console_id: str + device_id: str + profile_kind: str + package_status: str + save_count: int = 0 + total_size: int = 0 + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["source_path"] = str(self.source_path) + result["package_path"] = str(self.package_path or "") + return result + + +@dataclass +class SaveInfo: + profile_id: str + title_id: str + name: str + source_path: Path + package_magic: str + content_type: int + save_game_id: str + embedded_profile_id: str + console_id: str + device_id: str + size: int + modified_at: str + sha256: str + status: str + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["source_path"] = str(self.source_path) + return result + + +@dataclass +class ProfileScanResult: + root: Path + profiles: list[ProfileInfo] + saves: list[SaveInfo] + warnings: list[str] + scanned_at: str + + +@dataclass(frozen=True) +class RestoreResult: + snapshot_id: int + destination: Path + restored: int + skipped: int + conflicts: int + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def mask_identifier(value: str, visible: int = 4) -> str: + """Mask a personal identifier while keeping it distinguishable.""" + normalized = value.strip() + if not normalized: + return "Unknown" + if len(normalized) <= visible: + return "*" * len(normalized) + return "*" * (len(normalized) - visible) + normalized[-visible:] + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(COPY_CHUNK): + digest.update(chunk) + return digest.hexdigest().upper() + + +def _last_row_id(cursor: sqlite3.Cursor) -> int: + if cursor.lastrowid is None: + raise ProfileSaveError("Database did not return a record identifier.") + return int(cursor.lastrowid) + + +def _child_named(parent: Path, name: str) -> Path | None: + if not parent.is_dir(): + return None + expected = name.casefold() + try: + return next(child for child in parent.iterdir() if child.name.casefold() == expected) + except (OSError, StopIteration): + return None + + +def find_content_root(path: str | Path) -> Path: + """Locate an extracted Xbox 360 Content directory.""" + root = Path(path).expanduser().resolve() + if root.is_dir() and root.name.casefold() == "content": + return root + direct = _child_named(root, "Content") + if direct: + return direct + hdd = _child_named(root, "Hdd1") + nested = _child_named(hdd, "Content") if hdd else None + if nested: + return nested + raise ProfileSaveError( + "Choose an Xbox 360 Content directory or a folder containing Content." + ) + + +def _candidate_gamertag(package: StfsPackage | None, profile_id: str) -> str: + if package is None: + return "" + generic = { + "", + profile_id.casefold(), + "xbox 360 dashboard", + "gamer profile", + "profile", + } + for candidate in (package.display_name, package.title_name): + cleaned = candidate.strip() + if cleaned.casefold() not in generic: + return cleaned + return "" + + +class ProfileSaveScanner: + """Scan extracted profile folders without changing source content.""" + + def __init__(self, title_resolver: Callable[[str], str] | None = None) -> None: + self.title_resolver = title_resolver or (lambda _title_id: "") + + def scan(self, path: str | Path) -> ProfileScanResult: + content_root = find_content_root(path) + profiles: list[ProfileInfo] = [] + saves: list[SaveInfo] = [] + warnings: list[str] = [] + + for profile_dir in sorted(content_root.iterdir(), key=lambda item: item.name): + profile_id = profile_dir.name.upper() + if ( + not profile_dir.is_dir() + or not PROFILE_ID_RE.fullmatch(profile_id) + or profile_id == ZERO_PROFILE_ID + ): + continue + profile, profile_warnings = self._scan_profile(profile_dir, profile_id) + profile_saves, save_warnings = self._scan_saves(profile_dir, profile_id) + profile.save_count = len(profile_saves) + profile.total_size = sum(item.size for item in profile_saves) + profiles.append(profile) + saves.extend(profile_saves) + warnings.extend(profile_warnings) + warnings.extend(save_warnings) + + duplicate_hashes = { + digest + for digest in (item.sha256 for item in saves) + if digest and sum(save.sha256 == digest for save in saves) > 1 + } + for save in saves: + if save.sha256 in duplicate_hashes and save.status == "header-valid": + save.status = "duplicate" + + return ProfileScanResult( + content_root, + profiles, + saves, + warnings, + utc_now(), + ) + + def _scan_profile( + self, profile_dir: Path, profile_id: str + ) -> tuple[ProfileInfo, list[str]]: + warnings: list[str] = [] + package_path: Path | None = None + package: StfsPackage | None = None + dashboard = _child_named(profile_dir, PROFILE_TITLE_ID) + package_dir = _child_named(dashboard, PROFILE_CONTENT_DIRECTORY) if dashboard else None + if package_dir: + try: + package_path = next(path for path in package_dir.rglob("*") if path.is_file()) + except (OSError, StopIteration): + package_path = None + package_sha256 = "" + package_status = "missing" + if package_path: + try: + package = inspect_stfs(package_path) + package_sha256 = sha256_file(package_path) + package_status = "header-valid" + if package.content_type != 0x00010000: + package_status = "unexpected-content-type" + if package.profile_id not in {"", ZERO_PROFILE_ID, profile_id}: + package_status = "profile-mismatch" + warnings.append( + f"{mask_identifier(profile_id)} profile package identifies " + f"{mask_identifier(package.profile_id)}." + ) + except (InvalidPackageError, OSError) as exc: + package_status = "invalid" + warnings.append(f"{mask_identifier(profile_id)} profile: {exc}") + + return ( + ProfileInfo( + profile_id=profile_id, + gamertag=_candidate_gamertag(package, profile_id), + source_path=profile_dir, + package_path=package_path, + package_sha256=package_sha256, + console_id=package.console_id if package else "", + device_id=package.device_id if package else "", + profile_kind="profile-package" if package else "folder-only", + package_status=package_status, + ), + warnings, + ) + + def _scan_saves( + self, profile_dir: Path, profile_id: str + ) -> tuple[list[SaveInfo], list[str]]: + saves: list[SaveInfo] = [] + warnings: list[str] = [] + for title_dir in sorted(profile_dir.iterdir(), key=lambda item: item.name): + title_id = title_dir.name.upper() + if ( + not title_dir.is_dir() + or title_id == PROFILE_TITLE_ID + or not HEX8_RE.fullmatch(title_id) + ): + continue + save_dir = _child_named(title_dir, SAVE_CONTENT_DIRECTORY) + if not save_dir: + continue + for save_path in sorted(save_dir.rglob("*")): + if not save_path.is_file(): + continue + try: + package = inspect_stfs(save_path) + digest = sha256_file(save_path) + embedded = package.profile_id + status = "header-valid" + if package.content_type != 0x00000001: + status = "unexpected-content-type" + elif package.title_id != title_id: + status = "title-mismatch" + elif embedded not in {"", ZERO_PROFILE_ID, profile_id}: + status = "profile-mismatch" + name = ( + package.display_name + or self.title_resolver(title_id) + or package.title_name + or save_path.name + ) + saves.append( + SaveInfo( + profile_id=profile_id, + title_id=title_id, + name=name, + source_path=save_path, + package_magic=package.magic, + content_type=package.content_type, + save_game_id=package.save_game_id, + embedded_profile_id=embedded, + console_id=package.console_id, + device_id=package.device_id, + size=save_path.stat().st_size, + modified_at=datetime.fromtimestamp( + save_path.stat().st_mtime, timezone.utc + ).isoformat(), + sha256=digest, + status=status, + ) + ) + if status.endswith("mismatch"): + warnings.append(f"{save_path.name}: {status.replace('-', ' ')}") + except (InvalidPackageError, OSError) as exc: + warnings.append(f"{save_path}: {exc}") + return saves, warnings + + +class ProfileSaveManager: + """Persist profile inventories and create non-destructive save snapshots.""" + + def __init__( + self, + db_path: str | Path = DATABASE_PATH, + backup_root: str | Path = PROFILE_BACKUPS_DIR, + ) -> None: + self.db_path = Path(db_path) + self.backup_root = Path(backup_root) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self.backup_root.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + ensure_application_schema(connection) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.db_path, factory=_ClosingConnection) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection + + def _resolve_title(self, title_id: str) -> str: + with self._connect() as connection: + for query in ( + "SELECT name FROM titleids WHERE titleid = ?", + "SELECT name FROM xboxunity_title_catalog WHERE titleid = ?", + ): + try: + row = connection.execute(query, (title_id,)).fetchone() + except sqlite3.OperationalError: + continue + if row and str(row[0]).strip() not in {"", title_id, "Unknown"}: + return str(row[0]).strip() + return "" + + def scan(self, source: str | Path) -> ProfileScanResult: + started = utc_now() + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO profile_scan_runs(source_root, started_at, status) + VALUES (?, ?, 'running') + """, + (str(Path(source).expanduser()), started), + ) + run_id = _last_row_id(cursor) + connection.commit() + try: + result = ProfileSaveScanner(self._resolve_title).scan(source) + self._store_scan(result) + with self._connect() as connection: + connection.execute( + """ + UPDATE profile_scan_runs + SET completed_at=?, status='completed', profile_count=?, + save_count=?, warning_count=?, warnings_json=? + WHERE id=? + """, + ( + utc_now(), + len(result.profiles), + len(result.saves), + len(result.warnings), + json.dumps(result.warnings), + run_id, + ), + ) + connection.commit() + return result + except Exception as exc: + with self._connect() as connection: + connection.execute( + """ + UPDATE profile_scan_runs + SET completed_at=?, status='failed', error_message=? + WHERE id=? + """, + (utc_now(), str(exc), run_id), + ) + connection.commit() + raise + + def _store_scan(self, result: ProfileScanResult) -> None: + seen = result.scanned_at + with self._connect() as connection: + for profile in result.profiles: + connection.execute( + """ + INSERT INTO xbox_profiles( + profile_id, gamertag, source_path, package_path, + package_sha256, console_id, device_id, profile_kind, + package_status, first_seen_at, last_seen_at, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_id, source_path) DO UPDATE SET + gamertag=excluded.gamertag, + package_path=excluded.package_path, + package_sha256=excluded.package_sha256, + console_id=excluded.console_id, + device_id=excluded.device_id, + profile_kind=excluded.profile_kind, + package_status=excluded.package_status, + last_seen_at=excluded.last_seen_at, + metadata_json=excluded.metadata_json + """, + ( + profile.profile_id, + profile.gamertag, + str(profile.source_path), + str(profile.package_path or ""), + profile.package_sha256, + profile.console_id, + profile.device_id, + profile.profile_kind, + profile.package_status, + seen, + seen, + json.dumps( + { + "save_count": profile.save_count, + "total_size": profile.total_size, + } + ), + ), + ) + for save in result.saves: + connection.execute( + """ + INSERT INTO profile_saves( + profile_id, titleid, name, source_path, package_magic, + content_type, save_game_id, embedded_profile_id, + console_id, device_id, size, modified_at, sha256, + status, first_seen_at, last_seen_at, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_path) DO UPDATE SET + profile_id=excluded.profile_id, + titleid=excluded.titleid, + name=excluded.name, + package_magic=excluded.package_magic, + content_type=excluded.content_type, + save_game_id=excluded.save_game_id, + embedded_profile_id=excluded.embedded_profile_id, + console_id=excluded.console_id, + device_id=excluded.device_id, + size=excluded.size, + modified_at=excluded.modified_at, + sha256=excluded.sha256, + status=excluded.status, + last_seen_at=excluded.last_seen_at + """, + ( + save.profile_id, + save.title_id, + save.name, + str(save.source_path), + save.package_magic, + save.content_type, + save.save_game_id, + save.embedded_profile_id, + save.console_id, + save.device_id, + save.size, + save.modified_at, + save.sha256, + save.status, + seen, + seen, + "{}", + ), + ) + connection.commit() + + def list_profiles(self) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT p.*, + COUNT(s.id) AS save_count, + COALESCE(SUM(s.size), 0) AS total_size, + SUM(CASE WHEN s.status != 'header-valid' THEN 1 ELSE 0 END) + AS attention_count + FROM xbox_profiles p + LEFT JOIN profile_saves s ON s.profile_id = p.profile_id + GROUP BY p.id + ORDER BY COALESCE(NULLIF(p.gamertag, ''), p.profile_id) + """ + ).fetchall() + return [dict(row) for row in rows] + + def list_saves( + self, profile_id: str | None = None, search: str = "" + ) -> list[dict[str, Any]]: + clauses: list[str] = [] + values: list[str] = [] + if profile_id: + clauses.append("profile_id = ?") + values.append(profile_id) + if search.strip(): + clauses.append("(name LIKE ? OR titleid LIKE ?)") + term = f"%{search.strip()}%" + values.extend((term, term)) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with self._connect() as connection: + rows = connection.execute( + f""" + SELECT * FROM profile_saves + {where} + ORDER BY name COLLATE NOCASE, modified_at DESC + """, + values, + ).fetchall() + return [dict(row) for row in rows] + + def list_snapshots(self) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + "SELECT * FROM save_snapshots ORDER BY created_at DESC" + ).fetchall() + return [dict(row) for row in rows] + + def create_snapshot( + self, + profile_id: str, + *, + save_ids: Iterable[int] | None = None, + label: str = "", + ) -> int: + profile = self._profile(profile_id) + files = self._snapshot_sources(profile, save_ids) + if not files: + raise ProfileSaveError("No profile or save files were found to back up.") + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO save_snapshots( + profile_id, label, source_root, snapshot_path, created_at, status + ) VALUES (?, ?, ?, ?, ?, 'creating') + """, + (profile_id, label.strip(), profile["source_path"], "pending", utc_now()), + ) + snapshot_id = _last_row_id(cursor) + snapshot_path = self.backup_root / ( + f"snapshot-{snapshot_id:06d}-{datetime.now():%Y%m%d-%H%M%S}" + ) + connection.execute( + "UPDATE save_snapshots SET snapshot_path=? WHERE id=?", + (str(snapshot_path), snapshot_id), + ) + connection.commit() + + operation_id = self._start_operation("snapshot", snapshot_id, snapshot_path) + try: + snapshot_path.mkdir(parents=True, exist_ok=False) + manifest_files: list[dict[str, Any]] = [] + total_size = 0 + for source, relative, item_kind, title_id in files: + destination = snapshot_path / "files" / relative + digest = self._verified_copy(source, destination) + stat = source.stat() + total_size += stat.st_size + item = { + "source_path": str(source), + "relative_path": relative.as_posix(), + "sha256": digest, + "size": stat.st_size, + "modified_at": datetime.fromtimestamp( + stat.st_mtime, timezone.utc + ).isoformat(), + "item_kind": item_kind, + "titleid": title_id, + } + manifest_files.append(item) + with self._connect() as connection: + connection.execute( + """ + INSERT INTO save_snapshot_files( + snapshot_id, source_path, relative_path, sha256, size, + modified_at, item_kind, titleid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + snapshot_id, + item["source_path"], + item["relative_path"], + digest, + item["size"], + item["modified_at"], + item_kind, + title_id, + ), + ) + connection.commit() + + manifest = { + "schema": 1, + "snapshot_id": snapshot_id, + "created_at": utc_now(), + "profile_id": profile_id, + "source_root": profile["source_path"], + "files": manifest_files, + "attribution": ( + "Profile/STFS model informed by Dalavin (DJ SkunkieButt)'s " + "GPLv3 X360 library and Le Fluffie." + ), + } + manifest_path = snapshot_path / "manifest.json" + self._write_json_atomic(manifest_path, manifest) + manifest_sha = sha256_file(manifest_path) + with self._connect() as connection: + connection.execute( + """ + UPDATE save_snapshots + SET file_count=?, total_size=?, manifest_sha256=?, + status='complete' + WHERE id=? + """, + (len(manifest_files), total_size, manifest_sha, snapshot_id), + ) + connection.commit() + self._finish_operation(operation_id, "completed") + return snapshot_id + except Exception as exc: + with self._connect() as connection: + connection.execute( + "UPDATE save_snapshots SET status='failed', notes=? WHERE id=?", + (str(exc), snapshot_id), + ) + connection.commit() + self._finish_operation(operation_id, "failed", str(exc)) + raise + + def restore_snapshot( + self, + snapshot_id: int, + destination: str | Path, + *, + conflict: str = "keep_both", + ) -> RestoreResult: + if conflict not in {"keep_both", "skip"}: + raise ValueError("Restore conflict policy must be keep_both or skip.") + destination_root = Path(destination).expanduser().resolve() + destination_root.mkdir(parents=True, exist_ok=True) + snapshot = self._snapshot(snapshot_id) + snapshot_root = Path(snapshot["snapshot_path"]) + operation_id = self._start_operation("restore", snapshot_id, destination_root) + restored = 0 + skipped = 0 + conflicts = 0 + try: + with self._connect() as connection: + files = connection.execute( + """ + SELECT * FROM save_snapshot_files + WHERE snapshot_id=? ORDER BY relative_path + """, + (snapshot_id,), + ).fetchall() + for row in files: + relative = Path(str(row["relative_path"])) + source = snapshot_root / "files" / relative + if not source.is_file() or sha256_file(source) != row["sha256"]: + raise ProfileSaveError( + f"Snapshot file failed verification: {relative}" + ) + target = self._safe_target(destination_root, relative) + restore_status = "restored" + if target.exists(): + if sha256_file(target) == row["sha256"]: + skipped += 1 + restore_status = "already-present" + self._set_restore_status(int(row["id"]), restore_status) + continue + conflicts += 1 + if conflict == "skip": + skipped += 1 + restore_status = "conflict-skipped" + self._set_restore_status(int(row["id"]), restore_status) + continue + target = self._conflict_target(target) + restore_status = "restored-alongside-conflict" + self._verified_copy(source, target) + restored += 1 + self._set_restore_status(int(row["id"]), restore_status) + self._finish_operation( + operation_id, + "completed", + details={ + "restored": restored, + "skipped": skipped, + "conflicts": conflicts, + }, + ) + return RestoreResult( + snapshot_id, destination_root, restored, skipped, conflicts + ) + except Exception as exc: + self._finish_operation(operation_id, "failed", str(exc)) + raise + + def export_manifest(self, snapshot_id: int, destination: str | Path) -> Path: + snapshot = self._snapshot(snapshot_id) + source = Path(snapshot["snapshot_path"]) / "manifest.json" + if not source.is_file(): + raise ProfileSaveError("The selected snapshot has no manifest.") + target = Path(destination) + self._verified_copy(source, target) + return target + + def _profile(self, profile_id: str) -> sqlite3.Row: + with self._connect() as connection: + row = connection.execute( + """ + SELECT * FROM xbox_profiles + WHERE profile_id=? ORDER BY last_seen_at DESC LIMIT 1 + """, + (profile_id,), + ).fetchone() + if row is None: + raise ProfileSaveError("The selected profile is no longer in the inventory.") + return row + + def _snapshot(self, snapshot_id: int) -> sqlite3.Row: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM save_snapshots WHERE id=?", (snapshot_id,) + ).fetchone() + if row is None: + raise ProfileSaveError("Snapshot not found.") + if row["status"] != "complete": + raise ProfileSaveError("Only complete snapshots can be restored.") + return row + + def _snapshot_sources( + self, + profile: sqlite3.Row, + save_ids: Iterable[int] | None, + ) -> list[tuple[Path, Path, str, str]]: + profile_root = Path(profile["source_path"]).resolve() + requested = [int(value) for value in save_ids or ()] + if requested: + placeholders = ",".join("?" for _ in requested) + with self._connect() as connection: + rows = connection.execute( + f""" + SELECT * FROM profile_saves + WHERE profile_id=? AND id IN ({placeholders}) + """, + [profile["profile_id"], *requested], + ).fetchall() + result: list[tuple[Path, Path, str, str]] = [] + for row in rows: + source = Path(row["source_path"]).resolve() + try: + relative = source.relative_to(profile_root) + except ValueError: + relative = Path(row["titleid"]) / SAVE_CONTENT_DIRECTORY / source.name + result.append((source, relative, "save", row["titleid"])) + return result + + result = [] + for source in sorted(profile_root.rglob("*")): + if not source.is_file(): + continue + relative = source.relative_to(profile_root) + parts_upper = {part.upper() for part in relative.parts} + item_kind = ( + "save" if SAVE_CONTENT_DIRECTORY in parts_upper else "profile" + ) + title_id = next( + (part.upper() for part in relative.parts if HEX8_RE.fullmatch(part)), + "", + ) + result.append((source, relative, item_kind, title_id)) + return result + + def _start_operation( + self, operation_type: str, snapshot_id: int, target: Path + ) -> int: + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO profile_save_operations( + operation_type, target_path, snapshot_id, status, started_at + ) VALUES (?, ?, ?, 'running', ?) + """, + (operation_type, str(target), snapshot_id, utc_now()), + ) + connection.commit() + return _last_row_id(cursor) + + def _finish_operation( + self, + operation_id: int, + status: str, + error: str = "", + details: dict[str, Any] | None = None, + ) -> None: + with self._connect() as connection: + connection.execute( + """ + UPDATE profile_save_operations + SET status=?, completed_at=?, details_json=?, error_message=? + WHERE id=? + """, + ( + status, + utc_now(), + json.dumps(details or {}), + error or None, + operation_id, + ), + ) + connection.commit() + + def _set_restore_status(self, file_id: int, status: str) -> None: + with self._connect() as connection: + connection.execute( + "UPDATE save_snapshot_files SET restore_status=? WHERE id=?", + (status, file_id), + ) + connection.commit() + + @staticmethod + def _safe_target(root: Path, relative: Path) -> Path: + if relative.is_absolute() or ".." in relative.parts: + raise ProfileSaveError("Snapshot contains an unsafe relative path.") + target = (root / relative).resolve() + try: + target.relative_to(root) + except ValueError as exc: + raise ProfileSaveError("Snapshot path escapes the destination.") from exc + return target + + @staticmethod + def _conflict_target(path: Path) -> Path: + for index in range(1, 10_000): + candidate = path.with_name(f"{path.name}.restored-{index}") + if not candidate.exists(): + return candidate + raise ProfileSaveConflict(f"Could not create a conflict-safe path for {path}") + + @staticmethod + def _verified_copy(source: Path, destination: Path) -> str: + if not source.is_file(): + raise FileNotFoundError(source) + destination.parent.mkdir(parents=True, exist_ok=True) + expected = sha256_file(source) + descriptor, temporary_name = tempfile.mkstemp( + prefix=destination.name + ".", + suffix=".partial", + dir=destination.parent, + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + shutil.copy2(source, temporary) + if sha256_file(temporary) != expected: + raise ProfileSaveError(f"Copied file failed verification: {source}") + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + return expected + + @staticmethod + def _write_json_atomic(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".partial") + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True), + encoding="utf-8", + ) + temporary.replace(path) diff --git a/tests.py b/tests.py index 20c7115..9c2b36b 100644 --- a/tests.py +++ b/tests.py @@ -37,7 +37,7 @@ from knowledge_service import KnowledgeService from knowledge_sources import KnowledgeImportService, SourceInfo from library_service import LibraryService -from modern_gui import XEXTOOL_CREATOR, navigation_shortcut +from modern_gui import LE_FLUFFIE_CREATOR, XEXTOOL_CREATOR, navigation_shortcut from title_catalog import XboxUnityTitleCatalog from wiki_adapters import extract_article_text, parse_sitemap from backup_manager import ( @@ -58,6 +58,7 @@ from app_version import DISPLAY_VERSION from app_paths import resolve_storage_paths from platform_support import desktop_font_family, path_opener_command +from profile_manager import ProfileSaveManager, find_content_root, mask_identifier class TestPlatformSupport(unittest.TestCase): @@ -711,6 +712,9 @@ def test_bundled_xextool_has_documented_binary(self): def test_about_credits_xextool_creator(self): self.assertEqual(XEXTOOL_CREATOR, "xorloser") + def test_about_credits_le_fluffie_creator(self): + self.assertEqual(LE_FLUFFIE_CREATOR, "Dalavin (DJ SkunkieButt)") + class TestConsoleModsAdapters(unittest.TestCase): """Test ConsoleMods parsing helpers.""" @@ -1054,6 +1058,10 @@ def _stfs( mediaid="12345678", content_type=0x000D0000, title="Test Game", + profile_id="0000000000000000", + console_id="0000000000", + device_id="0000000000000000000000000000000000000000", + save_game_id="00000000", ): path = self.temp_dir / name header = bytearray(0x1791) @@ -1063,6 +1071,10 @@ def _stfs( header[0x360:0x364] = bytes.fromhex(titleid) header[0x366] = 1 header[0x367] = 1 + header[0x368:0x36C] = bytes.fromhex(save_game_id) + header[0x36C:0x371] = bytes.fromhex(console_id) + header[0x371:0x379] = bytes.fromhex(profile_id) + header[0x3FD:0x411] = bytes.fromhex(device_id) encoded = title.encode("utf-16-be") header[0x411:0x411 + len(encoded)] = encoded header[0x1691:0x1691 + len(encoded)] = encoded @@ -1078,6 +1090,22 @@ def test_inspect_stfs_and_destination(self): self.assertIn("000D0000", destination.parts) self.assertEqual(destination.parent.parent.name, "4D5307E6") + def test_inspect_stfs_reads_profile_ownership_fields(self): + package = inspect_stfs( + self._stfs( + content_type=0x00000001, + profile_id="E00015BF00008BD2", + console_id="0102030405", + device_id="11" * 20, + save_game_id="12345678", + ) + ) + self.assertEqual(package.content_label, "Saved Game") + self.assertEqual(package.profile_id, "E00015BF00008BD2") + self.assertEqual(package.console_id, "0102030405") + self.assertEqual(package.device_id, "11" * 20) + self.assertEqual(package.save_game_id, "12345678") + def test_rejects_unknown_stfs_content_type(self): with self.assertRaises(InvalidPackageError): inspect_stfs(self._stfs(content_type=0xDEADBEEF)) @@ -1292,6 +1320,111 @@ def test_config_validates_and_applies_allowlisted_values(self): self.assertFalse(self.scraper.config.use_https) +class TestProfileSaveManager(unittest.TestCase): + """Test read-only profile discovery and conflict-safe snapshots.""" + + PROFILE_ID = "E00015BF00008BD2" + TITLE_ID = "53510804" + + def setUp(self): + self.temp_dir = Path(tempfile.mkdtemp()) + self.content = self.temp_dir / "device" / "Content" + self.profile_root = self.content / self.PROFILE_ID + self.profile_package = ( + self.profile_root / "FFFE07D1" / "00010000" / self.PROFILE_ID + ) + self.save_path = ( + self.profile_root / self.TITLE_ID / "00000001" / "savegame" + ) + self._write_stfs( + self.profile_package, + titleid="FFFE07D1", + content_type=0x00010000, + title="TestPlayer", + ) + self._write_stfs( + self.save_path, + titleid=self.TITLE_ID, + content_type=0x00000001, + title="Hitman: Absolution Save", + ) + self.manager = ProfileSaveManager( + self.temp_dir / "profiles.db", + self.temp_dir / "snapshots", + ) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def _write_stfs(self, path, *, titleid, content_type, title): + path.parent.mkdir(parents=True, exist_ok=True) + header = bytearray(0x1791) + header[:4] = b"CON " + header[0x344:0x348] = content_type.to_bytes(4, "big") + header[0x354:0x358] = bytes.fromhex("12345678") + header[0x360:0x364] = bytes.fromhex(titleid) + header[0x368:0x36C] = bytes.fromhex("11223344") + header[0x36C:0x371] = bytes.fromhex("0102030405") + header[0x371:0x379] = bytes.fromhex(self.PROFILE_ID) + header[0x3FD:0x411] = bytes.fromhex("22" * 20) + encoded = title.encode("utf-16-be") + header[0x411 : 0x411 + len(encoded)] = encoded + header[0x1691 : 0x1691 + len(encoded)] = encoded + path.write_bytes(header + b"profile-save-payload") + + def test_content_root_and_masking(self): + self.assertEqual(find_content_root(self.temp_dir / "device"), self.content) + masked = mask_identifier(self.PROFILE_ID) + self.assertTrue(masked.endswith("8BD2")) + self.assertNotIn(self.PROFILE_ID, masked) + + def test_scan_indexes_profile_and_save_without_modifying_source(self): + original = self.save_path.read_bytes() + result = self.manager.scan(self.content) + profiles = self.manager.list_profiles() + saves = self.manager.list_saves(self.PROFILE_ID) + + self.assertEqual((len(result.profiles), len(result.saves)), (1, 1)) + self.assertEqual(profiles[0]["gamertag"], "TestPlayer") + self.assertEqual(profiles[0]["save_count"], 1) + self.assertEqual(saves[0]["titleid"], self.TITLE_ID) + self.assertEqual(saves[0]["status"], "header-valid") + self.assertEqual(self.save_path.read_bytes(), original) + + def test_snapshot_restore_preserves_destination_conflict(self): + self.manager.scan(self.content) + save_id = int(self.manager.list_saves(self.PROFILE_ID)[0]["id"]) + snapshot_id = self.manager.create_snapshot( + self.PROFILE_ID, + save_ids=[save_id], + label="Before transfer", + ) + snapshot = self.manager.list_snapshots()[0] + self.assertEqual(snapshot["status"], "complete") + self.assertEqual(snapshot["file_count"], 1) + + destination = self.temp_dir / "restore" + conflict = destination / self.TITLE_ID / "00000001" / "savegame" + conflict.parent.mkdir(parents=True) + conflict.write_bytes(b"keep this copy") + + result = self.manager.restore_snapshot(snapshot_id, destination) + restored = conflict.with_name("savegame.restored-1") + self.assertEqual(conflict.read_bytes(), b"keep this copy") + self.assertEqual(restored.read_bytes(), self.save_path.read_bytes()) + self.assertEqual((result.restored, result.conflicts), (1, 1)) + + def test_profile_snapshot_contains_manifest_and_every_profile_file(self): + self.manager.scan(self.content) + snapshot_id = self.manager.create_snapshot(self.PROFILE_ID) + snapshot = self.manager.list_snapshots()[0] + manifest = Path(snapshot["snapshot_path"]) / "manifest.json" + payload = json.loads(manifest.read_text(encoding="utf-8")) + self.assertEqual(snapshot_id, payload["snapshot_id"]) + self.assertEqual(len(payload["files"]), 2) + self.assertIn("DJ SkunkieButt", payload["attribution"]) + + class TestUnifiedV1Foundation(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() @@ -1315,12 +1448,16 @@ def test_versioned_migrations_create_all_foundation_tables(self): versions = connection.execute( "SELECT version FROM app_schema_migrations ORDER BY version" ).fetchall() - self.assertEqual([row[0] for row in versions], [1, 2, 3, 4, 5]) + self.assertEqual([row[0] for row in versions], [1, 2, 3, 4, 5, 6]) self.assertIn("collection_snapshots", tables) self.assertIn("preservation_matches", tables) self.assertIn("console_transfer_jobs", tables) self.assertIn("metadata_overrides", tables) self.assertIn("xboxunity_title_catalog", tables) + self.assertIn("xbox_profiles", tables) + self.assertIn("profile_saves", tables) + self.assertIn("save_snapshots", tables) + self.assertIn("profile_save_operations", tables) def test_xex_execution_info_is_parsed(self): from backup_manager import inspect_xex @@ -1434,6 +1571,7 @@ def run_tests(): suite.addTests(loader.loadTestsFromTestCase(TestIntegration)) suite.addTests(loader.loadTestsFromTestCase(TestBackupManager)) suite.addTests(loader.loadTestsFromTestCase(TestRestAPI)) + suite.addTests(loader.loadTestsFromTestCase(TestProfileSaveManager)) suite.addTests(loader.loadTestsFromTestCase(TestUnifiedV1Foundation)) # Run tests From 2b67b71a1f9ee27a9c4cebb915a6aedbce6b8685 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:33:49 -0400 Subject: [PATCH 2/2] test: normalize Windows content paths --- tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 9c2b36b..c38ebce 100644 --- a/tests.py +++ b/tests.py @@ -1373,7 +1373,10 @@ def _write_stfs(self, path, *, titleid, content_type, title): path.write_bytes(header + b"profile-save-payload") def test_content_root_and_masking(self): - self.assertEqual(find_content_root(self.temp_dir / "device"), self.content) + self.assertEqual( + find_content_root(self.temp_dir / "device"), + self.content.resolve(), + ) masked = mask_identifier(self.PROFILE_ID) self.assertTrue(masked.endswith("8BD2")) self.assertNotIn(self.PROFILE_ID, masked)