commit 0e0d51920a6ab19e69fa38d24947eec04d51fb60 Author: Mac Date: Fri Oct 2 01:36:32 2026 +0530 Packwright 1.0.0 A cross-platform (Windows, Linux, macOS) manager for PS5 dumps, images and packages, grown out of PS5 PKG Tool by pearlxcore (GPL-3.0). - Library of dumps, packages and images (cards or list), search language, grouping, saved views, health report and export (CSV, JSON, web page) - Edit and autofill details (including region, IDs, versions, features and icon), in the library or written into the file; optional Title ID lookup - Convert, build, extract, verify, repair and edit exFAT, FFPKG, FFPFSC and ZArchive images and debug packages, with presets - Send to console over FTP or by URL on the local network - Command-line mode - Installers for Windows, macOS and Linux, and an opt-in update check See README.md and THIRD_PARTY_NOTICES.md for credits, licenses and the list of changes made to the original project. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c02dae0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Keep the Windows launcher and installer script CRLF and the Linux packaging script LF, whatever the checkout settings. +*.cmd text eol=crlf +*.iss text eol=crlf +*.sh text eol=lf +*.webp binary +*.png binary +*.ico binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9b51ecb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,83 @@ +name: Bug report +description: Something does not work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. Please search existing issues first. + Packwright does not provide games, keys or Sony SDK files, and issues asking for them will be closed. + - type: input + id: version + attributes: + label: Packwright version + description: Shown on the About page and in the sidebar, for example 1.0.0 + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Windows + - Linux + - macOS (Apple Silicon) + - macOS (Intel) + validations: + required: true + - type: input + id: osversion + attributes: + label: OS version + placeholder: Windows 11 24H2, Ubuntu 24.04, macOS 15.1 + - type: dropdown + id: area + attributes: + label: Area + options: + - Library / scanning / list view + - Edit details / autofill / online lookup + - Tools (build, convert, extract, presets) + - Image editor / file browser + - Tasks + - Library health + - Send to console + - Export + - Command line + - Installer / updates + - ZArchive (.zar) + - Packages (.pkg) + - Other + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened? + description: What you expected and what you saw instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Tools + 2. Choose a game folder as the source + 3. ... + validations: + required: true + - type: input + id: format + attributes: + label: Input type and size + description: Folder dump, .pkg, .ffpkg, .exfat, .ffpfs, .zar and roughly how large it is. + - type: textarea + id: log + attributes: + label: Log + description: | + Copy the relevant lines from the Log page (Log > Open log folder), or attach the latest file from + `%LOCALAPPDATA%\Packwright\logs` (Windows) or `~/.local/share/Packwright/logs` (Linux/macOS). + Remove personal paths if you want to. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8d1624a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and ideas + url: https://github.com/itsmemac/Packwright/discussions + about: Ask usage questions here (enable Discussions in the repository settings). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..afffc5e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an improvement or a new capability +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: os + attributes: + label: Platforms affected + multiple: true + options: + - Windows + - Linux + - macOS diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c1cd8ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: Build + +# Pull requests are built on all four targets to catch breakage. Pushes to main are built and released +# by release.yml. +on: + pull_request: + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + rid: [win-x64, linux-x64, osx-arm64, osx-x64] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Publish + run: dotnet publish src/Packwright.App/Packwright.App.csproj -c Release -r ${{ matrix.rid }} --self-contained true -o publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..34e74f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,222 @@ +name: Release + +# Every push to main builds the app for Windows, Linux and macOS and publishes (or refreshes) a GitHub +# release named after the in src/Packwright.App/Packwright.App.csproj. To cut a new +# release, bump that version and push. +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +env: + PROJECT: src/Packwright.App/Packwright.App.csproj + APP: Packwright + +jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.read.outputs.version }} + steps: + - uses: actions/checkout@v4 + - id: read + run: | + version=$(sed -n 's:.*\(.*\).*:\1:p' "$PROJECT" | head -n1) + if [ -z "$version" ]; then echo "No found in $PROJECT" >&2; exit 1; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + build-linux: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Publish + run: dotnet publish "$PROJECT" -c Release -r linux-x64 --self-contained true -o publish + - name: Add the licenses + run: | + cp LICENSE THIRD_PARTY_NOTICES.md publish/ + mkdir -p publish/licenses + cp third-party/ProsperoPkgTool/LICENSE publish/licenses/ProsperoPkgTool-LICENSE + cp third-party/LibProsperoPkg12/LICENSE publish/licenses/LibProsperoPkg-LICENSE + cp third-party/LibProsperoPkg12/NOTICE publish/licenses/LibProsperoPkg-NOTICE + cp src/Packwright.Ufs2/LICENSE publish/licenses/UFS2Tool-LICENSE + cp third-party/licenses/* publish/licenses/ + - name: Build the .deb and .rpm + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq rpm + bash installer/linux/build-packages.sh "${{ needs.version.outputs.version }}" publish dist + - uses: actions/upload-artifact@v4 + with: + name: release-linux-packages + path: dist/* + + build-windows-installer: + needs: version + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Publish + shell: bash + run: dotnet publish "$PROJECT" -c Release -r win-x64 --self-contained true -o publish + - name: Add the licenses + shell: bash + run: | + cp LICENSE THIRD_PARTY_NOTICES.md publish/ + mkdir -p publish/licenses + cp third-party/ProsperoPkgTool/LICENSE publish/licenses/ProsperoPkgTool-LICENSE + cp third-party/LibProsperoPkg12/LICENSE publish/licenses/LibProsperoPkg-LICENSE + cp third-party/LibProsperoPkg12/NOTICE publish/licenses/LibProsperoPkg-NOTICE + cp src/Packwright.Ufs2/LICENSE publish/licenses/UFS2Tool-LICENSE + cp third-party/licenses/* publish/licenses/ + - name: Install Inno Setup + run: choco install innosetup -y --no-progress + - name: Build the installer + shell: pwsh + run: | + New-Item -ItemType Directory -Force dist | Out-Null + $iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe' + & $iscc "/DAppVersion=${{ needs.version.outputs.version }}" "/DSourceDir=$env:GITHUB_WORKSPACE\publish" "/O$env:GITHUB_WORKSPACE\dist" installer\Packwright.iss + if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed ($LASTEXITCODE)" } + - uses: actions/upload-artifact@v4 + with: + name: release-windows-installer + path: dist/*.exe + + build-macos: + needs: version + runs-on: macos-latest + strategy: + fail-fast: true + matrix: + include: + - { rid: osx-arm64, label: macos-arm64 } + - { rid: osx-x64, label: macos-x64 } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Publish + run: dotnet publish "$PROJECT" -c Release -r ${{ matrix.rid }} --self-contained true -o publish + - name: Create the app bundle + run: | + version="${{ needs.version.outputs.version }}" + bundle="Packwright.app" + mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Resources" + cp -R publish/. "$bundle/Contents/MacOS/" + chmod +x "$bundle/Contents/MacOS/$APP" + cp LICENSE THIRD_PARTY_NOTICES.md "$bundle/Contents/Resources/" + mkdir -p "$bundle/Contents/Resources/licenses" + cp third-party/ProsperoPkgTool/LICENSE "$bundle/Contents/Resources/licenses/ProsperoPkgTool-LICENSE" + cp src/Packwright.Ufs2/LICENSE "$bundle/Contents/Resources/licenses/UFS2Tool-LICENSE" + cp third-party/licenses/* "$bundle/Contents/Resources/licenses/" + + # Icon: build an .icns from the PNG + iconset=icon.iconset + mkdir "$iconset" + for size in 16 32 128 256 512; do + double=$((size * 2)) + sips -z $size $size src/Packwright.App/Assets/icon.png --out "$iconset/icon_${size}x${size}.png" > /dev/null + sips -z $double $double src/Packwright.App/Assets/icon.png --out "$iconset/icon_${size}x${size}@2x.png" > /dev/null + done + iconutil -c icns "$iconset" -o "$bundle/Contents/Resources/icon.icns" + + cat > "$bundle/Contents/Info.plist" < + + + + CFBundleNamePackwright + CFBundleDisplayNamePackwright + CFBundleIdentifierio.github.itsmemac.packwright + CFBundleVersion${version} + CFBundleShortVersionString${version} + CFBundleExecutable${APP} + CFBundleIconFileicon + CFBundlePackageTypeAPPL + LSMinimumSystemVersion13.0 + NSHighResolutionCapable + + + PLIST + - name: Sign ad hoc + # Apple Silicon refuses to start unsigned code. An ad-hoc signature is enough to run locally; + # the app is not notarized, so the first launch still needs right click, then Open. + run: codesign --force --deep --sign - "Packwright.app" + - name: Build the disk image + # The disk image holds the app and a shortcut to Applications: open it and drag the app across. + run: | + mkdir dist dmgroot + name="$APP-v${{ needs.version.outputs.version }}-${{ matrix.label }}" + ditto "Packwright.app" "dmgroot/Packwright.app" + ln -s /Applications dmgroot/Applications + hdiutil create -volname "Packwright" -srcfolder dmgroot -ov -format UDZO "dist/$name.dmg" + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.label }} + path: dist/* + + release: + needs: [version, build-linux, build-windows-installer, build-macos] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: dist + - name: Checksums and notes + run: | + version="${{ needs.version.outputs.version }}" + (cd dist && sha256sum * > SHA256SUMS.txt) + cat > notes.md <.deb\` / \`sudo dnf install ./.rpm\`. It adds a menu entry and a \`packwright\` command, and is removed with your package manager. + + Verify a download with \`SHA256SUMS.txt\`. + + **License and source:** GPL-3.0, with no warranty. Packwright grew out of PS5 PKG Tool by pearlxcore (GPL-3.0), whose engines still power parts of it; the changes and every bundled component are listed in THIRD_PARTY_NOTICES.md, and the license texts are in the \`licenses\` folder of the installed app. The source for this release is the \`v${version}\` tag of this repository. + NOTES + - name: Publish the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="v${{ needs.version.outputs.version }}" + # Replace an earlier release of the same version so the tag points at this build. + gh release delete "$tag" --cleanup-tag --yes 2>/dev/null || true + gh release create "$tag" dist/* \ + --target "$GITHUB_SHA" \ + --title "Packwright $tag" \ + --notes-file notes.md \ + --latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc1c2ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.vs/ +**/bin/ +**/obj/ +*.user +*.suo +publish/ + +# Locally built installers +installer/out/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a0ca524 --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + 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 + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/Packwright.slnx b/Packwright.slnx new file mode 100644 index 0000000..32c35f3 --- /dev/null +++ b/Packwright.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..412cc86 --- /dev/null +++ b/README.md @@ -0,0 +1,321 @@ +# Packwright + +One app to manage your PS5 dump and image collection, read PS5 packages, fix their details, and build, convert and send them. For **Windows, Linux and macOS**. + +![Packwright showing a library of PS5 games as artwork cards, with the details panel open](docs/library.webp) + +> Packwright grew out of [PS5 PKG Tool](https://github.com/pearlxcore/PS5PkgTool) by [pearlxcore](https://github.com/pearlxcore) +> (GPL-3.0), and its image and package engines still power parts of this app. Thank you to pearlxcore. +> Details are in [License and credits](#license-and-credits). + +**This is not software for obtaining free PS5 games.** + +*PlayStation and PS5 are trademarks of Sony Interactive Entertainment Inc. This project is independent and is not affiliated with, authorized or endorsed by Sony. No Sony software or SDK files are included.* + +> **Experimental:** Images and packages generated by this app have been validated and compared on PC, but they have not been tested on a jailbroken PS5. A successful build or validation does not guarantee that an image will mount, or that a package will install, launch, or play correctly on real hardware. The same goes for **Send to console**, which has only been tested against a simulated console. Keep backups and use generated output at your own risk. + +## Contents + +- [Highlights](#highlights) +- [Download and install](#download-and-install) +- [macOS help](#macos-help) +- [Getting started](#getting-started) +- [Features](#features) +- [Command line](#command-line) +- [Searching your library](#searching-your-library) +- [Where your data lives](#where-your-data-lives) +- [Platform support](#platform-support) +- [Build from source](#build-from-source) +- [Releases](#releases) +- [Project layout](#project-layout) +- [License and credits](#license-and-credits) + +## Highlights + +- **Library** of dumps, packages and images as artwork cards or a sortable list, with a search language, grouping, saved views and a details panel. +- **Fix missing details:** autofill what it can from the files, look titles up by Title ID, and edit names, IDs, firmware and icons, in the library or inside the file. +- **Image tools:** convert between exFAT, FFPKG, FFPFSC and ZArchive, build debug packages (FPKG), extract, verify, repair and edit files inside images, with presets. +- **Library health** report: moved or unreadable files, duplicates, superseded updates, wasted space and more. +- **Send to console** over FTP, or ask a console's installer to download a package from your PC. +- **Export** the library as CSV, JSON or a web page with artwork. +- **Command line** for scripts and servers. +- **Real installers** for every system, with a built-in update check that you control. + +## Download and install + +Download the installer for your system from the [latest release](https://github.com/itsmemac/Packwright/releases/latest). Everything is self-contained, so no .NET install is needed. + +| System | Download | Install | +| --- | --- | --- | +| Windows 10/11 (64-bit) | `Packwright-Setup-v-windows-x64.exe` | run it (no administrator rights needed) | +| macOS, Apple silicon | `Packwright-v-macos-arm64.dmg` | open it, drag **Packwright** onto **Applications** | +| macOS, Intel | `Packwright-v-macos-x64.dmg` | open it, drag **Packwright** onto **Applications** | +| Linux (64-bit), Debian, Ubuntu, Mint | `Packwright-v-linux-x64.deb` | your software installer, or `sudo apt install ./.deb` | +| Linux (64-bit), Fedora, openSUSE, RHEL | `Packwright-v-linux-x64.rpm` | your software installer, or `sudo dnf install ./.rpm` | + +- **Uninstall:** Windows, Settings > Apps (or the Start menu entry); macOS, delete Packwright from Applications; Linux, `sudo apt remove packwright` or `sudo dnf remove packwright`. +- **Windows:** the installer is not code-signed, so Windows may say the publisher is unknown. +- **macOS:** the app is not notarized by Apple, so macOS asks for your approval the first time you open it. See [macOS help](#macos-help) for the steps. +- **Linux:** the package adds a menu entry and a `packwright` command (see [Command line](#command-line)). +- Each release also has `SHA256SUMS.txt` so you can verify a download. + +Packwright only writes to your user profile (settings, library cache and thumbnails), never to the install folder. + +## macOS help + +Packwright needs **macOS 13 (Ventura) or later**. The macOS builds are less tested than the Windows one, so please report anything odd. + +**1. Pick the right download** + +Open the Apple menu, choose **About This Mac** and look at the line **Chip** or **Processor**: + +- **Apple M1, M2, M3, M4...** (Apple silicon): `Packwright-v-macos-arm64.dmg` +- **Intel**: `Packwright-v-macos-x64.dmg` + +**2. Install** + +1. Double-click the downloaded `.dmg`. +2. Drag **Packwright** onto the **Applications** shortcut in the window that opens. +3. Eject the disk image (the eject button next to it in Finder's sidebar), then you can delete the `.dmg`. + +**3. Open it the first time** + +Packwright is not notarized by Apple (that needs a paid Apple developer account), so macOS shows a warning such as "Apple could not verify Packwright is free of malware" the first time. This is expected, and you only have to do it once. Following Apple's own steps ([Safely open apps on your Mac](https://support.apple.com/en-us/102445)): + +1. Open **Packwright** from Applications. When the warning appears, click **Done** (or **OK**). +2. Open the Apple menu, then **System Settings**, then **Privacy & Security**. +3. Scroll down to the **Security** section. You will see a note that "Packwright" was blocked. Click **Open Anyway** and enter your password or use Touch ID. +4. Click **Open** in the next prompt. + +From then on, Packwright opens with a normal double-click. + +On macOS 14 (Sonoma) and earlier you can instead **Control-click** (or right-click) Packwright in Applications, choose **Open**, then click **Open** again. + +Only do this for downloads you got from this project's [releases page](https://github.com/itsmemac/Packwright/releases). You can check a download against `SHA256SUMS.txt` in Terminal with `shasum -a 256 .dmg`. + +**If it still will not open** + +- *"Packwright is damaged and can't be opened"* is macOS's wording for a blocked download. Run this once in Terminal, then open the app again: + + ``` + xattr -cr /Applications/Packwright.app + ``` + +- The window does not appear: open **Terminal** and run `/Applications/Packwright.app/Contents/MacOS/Packwright version`. If it prints a version, the app is fine and the problem is only the permission step above. If you report a bug, include the log (**Log** page, then **Open log folder**). +- Files and folders: the first time Packwright reads a folder on an external drive, Downloads or Desktop, macOS asks whether to allow it. Choose **Allow**, or add Packwright under **System Settings, Privacy & Security, Files and Folders** (or **Full Disk Access**). + +**Updating and removing** + +- **Update:** Packwright can check for new versions (**About**, then **Check for updates**). It downloads and verifies the new `.dmg`; open it and drag Packwright onto **Applications** again, choosing **Replace**. +- **Uninstall:** quit Packwright and drag it from Applications to the Bin. Its settings and library cache live in `~/.local/share/Packwright`; delete that folder too if you want a clean removal. +- **Command line:** `/Applications/Packwright.app/Contents/MacOS/Packwright help` (see [Command line](#command-line)). + +## Getting started + +1. Open **Library** and choose **Add folder**. Point it at a folder with PS5 dumps, `.pkg` files or images. Use **More > Add a package or image file** for a single item. +2. Browse your collection as artwork cards or as a list. Select a game to see its details on the right. +3. Right-click a game for the things you can do with it: convert or build it, edit its details, send it to a console, rename, move or delete it. +4. Open **Tools** to convert, extract, verify or repair something. The source can come from the library, the file pickers, drag and drop onto the window, or the command line: + + ``` + Packwright "D:\Games\MyGame" + Packwright --open "D:\Games\MyGame.pkg" + ``` + +5. Every job is queued on the **Tasks** page, where you can follow progress, cancel, retry or clear it. Queued work survives a restart. + +Packwright runs as one window: starting it again brings the open window to the front. It remembers the window's size, position and whether it was maximized. + +## Features + +### Library + +- Scans folders for unpacked dumps, Sony `.pkg` files, `.ffpfsc`, `.ffpkg` and `.exfat` images, and ZArchive (`.zar`) files. +- **Cards** with game artwork, or a **list**. In the list, click a column name to sort (click again to reverse) and right-click a column header to choose which columns are shown. +- Group by family (base game, updates and DLC together), Title ID, category, region, format or firmware. Updates that a newer patch replaces are marked. +- A [search language](#searching-your-library) and **saved views** that remember the search, filters, grouping, sort and columns. +- Rename with a preview (presets or your own format), rename packages by install order, move, delete, find duplicates, and list updates or DLC that are missing a base game. +- **Export** what is shown (**More > Export what is shown**) as a CSV spreadsheet, a JSON file, or a single web page with artwork, summary figures, search and sortable columns that loads nothing from the internet. For JSON and the web page you choose whether file paths are included. +- The layout adapts to the window: the details panel becomes a slide-over and the sidebar shrinks to icons when the window is narrow. + +### Game details + +- Overview, artwork (with save), trophies (with icon export), activities, executable, package data, statistics and raw `param.json`. +- A file explorer for dumps, images and packages: a folder tree with icons, sizes and file counts, filtering, extraction of single files, whole folders or everything, and a preview for images (including DDS), text and raw bytes. It can also open in its own window with the preview beside the tree. + +### Fix and complete details + +- **Edit details and icon** of any title (right click it, or use **Edit details** in the details panel). Every detail the Overview shows can be changed: title, Title ID, Content ID, region, Concept ID, category, version, master version, required firmware, SDK version, DRM, default language, created date, tool version and the declared features (HDR, VRR, 120 Hz, PS VR2, PS5 Pro and so on), plus the icon. The region is the first two letters of the Content ID, so choosing one rewrites it. Format, size and path describe the file itself and cannot be edited. Keep the change in the library only, or write it into the file itself or a new copy: dump folders, exFAT, FFPKG, FFPFSC, ZArchive and debug packages are supported. If a dump or archive has no `param.json`, one is created. Packages are rebuilt as debug packages (retail or encrypted ones cannot be), and the builder sets their required firmware from the game's SDK. +- **Autofill missing details:** titles that lack a `param.json` are marked "Missing details" (search them with `missing:yes`). **Autofill missing** in Edit details, or **More > Fix missing details** for the whole library, works out what it can from the title itself (`nptitle.dat`, trophy data, the executable header, artwork) and from its file and folder names, shows you what it found and where it came from, and saves it only when you confirm. It never uses the network. Dumps that were stripped of their metadata may have little to go on. +- **Search PlayStation Network:** in Edit details, one button looks up the title's Title ID (read from the file or its name) in a public PS5 title list (andshrew's list, a 2 MB file downloaded once from GitHub and kept on your computer) and shows the matching entries to pick from. Picking one fills the title, Title ID, content ID and, from the PlayStation Store, the icon. You can also search by game name. **More > Look up missing details online** does this for the whole library for every title that has a Title ID. The only things sent are that one file download and, for icons, the game's name to store.playstation.com, about one request per second and only when you ask. The store search is not an official API and can change. + +### Library health + +The **Health** page checks the whole library at a glance: files that were moved or deleted, files that cannot be read, missing details, file names that disagree with their Title IDs, updates without their base game, exact duplicates, older copies and superseded updates (with the space they take), and drives that are nearly full. **Check files too** also looks inside every title for a missing icon and checks unpacked dumps for launch problems. Each issue has buttons to show the title in the Library, fix its details, or open its folder, and the report can be exported as Markdown. Nothing is deleted for you. + +### Image tools + +- **Convert** between dump folders and **exFAT**, **FFPKG**, **FFPFSC** and **ZArchive (`.zar`)** images, or build a debug package (**FPKG**) from a dump or an image. A `.zar` can be used as a source too: extract it, verify it, convert it to another image, or build a package from it. +- **Presets:** save the target and all its options under a name and apply them again in one click. A few presets are built in (for example the smallest FFPFSC, or a reproducible package). A preset never contains the source or output path. +- **Extract** a dump tree out of an image or package, and **verify** images and packages. +- **Edit files** inside exFAT and FFPKG images (replace, add, import a folder, create folders, delete). Changes are rebuilt beside the original and swapped in only after verification. +- **Repair** a damaged exFAT image, **refresh the AMPR index**, and **rebuild** FFPKG metadata. +- ZArchive files are written and read by a managed implementation of the [ZArchive](https://github.com/Exzap/ZArchive) format (zstd blocks, SHA-256 integrity). A new archive is verified after the build, and a `.zar` in your library is browsed in place, without unpacking it. + +### Debug packages (FPKG) + +- Reads, verifies and extracts debug PS5 packages, including inner PFS browsing, trophies and PlayGo data. +- Builds a package from a dump or directly from an image. The Content ID is read from the source, and the passcode is configurable. +- Build options include inner compression (Auto, Kraken, uncompressed), Kraken level and threads, PlayGo chunks, DRM type, SDK version, a workspace folder, reproducible output, fake-signing and right.sprx injection. Free disk space is checked before a build starts. +- Retail packages are recognized but cannot be decoded without matching key material. Only debug packages built with the default or a known passcode are fully readable. + +### Send to console + +Right-click a game, image or package and choose **Send to console...** to put it on a console on your own network. + +- **FTP upload:** large files are streamed, a partly sent file is continued, a copy that is already there is skipped, the size is checked afterwards, and dropped connections are retried. Dump folders upload file by file. It runs as a background task with progress on the Tasks page. +- **Install by URL** (for a `.pkg`): Packwright shares the file from your PC for the length of the download and asks the console's package installer to fetch it. Installer payloads differ in the request they expect, so the request is a template you can adjust; its defaults follow the common "remote package installer" style and are **untested** on a real console. +- **Consoles** are set up in **Settings > Manage consoles** (address, FTP port, default folder, **Test connection**). +- **Privacy and safety:** only addresses on your local network are accepted, nothing is contacted until you press Send or Test, and no account or password is stored. Your firewall may ask you to allow Packwright while it shares a file. +- Also available as `packwright send` (see [Command line](#command-line)). + +### Updates + +Packwright can check GitHub for a newer version. It asks once, **About > Check for updates** works any time, and automatic checks run at most once a day and can be turned off in **Settings**. When a new version is out you see what is new and the download is checked against the release's `SHA256SUMS.txt`. On Windows, one button then installs it and restarts Packwright; on Linux and macOS it downloads the right package and tells you how to install it. Nothing is installed without you pressing a button, and the only server contacted is github.com. + +### Settings and everyday comfort + +- Light or dark theme (the moon button at the top of the sidebar), library layout, artwork on or off, output and rename defaults, preview limits, default passcode and builder, confirmation prompts. +- Import and export settings (the passcode is left out unless you ask for it). +- On Windows, an optional "Open with Packwright" entry for Explorer's right-click menu (per user, no administrator rights). + +## Command line + +Everything heavy can also run without the window, for scripts and servers. On Windows use `packwright.cmd` (installed beside `Packwright.exe`) so the prompt waits for the result; on Linux the package adds a `packwright` command, and on macOS run `/Applications/Packwright.app/Contents/MacOS/Packwright`. + +``` +packwright info "D:\Games\PS5\Quake\PPSA01880-app0.zar" +packwright scan D:\Games\PS5 --csv -o library.csv # or --json +packwright health D:\Games\PS5 --md # exit code 1 when a real problem is found +packwright verify game.ffpkg +packwright extract game.pkg -o out-folder --passcode 00000000000000000000000000000000 +packwright convert dump-folder --to ffpfsc --level 9 +packwright convert game.zar --to exfat --preset "exFAT image with AMPR index" +packwright send big.pkg --to "My PS5" --folder /data/games # FTP; add --install to let the console download a .pkg +packwright consoles # the consoles saved in Settings +packwright help +``` + +`convert` takes the same options as the Tools page (and its presets, saved or built in). Exit codes: 0 done, 1 failed, 2 wrong usage, 130 cancelled. Apart from `send`, which talks to a console on your local network, the command line only uses local files and never connects to the internet. + +## Searching your library + +Type in the search box. Words are combined with AND, `|` means OR, and a leading `-` excludes. + +| Term | Matches | +| --- | --- | +| `bloodborne` | any field containing the text | +| `"spider man"` | an exact phrase | +| `title:` `id:` `content:` | name, Title ID, Content ID | +| `category:` `role:` `region:` `format:` | category (Game, Patch, DLC), role, region, format | +| `version:` `fw:` `path:` `feature:` | version, required firmware, path, declared feature | +| `missing:yes` | titles with missing details | +| `size:>50GB` `version:>=01.05` | comparisons (`>` `>=` `<` `<=`) on size and version | +| `id:=PPSA12345` | `=` after the colon requires an exact match | +| `-dlc` | hide anything matching | + +Example: `id:PPSA01 size:>20GB -dlc | category:patch`. + +## Where your data lives + +| What | Location | +| --- | --- | +| Settings (including consoles and presets), library cache, task history | Windows `%LOCALAPPDATA%\Packwright`, Linux and macOS `~/.local/share/Packwright` | +| Library-only edits (`metadata-overrides.json`, `overrides/` for custom icons) | the same folder | +| Logs | `logs/` inside that folder (also shown on the **Log** page) | +| Temporary work files | your system temporary folder, or the workspace folder you choose | + +Delete the folder to reset the app. Setting the `PACKWRIGHT_DATA` environment variable to a folder makes a separate copy of the app use that folder instead (handy for testing). + +## Platform support + +| Feature | Windows | Linux | macOS | +| --- | :---: | :---: | :---: | +| Library, details, file preview | yes | yes | yes | +| exFAT, FFPKG, FFPFSC, ZArchive convert, extract, verify, edit | yes | yes | yes | +| FPKG build with ProsperoPkgTool | yes | yes | yes | +| FPKG build with LibProsperoPkg | yes | - | - | +| Kraken-compressed package content | yes | yes | yes | +| Send to console, health report, export, command line | yes | yes | yes | +| One-button update install | yes | - | - | +| Explorer context menu | yes | - | - | + +The filesystem, image, ZArchive and package code (including Kraken compression, which ProsperoPkgTool implements itself) is managed C# and behaves the same everywhere. LibProsperoPkg is a Windows-only library, so the other systems use ProsperoPkgTool for package builds. Linux and macOS builds are produced by the release workflow, but the Windows build has had the most testing. + +## Build from source + +Requirements: the [.NET 10 SDK](https://dotnet.microsoft.com/download). No other tools or sibling repositories are needed. + +``` +git clone https://github.com/itsmemac/Packwright.git +cd Packwright + +dotnet build Packwright.slnx -c Release +dotnet run --project src/Packwright.App +``` + +Publish a self-contained build for one system (run from the repository root): + +``` +dotnet publish src/Packwright.App -c Release -r win-x64 --self-contained true -o publish/win-x64 +dotnet publish src/Packwright.App -c Release -r linux-x64 --self-contained true -o publish/linux-x64 +dotnet publish src/Packwright.App -c Release -r osx-arm64 --self-contained true -o publish/osx-arm64 +dotnet publish src/Packwright.App -c Release -r osx-x64 --self-contained true -o publish/osx-x64 +``` + +The Windows publish also copies the Windows-only libraries from `third-party/`. Any system can publish for any target. + +The installers are built from a publish folder: `installer/Packwright.iss` (Inno Setup 6, Windows), `installer/linux/build-packages.sh` (`.deb` and `.rpm`, needs `dpkg-deb` and `rpmbuild`), and the macOS disk image step in `.github/workflows/release.yml`. + +## Releases + +Releases are automatic. Every push to `main` builds the app for Windows, Linux and macOS and publishes a GitHub release named after the `` in `src/Packwright.App/Packwright.App.csproj`. Every download is an installer (a Windows setup program, macOS disk images, and Linux `.deb` and `.rpm` packages), attached with `SHA256SUMS.txt`. There are no zip or tar archives. + +- To publish a new version, raise `` and push to `main`. +- Pushing again without changing the version refreshes that version's release with the new build. +- Changes that only touch Markdown files or `LICENSE` do not trigger a release. +- Pull requests are built on all four targets (without publishing) to catch breakage early. + +The workflows live in `.github/workflows/`: `release.yml` and `build.yml`. + +## Project layout + +``` +src/ + Packwright.App Avalonia application: pages, styles, queue glue, command line + Packwright.Core Packages, parsers, library scanner, builders, FTP client, update checker, task queue + Packwright.Containers exFAT, PFS/PFSC (FFPFSC) and ZArchive images + Packwright.Ufs2 UFS2 / FFPKG filesystem +installer/ Windows installer script and Linux package builder +third-party/ Vendored binaries (ProsperoPkgTool, LibProsperoPkg) and license texts +docs/ Screenshots +.github/workflows/ Build and release automation +Packwright.slnx Solution +``` + +## License and credits + +Copyright (c) 2026 itsmemac. Packwright is licensed under the [GNU General Public License v3.0](LICENSE), with no warranty. + +It started from [PS5 PKG Tool](https://github.com/pearlxcore/PS5PkgTool) by pearlxcore (Copyright (c) pearlxcore, GPL-3.0) and has since grown into a different application, with a new cross-platform interface and many new features. Parts of its image and package engines are still used, so its copyright and license notices are kept. The changes made in 2026 are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), which also lists every bundled component, its license and its copyright. The source of each release is the tag with the same version in this repository, and the license texts of the bundled components ship in the `licenses` folder of the installed app. + +- **[pearlxcore](https://github.com/pearlxcore/PS5PkgTool)**: author of PS5 PKG Tool, the project Packwright grew out of, and of the ProsperoPkgTool engine. +- **[SvenGDK](https://github.com/SvenGDK/LibProsperoPKG)**: LibProsperoPkg, and the UFS2Tool filesystem code the FFPKG support is based on. +- **[PSBrew / Renan Barreto](https://github.com/PSBrew/MkPFS)**: MkPFS. +- **[kerrdec97](https://github.com/kerrdec97/ps5-exfat-builder)**: ps5-exfat-builder. +- **[strongt1me](https://github.com/strongt1me/PS5-Dump-Image-Converter)**: PS5-Dump-Image-Converter. +- **[Exzap](https://github.com/Exzap/ZArchive)**: the ZArchive format. +- **[andshrew](https://github.com/andshrew/PlayStation-Titles)**: the PS5 title list that Title ID lookups use (MIT License). +- **[Avalonia UI](https://avaloniaui.net)**, **[ZstdSharp](https://github.com/oleg-st/ZstdSharp)**, **[CommunityToolkit](https://github.com/CommunityToolkit/dotnet)** (HighPerformance, used by LibProsperoPkg), **[ImageSharp](https://github.com/SixLabors/ImageSharp)** and **[BCnEncoder.Net](https://github.com/Nominom/BCnEncoder.NET)**. +- Robin Perris for [DarkUI](https://github.com/RobinPerris/DarkUI), which the original Windows interface was built on. +- Sony <3 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..1c143a9 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,183 @@ +# Third-party notices + +Packwright, Copyright (c) 2026 itsmemac, is licensed under the GNU General Public License v3.0 +(see `LICENSE`). It is based on the original application listed first below. The components after +it are used under their own terms. + +## PS5 PKG Tool (original application) + +Packwright is based on PS5 PKG Tool by pearlxcore (https://github.com/pearlxcore/PS5PkgTool), +licensed under the GNU General Public License v3.0. Copyright (c) pearlxcore. The exFAT, UFS2/FFPKG, +PFSC/FFPFSC and package code, the library scanner and the task queue originate from that project, and +the cross-platform application logic is ported from its Windows interface. The original copyright +notice and license are retained in this repository. + +### Modifications (GPL-3.0 section 5) + +Packwright is a **modified version** of PS5 PKG Tool. The changes below were made in 2026 by +itsmemac: + +- the Windows Forms interface was replaced by a cross-platform Avalonia interface (Windows, Linux, + macOS), with a new design, name and application icon; +- ZArchive (`.zar`) support was added: writing, reading, library scanning, browsing and conversion; +- the library gained artwork cards, grouping, saved views, a search syntax, a file explorer with + preview, rename/move/delete tools, task persistence and a task history with game icons; +- editing of a title's details (name, IDs, region, versions, firmware, SDK, DRM, language, features and icon), in the + library or written into dumps, images and packages, with offline autofill and an optional lookup of Title IDs in a + public title list; +- a library health report, export of the library (CSV, JSON, web page), saved presets for conversions, a command-line + mode, and sorting and column choice in the list view; +- "Send to console": an FTP client and a local file server for installers on the user's own network; +- an update check against this repository's GitHub releases, and installers for Windows (Inno Setup), macOS (disk image) + and Linux (.deb and .rpm); +- the projects and folders were renamed and reorganised, and build and release automation was added. + +Everything else remains under the original GPL-3.0 terms. The application icon is original to this +project (Copyright (c) 2026 itsmemac, GPL-3.0). + +## Source code + +The complete corresponding source of each release is the tag with the same version in +https://github.com/itsmemac/Packwright. The bundled LibProsperoPkg 1.2.0 files are binaries from +https://github.com/SvenGDK/LibProsperoPkg (GPL-3.0-or-later); their source is available from that +project. + +## ProsperoPkgTool + +The PS5 PKG (FIH/CNT/PFS/NAPS) reading, verification, extraction and debug-package creation +implementation is provided by the clean-room MIT engine ProsperoPkgTool, consumed as a vendored +managed library at `third-party/ProsperoPkgTool/ProsperoPkgTool.dll`. + +MIT License. Copyright (c) 2026 pearlxcore. No GPL, SDK, or decompiled code is included; the +engine is a clean-room reimplementation validated against independent oracles. + +## LibProsperoPkg + +The alternative package build/validate/extract backend is provided by LibProsperoPkg, loaded from a +vendored payload at `third-party/LibProsperoPkg12/` (version **1.2.0** - the version the +PPR-PKG / fpkg-gui builder ships; the newer 2.6.0 regressed the NAPS layout). It is loaded in an +isolated assembly-load context together with its bundled dependencies: `BCnEncoder.Net 2.3.0`, +`CommunityToolkit.HighPerformance 8.4.0` and `Magick.NET 14.15.0` (managed + its +`runtimes/win-x64/native/Magick.Native-Q8-x64.dll`). + +LibProsperoPkg can optionally use Sony's proprietary Publishing Tools library (`libScePubTools.dll`) +if it finds one from a Sony SDK installation. **That library is Sony's property and is not part of +this project or of any download.** Everything here works without it. + +LibProsperoPkg itself contains code derived from LibOrbisPkg by maxton +(https://github.com/maxton/LibOrbisPkg, GPL-3.0) and a translation of the "ooz" Kraken decoder by +Powzix (https://github.com/powzix/ooz, GPL-3.0); see `third-party/LibProsperoPkg12/NOTICE`. + +GNU General Public License v3.0 or later (GPL-3.0-or-later). +Copyright (c) SvenGDK 2026. https://github.com/SvenGDK/LibProsperoPkg + +Because LibProsperoPkg is GPL-3.0-or-later and Packwright loads it, the combined work is +distributed under the GPL-3.0 (see `LICENSE`). The full license text ships at +`third-party/LibProsperoPkg12/LICENSE` and, in a download, in the `licenses` folder. + +## UFS2Tool + +The `src/Packwright.Ufs2` filesystem implementation is based on +[SvenGDK/UFS2Tool](https://github.com/SvenGDK/UFS2Tool), used and modified under the BSD 2-Clause +License. Copyright (c) 2026, SvenGDK. The original copyright and license text are retained in +`src/Packwright.Ufs2/LICENSE` and in the imported source files. + +## Avalonia UI + +The cross-platform interface is built on Avalonia UI (and Avalonia.Controls.DataGrid, +Avalonia.Themes.Fluent, Avalonia.Fonts.Inter), licensed under the MIT License. +Copyright (c) AvaloniaUI OÜ and contributors. https://github.com/AvaloniaUI/Avalonia +The Inter font is licensed under the SIL Open Font License 1.1. + +## CommunityToolkit.HighPerformance + +Bundled with LibProsperoPkg (see above). MIT License. Copyright (c) .NET Foundation and Contributors. https://github.com/CommunityToolkit/dotnet + +## ZstdSharp.Port + +MIT License. Copyright (c) Oleg Stepanischev and contributors. https://github.com/oleg-st/ZstdSharp +It provides the zstd compression used for ZArchive output. zstd itself is BSD-licensed, Copyright +(c) Meta Platforms, Inc. and affiliates. + +## ZArchive + +The `.zar` container format was designed by Exzap (https://github.com/Exzap/ZArchive, Copyright 2022 +Exzap, "MIT No Attribution" license). The writer and reader in `src/Packwright.Containers` +(`ZArchiveWriter.cs`, `ZArchiveVolume.cs`, `ZArchiveImage.cs`) are managed C# implementations of the +format that follow the reference implementation, and were checked against archives made by it. + +## Graphics libraries bundled with Avalonia + +- **SkiaSharp** (MIT License, Copyright (c) Microsoft Corporation and contributors) with the **Skia** + graphics library (BSD 3-Clause, Copyright (c) 2011 Google Inc.): `third-party/licenses/Skia-BSD-3-Clause.txt`. +- **HarfBuzzSharp** (MIT License) with **HarfBuzz** (Old MIT License, Copyright (c) the HarfBuzz authors). +- **ANGLE** (`av_libglesv2.dll`, BSD 3-Clause, Copyright 2018 The ANGLE Project Authors): + `third-party/licenses/ANGLE-BSD-3-Clause.txt`. +- **MicroCom.Runtime**, **Tmds.DBus.Protocol** and the other Avalonia platform packages are MIT licensed. + +## Icons and fonts + +- Interface icons (sidebar and file-type symbols) are paths from **Material Design Icons** by Google, + licensed under the Apache License 2.0 (`third-party/licenses/Apache-2.0.txt`). +- The **Inter** font (bundled through Avalonia.Fonts.Inter) is Copyright (c) 2016 The Inter Project + Authors, licensed under the SIL Open Font License 1.1 (`third-party/licenses/Inter-OFL-1.1.txt`). +- The application icon is original to this project. + +## SixLabors ImageSharp and Magick.NET + +ImageSharp (used through BCnEncoder.Net.ImageSharp) is licensed under the Six Labors Split License. +This project is open source, and ImageSharp is also a transitive dependency here, so ImageSharp is used +under the Apache License 2.0 (`third-party/licenses/Apache-2.0.txt`). Magick.NET is licensed under the +Apache License 2.0 and ships with the Windows-only LibProsperoPkg payload. + +## DarkUI + +Earlier Windows-only versions of the original application were built on DarkUI (MIT License, +Copyright (c) 2017 Robin Perris, https://github.com/RobinPerris/DarkUI). DarkUI is no longer part of +this project. + +## BCnEncoder.Net + +BCnEncoder.Net (and BCnEncoder.Net.ImageSharp) is distributed under the MIT License. It is used to +encode BC7 textures for PS5 CNT media. https://github.com/Nominom/BCnEncoder.NET + +## PlayStation-Titles (title list, downloaded on request) + +When you ask Packwright to look a title up by its Title ID, it downloads the PS5 title list kept by andshrew, +https://github.com/andshrew/PlayStation-Titles (MIT License, Copyright (c) 2022 andshrew), from GitHub and keeps +a copy in its data folder. The list is not part of Packwright or of any download; it is fetched only when you use +that feature. Names and IDs in it belong to their owners. + +## Image format references + +The managed exFAT, UFS2/FFPKG, PFSC and PFS implementations were written from public format +documentation and validated against independent tools. Credit to the authors of the tools that +document and handle these formats: + +- MkPFS, PSBrew / Renan Barreto (https://github.com/PSBrew/MkPFS), PFS and PFSC/FFPFSC format work. +- UFS2Tool and LibProsperoPkg, SvenGDK (https://github.com/SvenGDK/UFS2Tool), UFS2/FFPKG work. UFS2Tool is used under the BSD 2-Clause License. +- ps5-exfat-builder, kerrdec97 (https://github.com/PSBrew/ps5-exfat-builder), exFAT format work. +- PS5 Dump and Image Converter, strongt1me (https://github.com/strongt1me/PS5-Dump-Image-Converter). + +## MIT License text + +This text applies to every component above that is marked "MIT License" (ProsperoPkgTool, Avalonia +UI, CommunityToolkit.HighPerformance, ZstdSharp.Port, BCnEncoder.Net, DarkUI and the PlayStation-Titles list), each with the +copyright notice given for it in its own section. + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/docs/library.webp b/docs/library.webp new file mode 100644 index 0000000..b57c0dd Binary files /dev/null and b/docs/library.webp differ diff --git a/installer/Packwright.iss b/installer/Packwright.iss new file mode 100644 index 0000000..34cfda8 --- /dev/null +++ b/installer/Packwright.iss @@ -0,0 +1,58 @@ +; Packwright installer (Inno Setup 6). Built by the release workflow: +; iscc /DAppVersion=1.0.0 /DSourceDir= /O installer\Packwright.iss +; Installs for the current user by default (no administrator prompt), so a silent update is smooth. The "for all +; users" choice is offered when the installer is started as administrator. + +#ifndef AppVersion + #define AppVersion "1.0.0" +#endif +#ifndef SourceDir + #define SourceDir "..\publish" +#endif + +[Setup] +AppId={{B7C1F3A2-5D1E-4C3B-9A0E-6F5D2E8C7A41} +AppName=Packwright +AppVersion={#AppVersion} +AppVerName=Packwright {#AppVersion} +AppPublisher=itsmemac +AppPublisherURL=https://github.com/itsmemac/Packwright +AppSupportURL=https://github.com/itsmemac/Packwright/issues +AppUpdatesURL=https://github.com/itsmemac/Packwright/releases +VersionInfoVersion={#AppVersion} +DefaultDirName={autopf}\Packwright +DefaultGroupName=Packwright +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputBaseFilename=Packwright-Setup-v{#AppVersion}-windows-x64 +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +SetupIconFile=..\src\Packwright.App\Assets\icon.ico +UninstallDisplayIcon={app}\Packwright.exe +LicenseFile=..\LICENSE +; Close a running Packwright before replacing its files, and start it again afterwards. +CloseApplications=yes +RestartApplications=no +CloseApplicationsFilter=Packwright.exe + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Shortcuts:"; Flags: unchecked + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +Name: "{autoprograms}\Packwright"; Filename: "{app}\Packwright.exe" +Name: "{autodesktop}\Packwright"; Filename: "{app}\Packwright.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\Packwright.exe"; Description: "Start Packwright"; Flags: nowait postinstall skipifsilent +; A silent update (started by the app's "Install now") has no finish page, so start Packwright again here. +Filename: "{app}\Packwright.exe"; Flags: nowait runasoriginaluser; Check: WizardSilent diff --git a/installer/linux/build-packages.sh b/installer/linux/build-packages.sh new file mode 100644 index 0000000..a4f0624 --- /dev/null +++ b/installer/linux/build-packages.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Builds the Linux installers from a linux-x64 publish folder: +# Packwright-v-linux-x64.deb (Debian, Ubuntu, Mint, ...) +# Packwright-v-linux-x64.rpm (Fedora, RHEL family, openSUSE, ...) +# +# Usage: installer/linux/build-packages.sh +# Needs dpkg-deb and rpmbuild (apt install dpkg rpm). Both packages put the app in /opt/packwright, add a +# `packwright` command and a menu entry, and are removed again by the normal package manager. +set -euo pipefail + +version="${1:?version}" +publish="$(cd "${2:?publish folder}" && pwd)" +mkdir -p "${3:?output folder}" +out="$(cd "$3" && pwd)" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +name="Packwright-v${version}-linux-x64" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +stage="$work/stage" + +install -d "$stage/opt/packwright" "$stage/usr/bin" "$stage/usr/share/applications" \ + "$stage/usr/share/icons/hicolor/512x512/apps" "$stage/usr/share/doc/packwright" +cp -a "$publish/." "$stage/opt/packwright/" +chmod 755 "$stage/opt/packwright/Packwright" +ln -s /opt/packwright/Packwright "$stage/usr/bin/packwright" +install -m 644 "$here/packwright.desktop" "$stage/usr/share/applications/packwright.desktop" +install -m 644 "$repo/src/Packwright.App/Assets/icon.png" "$stage/usr/share/icons/hicolor/512x512/apps/packwright.png" +install -m 644 "$repo/LICENSE" "$stage/usr/share/doc/packwright/copyright" +install -m 644 "$repo/THIRD_PARTY_NOTICES.md" "$stage/usr/share/doc/packwright/THIRD_PARTY_NOTICES.md" +find "$stage" -type d -exec chmod 755 {} + +find "$stage" -type f ! -perm /111 -exec chmod 644 {} + + +# ------------------------------------------------------------------ .deb +debroot="$work/deb" +cp -a "$stage" "$debroot" +install -d "$debroot/DEBIAN" +size_kb="$(du -sk "$debroot" | cut -f1)" +cat > "$debroot/DEBIAN/control" < +Homepage: https://github.com/itsmemac/Packwright +Description: PS5 package, dump and image tool + Manage a collection of PS5 dumps and images, read PS5 packages, and build or + convert exFAT, FFPKG, FFPFSC, ZArchive images and debug packages. + Starts from the menu, or from a terminal as "packwright" (try "packwright help"). +CONTROL +chmod 644 "$debroot/DEBIAN/control" +dpkg-deb --root-owner-group -Zxz --build "$debroot" "$out/$name.deb" + +# ------------------------------------------------------------------ .rpm +top="$work/rpm" +mkdir -p "$top"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} +cat > "$top/SPECS/packwright.spec" < + + + + + + diff --git a/src/Packwright.App/App.axaml.cs b/src/Packwright.App/App.axaml.cs new file mode 100644 index 0000000..7af079f --- /dev/null +++ b/src/Packwright.App/App.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Packwright.App.Views; + +namespace Packwright.App; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + // A failure in a timer or event handler is logged and the app keeps running. + Avalonia.Threading.Dispatcher.UIThread.UnhandledException += (_, e) => + { + Packwright.Infrastructure.Logger.Exception("UI thread", e.Exception); + e.Handled = true; + }; + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var window = new MainWindow(desktop.Args ?? []); + desktop.MainWindow = window; + // Another launch of the app brings this window forward instead of opening a second copy. + Program.Current?.Listen(paths => Avalonia.Threading.Dispatcher.UIThread.Post(() => window.BringToFront(paths))); + } + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/Packwright.App/Assets/icon.ico b/src/Packwright.App/Assets/icon.ico new file mode 100644 index 0000000..7047bf1 Binary files /dev/null and b/src/Packwright.App/Assets/icon.ico differ diff --git a/src/Packwright.App/Assets/icon.png b/src/Packwright.App/Assets/icon.png new file mode 100644 index 0000000..ad6dc13 Binary files /dev/null and b/src/Packwright.App/Assets/icon.png differ diff --git a/src/Packwright.App/Cli.cs b/src/Packwright.App/Cli.cs new file mode 100644 index 0000000..21083ff --- /dev/null +++ b/src/Packwright.App/Cli.cs @@ -0,0 +1,502 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Packwright.App.Models; +using Packwright.App.Services; +using Packwright.App.Views; +using Packwright.Containers; +using Packwright.Core.Backends; +using Packwright.Core.Builders; +using Packwright.Core.Models; +using Packwright.Core.Parsers; +using Packwright.Core.Services; +using Packwright.Core.Tasks; +using Packwright.Infrastructure; + +namespace Packwright.App; + +/// +/// Command-line mode: Packwright info|scan|convert|extract|verify|health .... It runs the same jobs as the +/// window, without starting the interface, and never touches the network. +/// +internal static class Cli +{ + private const int Ok = 0, Failed = 1, Usage = 2, Cancelled = 130; + + private static readonly string[] Commands = ["info", "scan", "convert", "extract", "verify", "health", "send", "consoles", "version", "help"]; + + // Options that take a value; everything else starting with '-' is a switch. + private static readonly HashSet ValueOptions = new(StringComparer.OrdinalIgnoreCase) + { + "to", "o", "output", "preset", "passcode", "level", "gain", "cluster", "block", "fragment", "density", "min-free", + "compression", "kraken-level", "threads", "playgo", "drm", "builder", "workspace", "format", "folder", "host", "port" + }; + + public static bool IsCommand(string[] args) => + args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) || + args[0] is "--help" or "-h" or "/?" or "--version" or "-v" || + // A bare word that is not a file or folder is a mistyped command, not something to open. + (!args[0].StartsWith('-') && args[0].IndexOfAny(['/', '\\', '.', ':']) < 0 && + !File.Exists(args[0]) && !Directory.Exists(args[0]))); + + [DllImport("kernel32.dll")] + private static extern bool AttachConsole(int processId); + [DllImport("kernel32.dll")] + private static extern nint GetStdHandle(int handle); + [DllImport("kernel32.dll")] + private static extern bool SetStdHandle(int handle, nint value); + + private sealed class Options + { + public List Positional { get; } = []; + public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet Switches { get; } = new(StringComparer.OrdinalIgnoreCase); + public string? Error { get; set; } + + public string? Get(params string[] names) => names.Select(name => Values.GetValueOrDefault(name)).FirstOrDefault(value => value is not null); + public bool Has(string name) => Switches.Contains(name); + public int? Int(string name) => Values.TryGetValue(name, out string? text) && int.TryParse(text, out int number) ? number : null; + } + + private static Options Parse(string[] args) + { + var options = new Options(); + for (int index = 0; index < args.Length; index++) + { + string arg = args[index]; + if (arg == "--") { options.Positional.AddRange(args.Skip(index + 1)); break; } + if (!arg.StartsWith('-') || arg.Length == 1) { options.Positional.Add(arg); continue; } + string name = arg.TrimStart('-'); + string? inline = null; + int equals = name.IndexOf('='); + if (equals > 0) { inline = name[(equals + 1)..]; name = name[..equals]; } + if (ValueOptions.Contains(name)) + { + if (inline is null && index + 1 < args.Length) inline = args[++index]; + if (inline is null) options.Error = $"The option --{name} needs a value."; + else options.Values[name] = inline; + } + else options.Switches.Add(name); + } + return options; + } + + public static int Run(string[] args) + { + if (OperatingSystem.IsWindows()) + { + // The window build has no console of its own: borrow the one it was started from. + // AttachConsole swaps the standard handles for the console's, which would drop '>' and '|' redirection: + // remember what was passed in and put it back. + nint output = GetStdHandle(-11), error = GetStdHandle(-12); + AttachConsole(-1); + if (output != 0 && output != -1) SetStdHandle(-11, output); + if (error != 0 && error != -1) SetStdHandle(-12, error); + Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true }); + Console.SetError(new StreamWriter(Console.OpenStandardError(), new UTF8Encoding(false)) { AutoFlush = true }); + } + string command = args[0].ToLowerInvariant(); + Options options = Parse(args.Skip(1).ToArray()); + if (options.Error is not null) return Fail(options.Error, Usage); + if (options.Has("help") || options.Has("h")) return Help(command); + using var cancel = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cancel.Cancel(); }; + try + { + return command switch + { + "version" or "--version" or "-v" => Version(), + "help" or "--help" or "-h" or "/?" => Help(options.Positional.FirstOrDefault()), + "info" => Info(options), + "scan" => ScanAsync(options, cancel.Token).GetAwaiter().GetResult(), + "health" => HealthAsync(options, cancel.Token).GetAwaiter().GetResult(), + "send" => SendAsync(options, cancel.Token).GetAwaiter().GetResult(), + "consoles" => Consoles(), + "convert" or "extract" or "verify" => JobAsync(command, options, cancel.Token).GetAwaiter().GetResult(), + _ => Fail("Unknown command: " + command, Usage) + }; + } + catch (OperationCanceledException) + { + Console.Error.WriteLine("Cancelled."); + return Cancelled; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or NotSupportedException + or ArgumentException or InvalidOperationException or JsonException) + { + Logger.Exception("CLI " + command, ex); + return Fail(ex.Message, Failed); + } + } + + private static int Fail(string message, int code) + { + Console.Error.WriteLine("Error: " + message); + if (code == Usage) Console.Error.WriteLine("Run 'Packwright help' for the commands."); + return code; + } + + private static int Version() + { + Console.WriteLine("Packwright " + UpdateService.CurrentVersion); + return Ok; + } + + private static int Help(string? topic) + { + Console.WriteLine($""" + Packwright {UpdateService.CurrentVersion} command line + + Usage: Packwright [options] + (On Windows use the packwright.cmd next to Packwright.exe so the prompt waits for the result.) + + Commands: + info Show the title, IDs, version and size of a dump folder, image or package. + scan ... List every title found. --json | --csv | --no-recursive | -o + health ... Check a library for problems. --md | --json | -o + convert --to Convert or build. is exfat, ffpkg, ffpfsc, zar or pkg. + extract Extract an image or package to a folder. -o + verify Check an image or package for damage. + send --to Send to a console on your network (FTP). --folder | --install (a .pkg: the + console downloads it from this PC) | --host --port instead of --to + consoles List the consoles saved in Settings. + version Print the version. + + convert options: + -o, --output Where to write it (default: next to the source). --overwrite replaces an existing file. + --preset Use a saved or built-in preset from the Tools page (its target and options). + --level 1-9 --gain 0-100 FFPFSC compression + --cluster 32|64 --no-ampr exFAT + --block 32|64 --fragment 4|64 --density 256|512|1024 --min-free 0-50 FFPKG + --builder --compression auto|kraken|stored --kraken-level N --threads N --playgo N + --deterministic --no-fake-sign --no-right-sprx --drm upgradable|free|standard|keep --workspace + extract/verify on a package: --passcode <32 characters> (default: the debug passcode) + + Exit codes: 0 done, 1 failed, 2 wrong usage, 130 cancelled. + Only local files are used; the command line never connects to the internet. + """); + return Ok; + } + + // ------------------------------------------------------------------ info / scan / health + + private static string Describe(Ps5GameInfo game, string path) + { + var item = new LibraryItem(game); + var text = new StringBuilder(); + void Line(string label, string value) { if (value.Length > 0) text.AppendLine($"{label,-12}{value}"); } + Line("Title", item.Title); + Line("Title ID", item.TitleId); + Line("Content ID", item.ContentId); + Line("Category", item.Category); + Line("Version", item.Version); + Line("Firmware", item.Firmware); + Line("Region", item.Region); + Line("Format", item.Format); + Line("Size", item.SizeText); + Line("DRM", game.DrmType); + Line("Path", path); + if (item.HasMissing) text.AppendLine($"{"Missing",-12}{string.Join(", ", item.MissingFields)}"); + return text.ToString(); + } + + private static int Info(Options options) + { + if (options.Positional.Count != 1) return Fail("info needs one path.", Usage); + string path = Path.GetFullPath(options.Positional[0]); + Ps5GameInfo? game = SourceInfo.Read(path); + if (game is null) return Fail("That is not a PS5 dump folder, image or package: " + path, Failed); + Console.Write(Describe(game, path)); + return Ok; + } + + private static async Task> ScanFoldersAsync(Options options, CancellationToken token) + { + if (options.Positional.Count == 0) throw new ArgumentException("Give at least one folder."); + var folders = options.Positional.Select(Path.GetFullPath).ToList(); + foreach (string folder in folders.Where(folder => !Directory.Exists(folder))) + throw new ArgumentException("Folder not found: " + folder); + var progress = new Progress(value => + { + if (!Console.IsErrorRedirected && value.Total > 0) Console.Error.Write($"\rScanning {value.Processed:N0} of {value.Total:N0} "); + }); + Ps5ScanResult result = await new Ps5LibraryScanner().ScanAsync(folders, !options.Has("no-recursive"), null, progress, token); + if (!Console.IsErrorRedirected) Console.Error.Write("\r" + new string(' ', 40) + "\r"); + foreach (string error in result.Errors) Console.Error.WriteLine("Warning: " + error); + return result.Games.Select(game => new LibraryItem(game)).OrderBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase).ToList(); + } + + private static async Task WriteOutputAsync(Options options, string text) + { + if (options.Get("o", "output") is { } file) + { + await File.WriteAllTextAsync(Path.GetFullPath(file), text, new UTF8Encoding(false)); + Console.Error.WriteLine("Wrote " + Path.GetFullPath(file)); + } + else Console.Write(text); + return Ok; + } + + private static async Task ScanAsync(Options options, CancellationToken token) + { + List items; + try { items = await ScanFoldersAsync(options, token); } + catch (ArgumentException ex) { return Fail(ex.Message, Usage); } + if (options.Has("json")) return await WriteOutputAsync(options, LibraryExport.ToJson(items, includePaths: true) + Environment.NewLine); + if (options.Has("csv")) return await WriteOutputAsync(options, LibraryActions.ToCsv(items)); + var text = new StringBuilder(); + int width = Math.Clamp(items.Count == 0 ? 10 : items.Max(item => item.Title.Length), 10, 48); + text.AppendLine($"{"Title".PadRight(width)} {"Title ID",-10} {"Type",-6} {"Version",-8} {"Size",10} Format"); + foreach (LibraryItem item in items) + text.AppendLine($"{Trim(item.Title, width).PadRight(width)} {item.TitleId,-10} {item.Category,-6} {item.Version,-8} {item.SizeText,10} {item.Format}"); + text.AppendLine().AppendLine($"{items.Count:N0} title(s), {Ps5LibraryHealth.FormatBytes(items.Sum(item => item.SizeBytes))}."); + return await WriteOutputAsync(options, text.ToString()); + } + + private static string Trim(string text, int width) => text.Length <= width ? text : text[..(width - 1)] + "…"; + + private static async Task HealthAsync(Options options, CancellationToken token) + { + List items; + try { items = await ScanFoldersAsync(options, token); } + catch (ArgumentException ex) { return Fail(ex.Message, Usage); } + HealthReport report = Ps5LibraryHealth.Analyze(items.Select(HealthView.ToHealthItem).ToList(), []); + Ps5LibraryHealth.Sort(report); + string text = options.Has("json") + ? JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) + Environment.NewLine + : Ps5LibraryHealth.ToMarkdown(report, issue => issue.ItemPath); + await WriteOutputAsync(options, text); + // A non-zero code when something is actually wrong makes this usable in scripts. + return report.Count(HealthSeverity.Problem) > 0 ? Failed : Ok; + } + + // ------------------------------------------------------------------ send to console + + private static int Consoles() + { + List consoles = new AppStateStore().LoadSettings().Consoles; + if (consoles.Count == 0) { Console.WriteLine("No consoles are saved yet. Add one in Settings, or use --host."); return Ok; } + foreach (ConsoleProfile console in consoles) + Console.WriteLine($"{console.Name,-24}{console.Host}:{console.FtpPort} folder {console.DefaultFolder}"); + return Ok; + } + + private static async Task SendAsync(Options options, CancellationToken token) + { + if (options.Positional.Count != 1) return Fail("send needs one file or folder.", Usage); + string source = Path.GetFullPath(options.Positional[0]); + if (!File.Exists(source) && !Directory.Exists(source)) return Fail("Not found: " + source, Failed); + ConsoleProfile? profile; + if (options.Get("to") is { } name) + { + profile = new AppStateStore().LoadSettings().Consoles.FirstOrDefault(console => console.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (profile is null) return Fail($"No console named '{name}'. Run 'Packwright consoles' to see the saved ones.", Usage); + } + else if (options.Get("host") is { } host) + profile = new ConsoleProfile { Name = host, Host = host, FtpPort = options.Int("port") ?? 1337 }; + else + return Fail("send needs --to or --host
.", Usage); + var progress = new ConsoleProgress(); + Console.Error.WriteLine($"send: {source}"); + if (options.Has("install")) + { + Console.Error.WriteLine($" -> {profile.Name}: install by URL"); + await ConsoleSender.InstallByUrlAsync(profile, source, progress, token); + } + else + { + string folder = options.Get("folder") ?? profile.DefaultFolder; + Console.Error.WriteLine($" -> {profile.Name}: {ConsoleSender.RemotePathFor(folder, source)}"); + await ConsoleSender.UploadAsync(profile, source, folder, progress, token); + } + progress.Finish(); + Console.Error.WriteLine("Done."); + return Ok; + } + + // ------------------------------------------------------------------ convert / extract / verify + + private static async Task JobAsync(string command, Options options, CancellationToken token) + { + if (options.Positional.Count != 1) return Fail($"{command} needs exactly one source path.", Usage); + string source = Path.GetFullPath(options.Positional[0]); + bool isDirectory = Directory.Exists(source); + if (!isDirectory && !File.Exists(source)) return Fail("Not found: " + source, Failed); + bool isPackage = !isDirectory && source.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase); + Ps5ImageFormat format = isDirectory || isPackage ? Ps5ImageFormat.Unknown : Ps5ImageFormatProbe.Detect(source); + if (!isDirectory && !isPackage && format == Ps5ImageFormat.Unknown) + return Fail("That file is not a PS5 image or package: " + source, Failed); + + var spec = new JobSpec + { + Source = source, + Format = format.ToString(), + FromPackage = isPackage, + Overwrite = options.Has("overwrite"), + Passcode = options.Get("passcode") ?? string.Empty + }; + if (spec.Passcode.Length > 0 && spec.Passcode.Length != 32) return Fail("The passcode must be 32 characters.", Usage); + + switch (command) + { + case "verify": + spec.Kind = JobSpec.Verify; + break; + case "extract": + spec.Kind = JobSpec.Extract; + spec.Output = Path.GetFullPath(options.Get("o", "output") ?? SiblingName(source, isDirectory, "-files")); + if (!spec.Overwrite) spec.Output = Jobs.FindAvailablePath(spec.Output); + break; + default: + if (BuildConvertSpec(spec, options, isDirectory, isPackage, format) is { } problem) return Fail(problem, Usage); + break; + } + + Func, CancellationToken, Task>? run = spec.Executor(); + if (run is null) return Fail("That job cannot run with these arguments.", Usage); + var printer = new ConsoleProgress(); + Console.Error.WriteLine($"{command}: {source}"); + if (spec.Output.Length > 0) Console.Error.WriteLine(" -> " + spec.Output); + await run(printer, token); + printer.Finish(); + Console.Error.WriteLine(command == "verify" ? "Verified: no problems found." : "Done."); + return Ok; + } + + private static string SiblingName(string source, bool isDirectory, string suffix) + { + string trimmed = source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string name = isDirectory ? Path.GetFileName(trimmed) : Path.GetFileNameWithoutExtension(trimmed); + string parent = (isDirectory ? Path.GetDirectoryName(trimmed) : Path.GetDirectoryName(source)) ?? "."; + return Path.Combine(parent, name + suffix); + } + + /// Fills a convert or build job from the options; returns a message when they do not make sense. + private static string? BuildConvertSpec(JobSpec spec, Options options, bool isDirectory, bool isPackage, Ps5ImageFormat format) + { + ToolPreset? preset = null; + if (options.Get("preset") is { } presetName) + { + AppSettings settings = new AppStateStore().LoadSettings(); + preset = ToolsView.BuiltInPresets.Concat(settings.Presets) + .FirstOrDefault(item => item.Name.Equals(presetName, StringComparison.OrdinalIgnoreCase)); + if (preset is null) + return $"No preset named '{presetName}'. Built-in: {string.Join(", ", ToolsView.BuiltInPresets.Select(item => "\"" + item.Name + "\""))}."; + } + string? to = options.Get("to", "format") ?? preset?.Target; + if (string.IsNullOrWhiteSpace(to)) return "convert needs --to exfat|ffpkg|ffpfsc|zar|pkg (or a preset that has a target)."; + string key = to.Trim().ToLowerInvariant(); + (string Name, string Extension)? target = key switch + { + "exfat" => ("Exfat", ".exfat"), + "ffpkg" => ("Ffpkg", ".ffpkg"), + "ffpfsc" => ("Ffpfsc", ".ffpfsc"), + "zar" or "zarchive" => ("ZArchive", ".zar"), + "pkg" or "fpkg" => ("Fpkg", ".pkg"), + _ => null + }; + if (target is null) return $"Unknown target '{to}'. Use exfat, ffpkg, ffpfsc, zar or pkg."; + if (target.Value.Name != "Fpkg") + { + if (!isDirectory && !isPackage && !Enum.TryParse(target.Value.Name, out Ps5ImageConversionTarget parsed)) + return "Unsupported target."; + if (!isDirectory && !isPackage && Enum.TryParse(target.Value.Name, out Ps5ImageConversionTarget check) && + !Ps5ImageConversionService.IsSupported(format, check)) + return $"A {format} image cannot be converted to {target.Value.Name}."; + if (isPackage && target.Value.Name == "Fpkg") return "A package cannot be converted to a package."; + } + + spec.Output = Path.GetFullPath(options.Get("o", "output") ?? SiblingName(spec.Source, isDirectory, target.Value.Extension)); + if (!spec.Overwrite && (File.Exists(spec.Output) || Directory.Exists(spec.Output))) + return $"The output already exists: {spec.Output}. Use --overwrite or -o ."; + + // Defaults come from the preset (if any); explicit options win. + ToolPreset basis = preset ?? new ToolPreset(); + int Pick(string name, int fallback) => options.Int(name) ?? fallback; + string? Choice(string name) => options.Get(name)?.Trim().ToLowerInvariant(); + + spec.ClusterSize = Choice("cluster") switch { "32" => 32768, "64" => 65536, null => basis.ClusterIndex switch { 1 => 32768, 2 => 65536, _ => 0 }, _ => 0 }; + spec.GenerateAmpr = !options.Has("no-ampr") && basis.GenerateAmpr; + spec.CompressionLevel = Math.Clamp(Pick("level", basis.Level), 1, 9); + spec.MinimumGain = Math.Clamp(Pick("gain", basis.Gain), 0, 100); + spec.BlockSize = (Choice("block") ?? (basis.BlockIndex == 1 ? "64" : "32")) == "64" ? 65536 : 32768; + spec.FragmentSize = (Choice("fragment") ?? (basis.FragmentIndex == 1 ? "64" : "4")) == "64" ? 65536 : 4096; + if (spec.FragmentSize > spec.BlockSize) spec.FragmentSize = spec.BlockSize; + spec.BytesPerInode = (Choice("density") ?? basis.DensityIndex switch { 1 => "512", 2 => "1024", _ => "256" }) switch + { "512" => 524288, "1024" => 1048576, _ => 262144 }; + spec.MinFreePercent = Math.Clamp(Pick("min-free", basis.MinFree), 0, 50); + + if (target.Value.Name == "Fpkg") + { + string contentId = Jobs.ReadContentId(spec.Source, format); + if (contentId.Length == 0) return "The source has no content ID (sce_sys/param.json), so a package cannot be built from it."; + spec.Kind = JobSpec.BuildPackage; + spec.ContentId = contentId; + spec.Backend = (options.Get("builder") ?? (basis.Backend.Length > 0 ? basis.Backend : AppSettingsDefaultBackend())).Trim(); + spec.Compression = (Choice("compression") switch + { + "kraken" => Ps5InnerCompression.Kraken, + "stored" or "none" => Ps5InnerCompression.Stored, + "auto" => Ps5InnerCompression.Auto, + _ => basis.CompressionIndex switch { 1 => Ps5InnerCompression.Kraken, 2 => Ps5InnerCompression.Stored, _ => Ps5InnerCompression.Auto } + }).ToString(); + spec.KrakenLevel = Math.Clamp(Pick("kraken-level", basis.KrakenLevel), -4, 9); + spec.KrakenThreads = Math.Clamp(Pick("threads", basis.KrakenThreads), 0, 64); + spec.PlayGoChunks = Math.Clamp(Pick("playgo", basis.PlayGoChunks), 1, 64); + spec.Deterministic = options.Has("deterministic") || basis.Deterministic; + spec.FakeSign = !options.Has("no-fake-sign") && basis.FakeSign; + spec.RightSprx = !options.Has("no-right-sprx") && basis.RightSprx; + spec.Drm = (Choice("drm") ?? basis.DrmIndex switch { 1 => "free", 2 => "standard", 3 => "keep", _ => "upgradable" }) switch + { "free" => "free", "standard" => "standard", "keep" => null, _ => "upgradable" }; + spec.Sdk = basis.Sdk.Length > 0 ? SdkFromVersion(basis.Sdk) : null; + spec.TempDirectory = options.Get("workspace") ?? (basis.TempDirectory.Length > 0 ? basis.TempDirectory : null); + return null; + } + spec.Kind = JobSpec.Convert; + spec.Target = target.Value.Name; + return null; + } + + private static string AppSettingsDefaultBackend() => new AppStateStore().LoadSettings().BuildBackend; + + private static ulong? SdkFromVersion(string version) + { + int index = Ps5SdkVersions.Releases.ToList().FindIndex(release => release.Version == version); + return index >= 0 ? Ps5SdkVersions.ExecutableVersionAt(index) : null; + } + + /// Prints the current step and a percentage to the error stream, a line at a time when it is redirected. + private sealed class ConsoleProgress : IProgress + { + private string _stage = string.Empty; + private int _lastPercent = -1; + private bool _lineOpen; + + public void Report(PackageTaskProgress value) + { + int percent = value.StagePercent >= 0 ? (int)value.StagePercent + : value.TotalBytes > 0 ? (int)(value.CurrentBytes * 100 / value.TotalBytes) : -1; + bool newStage = value.Stage.Length > 0 && value.Stage != _stage; + if (newStage) + { + Finish(); + _stage = value.Stage; + _lastPercent = -1; + Console.Error.Write(_stage); + _lineOpen = true; + if (Console.IsErrorRedirected) Console.Error.WriteLine(); + } + if (percent < 0 || percent == _lastPercent) return; + if (Console.IsErrorRedirected ? percent % 10 != 0 : false) return; + _lastPercent = percent; + if (Console.IsErrorRedirected) Console.Error.WriteLine($" {percent}%"); + else Console.Error.Write($"\r{_stage} {percent}% "); + } + + public void Finish() + { + if (_lineOpen && !Console.IsErrorRedirected) Console.Error.WriteLine(); + _lineOpen = false; + } + } +} diff --git a/src/Packwright.App/Infrastructure/AppSettingsNormalizer.cs b/src/Packwright.App/Infrastructure/AppSettingsNormalizer.cs new file mode 100644 index 0000000..2a06bdf --- /dev/null +++ b/src/Packwright.App/Infrastructure/AppSettingsNormalizer.cs @@ -0,0 +1,191 @@ +namespace Packwright.Infrastructure; + +/// +/// The single normalization/validation contract for settings loaded from disk, imported from a file or +/// edited in the dialog. It never throws and never leaves null collections or out-of-range numbers, so +/// every consumer can assume a consistent shape regardless of where the settings came from. +/// +public static class AppSettingsNormalizer +{ + public const int MinRowHeight = 16; + public const int MaxRowHeight = 60; + public const int MinPreviewMb = 1; + public const int MaxPreviewMb = 1024; + public const int MinHexPageKb = 1; + public const int MaxHexPageKb = 256; + public const int MinThumbnailCache = 1; + public const int MaxThumbnailCache = 100000; + + /// Grouping keys the library understands; anything else normalizes to "None". + public static readonly string[] GroupKeys = ["", "family", "titleid", "category", "region", "source", "firmware"]; + + public const string DefaultRenameFormat = "{TITLE} [{TITLE_ID}]"; + public const string DefaultTheme = "Default (Charcoal)"; + + public static AppSettings Normalize(AppSettings? settings) + { + settings ??= new AppSettings(); + + settings.LibraryFolders = CleanPaths(settings.LibraryFolders); + settings.RecentFolders = CleanList(settings.RecentFolders, 10); + settings.ManualSources = CleanPaths(settings.ManualSources); + + settings.LibraryColumnOrder = CleanList(settings.LibraryColumnOrder); + settings.LibraryHiddenColumns = CleanList(settings.LibraryHiddenColumns); + settings.LibraryColumnWeights = CleanWeights(settings.LibraryColumnWeights); + settings.LibrarySortKeys = CleanSortKeys(settings.LibrarySortKeys); + settings.SavedViews = CleanViews(settings.SavedViews); + settings.Presets = CleanPresets(settings.Presets); + settings.Consoles = CleanConsoles(settings.Consoles); + settings.LibrarySortColumn = string.IsNullOrWhiteSpace(settings.LibrarySortColumn) + ? "Title" + : settings.LibrarySortColumn.Trim(); + + settings.Theme = string.IsNullOrWhiteSpace(settings.Theme) ? DefaultTheme : settings.Theme.Trim(); + settings.GridRowHeight = Math.Clamp(settings.GridRowHeight, MinRowHeight, MaxRowHeight); + settings.DefaultGroupBy = Array.IndexOf(GroupKeys, settings.DefaultGroupBy) >= 0 ? settings.DefaultGroupBy : string.Empty; + + settings.RenameFormat = string.IsNullOrWhiteSpace(settings.RenameFormat) ? DefaultRenameFormat : settings.RenameFormat; + + settings.MaxPreviewMb = Math.Clamp(settings.MaxPreviewMb, MinPreviewMb, MaxPreviewMb); + settings.HexPageKb = Math.Clamp(settings.HexPageKb, MinHexPageKb, MaxHexPageKb); + settings.ThumbnailCacheCount = Math.Clamp(settings.ThumbnailCacheCount, MinThumbnailCache, MaxThumbnailCache); + + settings.OutputDirectory = (settings.OutputDirectory ?? string.Empty).Trim(); + + // A passcode is either blank (meaning the built-in all-zero default) or exactly 32 printable + // ASCII characters. Anything else is discarded rather than carried into a build. + settings.DebugPasscode ??= string.Empty; + if (settings.DebugPasscode.Length > 0 && !IsValidPasscode(settings.DebugPasscode)) + settings.DebugPasscode = string.Empty; + + settings.BuildBackend = (settings.BuildBackend ?? string.Empty).Trim(); + + settings.WindowWidth = ClampNonNegative(settings.WindowWidth); + settings.WindowHeight = ClampNonNegative(settings.WindowHeight); + settings.TaskSplitterDistance = ClampNonNegative(settings.TaskSplitterDistance); + + return settings; + } + + public static bool IsValidPasscode(string value) => + value.Length == 32 && value.All(character => character is >= (char)0x20 and <= (char)0x7E); + + private static int ClampNonNegative(int value) => Math.Clamp(value, 0, 32767); + + private static List CleanList(List? values, int max = int.MaxValue) + { + var result = new List(); + if (values is null) return result; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string? value in values) + { + if (string.IsNullOrWhiteSpace(value)) continue; + if (!seen.Add(value.Trim())) continue; + result.Add(value.Trim()); + if (result.Count >= max) break; + } + return result; + } + + private static List CleanPaths(List? values) + { + var result = new List(); + if (values is null) return result; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string? value in values) + { + if (string.IsNullOrWhiteSpace(value)) continue; + string path = NormalizePath(value); + if (!seen.Add(path)) continue; + result.Add(path); + } + return result; + } + + /// Returns a full path when possible, otherwise the trimmed original string. + public static string NormalizePath(string value) + { + string trimmed = value.Trim(); + try + { + return Path.GetFullPath(trimmed); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return trimmed; + } + } + + private static Dictionary CleanWeights(Dictionary? weights) + { + var result = new Dictionary(StringComparer.Ordinal); + if (weights is null) return result; + foreach ((string key, float value) in weights) + if (!string.IsNullOrWhiteSpace(key) && value > 0 && float.IsFinite(value)) + result[key] = value; + return result; + } + + private static List CleanSortKeys(List? values) => CleanList(values); + + private static List CleanConsoles(List? consoles) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ConsoleProfile? console in consoles ?? []) + { + if (console is null || string.IsNullOrWhiteSpace(console.Name) || !seen.Add(console.Name.Trim())) continue; + console.Name = console.Name.Trim(); + console.Host = (console.Host ?? string.Empty).Trim(); + console.FtpPort = console.FtpPort is > 0 and <= 65535 ? console.FtpPort : 1337; + console.DefaultFolder = string.IsNullOrWhiteSpace(console.DefaultFolder) ? "/data" : console.DefaultFolder.Trim(); + console.InstallUrl ??= string.Empty; + console.InstallBody ??= string.Empty; + console.InstallContentType = string.IsNullOrWhiteSpace(console.InstallContentType) ? "application/json" : console.InstallContentType.Trim(); + result.Add(console); + } + return result; + } + + private static List CleanPresets(List? presets) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ToolPreset? preset in presets ?? []) + { + if (preset is null || string.IsNullOrWhiteSpace(preset.Name) || !seen.Add(preset.Name.Trim())) continue; + preset.Name = preset.Name.Trim(); + preset.Target ??= string.Empty; + preset.Backend ??= string.Empty; + preset.Sdk ??= string.Empty; + preset.TempDirectory ??= string.Empty; + result.Add(preset); + } + return result; + } + + private static List CleanViews(List? views) + { + var result = new List(); + if (views is null) return result; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (SavedLibraryView? view in views) + { + if (view is null || string.IsNullOrWhiteSpace(view.Name)) continue; + string name = view.Name.Trim(); + if (!seen.Add(name)) continue; + view.Name = name; + view.Query ??= string.Empty; + view.GroupBy = Array.IndexOf(GroupKeys, view.GroupBy) >= 0 ? view.GroupBy : string.Empty; + view.Categories = CleanList(view.Categories); + view.Regions = CleanList(view.Regions); + view.Formats = CleanList(view.Formats); + view.SortKeys = CleanList(view.SortKeys); + view.HiddenColumns = CleanList(view.HiddenColumns); + view.ColumnOrder = CleanList(view.ColumnOrder); + result.Add(view); + } + return result; + } +} diff --git a/src/Packwright.App/Infrastructure/AppStateStore.cs b/src/Packwright.App/Infrastructure/AppStateStore.cs new file mode 100644 index 0000000..c0a11dd --- /dev/null +++ b/src/Packwright.App/Infrastructure/AppStateStore.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using Packwright.Core.Models; + +namespace Packwright.Infrastructure; + +/// A named snapshot of the library layout: filters, grouping, sorting and columns. +public sealed class SavedLibraryView +{ + public string Name { get; set; } = string.Empty; + public string Query { get; set; } = string.Empty; + public List Categories { get; set; } = []; + public List Regions { get; set; } = []; + public List Formats { get; set; } = []; + public string GroupBy { get; set; } = string.Empty; + public List SortKeys { get; set; } = []; + public List HiddenColumns { get; set; } = []; + public List ColumnOrder { get; set; } = []; +} + +/// A named set of Image tools options (target format and its settings). The source and output paths are never part of it. +public sealed class ToolPreset +{ + public string Name { get; set; } = string.Empty; + /// Exfat, Ffpkg, Ffpfsc, ZArchive or Fpkg; empty keeps whatever is chosen. + public string Target { get; set; } = string.Empty; + public int ClusterIndex { get; set; } + public bool GenerateAmpr { get; set; } = true; + public int Level { get; set; } = 7; + public int Gain { get; set; } = 1; + public int BlockIndex { get; set; } + public int FragmentIndex { get; set; } + public int DensityIndex { get; set; } + public int MinFree { get; set; } + public string Backend { get; set; } = string.Empty; + public int CompressionIndex { get; set; } + public int KrakenLevel { get; set; } = 7; + public int KrakenThreads { get; set; } + public int PlayGoChunks { get; set; } = 1; + public bool Deterministic { get; set; } + public bool FakeSign { get; set; } = true; + public bool RightSprx { get; set; } = true; + public int DrmIndex { get; set; } + public string Sdk { get; set; } = string.Empty; + public string TempDirectory { get; set; } = string.Empty; +} + +/// A console on the local network that files can be sent to. Nothing is contacted until the user sends something. +public sealed class ConsoleProfile +{ + public string Name { get; set; } = string.Empty; + /// The console's address on the local network, such as 192.168.1.50. + public string Host { get; set; } = string.Empty; + public int FtpPort { get; set; } = 1337; + /// Where files go on the console unless another folder is chosen when sending. + public string DefaultFolder { get; set; } = "/data"; + + // "Install by URL": the console's installer is asked to download a package from this PC. Different installers + // use different requests, so the request is a template. {host} is the console, {url} the address of the file. + public string InstallUrl { get; set; } = "http://{host}:12800/api/install"; + public string InstallBody { get; set; } = "{\"type\":\"direct\",\"packages\":[\"{url}\"]}"; + public string InstallContentType { get; set; } = "application/json"; +} + +public sealed class AppSettings +{ + public List LibraryFolders { get; set; } = []; + public bool RecursiveScan { get; set; } = true; + public List RecentFolders { get; set; } = []; + public List ManualSources { get; set; } = []; + public int WindowWidth { get; set; } + public int WindowHeight { get; set; } + public bool WindowMaximized { get; set; } + public bool WindowHasPosition { get; set; } + public int WindowX { get; set; } + public int WindowY { get; set; } + public string LibrarySortColumn { get; set; } = "Title"; + public bool LibrarySortAscending { get; set; } = true; + /// Ordered sort keys ("Title:asc", "Size:desc") for multi-column sorting. + public List LibrarySortKeys { get; set; } = []; + /// Named saved library views (filters, grouping, sorting and column layout). + public List SavedViews { get; set; } = []; + /// Named Image tools presets saved by the user. + public List Presets { get; set; } = []; + /// Consoles that files can be sent to. + public List Consoles { get; set; } = []; + public List LibraryColumnOrder { get; set; } = []; + public List LibraryHiddenColumns { get; set; } = []; + /// Remembered column fill weights by column name so user-resized widths survive a restart. + public Dictionary LibraryColumnWeights { get; set; } = []; + + // Appearance + public string Theme { get; set; } = "Default (Charcoal)"; + /// Library presentation: "Cards" (artwork tiles) or "List". + public string LibraryLayout { get; set; } = "Cards"; + public int GridRowHeight { get; set; } = 22; + public bool ShowThumbnails { get; set; } = true; + public bool ShowGridLines { get; set; } = true; + /// Show the inline file preview pane in the Files tab (list-only when disabled). + public bool ShowFilePreview { get; set; } = true; + public string DefaultGroupBy { get; set; } = string.Empty; + + // Library & scanning + public bool RefreshOnStartup { get; set; } + public string RenameFormat { get; set; } = "{TITLE} [{TITLE_ID}]"; + public bool LogAutoScroll { get; set; } = true; + + // Files & preview + public int MaxPreviewMb { get; set; } = 16; + public int HexPageKb { get; set; } = 16; + + // Performance + public int ThumbnailCacheCount { get; set; } = 512; + + // Paths & outputs + public string OutputDirectory { get; set; } = string.Empty; + public bool OpenOutputAfterTask { get; set; } + + // Tasks workspace + /// Remembered vertical split between the task list and the details panel. + public int TaskSplitterDistance { get; set; } + /// True when the details panel is collapsed to give the list more height. + public bool TaskDetailsCollapsed { get; set; } + + // Build defaults + public string DebugPasscode { get; set; } = string.Empty; + + /// Selected package backend id ("lpp" or "ppt"). Empty/unknown falls back to the default. + public string BuildBackend { get; set; } = string.Empty; + + // Updates (nothing is checked until the user allows it) + public bool CheckForUpdates { get; set; } + public bool UpdateCheckAsked { get; set; } + public DateTime LastUpdateCheckUtc { get; set; } + public string SkippedUpdateVersion { get; set; } = string.Empty; + + // Safety + public bool ConfirmDelete { get; set; } = true; + public bool ConfirmMove { get; set; } = true; + public bool PermanentDelete { get; set; } +} + +public sealed class LibraryManifest +{ + public DateTime CreatedUtc { get; set; } + public List Games { get; set; } = []; +} + +/// Result of loading settings, including a non-fatal warning worth surfacing to the user. +public sealed record SettingsLoadResult(AppSettings Settings, string? Warning); + +public sealed class AppStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + public AppStateStore() + { + // PACKWRIGHT_DATA lets a test copy use its own folder instead of the real settings and library. + string? custom = Environment.GetEnvironmentVariable("PACKWRIGHT_DATA"); + AppDataDirectory = !string.IsNullOrWhiteSpace(custom) + ? Path.GetFullPath(custom) + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packwright"); + Directory.CreateDirectory(AppDataDirectory); + } + + public string AppDataDirectory { get; } + public string SettingsPath => Path.Combine(AppDataDirectory, "settings.json"); + public string ManifestPath => Path.Combine(AppDataDirectory, "manifest.json"); + + public AppSettings LoadSettings() => LoadSettingsWithDiagnostics().Settings; + + /// + /// Loads and normalizes settings. A missing file simply yields defaults; an unreadable file is + /// quarantined (renamed) rather than silently overwritten by a later automatic save, and a warning + /// describing what happened is returned. + /// + public SettingsLoadResult LoadSettingsWithDiagnostics() + { + if (!File.Exists(SettingsPath)) return new SettingsLoadResult(new AppSettings(), null); + try + { + AppSettings settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath), JsonOptions) + ?? new AppSettings(); + return new SettingsLoadResult(AppSettingsNormalizer.Normalize(settings), null); + } + catch (JsonException ex) + { + string quarantine = SettingsPath + ".invalid-" + DateTime.Now.ToString("yyyyMMddHHmmss"); + try { File.Move(SettingsPath, quarantine, overwrite: true); } + catch (Exception moveEx) when (moveEx is IOException or UnauthorizedAccessException) { } + return new SettingsLoadResult(new AppSettings(), + $"The settings file could not be read and was moved to {Path.GetFileName(quarantine)}. " + + $"Defaults are in use. ({ex.Message})"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new SettingsLoadResult(new AppSettings(), + $"Settings could not be read: {ex.Message}. Defaults are in use for this session."); + } + } + + public LibraryManifest LoadManifest() + { + if (!File.Exists(ManifestPath)) return new LibraryManifest(); + try + { + LibraryManifest manifest = JsonSerializer.Deserialize( + File.ReadAllText(ManifestPath), JsonOptions) ?? new LibraryManifest(); + manifest.Games ??= []; + return manifest; + } + catch (JsonException ex) + { + // Quarantine rather than silently presenting an empty library (which looks like data + // loss) and then overwriting the file on the next save. + string quarantine = ManifestPath + ".invalid-" + DateTime.Now.ToString("yyyyMMddHHmmss"); + try { File.Move(ManifestPath, quarantine, overwrite: true); } + catch (Exception moveEx) when (moveEx is IOException or UnauthorizedAccessException) { } + Logger.Warn($"The library manifest could not be read and was moved to " + + $"{Path.GetFileName(quarantine)}. The library will be rebuilt on the next refresh. ({ex.Message})"); + return new LibraryManifest(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn($"The library manifest could not be read: {ex.Message}. " + + "The library may appear empty until a refresh."); + return new LibraryManifest(); + } + } + + public void SaveSettings(AppSettings settings) => Save(SettingsPath, AppSettingsNormalizer.Normalize(settings)); + public void SaveManifest(IReadOnlyCollection games) => Save(ManifestPath, new LibraryManifest + { + CreatedUtc = DateTime.UtcNow, + Games = games.ToList() + }); + + private static void Save(string path, T value) + { + string tempPath = path + ".tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(value, JsonOptions)); + File.Move(tempPath, path, true); + } +} diff --git a/src/Packwright.App/Infrastructure/Logger.cs b/src/Packwright.App/Infrastructure/Logger.cs new file mode 100644 index 0000000..67b100e --- /dev/null +++ b/src/Packwright.App/Infrastructure/Logger.cs @@ -0,0 +1,97 @@ +using System.Text; + +namespace Packwright.Infrastructure; + +public enum LogLevel +{ + Info, + Warn, + Error +} + +/// One structured log record, raised through . +public readonly record struct LogEntry(DateTime Time, LogLevel Level, string Message); + +/// +/// Minimal thread-safe file logger. Writes to %LocalAppData%\Packwright\logs, rotates once at +/// a soft size cap, and never throws because logging must not break the application. The file is +/// the full record of every log; feeds the in-app Log tab. +/// +public static class Logger +{ + private static readonly object Gate = new(); + private const long MaximumBytes = 5 * 1024 * 1024; + + public static string LogDirectory { get; } = Path.Combine( + Environment.GetEnvironmentVariable("PACKWRIGHT_DATA") is { Length: > 0 } custom + ? Path.GetFullPath(custom) + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packwright"), + "logs"); + + public static string LogPath { get; } = Path.Combine(LogDirectory, "Packwright.log"); + + public static event Action? Logged; + + public static void Info(string message) => Write(LogLevel.Info, message); + public static void Warn(string message) => Write(LogLevel.Warn, message); + public static void Error(string message) => Write(LogLevel.Error, message); + public static void Exception(string context, Exception exception) => Write(LogLevel.Error, $"{context}: {exception}"); + + private static void Write(LogLevel level, string message) + { + var entry = new LogEntry(DateTime.Now, level, message); + string line = $"{entry.Time:yyyy-MM-dd HH:mm:ss.fff} [{LevelText(level)}] {message}"; + try + { + lock (Gate) + { + System.IO.Directory.CreateDirectory(LogDirectory); + if (File.Exists(LogPath) && new FileInfo(LogPath).Length > MaximumBytes) + File.Move(LogPath, LogPath + ".1", true); + // Shared write access, so a second running copy (or a viewer) never blocks logging. + using var stream = new FileStream(LogPath, FileMode.Append, FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + byte[] bytes = Encoding.UTF8.GetBytes(line + Environment.NewLine); + stream.Write(bytes, 0, bytes.Length); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Logging is best-effort only. + } + Logged?.Invoke(entry); + } + + /// Formats an entry exactly as it is written to the log file. + public static string Format(LogEntry entry) => + $"{entry.Time:yyyy-MM-dd HH:mm:ss.fff} [{LevelText(entry.Level)}] {entry.Message}"; + + /// The last lines of the log file, oldest first, for showing history in the app. + public static IReadOnlyList ReadTail(int maximumLines) + { + try + { + if (!File.Exists(LogPath)) return []; + using var stream = new FileStream(LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + // Read only the end of the file; 64 KiB per 200 lines is ample for typical entries. + long window = Math.Min(stream.Length, Math.Max(64 * 1024, maximumLines * 320L)); + stream.Position = stream.Length - window; + using var reader = new StreamReader(stream, Encoding.UTF8); + string text = reader.ReadToEnd(); + List lines = text.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.Length > 0).ToList(); + if (window < stream.Length && lines.Count > 0) lines.RemoveAt(0); // the first line is usually cut off + return lines.Count <= maximumLines ? lines : lines.GetRange(lines.Count - maximumLines, maximumLines); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return []; + } + } + + private static string LevelText(LogLevel level) => level switch + { + LogLevel.Warn => "WARN", + LogLevel.Error => "ERROR", + _ => "INFO" + }; +} diff --git a/src/Packwright.App/Infrastructure/ShellIntegration.cs b/src/Packwright.App/Infrastructure/ShellIntegration.cs new file mode 100644 index 0000000..34c1e53 --- /dev/null +++ b/src/Packwright.App/Infrastructure/ShellIntegration.cs @@ -0,0 +1,56 @@ +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace Packwright.Infrastructure; + +/// +/// Per-user (HKCU, no admin) File Explorer context menu integration for PS5 package and image files +/// and for folders. Adds an "Open with Packwright" verb that launches the app with +/// --open "<path>". Windows only. +/// +[SupportedOSPlatform("windows")] +public static class ShellIntegration +{ + private static readonly string[] FileKeys = + [ + @"Software\Classes\SystemFileAssociations\.pkg\shell\Packwright", + @"Software\Classes\SystemFileAssociations\.ffpfsc\shell\Packwright", + @"Software\Classes\SystemFileAssociations\.ffpkg\shell\Packwright", + @"Software\Classes\SystemFileAssociations\.exfat\shell\Packwright", + @"Software\Classes\Directory\shell\Packwright" + ]; + + private static string ExecutablePath => + Environment.ProcessPath ?? throw new InvalidOperationException("The application path is unknown."); + + public static bool IsInstalled() + { + foreach (string path in FileKeys) + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(path); + if (key is not null) return true; + } + return false; + } + + public static void Install() + { + string executable = ExecutablePath; + foreach (string path in FileKeys) + { + using RegistryKey verb = Registry.CurrentUser.CreateSubKey(path, true) + ?? throw new InvalidOperationException($"Unable to create {path}."); + verb.SetValue("MUIVerb", "Open with Packwright"); + verb.SetValue("Icon", $"\"{executable}\""); + using RegistryKey command = verb.CreateSubKey("command", true) + ?? throw new InvalidOperationException("Unable to create the shell command key."); + command.SetValue(null, $"\"{executable}\" --open \"%1\""); + } + } + + public static void Uninstall() + { + foreach (string path in FileKeys) + Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false); + } +} diff --git a/src/Packwright.App/Infrastructure/SingleInstance.cs b/src/Packwright.App/Infrastructure/SingleInstance.cs new file mode 100644 index 0000000..8a764a6 --- /dev/null +++ b/src/Packwright.App/Infrastructure/SingleInstance.cs @@ -0,0 +1,97 @@ +using System.IO.Pipes; +using System.Text; + +namespace Packwright.Infrastructure; + +/// +/// Keeps one Packwright running per user. A second launch hands its arguments to the first copy, which brings +/// its window to the front, and then exits, so two copies never fight over the same settings and library cache. +/// +public sealed class SingleInstance : IDisposable +{ + private readonly Mutex _mutex; + private readonly string _pipeName; + private readonly CancellationTokenSource _stop = new(); + + private SingleInstance(Mutex mutex, string pipeName) + { + _mutex = mutex; + _pipeName = pipeName; + } + + // A test copy started with its own PACKWRIGHT_DATA folder is a separate "installation" and may run alongside. + private static string Suffix => + new string(Environment.UserName.Where(char.IsLetterOrDigit).ToArray()) + + (Environment.GetEnvironmentVariable("PACKWRIGHT_DATA") is { Length: > 0 } custom + ? Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(custom))))[..12] + : string.Empty); + + /// Returns the lock when this is the first copy, or null after passing on. + public static SingleInstance? TryAcquire(string[] args) + { + string suffix = Suffix; + string pipe = "Packwright.Activate." + suffix; + Mutex mutex; + try + { + mutex = new Mutex(true, "Packwright.SingleInstance." + suffix, out bool created); + if (created) return new SingleInstance(mutex, pipe); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException) + { + // If the lock cannot be used at all, start normally rather than refuse to run. + return new SingleInstance(new Mutex(), pipe); + } + + mutex.Dispose(); + try + { + using var client = new NamedPipeClientStream(".", pipe, PipeDirection.Out); + client.Connect(4000); + byte[] message = Encoding.UTF8.GetBytes(string.Join('\n', args.Where(arg => arg.Length > 0))); + client.Write(message, 0, message.Length); + } + catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) + { + Logger.Warn("Packwright is already running but could not be reached: " + ex.Message); + } + return null; + } + + /// Calls (with any paths given) each time another launch is attempted. + public void Listen(Action onActivate) + { + _ = Task.Run(async () => + { + while (!_stop.IsCancellationRequested) + { + try + { + await using var server = new NamedPipeServerStream(_pipeName, PipeDirection.In, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + await server.WaitForConnectionAsync(_stop.Token).ConfigureAwait(false); + using var reader = new StreamReader(server, Encoding.UTF8); + string text = await reader.ReadToEndAsync(_stop.Token).ConfigureAwait(false); + onActivate(text.Split('\n', StringSplitOptions.RemoveEmptyEntries)); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ObjectDisposedException) + { + await Task.Delay(500).ConfigureAwait(false); + } + } + }); + } + + public void Dispose() + { + _stop.Cancel(); + try { _mutex.ReleaseMutex(); } + catch (ApplicationException) { } + _mutex.Dispose(); + } +} diff --git a/src/Packwright.App/Models/FileNode.cs b/src/Packwright.App/Models/FileNode.cs new file mode 100644 index 0000000..c8aacca --- /dev/null +++ b/src/Packwright.App/Models/FileNode.cs @@ -0,0 +1,142 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Media; +using Packwright.Core.Models; + +namespace Packwright.App.Models; + +/// One folder or file in the game file browser tree. +public sealed class FileNode : INotifyPropertyChanged +{ + private static readonly Geometry FolderIcon = Geometry.Parse("M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"); + private static readonly Geometry FileIcon = Geometry.Parse("M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"); + private static readonly Geometry ImageIcon = Geometry.Parse("M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"); + private static readonly Geometry CodeIcon = Geometry.Parse("M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"); + private static readonly Geometry MediaIcon = Geometry.Parse("M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"); + private static readonly Geometry BinaryIcon = Geometry.Parse("M4 4h6v6H4zm10 0h6v6h-6zM4 14h6v6H4zm10 0h6v6h-6z"); + + private static readonly IBrush FolderBrush = new SolidColorBrush(Color.Parse("#F5B642")); + private static readonly IBrush ImageBrush = new SolidColorBrush(Color.Parse("#3DD68C")); + private static readonly IBrush CodeBrush = new SolidColorBrush(Color.Parse("#7C8CFF")); + private static readonly IBrush MediaBrush = new SolidColorBrush(Color.Parse("#C084FC")); + private static readonly IBrush PlainBrush = new SolidColorBrush(Color.Parse("#8E94AE")); + + private static readonly HashSet ImageTypes = new(StringComparer.OrdinalIgnoreCase) + { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".dds", ".tga", ".ico" }; + private static readonly HashSet CodeTypes = new(StringComparer.OrdinalIgnoreCase) + { ".txt", ".json", ".xml", ".cfg", ".ini", ".log", ".lua", ".js", ".md", ".csv", ".yml", ".yaml", ".html", ".css", ".py", ".sh", ".conf", ".sfo", ".meta", ".self", ".elf", ".sprx", ".prx" }; + private static readonly HashSet MediaTypes = new(StringComparer.OrdinalIgnoreCase) + { ".at9", ".mp3", ".ogg", ".wav", ".mp4", ".webm", ".mkv", ".avi", ".bk2", ".usm", ".wem", ".opus", ".m4a" }; + + private bool _isExpanded; + + public event PropertyChangedEventHandler? PropertyChanged; + + public string Name { get; init; } = string.Empty; + public string FullPath { get; init; } = string.Empty; + public bool IsDirectory { get; init; } + public long Size { get; set; } + public int FileCount { get; set; } + public Ps5FileInfo? Info { get; init; } + public List Children { get; } = []; + + public Geometry Icon { get; private set; } = FileIcon; + public IBrush IconBrush { get; private set; } = PlainBrush; + + public string Detail => IsDirectory + ? $"{FileCount:N0} file{(FileCount == 1 ? string.Empty : "s")} · {LibraryItem.FormatBytes(Size)}" + : LibraryItem.FormatBytes(Size); + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded == value) return; + _isExpanded = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded))); + } + } + + public void ApplyIcon() + { + if (IsDirectory) { Icon = FolderIcon; IconBrush = FolderBrush; return; } + string extension = Path.GetExtension(Name); + (Icon, IconBrush) = extension switch + { + _ when ImageTypes.Contains(extension) => (ImageIcon, ImageBrush), + _ when CodeTypes.Contains(extension) => (CodeIcon, CodeBrush), + _ when MediaTypes.Contains(extension) => (MediaIcon, MediaBrush), + ".bin" or ".dat" or ".pak" or ".pkg" or ".psarc" => (BinaryIcon, PlainBrush), + _ => (FileIcon, PlainBrush) + }; + } + + /// Builds the folder tree for a set of files and returns its top-level nodes. + public static List BuildTree(IEnumerable files, bool expandAll) + { + var roots = new List(); + var folders = new Dictionary(StringComparer.Ordinal); + + FileNode Folder(string path) + { + if (folders.TryGetValue(path, out FileNode? existing)) return existing; + int slash = path.LastIndexOf('/'); + var node = new FileNode { Name = slash < 0 ? path : path[(slash + 1)..], FullPath = path, IsDirectory = true, _isExpanded = expandAll }; + node.ApplyIcon(); + folders[path] = node; + (slash < 0 ? roots : Folder(path[..slash]).Children).Add(node); + return node; + } + + foreach (Ps5FileInfo file in files) + { + string path = file.RelativePath.Replace('\\', '/').Trim('/'); + if (path.Length == 0) continue; + int slash = path.LastIndexOf('/'); + var leaf = new FileNode + { + Name = slash < 0 ? path : path[(slash + 1)..], + FullPath = path, + Size = file.Size, + FileCount = 1, + Info = file + }; + leaf.ApplyIcon(); + (slash < 0 ? roots : Folder(path[..slash]).Children).Add(leaf); + } + + Finish(roots); + return roots; + } + + private static void Finish(List nodes) + { + foreach (FileNode node in nodes.Where(node => node.IsDirectory)) + { + Finish(node.Children); + node.Size = node.Children.Sum(child => child.Size); + node.FileCount = node.Children.Sum(child => child.FileCount); + } + nodes.Sort((a, b) => a.IsDirectory != b.IsDirectory + ? a.IsDirectory ? -1 : 1 + : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + + public IEnumerable SelfAndDescendants() + { + yield return this; + foreach (FileNode child in Children) + foreach (FileNode inner in child.SelfAndDescendants()) + yield return inner; + } + + public IEnumerable Files() => SelfAndDescendants().Where(node => !node.IsDirectory); + + public static void SetExpanded(IEnumerable nodes, bool expanded) + { + foreach (FileNode node in nodes) + foreach (FileNode inner in node.SelfAndDescendants().Where(inner => inner.IsDirectory)) + inner.IsExpanded = expanded; + } +} diff --git a/src/Packwright.App/Models/LibraryItem.cs b/src/Packwright.App/Models/LibraryItem.cs new file mode 100644 index 0000000..66d6bfe --- /dev/null +++ b/src/Packwright.App/Models/LibraryItem.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Media.Imaging; +using Packwright.App.Services; +using Packwright.Core.Models; + +namespace Packwright.App.Models; + +/// A library row: display-ready values derived from a scanned . +public sealed class LibraryItem : INotifyPropertyChanged +{ + private Bitmap? _thumbnail; + private bool _thumbnailRequested; + private string _groupKey = string.Empty; + private string _role = "Unknown"; + + public LibraryItem(Ps5GameInfo game) + { + Game = game; + Format = game.SourceDescription; + SizeText = game.SourceSize > 0 ? FormatBytes(game.SourceSize) : string.Empty; + ApplyOverride(MetadataOverrides.Get(game.RootPath)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public Ps5GameInfo Game { get; } + public string Title { get; private set; } = string.Empty; + public string TitleId { get; private set; } = string.Empty; + public string ContentId { get; private set; } = string.Empty; + public string Category { get; private set; } = string.Empty; + public string Version { get; private set; } = string.Empty; + public string Region { get; private set; } = string.Empty; + public string ConceptId { get; private set; } = string.Empty; + public string MasterVersion { get; private set; } = string.Empty; + public string SdkVersion { get; private set; } = string.Empty; + public string Drm { get; private set; } = string.Empty; + public string DefaultLanguage { get; private set; } = string.Empty; + public string Created { get; private set; } = string.Empty; + public string ToolVersion { get; private set; } = string.Empty; + public IReadOnlyList Features { get; private set; } = []; + /// True when the user edited this item's details. + public bool IsEdited { get; private set; } + + /// Details the title still lacks (after the user's edits), such as "Title ID" or "Firmware". + public IReadOnlyList MissingFields { get; private set; } = []; + public bool HasMissing => MissingFields.Count > 0; + public string MissingText => "Missing: " + string.Join(", ", MissingFields); + public bool HasTitleId => TitleId.Length > 0; + public string Format { get; } + public long SizeBytes => Game.SourceSize; + public string SizeText { get; } + public string Firmware { get; private set; } = string.Empty; + public string Path => Game.RootPath; + public string FileName => + System.IO.Path.GetFileName(Game.RootPath.TrimEnd(System.IO.Path.DirectorySeparatorChar, + System.IO.Path.AltDirectorySeparatorChar)); + + /// Recomputes the displayed values from the file's own metadata plus the user's edits. + public void ApplyOverride(MetadataOverride? edit) + { + string Pick(string custom, string detected) => string.IsNullOrWhiteSpace(custom) ? detected : custom.Trim(); + Title = Pick(edit?.Title ?? string.Empty, string.IsNullOrWhiteSpace(Game.Title) ? FileName : Game.Title); + TitleId = Pick(edit?.TitleId ?? string.Empty, Game.TitleId); + ContentId = Pick(edit?.ContentId ?? string.Empty, Game.ContentId); + Version = Pick(edit?.Version ?? string.Empty, Game.DisplayVersion); + Firmware = Pick(edit?.Firmware ?? string.Empty, Game.RequiredSystemSoftware); + Category = Pick(edit?.Category ?? string.Empty, CategoryOf(Game)); + ConceptId = Pick(edit?.ConceptId ?? string.Empty, Game.ConceptId); + MasterVersion = Pick(edit?.MasterVersion ?? string.Empty, Game.MasterVersion); + SdkVersion = Pick(edit?.Sdk ?? string.Empty, Game.SdkVersion); + Drm = Pick(edit?.Drm ?? string.Empty, Game.DrmType); + DefaultLanguage = Pick(edit?.DefaultLanguage ?? string.Empty, Game.DefaultLanguage); + Created = Pick(edit?.Created ?? string.Empty, Game.CreationDate); + ToolVersion = Pick(edit?.ToolVersion ?? string.Empty, Game.ToolVersion); + Features = edit?.Features is { } features ? features : Game.DeclaredFeatures; + Region = RegionOfId(ContentId.Length > 0 ? ContentId : TitleId); + IsEdited = edit is { IsEmpty: false }; + var missing = new List(); + if (Game.LocalizedTitles.Count == 0 && string.IsNullOrWhiteSpace(edit?.Title)) missing.Add("Title"); + if (TitleId.Length == 0) missing.Add("Title ID"); + if (ContentId.Length == 0) missing.Add("Content ID"); + if (Version.Length == 0) missing.Add("Version"); + if (Firmware.Length == 0) missing.Add("Firmware"); + MissingFields = missing; + _role = Category switch + { + "Game" => "Base", + "Patch" => "Update", + "DLC" or "Add-on" => "DLC", + "App" => "App", + _ => "Unknown" + }; + Raise(string.Empty); + } + + /// The item's role in its title family: Base, Update, DLC or App. + public string Role + { + get => _role; + set { if (_role != value) { _role = value; Raise(); } } + } + + /// Key of the group this row currently sits in (empty when grouping is off). + public string GroupKey + { + get => _groupKey; + set { if (_groupKey != value) { _groupKey = value; Raise(); } } + } + + public string Subtitle + { + get + { + var parts = new List(); + if (TitleId.Length > 0) parts.Add(TitleId); + if (Version.Length > 0) parts.Add("v" + Version.TrimStart('v', 'V')); + return string.Join(" · ", parts); + } + } + + /// Artwork, loaded lazily the first time a view asks for it. + public Bitmap? Thumbnail + { + get + { + if (!_thumbnailRequested) + { + _thumbnailRequested = true; + ThumbnailService.Request(this); + } + return _thumbnail; + } + } + + public bool HasThumbnail => _thumbnail is not null; + + public void SetThumbnail(Bitmap? bitmap) + { + _thumbnail = bitmap; + Raise(nameof(Thumbnail)); + Raise(nameof(HasThumbnail)); + } + + /// Forgets a cached bitmap so it can be loaded again after eviction. + public void ResetThumbnail() + { + _thumbnail = null; + _thumbnailRequested = false; + Raise(nameof(Thumbnail)); + Raise(nameof(HasThumbnail)); + } + + private void Raise([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + public static string RegionOf(Ps5GameInfo game) => + RegionOfId(!string.IsNullOrWhiteSpace(game.ContentId) ? game.ContentId : game.TitleId); + + /// The regions a Content ID can name, with the two letters it starts with. + public static readonly (string Name, string Prefix)[] Regions = + [ + ("Americas", "UP"), ("Europe", "EP"), ("Japan", "JP"), ("Korea", "KP"), ("Asia", "AP"), ("Hong Kong", "HP") + ]; + + public static string RegionOfId(string id) + { + if (id.Length < 1) return "Unknown"; + return char.ToUpperInvariant(id[0]) switch + { + 'U' => "Americas", + 'E' => "Europe", + 'J' => "Japan", + 'K' => "Korea", + 'A' => "Asia", + 'H' => "Hong Kong", + _ => "Other" + }; + } + + public static string CategoryOf(Ps5GameInfo game) + { + string raw = game.ApplicationCategory; + if (!string.IsNullOrWhiteSpace(raw)) + { + string token = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? raw; + if (int.TryParse(token, out int number)) + return number switch { 1 => "DLC", 2 => "Patch", 3 => "App", _ => "Game" }; + return raw; + } + return game.Package?.ContentType switch + { + 0x20 => "Game", + 0x21 => "DLC", + 0x22 => "DLC", + _ => game.SourceKind == Ps5SourceKind.LooseDump ? "Game" : "Unknown" + }; + } + + public static string FormatBytes(long bytes) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + double value = Math.Max(0, bytes); + int unit = 0; + while (value >= 1024 && unit < units.Length - 1) { value /= 1024; unit++; } + return $"{value:0.##} {units[unit]}"; + } +} diff --git a/src/Packwright.App/Packwright.App.csproj b/src/Packwright.App/Packwright.App.csproj new file mode 100644 index 0000000..5338051 --- /dev/null +++ b/src/Packwright.App/Packwright.App.csproj @@ -0,0 +1,55 @@ + + + WinExe + net10.0 + enable + enable + latest + Packwright + Packwright.App + Assets\icon.ico + false + 1.0.0 + 1.0.0.0 + Packwright + itsmemac + itsmemac + Copyright (c) 2026 itsmemac + Cross-platform PS5 dump, image and package manager. Based on PS5 PKG Tool by pearlxcore (GPL-3.0). + false + en + + false + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + LibProsperoPkg12\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + diff --git a/src/Packwright.App/Program.cs b/src/Packwright.App/Program.cs new file mode 100644 index 0000000..309cfbb --- /dev/null +++ b/src/Packwright.App/Program.cs @@ -0,0 +1,37 @@ +using Avalonia; +using Packwright.Infrastructure; + +namespace Packwright.App; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Logger.Exception("AppDomain", e.ExceptionObject as Exception ?? new Exception("Unknown error")); + TaskScheduler.UnobservedTaskException += (_, e) => + { + Logger.Exception("Unobserved task", e.Exception); + e.SetObserved(); + }; + if (Cli.IsCommand(args)) Environment.Exit(Cli.Run(args)); + using SingleInstance? instance = SingleInstance.TryAcquire(args); + if (instance is null) return; + Current = instance; + Logger.Info($"Packwright {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)} started on {System.Runtime.InteropServices.RuntimeInformation.OSDescription}."); + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + Logger.Info("Packwright exited."); + } + + /// The lock that keeps this the only running copy. + internal static SingleInstance? Current { get; private set; } + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace() + // Draw into a normal window surface. The default DirectComposition surface has no redirection + // bitmap, which some screenshot and screen-recording tools capture only partly. + .With(new Win32PlatformOptions { CompositionMode = [Win32CompositionMode.RedirectionSurface] }) + // The default lets Skia keep ~100 MB of GPU resources cached; this UI needs far less. + .With(new SkiaOptions { MaxGpuResourceSizeBytes = 32 * 1024 * 1024 }); +} diff --git a/src/Packwright.App/Services/AppServices.cs b/src/Packwright.App/Services/AppServices.cs new file mode 100644 index 0000000..069d1d8 --- /dev/null +++ b/src/Packwright.App/Services/AppServices.cs @@ -0,0 +1,212 @@ +using Packwright.Core.Models; +using Packwright.Core.Services; +using Packwright.Core.Tasks; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// Process-wide state shared by every view: settings, the library and the task queue. +public sealed class AppServices +{ + public static AppServices Instance { get; } = new(); + + private readonly Ps5LibraryScanner _scanner = new(); + private readonly object _gameGate = new(); + + private AppServices() + { + Store = new AppStateStore(); + SettingsLoadResult loaded = Store.LoadSettingsWithDiagnostics(); + Settings = loaded.Settings; + SettingsWarning = loaded.Warning; + Queue = new PackageTaskQueue(); + Queue.TasksChanged += (_, _) => LogTaskChanges(); + string taskFile = Path.Combine(Store.AppDataDirectory, "studio-tasks.json"); + Queue.SetPersistencePath(taskFile); + Queue.RestoreFromDisk(RestoreTask); + foreach (QueuedPackageTask restored in Queue.Tasks) _loggedStatus[restored.Id] = restored.Status; + Queue.EnablePersistence(taskFile); + Games = Store.LoadManifest().Games; + Logger.Info($"Loaded {Games.Count:N0} cached library item(s); {Settings.LibraryFolders.Count} library folder(s), " + + $"{Settings.ManualSources.Count} added item(s)."); + } + + private readonly Dictionary _loggedStatus = []; + + /// Writes one log line whenever a task starts, finishes, fails or is cancelled. + private void LogTaskChanges() + { + foreach (QueuedPackageTask task in Queue.Tasks) + { + PackageTaskStatus previous; + bool known; + lock (_loggedStatus) + { + known = _loggedStatus.TryGetValue(task.Id, out previous); + if (known && previous == task.Status) continue; + _loggedStatus[task.Id] = task.Status; + } + string name = $"{task.DisplayName} ({task.Operation}{(task.FormatRoute.Length > 0 ? ", " + task.FormatRoute : string.Empty)})"; + switch (task.Status) + { + case PackageTaskStatus.Queued when !known: + Logger.Info($"Task queued: {name}. From {task.SourcePath}" + + (task.OutputPath.Length > 0 && task.OutputPath != task.SourcePath ? $" to {task.OutputPath}" : string.Empty)); + break; + case PackageTaskStatus.Running: + Logger.Info($"Task started: {name}"); + break; + case PackageTaskStatus.Completed: + Logger.Info($"Task completed: {name}"); + break; + case PackageTaskStatus.Failed: + Logger.Error($"Task failed: {name}: {task.Failure?.Message ?? task.Message}"); + if (task.Failure is { } failure) Logger.Error(failure.ToString()); + break; + case PackageTaskStatus.Cancelled: + Logger.Warn($"Task cancelled: {name}"); + break; + case PackageTaskStatus.Interrupted when !known: + Logger.Warn($"Task interrupted in an earlier session: {name}"); + break; + } + } + } + + public AppStateStore Store { get; } + public AppSettings Settings { get; private set; } + public string? SettingsWarning { get; } + public PackageTaskQueue Queue { get; } + public List Games { get; private set; } + + public event Action? LibraryChanged; + + /// Files the last scan found but could not read ("path: reason"). + public IReadOnlyList ScanProblems { get; private set; } = []; + + /// Raised when another page wants the Library to show (and optionally edit) one title. + public event Action? ShowTitleRequested; + + public void RequestShowTitle(string path, bool edit) => ShowTitleRequested?.Invoke(path, edit); + + /// Raised after settings were saved so views can re-read appearance options. + public event Action? SettingsChanged; + + public void NotifySettingsChanged() => SettingsChanged?.Invoke(); + + /// Raised when another view wants the Tools tab to work on a path. + public event Action? ToolsSourceRequested; + + public void RequestToolsSource(string path) => ToolsSourceRequested?.Invoke(path); + + /// Queues a job described by ; it is saved and restored across restarts. + public QueuedPackageTask EnqueueJob(JobSpec spec, string displayName, string operation, string sourceFormat, + string targetFormat, string qualifier = "") + { + var executor = spec.Executor() ?? throw new InvalidOperationException("This job cannot be run."); + var task = new QueuedPackageTask + { + Id = Guid.NewGuid().ToString("N"), + Type = spec.TaskType, + DisplayName = displayName, + SourcePath = spec.Source, + OutputPath = spec.Output.Length > 0 ? spec.Output : spec.Source, + PersistencePayload = spec.ToJson(), + Operation = operation, + SourceFormat = sourceFormat, + TargetFormat = targetFormat, + TargetQualifier = qualifier, + StagePlan = spec.Plan(), + Execute = executor + }; + // Each task reports its own state changes (started, finished, failed); log them as they happen. + task.Changed += (_, _) => LogTaskChanges(); + return Queue.Enqueue(task); + } + + private QueuedPackageTask? RestoreTask(PersistedPackageTask entry) + { + JobSpec? spec = JobSpec.FromJson(entry.Payload); + var task = new QueuedPackageTask + { + Id = entry.Id, + Type = entry.Type, + DisplayName = entry.DisplayName, + SourcePath = entry.SourcePath, + OutputPath = entry.OutputPath, + PersistencePayload = entry.Payload, + Operation = entry.Operation, + SourceFormat = entry.SourceFormat, + TargetFormat = entry.TargetFormat, + TargetQualifier = entry.TargetQualifier, + StagePlan = spec?.Plan() ?? [], + Execute = spec?.Executor(), + CreatedUtc = entry.CreatedUtc == default ? DateTime.UtcNow : entry.CreatedUtc + }; + task.Changed += (_, _) => LogTaskChanges(); + return task; + } + + public void SaveSettings() + { + try { Store.SaveSettings(Settings); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn("Settings could not be saved: " + ex.Message); + } + } + + public void ReplaceSettings(AppSettings settings) + { + Settings = AppSettingsNormalizer.Normalize(settings); + SaveSettings(); + } + + /// Library folders plus single items added by path (manual sources). + public IReadOnlyList ScanSources => + Settings.LibraryFolders.Concat(Settings.ManualSources).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + public void AddManualSource(string path) + { + if (string.IsNullOrWhiteSpace(path)) return; + if (!Settings.ManualSources.Contains(path, StringComparer.OrdinalIgnoreCase)) + Settings.ManualSources.Add(path); + SaveSettings(); + } + + public void AddLibraryFolder(string path) + { + if (string.IsNullOrWhiteSpace(path)) return; + if (!Settings.LibraryFolders.Contains(path, StringComparer.OrdinalIgnoreCase)) + Settings.LibraryFolders.Add(path); + Settings.RecentFolders.RemoveAll(existing => string.Equals(existing, path, StringComparison.OrdinalIgnoreCase)); + Settings.RecentFolders.Insert(0, path); + SaveSettings(); + } + + public void RemoveLibraryFolder(string path) + { + Settings.LibraryFolders.RemoveAll(existing => string.Equals(existing, path, StringComparison.OrdinalIgnoreCase)); + SaveSettings(); + } + + /// Rescans every library folder (reusing cached entries for unchanged sources). + public async Task RefreshLibraryAsync(IProgress? progress, + CancellationToken cancellationToken) + { + Logger.Info($"Scanning {ScanSources.Count} source(s){(Settings.RecursiveScan ? " including sub-folders" : string.Empty)}..."); + Ps5ScanResult result = await _scanner.ScanAsync(ScanSources, Settings.RecursiveScan, + Games, progress, cancellationToken).ConfigureAwait(false); + lock (_gameGate) Games = result.Games; + try { Store.SaveManifest(result.Games); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn("The library manifest could not be saved: " + ex.Message); + } + ScanProblems = result.Errors.ToList(); + foreach (string error in result.Errors) Logger.Warn("Scan: " + error); + Logger.Info($"Scan finished: {result.Games.Count:N0} item(s), {result.Errors.Count} problem(s)."); + LibraryChanged?.Invoke(); + return result; + } +} diff --git a/src/Packwright.App/Services/ConsoleSender.cs b/src/Packwright.App/Services/ConsoleSender.cs new file mode 100644 index 0000000..e2cc325 --- /dev/null +++ b/src/Packwright.App/Services/ConsoleSender.cs @@ -0,0 +1,176 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Packwright.Core.Services; +using Packwright.Core.Tasks; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// +/// Sends files to a console on the local network: FTP upload, or "install by URL" where the console's package installer +/// downloads a .pkg from this PC. Only addresses on the local network are used, and only when the user sends something. +/// +public static class ConsoleSender +{ + private const int Attempts = 3; + + /// The remote folder a source will end up in: the chosen folder plus the file or dump folder name. + public static string RemotePathFor(string remoteFolder, string source) => + FtpClient.Normalize(FtpClient.Normalize(remoteFolder) + "/" + + Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))); + + /// Connects and reports what the console's FTP server says, so a profile can be checked before sending. + public static async Task TestFtpAsync(ConsoleProfile profile, CancellationToken token) + { + IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false); + await using FtpClient client = await ConnectAsync(address, profile, token).ConfigureAwait(false); + string system = await client.SystemAsync(token).ConfigureAwait(false); + (int code, string text) = await client.CommandAsync("PWD", token).ConfigureAwait(false); + int open = text.IndexOf('"'), close = text.IndexOf('"', open + 1); + string folder = code == 257 && open >= 0 && close > open ? text[(open + 1)..close] : "unknown"; + return $"Connected to {address}:{profile.FtpPort}. {(system.Length > 0 ? system + ". " : string.Empty)}Start folder: {folder}."; + } + + private static Task ConnectAsync(IPAddress address, ConsoleProfile profile, CancellationToken token) => + // Console FTP payloads accept anyone, so no account is used (and no password is stored). + FtpClient.ConnectAsync(address.ToString(), profile.FtpPort, "anonymous", "packwright@localhost", token); + + // ------------------------------------------------------------------ FTP upload + + public static async Task UploadAsync(ConsoleProfile profile, string source, string remoteFolder, + IProgress progress, CancellationToken token) + { + IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false); + string remoteRoot = RemotePathFor(remoteFolder, source); + List<(string Local, string Remote, long Size)> files = []; + if (Directory.Exists(source)) + { + string root = Path.GetFullPath(source); + foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + files.Add((file, remoteRoot + "/" + Path.GetRelativePath(root, file).Replace('\\', '/'), new FileInfo(file).Length)); + } + else if (File.Exists(source)) + files.Add((source, remoteRoot, new FileInfo(source).Length)); + else + throw new FileNotFoundException("The file to send was not found.", source); + if (files.Count == 0) throw new InvalidDataException("There is nothing in that folder to send."); + + long total = files.Sum(file => file.Size), done = 0; + var watch = Stopwatch.StartNew(); + long lastReport = 0; + FtpClient? client = null; + try + { + for (int index = 0; index < files.Count; index++) + { + (string local, string remote, long size) = files[index]; + for (int attempt = 1; ; attempt++) + { + token.ThrowIfCancellationRequested(); + try + { + client ??= await ConnectAsync(address, profile, token).ConfigureAwait(false); + await client.EnsureDirectoryAsync(remote[..remote.LastIndexOf('/')], token).ConfigureAwait(false); + long before = done; + await client.UploadAsync(local, remote, sent => + { + long now = watch.ElapsedMilliseconds; + if (now - lastReport < 250 && sent < size) return; + lastReport = now; + progress.Report(new PackageTaskProgress("Sending to " + profile.Name, 1, 1, before + sent, total, + index + 1, files.Count, remote, StagePercent: total > 0 ? (before + sent) * 100.0 / total : -1)); + }, token).ConfigureAwait(false); + done += size; + break; + } + catch (Exception ex) when (attempt < Attempts && IsRetryable(ex)) + { + // The link dropped or the console was busy: reconnect and carry on (a partly sent file is continued). + Logger.Warn($"Send to {profile.Name}: attempt {attempt} failed ({ex.Message}); retrying."); + if (client is not null) await client.DisposeAsync().ConfigureAwait(false); + client = null; + await Task.Delay(TimeSpan.FromSeconds(2), token).ConfigureAwait(false); + } + } + } + progress.Report(new PackageTaskProgress("Sent to " + profile.Name, 1, 1, total, total, files.Count, files.Count, string.Empty, 100)); + } + finally + { + if (client is not null) await client.DisposeAsync().ConfigureAwait(false); + } + Logger.Info($"Sent {files.Count:N0} file(s), {total:N0} bytes, to {profile.Name} ({address}) at {remoteRoot}."); + } + + /// Dropped links and busy servers are worth another try; refusals (5xx) are not. + private static bool IsRetryable(Exception ex) => ex switch + { + FtpException ftp => ftp.IsTemporary, + IOException or SocketException => true, + _ => false + }; + + // ------------------------------------------------------------------ install by URL + + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) }; + + /// Fills the placeholders of the install request. {url} is escaped for JSON bodies. + public static (string Url, string Body) BuildInstallRequest(ConsoleProfile profile, string fileUrl) + { + string url = profile.InstallUrl.Replace("{host}", profile.Host, StringComparison.Ordinal) + .Replace("{url}", Uri.EscapeDataString(fileUrl), StringComparison.Ordinal); + bool json = profile.InstallContentType.Contains("json", StringComparison.OrdinalIgnoreCase); + string value = json ? JsonEncodedText.Encode(fileUrl).ToString() : fileUrl; + return (url, profile.InstallBody.Replace("{url}", value, StringComparison.Ordinal)); + } + + public static async Task InstallByUrlAsync(ConsoleProfile profile, string package, + IProgress progress, CancellationToken token) + { + if (!File.Exists(package) || !package.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Install by URL needs a .pkg file."); + if (string.IsNullOrWhiteSpace(profile.InstallUrl)) + throw new InvalidOperationException("This console has no install address. Set it in the console's settings."); + IPAddress console = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false); + IPAddress local = ConsoleNetwork.LocalAddressFor(console); + await using SingleFileServer server = SingleFileServer.Start(package, local); + Logger.Info($"Serving {Path.GetFileName(package)} at {local}:{server.Port} for {profile.Name}."); + + (string requestUrl, string body) = BuildInstallRequest(profile, server.Url); + if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out Uri? target) || target.Scheme != Uri.UriSchemeHttp) + throw new InvalidOperationException("The install address is not a valid http:// address: " + requestUrl); + await ConsoleNetwork.ResolveLocalAsync(target.Host, token).ConfigureAwait(false); + + progress.Report(new PackageTaskProgress("Asking " + profile.Name + " to install", 1, 3, 0, server.Length, 0, 0, string.Empty, 0)); + using var content = new StringContent(body, Encoding.UTF8); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(profile.InstallContentType); + using HttpResponseMessage response = await Http.PostAsync(target, content, token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string text = (await response.Content.ReadAsStringAsync(token).ConfigureAwait(false)).Trim(); + throw new IOException($"The console's installer answered {(int)response.StatusCode} {response.ReasonPhrase}. {(text.Length > 200 ? text[..200] : text)}"); + } + + // Keep the file available while the console downloads it: wait until it has taken the whole file and has been quiet. + DateTime asked = DateTime.UtcNow; + while (true) + { + token.ThrowIfCancellationRequested(); + await Task.Delay(500, token).ConfigureAwait(false); + double percent = server.Length > 0 ? Math.Min(100.0, server.BytesServed * 100.0 / server.Length) : 0; + bool started = server.Requests > 0; + progress.Report(new PackageTaskProgress(started ? profile.Name + " is downloading the package" : "Waiting for " + profile.Name + " to start the download", + 2, 3, Math.Min(server.BytesServed, server.Length), server.Length, 0, 0, string.Empty, percent)); + if (!started && DateTime.UtcNow - asked > TimeSpan.FromMinutes(5)) + throw new IOException("The console never asked for the file. Check the install address in the console's settings, and that the firewall allows Packwright on your network."); + if (server.FullyServed && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromSeconds(20)) break; + if (started && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromMinutes(5)) + throw new IOException("The console stopped downloading before it had the whole file."); + } + progress.Report(new PackageTaskProgress("The console has the package", 3, 3, server.Length, server.Length, 0, 0, string.Empty, 100)); + Logger.Info($"{profile.Name} downloaded {Path.GetFileName(package)}."); + } +} diff --git a/src/Packwright.App/Services/Dialogs.cs b/src/Packwright.App/Services/Dialogs.cs new file mode 100644 index 0000000..88f0c6f --- /dev/null +++ b/src/Packwright.App/Services/Dialogs.cs @@ -0,0 +1,250 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Platform.Storage; + +namespace Packwright.App.Services; + +/// Small helpers for message boxes and the platform file/folder pickers. +public static class Dialogs +{ + public static Window? OwnerOf(Control control) => TopLevel.GetTopLevel(control) as Window; + + public static async Task ShowMessageAsync(Window? owner, string title, string message) + { + await ShowAsync(owner, title, message, ["OK"]); + } + + public static async Task ConfirmAsync(Window? owner, string title, string message, + string yes = "Yes", string no = "Cancel") => + await ShowAsync(owner, title, message, [yes, no]) == 0; + + private static async Task ShowAsync(Window? owner, string title, string message, string[] buttons) + { + int result = -1; + var window = new Window + { + Title = title, + Width = 460, + SizeToContent = SizeToContent.Height, + MinHeight = 140, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + var row = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 8 }; + for (int i = 0; i < buttons.Length; i++) + { + int index = i; + var button = new Button { Content = buttons[i], MinWidth = 80, HorizontalContentAlignment = HorizontalAlignment.Center }; + button.Click += (_, _) => { result = index; window.Close(); }; + if (i == 0) button.Classes.Add("accent"); + row.Children.Add(button); + } + window.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 18, + Children = + { + new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + row + } + }; + if (owner is not null) await window.ShowDialog(owner); + else window.Show(); + return result; + } + + /// Shows long text in a scrollable box; returns true when the user picks the first button. + public static async Task ConfirmTextAsync(Window? owner, string title, string header, string body, + string yes = "Apply", string no = "Cancel") + { + bool result = false; + var window = new Window + { + Title = title, + Width = 720, + Height = 520, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + var apply = new Button { Content = yes, MinWidth = 90 }; + apply.Classes.Add("accent"); + apply.Click += (_, _) => { result = true; window.Close(); }; + var cancel = new Button { Content = no, MinWidth = 90 }; + cancel.Click += (_, _) => window.Close(); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right }; + if (yes.Length > 0) buttons.Children.Add(apply); + buttons.Children.Add(cancel); + var grid = new Grid { Margin = new Thickness(16), RowDefinitions = new RowDefinitions("Auto,*,Auto") }; + grid.Children.Add(new TextBlock { Text = header, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 10) }); + var box = new TextBox + { + Text = body, + IsReadOnly = true, + AcceptsReturn = true, + FontFamily = new Avalonia.Media.FontFamily("Consolas,Menlo,monospace") + }; + Grid.SetRow(box, 1); + grid.Children.Add(box); + Grid.SetRow(buttons, 2); + buttons.Margin = new Thickness(0, 10, 0, 0); + grid.Children.Add(buttons); + window.Content = grid; + if (owner is not null) await window.ShowDialog(owner); + else window.Show(); + return result; + } + + /// Asks for one line of text; returns null when cancelled. + public static async Task PromptAsync(Window? owner, string title, string label, string initial = "") + { + string? result = null; + var window = new Window + { + Title = title, + Width = 420, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + var box = new TextBox { Text = initial }; + var ok = new Button { Content = "OK", MinWidth = 80 }; + ok.Classes.Add("accent"); + ok.Click += (_, _) => { result = box.Text; window.Close(); }; + var cancel = new Button { Content = "Cancel", MinWidth = 80 }; + cancel.Click += (_, _) => window.Close(); + box.KeyDown += (_, e) => { if (e.Key == Avalonia.Input.Key.Enter) { result = box.Text; window.Close(); } }; + window.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 12, + Children = + { + new TextBlock { Text = label }, + box, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { ok, cancel } } + } + }; + if (owner is not null) await window.ShowDialog(owner); + else window.Show(); + return string.IsNullOrWhiteSpace(result) ? null : result.Trim(); + } + + /// Shows a list of checkboxes; returns the new states or null when cancelled. + public static async Task ChecklistAsync(Window? owner, string title, IReadOnlyList<(string Label, bool Checked)> items) + { + bool[]? result = null; + var window = new Window + { + Title = title, + Width = 360, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + var boxes = items.Select(item => new CheckBox { Content = item.Label, IsChecked = item.Checked }).ToList(); + var ok = new Button { Content = "Apply", MinWidth = 80 }; + ok.Classes.Add("accent"); + ok.Click += (_, _) => { result = boxes.Select(box => box.IsChecked == true).ToArray(); window.Close(); }; + var cancel = new Button { Content = "Cancel", MinWidth = 80 }; + cancel.Click += (_, _) => window.Close(); + var list = new StackPanel { Spacing = 4 }; + foreach (CheckBox box in boxes) list.Children.Add(box); + window.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 14, + Children = + { + list, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { ok, cancel } } + } + }; + if (owner is not null) await window.ShowDialog(owner); + else window.Show(); + return result; + } + + public static async Task PickFolderAsync(Visual anchor, string title) + { + TopLevel? top = TopLevel.GetTopLevel(anchor); + if (top is null) return null; + IReadOnlyList folders = await top.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions { Title = title, AllowMultiple = false }); + return folders.Count > 0 ? folders[0].TryGetLocalPath() : null; + } + + public static async Task PickFileAsync(Visual anchor, string title, string[]? patterns = null) + { + TopLevel? top = TopLevel.GetTopLevel(anchor); + if (top is null) return null; + IReadOnlyList files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(patterns is null ? "PS5 images and packages" : "Files") + { + Patterns = patterns ?? ["*.pkg", "*.exfat", "*.ffpkg", "*.ffpfsc", "*.zar"] + }, + FilePickerFileTypes.All + ] + }); + return files.Count > 0 ? files[0].TryGetLocalPath() : null; + } + + public static async Task SaveFileAsync(Visual anchor, string title, string suggestedName, string extension) + { + TopLevel? top = TopLevel.GetTopLevel(anchor); + if (top is null) return null; + IStorageFile? file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + SuggestedFileName = suggestedName, + DefaultExtension = extension.TrimStart('.'), + ShowOverwritePrompt = false + }); + return file?.TryGetLocalPath(); + } + + public static void OpenPath(string path) + { + try + { + if (OperatingSystem.IsWindows()) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + else if (OperatingSystem.IsMacOS()) + System.Diagnostics.Process.Start("open", [path]); + else + System.Diagnostics.Process.Start("xdg-open", [path]); + } + catch (Exception ex) + { + Packwright.Infrastructure.Logger.Warn("Could not open " + path + ": " + ex.Message); + } + } + + /// Shows a file or folder in the platform file manager. + public static void Reveal(string path) + { + try + { + string folder = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; + if (OperatingSystem.IsWindows() && File.Exists(path)) + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); + else if (OperatingSystem.IsMacOS() && File.Exists(path)) + System.Diagnostics.Process.Start("open", ["-R", path]); + else + OpenPath(folder); + } + catch (Exception ex) + { + Packwright.Infrastructure.Logger.Warn("Could not reveal " + path + ": " + ex.Message); + } + } +} diff --git a/src/Packwright.App/Services/IconTools.cs b/src/Packwright.App/Services/IconTools.cs new file mode 100644 index 0000000..98044f1 --- /dev/null +++ b/src/Packwright.App/Services/IconTools.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using Packwright.Core.Models; + +namespace Packwright.App.Services; + +/// Turns artwork into the 512x512 PNG a title uses as its icon (sce_sys/icon0.png). +public static class IconTools +{ + public const int IconSize = 512; + + /// Centre-crops to a square and scales it to 512x512. Runs on the UI thread. + public static Task SquarePngAsync(Ps5ImageData source) => + Dispatcher.UIThread.InvokeAsync(() => SquarePng(source)).GetTask(); + + public static byte[]? SquarePng(Ps5ImageData source) + { + try + { + using Bitmap decoded = Decode(source); + return Square(decoded); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException) + { + return null; + } + } + + public static byte[]? SquarePng(string file) + { + try + { + using var decoded = new Bitmap(file); + return Square(decoded); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static byte[] Square(Bitmap decoded) + { + using var square = new RenderTargetBitmap(new PixelSize(IconSize, IconSize)); + using (DrawingContext context = square.CreateDrawingContext()) + { + double side = Math.Min(decoded.PixelSize.Width, decoded.PixelSize.Height); + var crop = new Rect((decoded.PixelSize.Width - side) / 2, (decoded.PixelSize.Height - side) / 2, side, side); + context.DrawImage(decoded, crop, new Rect(0, 0, IconSize, IconSize)); + } + using var memory = new MemoryStream(); + square.Save(memory); + return memory.ToArray(); + } + + private static Bitmap Decode(Ps5ImageData data) + { + if (!data.IsRgba) return new Bitmap(new MemoryStream(data.Bytes, writable: false)); + var bitmap = new WriteableBitmap(new PixelSize(data.Width, data.Height), new Vector(96, 96), + PixelFormat.Rgba8888, AlphaFormat.Unpremul); + using ILockedFramebuffer buffer = bitmap.Lock(); + int rowBytes = data.Width * 4; + for (int y = 0; y < data.Height; y++) + System.Runtime.InteropServices.Marshal.Copy(data.Bytes, y * rowBytes, buffer.Address + y * buffer.RowBytes, rowBytes); + return bitmap; + } +} diff --git a/src/Packwright.App/Services/JobSpec.cs b/src/Packwright.App/Services/JobSpec.cs new file mode 100644 index 0000000..a374c59 --- /dev/null +++ b/src/Packwright.App/Services/JobSpec.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using Packwright.Core.Backends; +using Packwright.Core.Builders; +using Packwright.Core.Tasks; +using Packwright.Containers; +using Packwright.Infrastructure; +using UFS2Tool; + +namespace Packwright.App.Services; + +/// +/// A serializable description of one queued job. The same spec drives the job when it is first queued +/// and when it is restored after a restart, so there is one place that knows how to run each kind. +/// +public sealed class JobSpec +{ + public const string Convert = "convert"; + public const string BuildPackage = "build"; + public const string Extract = "extract"; + public const string Verify = "verify"; + public const string Repair = "repair"; + public const string RefreshAmpr = "ampr"; + public const string RebuildFfpkg = "rebuild"; + public const string EditDetails = "editmeta"; + public const string SendFtp = "sendftp"; + public const string SendInstall = "sendurl"; + + public string Kind { get; set; } = string.Empty; + + /// The game's name and a small PNG icon (base64), shown on the task card. + public string Title { get; set; } = string.Empty; + public string? Icon { get; set; } + public string Source { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; // Ps5ImageFormat name of the source + public string Target { get; set; } = string.Empty; // Ps5ImageConversionTarget name + public bool Overwrite { get; set; } + public bool FromPackage { get; set; } + public string Passcode { get; set; } = string.Empty; + + // Image options + public int ClusterSize { get; set; } + public bool GenerateAmpr { get; set; } = true; + public int CompressionLevel { get; set; } = 7; + public int MinimumGain { get; set; } = 1; + public int BlockSize { get; set; } = 32768; + public int FragmentSize { get; set; } = 4096; + public int BytesPerInode { get; set; } = 262144; + public int MinFreePercent { get; set; } + + /// For the send kinds: a copy of the console's settings at the time, and the folder on the console. + public ConsoleProfile? Console { get; set; } + public string RemoteFolder { get; set; } = string.Empty; + + /// For : the details to write. Output empty means edit in place. + public MetadataEdit? Edit { get; set; } + + // Package build + public string ContentId { get; set; } = string.Empty; + public string Backend { get; set; } = string.Empty; + public string Compression { get; set; } = nameof(Ps5InnerCompression.Auto); + public int KrakenLevel { get; set; } = 7; + public int KrakenThreads { get; set; } + public int PlayGoChunks { get; set; } = 1; + public bool Deterministic { get; set; } + public bool FakeSign { get; set; } = true; + public bool RightSprx { get; set; } = true; + public string? Drm { get; set; } = "upgradable"; + public ulong? Sdk { get; set; } + public string? TempDirectory { get; set; } + + public string ToJson() => JsonSerializer.Serialize(this); + + public static JobSpec? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json); } + catch (JsonException) { return null; } + } + + public PackageTaskStage[] Plan() => Kind switch + { + Convert => FromPackage ? PackageTaskPlans.ConvertPackage : PackageTaskPlans.ConvertImage, + BuildPackage => PackageTaskPlans.BuildPackageFor(Source, Backend == BackendRegistry.LppId && !Directory.Exists(Source)), + Extract => PackageTaskPlans.Extract, + Verify => PackageTaskPlans.Verify, + RefreshAmpr => PackageTaskPlans.Ampr, + _ => PackageTaskPlans.Single + }; + + public string TaskType => Kind switch + { + Convert => PackageTaskTypes.ImageConvert, + BuildPackage => PackageTaskTypes.ImageBuildPackage, + Extract => PackageTaskTypes.PackageExtract, + Verify => FromPackage ? PackageTaskTypes.PackageVerify : PackageTaskTypes.ImageVerify, + Repair => PackageTaskTypes.ExfatRepair, + RefreshAmpr => PackageTaskTypes.ExfatAmpr, + EditDetails => PackageTaskTypes.EditDetails, + SendFtp or SendInstall => PackageTaskTypes.SendToConsole, + _ => PackageTaskTypes.FfpkgRebuild + }; + + private Ps5ImageFormat ImageFormat => Enum.TryParse(Format, out Ps5ImageFormat parsed) ? parsed : Ps5ImageFormat.Unknown; + + /// The delegate that runs this job inside the task queue. + public Func, CancellationToken, Task>? Executor() + { + if (Source.Length == 0) return null; + switch (Kind) + { + case Convert: + if (Output.Length == 0 || !Enum.TryParse(Target, out Ps5ImageConversionTarget target)) return null; + return (progress, token) => Jobs.ConvertAsync(Source, Output, target, Overwrite, FromPackage, progress, token, + target is Ps5ImageConversionTarget.Ffpkg or Ps5ImageConversionTarget.ZArchive + ? null + : new ExfatBuildOptions { ClusterSize = ClusterSize > 0 ? ClusterSize : null, GenerateAmprIndex = GenerateAmpr }, + target == Ps5ImageConversionTarget.Ffpfsc + ? new FfpfscBuildOptions + { + Compression = new PfscCompressionOptions { CompressionLevel = CompressionLevel, MinimumGainPercent = MinimumGain } + } + : null, + target == Ps5ImageConversionTarget.Ffpkg + ? new FfpkgBuildOptions + { + BlockSize = BlockSize, + FragmentSize = FragmentSize, + BytesPerInode = BytesPerInode, + MinFreePercent = MinFreePercent + } + : null); + case BuildPackage: + if (Output.Length == 0 || ContentId.Length == 0) return null; + var settings = new PackageBuildSettings( + Enum.TryParse(Compression, out Ps5InnerCompression compression) ? compression : Ps5InnerCompression.Auto, + KrakenLevel, KrakenThreads, PlayGoChunks, Deterministic, FakeSign, RightSprx, Drm, Sdk, TempDirectory); + IPackageBackend backend = Jobs.ResolveBackend(Backend); + string passcode = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode; + return (progress, token) => Jobs.BuildPackageAsync(Source, Output, ContentId, passcode, Overwrite, settings, + backend, progress, token); + case Extract: + if (Output.Length == 0) return null; + if (FromPackage) + { + string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode; + return async (progress, token) => + await Jobs.ExtractPackageAsync(Source, Output, code, progress, token).ConfigureAwait(false); + } + Ps5ImageFormat extractFormat = ImageFormat; + return (progress, token) => Jobs.ExtractImageAsync(Source, extractFormat, Output, progress, token); + case Verify: + if (FromPackage) + { + string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode; + return (progress, token) => + { + SonyDebugPackageValidationResult result = Jobs.VerifyPackage(Source, code); + if (!result.IsValid) throw new InvalidDataException(result.Message); + Logger.Info($"Package verified: {result.IndexedFiles:N0} indexed file(s)."); + return Task.CompletedTask; + }; + } + Ps5ImageFormat verifyFormat = ImageFormat; + return (progress, token) => Jobs.VerifyImageAsync(Source, verifyFormat, token); + case Repair: + return async (progress, token) => + await ExfatImageMaintenance.RepairAsync(Source, Jobs.Adapt(progress), token).ConfigureAwait(false); + case RefreshAmpr: + return async (progress, token) => + await ExfatAmprPatcher.RefreshAsync(Source, Jobs.Adapt(progress), token).ConfigureAwait(false); + case RebuildFfpkg: + return async (progress, token) => + await Ufs2Operations.RebuildWithEditsAsync(Source, static _ => { }, Jobs.AdaptUfs2(progress), token) + .ConfigureAwait(false); + case EditDetails: + if (Edit is not { IsEmpty: false } edit) return null; + string editCode = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode; + return (progress, token) => MetadataEditor.ApplyAsync(Source, Output, FromPackage, edit, editCode, + Backend, progress, token); + case SendFtp: + if (Console is null) return null; + ConsoleProfile ftpTarget = Console; + string folder = RemoteFolder.Length > 0 ? RemoteFolder : ftpTarget.DefaultFolder; + return (progress, token) => ConsoleSender.UploadAsync(ftpTarget, Source, folder, progress, token); + case SendInstall: + if (Console is null) return null; + ConsoleProfile installTarget = Console; + return (progress, token) => ConsoleSender.InstallByUrlAsync(installTarget, Source, progress, token); + default: + return null; + } + } +} diff --git a/src/Packwright.App/Services/Jobs.cs b/src/Packwright.App/Services/Jobs.cs new file mode 100644 index 0000000..5d7ffd9 --- /dev/null +++ b/src/Packwright.App/Services/Jobs.cs @@ -0,0 +1,325 @@ +using Packwright.Core.Backends; +using Packwright.Core.Builders; +using Packwright.Core.Services; +using Packwright.Core.Tasks; +using Packwright.Containers; +using Packwright.Infrastructure; +using UFS2Tool; + +namespace Packwright.App.Services; + +/// Settings for a debug package (FPKG) build. +public sealed record PackageBuildSettings( + Ps5InnerCompression Compression = Ps5InnerCompression.Auto, + int KrakenLevel = 7, + int KrakenThreads = 0, + int PlayGoChunks = 1, + bool Deterministic = false, + bool FakeSignModules = true, + bool InjectRightSprx = true, + string? DrmType = "upgradable", + ulong? SdkVersionOverride = null, + string? TempDirectory = null); + +/// +/// The long-running operations behind the Tools tab. Each method is UI-free and reports progress +/// through , so it can run inside the shared task queue. +/// +public static class Jobs +{ + public static IProgress NoProgress { get; } = new Progress(); + + public static IProgress Adapt(IProgress target) => + new Progress(value => target.Report(new PackageTaskProgress( + value.Stage, 0, 0, value.BytesProcessed, value.TotalBytes, 0, 0, string.Empty))); + + public static IProgress AdaptUfs2(IProgress target) => + new Progress(value => target.Report(new PackageTaskProgress( + value.Stage, 0, 0, value.BytesProcessed, value.TotalBytes, + checked((int)Math.Min(value.Completed, int.MaxValue)), + checked((int)Math.Min(value.Total, int.MaxValue)), value.Unit))); + + public static IProgress AdaptConversion(IProgress target) => + new Progress(value => target.Report(new PackageTaskProgress( + value.Stage, 0, 0, value.Completed, value.Total, 0, 0, string.Empty))); + + public static IProgress AdaptExtract(IProgress target) => + new Progress(value => target.Report(new PackageTaskProgress( + "Extracting", 0, 0, value.CompletedBytes, value.TotalBytes, 0, 0, value.CurrentPath))); + + /// The builder to use: the saved choice, falling back when LibProsperoPkg is not usable on this OS. + public static IPackageBackend ResolveBackend(string? id) + { + IPackageBackend backend = BackendRegistry.Get(id); + if (backend.Id == BackendRegistry.LppId && !PlatformSupportsLpp) + backend = BackendRegistry.Get(BackendRegistry.PptId); + return backend; + } + + public static bool PlatformSupportsLpp => OperatingSystem.IsWindows() && + File.Exists(Path.Combine(AppContext.BaseDirectory, "LibProsperoPkg12", "LibProsperoPkg.dll")); + + public static Task ConvertAsync(string source, string output, Ps5ImageConversionTarget target, bool overwrite, + bool fromPackage, IProgress progress, CancellationToken token, + ExfatBuildOptions? exfatOptions = null, FfpfscBuildOptions? ffpfscOptions = null, + FfpkgBuildOptions? ffpkgOptions = null) + { + IProgress bridge = AdaptConversion(progress); + return fromPackage + ? SonyPackageImageConversion.ConvertAsync(source, output, target, overwrite, bridge, token, + exfatOptions, ffpfscOptions, ffpkgOptions) + : Ps5ImageConversionService.ConvertAsync(source, output, target, overwrite, bridge, token, + exfatOptions, ffpfscOptions, ffpkgOptions); + } + + public static async Task ExtractImageAsync(string source, Ps5ImageFormat format, string output, + IProgress progress, CancellationToken token) + { + switch (format) + { + case Ps5ImageFormat.Exfat: + await ExfatImage.ExtractDirectoryAsync(source, output, Adapt(progress), token).ConfigureAwait(false); + break; + case Ps5ImageFormat.Ufs2: + await Ufs2Operations.ExtractAsync(source, output, AdaptUfs2(progress), token).ConfigureAwait(false); + break; + case Ps5ImageFormat.Pfs: + await FfpfscImage.ExtractToDirectoryAsync(source, output, overwrite: true, Adapt(progress), token) + .ConfigureAwait(false); + break; + case Ps5ImageFormat.ZArchive: + await ZArchiveImage.ExtractToDirectoryAsync(source, output, overwrite: true, Adapt(progress), token) + .ConfigureAwait(false); + break; + default: + throw new InvalidDataException("Unsupported source image format."); + } + } + + public static async Task VerifyImageAsync(string source, Ps5ImageFormat format, CancellationToken token) + { + switch (format) + { + case Ps5ImageFormat.Exfat: + await ExfatImage.VerifyAsync(source, null, token).ConfigureAwait(false); + break; + case Ps5ImageFormat.Ufs2: + await Ufs2Operations.VerifyAsync(source, null, token).ConfigureAwait(false); + break; + case Ps5ImageFormat.Pfs: + FfpfscVerificationResult result = await FfpfscImage.TryVerifyAsync(source, null, null, token) + .ConfigureAwait(false); + if (!result.StructureValid) + throw new InvalidDataException("The FFPFSC structure is invalid. " + (result.Error ?? string.Empty)); + if (!result.EveryPfscBlockDecodes) + throw new InvalidDataException("The FFPFSC block decode failed. " + (result.Error ?? string.Empty)); + break; + case Ps5ImageFormat.ZArchive: + await ZArchiveImage.VerifyAsync(source, null, token).ConfigureAwait(false); + break; + default: + throw new InvalidDataException("Unsupported image format."); + } + } + + public static async Task ExtractPackageAsync(string source, string output, + string passcode, IProgress progress, CancellationToken token) => + await SonyPackageExtraction.ExtractAsync(source, output, passcode, AdaptExtract(progress), token) + .ConfigureAwait(false); + + public static SonyDebugPackageValidationResult VerifyPackage(string source, string passcode) => + SonyDebugPackageBuilder.Validate(source, passcode); + + /// Reads the content ID from a dump folder or image so a package can be built from it. + public static string ReadContentId(string source, Ps5ImageFormat format) + { + byte[]? bytes = TryReadParamBytes(source, format); + if (bytes is null || bytes.Length == 0) return string.Empty; + try + { + using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(bytes); + return document.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object && + document.RootElement.TryGetProperty("contentId", out System.Text.Json.JsonElement element) && + element.ValueKind == System.Text.Json.JsonValueKind.String + ? element.GetString() ?? string.Empty + : string.Empty; + } + catch (System.Text.Json.JsonException) + { + return string.Empty; + } + } + + private static byte[]? TryReadParamBytes(string source, Ps5ImageFormat format) + { + try + { + if (Directory.Exists(source)) + { + string path = Path.Combine(source, "sce_sys", "param.json"); + return File.Exists(path) ? File.ReadAllBytes(path) : null; + } + switch (format) + { + case Ps5ImageFormat.Exfat: + using (var volume = new ExfatVolume(File.OpenRead(source))) + return volume.ReadAllBytes("sce_sys/param.json", 4 * 1024 * 1024); + case Ps5ImageFormat.Ufs2: + using (var volume = new Ufs2Volume(source)) + return volume.ReadAllBytes("sce_sys/param.json", 4 * 1024 * 1024); + case Ps5ImageFormat.Pfs: + using (var volume = FfpfscVolume.Open(source)) + return volume.ReadAllBytes("sce_sys/param.json", 4 * 1024 * 1024); + case Ps5ImageFormat.ZArchive: + using (var volume = new ZArchiveVolume(source)) + { + string param = volume.Entries.FirstOrDefault(entry => !entry.IsDirectory && + (entry.Path.Equals("sce_sys/param.json", StringComparison.OrdinalIgnoreCase) || + entry.Path.EndsWith("/sce_sys/param.json", StringComparison.OrdinalIgnoreCase)))?.Path ?? string.Empty; + return param.Length == 0 ? null : volume.ReadAllBytes(param, 4 * 1024 * 1024); + } + default: + return null; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or + ArgumentException or NotSupportedException) + { + return null; + } + } + + private static byte[] DeterministicSeed(string contentId, string passcode) => + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(contentId + "\0" + passcode))[..16]; + + public static async Task BuildPackageAsync(string source, string output, string contentId, string passcode, + bool overwrite, PackageBuildSettings settings, IPackageBackend backend, + IProgress progress, CancellationToken token) + { + if (!overwrite && File.Exists(output)) + throw new IOException($"The output file already exists: {output}"); + string outputDirectory = Path.GetDirectoryName(Path.GetFullPath(output)) ?? Directory.GetCurrentDirectory(); + Directory.CreateDirectory(outputDirectory); + string jobDirectory = Path.Combine(outputDirectory, ".packwright-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(jobDirectory); + string partial = Path.Combine(jobDirectory, Path.GetFileName(output) + ".partial"); + var options = new SonyDebugPackageBuildOptions + { + ContentId = contentId, + Passcode = passcode, + SdkVersionOverride = settings.SdkVersionOverride, + TempDirectory = settings.TempDirectory, + DrmTypeOverride = settings.DrmType, + Log = Logger.Info, + Compression = settings.Compression, + KrakenLevel = settings.KrakenLevel, + KrakenThreads = settings.KrakenThreads, + PlayGoChunkCount = settings.PlayGoChunks, + Seed = settings.Deterministic ? DeterministicSeed(contentId, passcode) : null, + FakeSignModules = settings.FakeSignModules, + InjectRightSprx = settings.InjectRightSprx + }; + var bridge = new Progress(value => + progress.Report(new PackageTaskProgress(value.Stage, 0, 0, value.CompletedBytes, value.TotalBytes, + 0, 0, value.CurrentPath))); + bool isDirectory = Directory.Exists(source); + Ps5ImageFormat sourceFormat = isDirectory ? Ps5ImageFormat.Unknown : Ps5ImageFormatProbe.Detect(source); + // A ZArchive, and any image built with LibProsperoPkg, is unpacked to a staging folder first. + bool stageFromImage = !isDirectory && (backend.Id == BackendRegistry.LppId || sourceFormat == Ps5ImageFormat.ZArchive); + IPackageBackend effective = isDirectory || stageFromImage ? backend : BackendRegistry.Get(BackendRegistry.PptId); + + string? staging = null; + try + { + string buildSource = source; + if (stageFromImage) + { + long rawBytes = sourceFormat == Ps5ImageFormat.ZArchive + ? ZArchiveLogicalBytes(source) + : VolumeDebugPackageBuilder.EstimatePayloadBytes(source); + // The extractors create the folder themselves (some refuse one that already exists). + staging = Path.Combine(Path.GetTempPath(), "Packwright-image-" + Guid.NewGuid().ToString("N")); + progress.Report(new PackageTaskProgress("Extract image", 0, 0, 0, rawBytes, 0, 0, + Path.GetFileName(source))); + await ExtractImageAsync(source, Ps5ImageFormatProbe.Detect(source), staging, progress, token) + .ConfigureAwait(false); + buildSource = staging; + } + + async Task BuildAsync() + { + if (isDirectory || staging is not null) + await effective.BuildFromDirectoryAsync(buildSource, partial, options, bridge, token) + .ConfigureAwait(false); + else + await effective.BuildFromImageAsync(source, partial, options, bridge, token) + .ConfigureAwait(false); + } + + try + { + await BuildAsync().ConfigureAwait(false); + } + catch (BackendNotSupportedException ex) when (effective.Id == BackendRegistry.LppId) + { + Logger.Info($"Builder {effective.DisplayName} could not lay out this title ({ex.Message}); " + + "retrying with ProsperoPkgTool."); + effective = BackendRegistry.Get(BackendRegistry.PptId); + TryDeleteFile(partial); + await BuildAsync().ConfigureAwait(false); + } + + PackageReaderVerificationResult result = PackageReaderVerification.Inspect(partial, passcode); + Logger.Info($"Verified built package: {result.FileCount:N0} file(s), eboot.bin " + + (result.HasEboot ? "present" : "missing") + "."); + if (result.FileCount == 0 || !result.HasEboot) + throw new InvalidDataException("The built package failed verification: " + + (result.FileCount == 0 ? "the filesystem is empty." : "eboot.bin is missing.")); + token.ThrowIfCancellationRequested(); + File.Move(partial, output, overwrite); + } + catch + { + TryDeleteFile(partial); + throw; + } + finally + { + TryDeleteDirectory(jobDirectory); + if (staging is not null) TryDeleteDirectory(staging); + } + } + + private static long ZArchiveLogicalBytes(string path) + { + using var volume = new ZArchiveVolume(path); + return volume.TotalFileBytes; + } + + public static string FindAvailablePath(string preferredPath) + { + string fullPath = Path.GetFullPath(preferredPath); + if (!Directory.Exists(fullPath) && !File.Exists(fullPath)) return fullPath; + for (int index = 2; index < 10_000; index++) + { + string candidate = fullPath + $" ({index})"; + if (!Directory.Exists(candidate) && !File.Exists(candidate)) return candidate; + } + throw new IOException("Unable to find an available output name."); + } + + private static void TryDeleteFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static void TryDeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/src/Packwright.App/Services/LibraryActions.cs b/src/Packwright.App/Services/LibraryActions.cs new file mode 100644 index 0000000..f7e51e0 --- /dev/null +++ b/src/Packwright.App/Services/LibraryActions.cs @@ -0,0 +1,310 @@ +using System.Collections; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Packwright.App.Models; +using Packwright.Core.Models; +using Packwright.Core.Services; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// One planned rename: the source, where it would land and whether a numbered name was needed. +public sealed record RenamePlan(Ps5GameInfo Game, string Source, string Target, bool Conflict, string? Error) +{ + public bool Unchanged => Error is null && Target.Length == 0; +} + +/// File operations behind the library context menu: rename, move, delete, export and duplicates. +public static class LibraryActions +{ + public static string DefaultRenameFormat(AppSettings settings) => + string.IsNullOrWhiteSpace(settings.RenameFormat) ? AppSettingsNormalizer.DefaultRenameFormat : settings.RenameFormat.Trim(); + + private static Ps5RenameTokens Tokens(Ps5GameInfo game) => new( + Title: game.Title, + TitleId: game.TitleId, + ContentId: game.ContentId, + ConceptId: game.ConceptId, + Version: game.DisplayVersion, + ContentVersion: game.ContentVersion, + MasterVersion: game.MasterVersion, + Category: LibraryItem.CategoryOf(game), + Region: LibraryItem.RegionOf(game), + Platform: game.Platform, + SystemVersion: game.RequiredSystemSoftware, + SdkVersion: game.SdkVersion, + Source: game.SourceDescription, + Size: game.SourceSize > 0 ? LibraryItem.FormatBytes(game.SourceSize) : string.Empty, + Language: game.DefaultLanguage, + Drm: game.DrmType, + Date: game.CreationDate, + Tool: game.ToolVersion); + + public static IReadOnlyList PlanRenames(IEnumerable games, string format) => + games.Select(game => Resolve(game, Ps5RenameFormatter.Expand(format, Tokens(game), FallbackName(game)))).ToList(); + + private static string FallbackName(Ps5GameInfo game) + { + string name = Path.GetFileNameWithoutExtension(game.RootPath); + return string.IsNullOrWhiteSpace(name) ? "PS5_GAME" : name; + } + + private static RenamePlan Resolve(Ps5GameInfo game, string baseName) + { + string source = game.RootPath; + bool isDirectory = Directory.Exists(source); + if (!isDirectory && !File.Exists(source)) + return new RenamePlan(game, source, string.Empty, false, "source path no longer exists"); + string safe = Ps5RenameFormatter.Sanitize(baseName); + if (safe.Length == 0) return new RenamePlan(game, source, string.Empty, false, "empty name"); + string? parent = Path.GetDirectoryName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrEmpty(parent)) return new RenamePlan(game, source, string.Empty, false, "no parent folder"); + string extension = isDirectory ? string.Empty : Path.GetExtension(source); + string raw = Path.Combine(parent, safe + extension); + if (string.Equals(raw, source, StringComparison.OrdinalIgnoreCase)) + return new RenamePlan(game, source, string.Empty, false, null); + bool conflict = File.Exists(raw) || Directory.Exists(raw); + return new RenamePlan(game, source, conflict ? MakeUnique(raw, isDirectory) : raw, conflict, null); + } + + /// + /// Numbers packages per Title ID in install order (base game, then updates, then add-ons), so sorting + /// the files by name installs them in the right order. + /// + public static IReadOnlyList PlanInstallOrder(IEnumerable games, string format) + { + var plans = new List(); + foreach (IGrouping group in games + .Where(game => game.SourceKind == Ps5SourceKind.SonyPackage && !string.IsNullOrWhiteSpace(game.TitleId)) + .GroupBy(game => game.TitleId, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) + { + List ordered = group + .OrderBy(game => CategoryPriority(LibraryItem.CategoryOf(game))) + .ThenBy(game => game.DisplayVersion, StringComparer.OrdinalIgnoreCase) + .ThenBy(game => game.RootPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + for (int index = 0; index < ordered.Count; index++) + { + Ps5GameInfo game = ordered[index]; + string name = Ps5RenameFormatter.Expand(format, Tokens(game), FallbackName(game)); + plans.Add(Resolve(game, $"{index:D2} - {name}")); + } + } + return plans; + } + + private static int CategoryPriority(string category) => category switch + { + "Game" => 0, + "Patch" => 1, + "DLC" or "Add-on" => 2, + "App" => 3, + _ => 4 + }; + + /// Lists updates and add-ons whose Title ID has no base game in the library. + public static string MissingBaseReport(IEnumerable items) + { + List all = items.ToList(); + HashSet bases = all.Where(item => item.Category == "Game" && item.TitleId.Length > 0) + .Select(item => item.TitleId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var text = new StringBuilder(); + foreach (LibraryItem item in all + .Where(item => item.Category is "Patch" or "DLC" or "Add-on" && item.TitleId.Length > 0 && + !bases.Contains(item.TitleId)) + .OrderBy(item => item.TitleId, StringComparer.OrdinalIgnoreCase)) + text.AppendLine($"{item.TitleId} {item.Category,-6} v{item.Version,-9} {item.Title}\n {item.Path}"); + return text.Length == 0 ? "Every update and add-on has a base game in the library." : text.ToString(); + } + + public static string MakeUnique(string target, bool isDirectory) + { + if (!File.Exists(target) && !Directory.Exists(target)) return target; + string parent = Path.GetDirectoryName(target) ?? string.Empty; + string name = isDirectory ? Path.GetFileName(target) : Path.GetFileNameWithoutExtension(target); + string extension = isDirectory ? string.Empty : Path.GetExtension(target); + for (int index = 2; index < 10000; index++) + { + string candidate = Path.Combine(parent, $"{name} ({index}){extension}"); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) return candidate; + } + return target; + } + + public static string PreviewText(IReadOnlyList plans) + { + var text = new StringBuilder(); + foreach (RenamePlan plan in plans) + { + string name = Path.GetFileName(plan.Source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (plan.Error is not null) text.AppendLine($"x {name} ({plan.Error})"); + else if (plan.Unchanged) text.AppendLine($"= {name} (unchanged)"); + else + { + text.AppendLine($" {name}"); + text.AppendLine($"-> {Path.GetFileName(plan.Target)}{(plan.Conflict ? " [name already taken]" : string.Empty)}"); + } + } + return text.ToString(); + } + + /// Applies the plans and returns how many items were renamed. + public static int ApplyRenames(IEnumerable plans) + { + int renamed = 0; + foreach (RenamePlan plan in plans.Where(plan => plan.Error is null && plan.Target.Length > 0)) + { + try + { + if (Directory.Exists(plan.Source)) Directory.Move(plan.Source, plan.Target); + else File.Move(plan.Source, plan.Target); + renamed++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + Logger.Warn($"Rename skipped for '{plan.Source}': {ex.Message}"); + } + } + return renamed; + } + + public static int MoveAll(IEnumerable games, string destination, CancellationToken token) + { + Directory.CreateDirectory(destination); + int moved = 0; + foreach (Ps5GameInfo game in games) + { + string source = game.RootPath; + bool isDirectory = Directory.Exists(source); + if (!isDirectory && !File.Exists(source)) continue; + string name = Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + string target = MakeUnique(Path.Combine(destination, name), isDirectory); + try + { + LibraryFileMover.Move(source, target, token); + moved++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn($"Move skipped for '{source}': {ex.Message}"); + } + } + return moved; + } + + public static int DeleteAll(IEnumerable games) + { + int deleted = 0; + foreach (Ps5GameInfo game in games) + { + try + { + if (Directory.Exists(game.RootPath)) Directory.Delete(game.RootPath, recursive: true); + else if (File.Exists(game.RootPath)) File.Delete(game.RootPath); + else continue; + deleted++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn($"Delete skipped for '{game.RootPath}': {ex.Message}"); + } + } + return deleted; + } + + public static string ToCsv(IEnumerable items) + { + var text = new StringBuilder(); + text.AppendLine("Title,TitleId,ContentId,Category,Region,Format,SizeBytes,Version,RequiredFirmware,DRM,FileName,Path"); + foreach (LibraryItem item in items) + text.AppendLine(string.Join(",", new[] + { + item.Title, item.TitleId, item.ContentId, item.Category, item.Region, item.Format, + item.SizeBytes.ToString(), item.Version, item.Firmware, item.Game.DrmType, item.FileName, item.Path + }.Select(Csv))); + return text.ToString(); + } + + private static string Csv(string value) + { + if (value.Length > 0 && value[0] is '=' or '+' or '-' or '@') value = "'" + value; + return value.IndexOfAny([',', '"', '\n', '\r']) >= 0 ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + } + + /// Groups items that share the same param.json bytes (exact copies) or the same content ID. + public static string DuplicatesReport(IEnumerable items) + { + var text = new StringBuilder(); + int groups = 0; + foreach (IGrouping group in items + .Where(item => item.Game.RawParamJson.Length > 0) + .GroupBy(item => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(item.Game.RawParamJson)))) + .Where(group => group.Count() > 1)) + { + groups++; + text.AppendLine($"Identical param.json ({group.Count()} copies): {group.First().Title}"); + foreach (LibraryItem item in group) text.AppendLine(" " + item.Path); + } + var identical = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (IGrouping group in items + .Where(item => item.ContentId.Length > 0) + .GroupBy(item => item.ContentId + "|" + item.Version, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1)) + { + if (group.Select(item => item.Path).All(identical.Contains)) continue; + groups++; + text.AppendLine($"Same content ID and version ({group.Count()}): {group.First().Title}"); + foreach (LibraryItem item in group) text.AppendLine(" " + item.Path); + } + return groups == 0 ? "No duplicates found." : text.ToString(); + } + + /// Renders a parsed model as readable "name: value" lines (lists are summarised and truncated). + public static string Describe(object? value, int maxListItems = 40) + { + if (value is null) return "Nothing to show."; + var text = new StringBuilder(); + Write(text, value, 0, maxListItems); + return text.ToString(); + } + + private static void Write(StringBuilder text, object value, int depth, int maxItems) + { + string indent = new(' ', depth * 2); + foreach (PropertyInfo property in value.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) continue; + object? item; + try { item = property.GetValue(value); } + catch (TargetInvocationException) { continue; } + if (item is null || item is byte[]) continue; + if (item is string or bool or IFormattable || item.GetType().IsEnum) + { + text.AppendLine($"{indent}{property.Name}: {item}"); + } + else if (item is IEnumerable sequence) + { + List list = sequence.Cast().ToList(); + text.AppendLine($"{indent}{property.Name}: {list.Count} item(s)"); + if (depth >= 1) continue; + foreach (object entry in list.Take(maxItems)) + { + if (entry is string or IFormattable) text.AppendLine($"{indent} - {entry}"); + else + { + text.AppendLine($"{indent} -"); + Write(text, entry, depth + 2, maxItems); + } + } + if (list.Count > maxItems) text.AppendLine($"{indent} ... {list.Count - maxItems} more"); + } + else if (depth < 1) + { + text.AppendLine($"{indent}{property.Name}:"); + Write(text, item, depth + 1, maxItems); + } + } + } +} diff --git a/src/Packwright.App/Services/LibraryExport.cs b/src/Packwright.App/Services/LibraryExport.cs new file mode 100644 index 0000000..21aa31b --- /dev/null +++ b/src/Packwright.App/Services/LibraryExport.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Avalonia.Media.Imaging; +using Packwright.App.Models; +using Packwright.Core.Services; + +namespace Packwright.App.Services; + +/// Writes the library as a JSON file or as one self-contained web page. Nothing here uses the network. +public static class LibraryExport +{ + private const int IconWidth = 96; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private sealed record Row(string Title, string TitleId, string ContentId, string Category, string Role, string Region, + string Format, long SizeBytes, string Version, string Firmware, string Drm, string Sdk, string FileName, + bool Edited, string[] Missing, string? Path); + + private static Row ToRow(LibraryItem item, bool includePaths) => new( + item.Title, item.TitleId, item.ContentId, item.Category, item.Role, item.Region, item.Format, item.SizeBytes, + item.Version, item.Firmware, item.Game.DrmType, item.Game.SdkVersion, item.FileName, item.IsEdited, + [.. item.MissingFields], includePaths ? item.Path : null); + + public static string ToJson(IReadOnlyCollection items, bool includePaths) + { + var document = new + { + generator = "Packwright " + UpdateService.CurrentVersion, + exportedUtc = DateTime.UtcNow, + count = items.Count, + totalBytes = items.Sum(item => item.SizeBytes), + items = items.Select(item => ToRow(item, includePaths)).ToList() + }; + return JsonSerializer.Serialize(document, JsonOptions); + } + + /// A small PNG of the title's artwork for the web page, or null when it has none. + public static byte[]? ReadSmallIcon(LibraryItem item) + { + try + { + if (ThumbnailService.ReadIcon(item) is not { Length: > 0 } bytes) return null; + using var input = new MemoryStream(bytes); + using Bitmap bitmap = Bitmap.DecodeToWidth(input, IconWidth, BitmapInterpolationMode.MediumQuality); + using var output = new MemoryStream(); + bitmap.Save(output); + return output.ToArray(); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException + or NotSupportedException or ArgumentException or InvalidOperationException) + { + return null; + } + } + + public static string ToHtml(IReadOnlyCollection items, bool includePaths, + IReadOnlyDictionary? icons) + { + static string H(string? text) => WebUtility.HtmlEncode(text ?? string.Empty); + long total = items.Sum(item => item.SizeBytes); + var html = new StringBuilder(64 * 1024); + html.Append(""" + + + + + PS5 library +
+ """); + html.Append("

PS5 library

") + .Append(H($"Exported {DateTime.Now:d MMMM yyyy, HH:mm} with Packwright {UpdateService.CurrentVersion}")) + .Append("
"); + Stat(html, items.Count.ToString("N0"), "titles"); + Stat(html, Ps5LibraryHealth.FormatBytes(total), "total size"); + foreach (IGrouping group in items.GroupBy(item => item.Category.Length > 0 ? item.Category : "Other") + .OrderByDescending(group => group.Count())) + Stat(html, group.Count().ToString("N0"), group.Key); + int missing = items.Count(item => item.HasMissing); + if (missing > 0) Stat(html, missing.ToString("N0"), "with missing details"); + html.Append("
"); + html.Append("
") + .Append(""); + if (includePaths) html.Append(""); + html.Append(""); + foreach (LibraryItem item in items.OrderBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase)) + { + html.Append(""); + if (includePaths) html.Append(""); + html.Append(""); + } + html.Append(""" +
TitleCategoryRegionFormatVersionFirmwareSizePath
"); + if (icons is not null && icons.TryGetValue(item.Path, out byte[]? png)) + html.Append("\"\""); + else html.Append("
"); + html.Append("
").Append(H(item.Title)).Append("
") + .Append(H(item.TitleId.Length > 0 ? item.TitleId : item.FileName)); + if (item.ContentId.Length > 0) html.Append(" · ").Append(H(item.ContentId)); + html.Append("
"); + if (item.HasMissing) html.Append("
").Append(H(item.MissingText)).Append("
"); + html.Append("
").Append(H(item.Category)).Append("").Append(H(item.Region)) + .Append("").Append(H(item.Format)).Append("").Append(H(item.Version)).Append("") + .Append(H(item.Firmware)).Append("") + .Append(H(item.SizeText)).Append("").Append(H(item.Path)).Append("
+
Made with Packwright. This page is a single file; it loads nothing from the internet.
+
+ """); + return html.ToString(); + } + + private static void Stat(StringBuilder html, string value, string label) => + html.Append("
").Append(WebUtility.HtmlEncode(value)).Append("") + .Append(WebUtility.HtmlEncode(label)).Append("
"); +} diff --git a/src/Packwright.App/Services/LibraryQuery.cs b/src/Packwright.App/Services/LibraryQuery.cs new file mode 100644 index 0000000..15dbfab --- /dev/null +++ b/src/Packwright.App/Services/LibraryQuery.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using Packwright.App.Models; + +namespace Packwright.App.Services; + +/// +/// The library search grammar: plain words, quoted phrases, field:value terms +/// (title id content category role region version fw feature path size), -exclude, +/// | for OR and = after the colon for an exact match (id:=PPSA12345). +/// Comparisons work on size and version (size:>50GB, version:>=01.05). +/// +public sealed class LibraryQuery +{ + private sealed record Term(string Field, string Value, bool Exclude, bool Exact, string Op); + + private readonly List> _alternatives = []; + + public List Problems { get; } = []; + + public bool IsEmpty => _alternatives.Count == 0 || _alternatives.All(group => group.Count == 0); + + public static LibraryQuery Parse(string? text) + { + var query = new LibraryQuery(); + var current = new List(); + query._alternatives.Add(current); + foreach (string token in Tokenize(text ?? string.Empty)) + { + if (token == "|") + { + current = []; + query._alternatives.Add(current); + continue; + } + string body = token; + bool exclude = body.Length > 1 && body[0] == '-'; + if (exclude) body = body[1..]; + string field = string.Empty; + int colon = body.IndexOf(':'); + if (colon > 0 && IsField(body[..colon])) + { + field = body[..colon].ToLowerInvariant(); + body = body[(colon + 1)..]; + } + else if (colon > 0 && body[..colon].All(char.IsLetter) && !body.Contains(' ')) + { + query.Problems.Add(body[..colon]); + } + string op = string.Empty; + bool exact = false; + if (body.StartsWith('=')) { exact = true; body = body[1..]; } + else + { + foreach (string candidate in new[] { ">=", "<=", ">", "<" }) + if (body.StartsWith(candidate, StringComparison.Ordinal)) { op = candidate; body = body[candidate.Length..]; break; } + } + if (body.Length == 0) continue; + current.Add(new Term(field, body, exclude, exact, op)); + } + return query; + } + + public bool Matches(LibraryItem item) + { + if (IsEmpty) return true; + return _alternatives.Any(group => group.All(term => Evaluate(term, item))); + } + + private bool Evaluate(Term term, LibraryItem item) + { + bool result = term.Field switch + { + "" => Any(term, item.Title, item.TitleId, item.ContentId, item.Version, item.Category, item.Path, + item.Region, item.Format), + "title" => Text(term, item.Title), + "id" => Text(term, item.TitleId), + "content" => Text(term, item.ContentId), + "category" => Text(term, item.Category), + "role" => Text(term, item.Role), + "region" => Text(term, item.Region), + "format" or "source" => Text(term, item.Format), + "path" => Text(term, item.Path), + "version" => Compare(term, item.Version), + "fw" => Compare(term, item.Firmware), + "feature" => item.Game.DeclaredFeatures.Any(feature => Text(term, feature)), + "missing" => item.HasMissing, + "size" => CompareSize(term, item.SizeBytes), + _ => true + }; + return term.Exclude ? !result : result; + } + + private static bool Any(Term term, params string[] values) => values.Any(value => Text(term, value)); + + private static bool Text(Term term, string value) => term.Exact + ? value.Equals(term.Value, StringComparison.OrdinalIgnoreCase) + : value.Contains(term.Value, StringComparison.OrdinalIgnoreCase); + + private static bool Compare(Term term, string value) + { + if (term.Op.Length == 0) return Text(term, value); + int order = CompareVersions(value, term.Value); + return Apply(term.Op, order); + } + + private static bool CompareSize(Term term, long bytes) + { + if (!TryParseSize(term.Value, out long wanted)) return false; + return term.Op.Length == 0 ? bytes == wanted : Apply(term.Op, bytes.CompareTo(wanted)); + } + + private static bool Apply(string op, int order) => op switch + { + ">" => order > 0, + ">=" => order >= 0, + "<" => order < 0, + "<=" => order <= 0, + _ => order == 0 + }; + + private static int CompareVersions(string left, string right) + { + string[] a = left.Split('.', StringSplitOptions.RemoveEmptyEntries); + string[] b = right.Split('.', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < Math.Max(a.Length, b.Length); i++) + { + bool okA = i < a.Length && double.TryParse(a[i], NumberStyles.Float, CultureInfo.InvariantCulture, out _); + bool okB = i < b.Length && double.TryParse(b[i], NumberStyles.Float, CultureInfo.InvariantCulture, out _); + double x = okA ? double.Parse(a[i], CultureInfo.InvariantCulture) : 0; + double y = okB ? double.Parse(b[i], CultureInfo.InvariantCulture) : 0; + if (x != y) return x.CompareTo(y); + } + return 0; + } + + private static bool TryParseSize(string text, out long bytes) + { + bytes = 0; + text = text.Trim(); + int split = text.Length; + while (split > 0 && !char.IsDigit(text[split - 1]) && text[split - 1] != '.') split--; + if (!double.TryParse(text[..split], NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) return false; + double factor = text[split..].Trim().ToUpperInvariant() switch + { + "" or "B" => 1, + "K" or "KB" or "KIB" => 1024d, + "M" or "MB" or "MIB" => 1024d * 1024, + "G" or "GB" or "GIB" => 1024d * 1024 * 1024, + "T" or "TB" or "TIB" => 1024d * 1024 * 1024 * 1024, + _ => -1 + }; + if (factor < 0) return false; + bytes = (long)(number * factor); + return true; + } + + private static bool IsField(string name) => name.ToLowerInvariant() is "title" or "id" or "content" or "category" + or "role" or "region" or "format" or "source" or "path" or "version" or "fw" or "feature" or "size"; + + private static IEnumerable Tokenize(string text) + { + var token = new System.Text.StringBuilder(); + bool quoted = false; + foreach (char ch in text) + { + if (ch == '"') { quoted = !quoted; continue; } + if (!quoted && char.IsWhiteSpace(ch)) + { + if (token.Length > 0) { yield return token.ToString(); token.Clear(); } + continue; + } + if (!quoted && ch == '|') + { + if (token.Length > 0) { yield return token.ToString(); token.Clear(); } + yield return "|"; + continue; + } + token.Append(ch); + } + if (token.Length > 0) yield return token.ToString(); + } +} diff --git a/src/Packwright.App/Services/MemoryTrimmer.cs b/src/Packwright.App/Services/MemoryTrimmer.cs new file mode 100644 index 0000000..52cb90c --- /dev/null +++ b/src/Packwright.App/Services/MemoryTrimmer.cs @@ -0,0 +1,49 @@ +using System.Runtime; +using System.Runtime.InteropServices; +using Avalonia.Threading; + +namespace Packwright.App.Services; + +/// +/// Gives memory back after bursts of work (loading a game's details, scanning, switching pages): a short +/// idle delay, then a full collection that also compacts the large-object heap, and on Windows a working-set +/// trim so the figure Task Manager shows reflects what the app really needs. +/// +public static class MemoryTrimmer +{ + private static DispatcherTimer? _timer; + + /// Trims once the app has been quiet for ; repeated calls restart the wait. + public static void Schedule(int seconds = 4) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Schedule(seconds)); + return; + } + _timer?.Stop(); + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) }; + _timer.Tick += (_, _) => + { + _timer?.Stop(); + _ = Task.Run(TrimNow); + }; + _timer.Start(); + } + + public static void TrimNow() + { + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + if (OperatingSystem.IsWindows()) + { + try { SetProcessWorkingSetSize(System.Diagnostics.Process.GetCurrentProcess().Handle, -1, -1); } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) { } + } + } + + [DllImport("kernel32.dll")] + private static extern bool SetProcessWorkingSetSize(IntPtr process, nint minimum, nint maximum); +} diff --git a/src/Packwright.App/Services/MetadataEditor.cs b/src/Packwright.App/Services/MetadataEditor.cs new file mode 100644 index 0000000..ef7136e --- /dev/null +++ b/src/Packwright.App/Services/MetadataEditor.cs @@ -0,0 +1,494 @@ +using System.Globalization; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using Packwright.Core.Backends; +using Packwright.Core.Tasks; +using Packwright.Containers; +using Packwright.Infrastructure; +using UFS2Tool; + +namespace Packwright.App.Services; + +/// The values to write into a title's param.json and icon. Empty values are left as they are. +/// The feature flags a title can declare, and which bit of param.json each one is. +public static class DeclaredFeatureFlags +{ + public static readonly (string Name, string Key, int Bit)[] All = + [ + ("HDR", "attribute", 29), ("120 Hz", "attribute3", 6), ("PS VR2 supported", "attribute3", 10), + ("PS VR2 required", "attribute3", 11), ("VRR", "attribute3", 18), ("VRR 120 Hz", "attribute3", 19), + ("VRR disabled", "attribute3", 20), ("PS5 Pro", "attribute3", 22), ("PS5 Pro 8K", "attribute3", 23), + ("Power Saver", "attribute4", 0) + ]; + + public static IEnumerable Names => All.Select(flag => flag.Name); +} + +public sealed class MetadataEdit +{ + public string Title { get; set; } = string.Empty; + public string TitleId { get; set; } = string.Empty; + public string ContentId { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Firmware { get; set; } = string.Empty; + /// A PNG file that becomes sce_sys/icon0.png. + public string IconPath { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string ConceptId { get; set; } = string.Empty; + public string MasterVersion { get; set; } = string.Empty; + public string Sdk { get; set; } = string.Empty; + public string Drm { get; set; } = string.Empty; + public string DefaultLanguage { get; set; } = string.Empty; + public string Created { get; set; } = string.Empty; + public string ToolVersion { get; set; } = string.Empty; + /// The features to declare (see ). Null leaves them as they are. + public List? Features { get; set; } + + public bool IsEmpty => Title.Length == 0 && TitleId.Length == 0 && ContentId.Length == 0 && Version.Length == 0 && + Firmware.Length == 0 && IconPath.Length == 0 && Category.Length == 0 && ConceptId.Length == 0 && + MasterVersion.Length == 0 && Sdk.Length == 0 && Drm.Length == 0 && DefaultLanguage.Length == 0 && + Created.Length == 0 && ToolVersion.Length == 0 && Features is null; +} + +/// +/// Writes edited details into the file itself: param.json and icon0.png inside a dump folder, an exFAT or +/// FFPKG image, an FFPFSC image, a ZArchive or a debug package. Nothing is left half-written: images are +/// rebuilt beside the original and swapped in only after they verify. +/// +public static class MetadataEditor +{ + private const string ParamPath = "sce_sys/param.json"; + private const string IconPath = "sce_sys/icon0.png"; + private const int MaximumParamBytes = 4 * 1024 * 1024; + + private static readonly JsonSerializerOptions WriteOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + // ---------------------------------------------------------------- param.json + + /// + /// Returns with the edited fields changed and everything else kept, or a new + /// minimal param.json when there is none. + /// + public static byte[] PatchParam(byte[]? original, MetadataEdit edit) + { + JsonObject root; + if (original is null) + { + // The title has no param.json (a bare dump or archive): start a minimal one. + root = new JsonObject { ["applicationCategoryType"] = 0 }; + } + else + { + string text = Encoding.UTF8.GetString(original).TrimStart('\uFEFF').TrimEnd('\0'); + root = JsonNode.Parse(text, null, new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }) as JsonObject ?? throw new InvalidDataException("param.json is not a JSON object."); + } + + if (edit.TitleId.Length > 0) root["titleId"] = edit.TitleId; + if (edit.ContentId.Length > 0) root["contentId"] = edit.ContentId; + if (edit.Version.Length > 0) + { + if (root.ContainsKey("contentVersion") || !root.ContainsKey("masterVersion") || edit.MasterVersion.Length > 0) + root["contentVersion"] = edit.Version; + else + root["masterVersion"] = edit.Version; + } + if (edit.Firmware.Length > 0) root["requiredSystemSoftwareVersion"] = FirmwareToParam(edit.Firmware, root["requiredSystemSoftwareVersion"]?.ToString()); + if (edit.Title.Length > 0) SetTitle(root, edit.Title); + if (edit.ConceptId.Length > 0) root["conceptId"] = edit.ConceptId; + if (edit.MasterVersion.Length > 0) root["masterVersion"] = edit.MasterVersion; + if (edit.Sdk.Length > 0) root["sdkVersion"] = FirmwareToParam(edit.Sdk, root["sdkVersion"]?.ToString()); + if (edit.Drm.Length > 0) root["applicationDrmType"] = edit.Drm; + if (edit.Category.Length > 0) root["applicationCategoryType"] = CategoryToParam(edit.Category); + if (edit.Created.Length > 0) PubTools(root)["creationDate"] = edit.Created; + if (edit.ToolVersion.Length > 0) PubTools(root)["toolVersion"] = edit.ToolVersion; + if (edit.DefaultLanguage.Length > 0) SetDefaultLanguage(root, edit.DefaultLanguage, edit.Title); + if (edit.Features is not null) ApplyFeatures(root, edit.Features); + return Encoding.UTF8.GetBytes(root.ToJsonString(WriteOptions)); + } + + private static JsonObject PubTools(JsonObject root) + { + if (root["pubtools"] is JsonObject existing) return existing; + var created = new JsonObject(); + root["pubtools"] = created; + return created; + } + + /// The number the app reads back as the category (the same mapping the library uses). + private static int CategoryToParam(string category) => category switch { "DLC" => 1, "Patch" => 2, "App" => 3, _ => 0 }; + + /// Makes the default language, adding an entry for it (with the title) when it has none. + private static void SetDefaultLanguage(JsonObject root, string language, string title) + { + if (root["localizedParameters"] is not JsonObject localized) + { + localized = new JsonObject(); + root["localizedParameters"] = localized; + } + string previous = localized["defaultLanguage"]?.ToString() ?? string.Empty; + localized["defaultLanguage"] = language; + if (localized[language] is JsonObject) return; + string name = title; + if (name.Length == 0 && previous.Length > 0 && localized[previous] is JsonObject old) name = old["titleName"]?.ToString() ?? string.Empty; + if (name.Length == 0) + foreach ((string key, JsonNode? value) in localized) + if (value is JsonObject other && other["titleName"]?.ToString() is { Length: > 0 } found) { name = found; break; } + localized[language] = new JsonObject { ["titleName"] = name }; + } + + /// Sets or clears the known feature bits and leaves every other bit as it was. + private static void ApplyFeatures(JsonObject root, IReadOnlyCollection features) + { + foreach (IGrouping word in DeclaredFeatureFlags.All.GroupBy(flag => flag.Key)) + { + long value = 0; + if (root[word.Key] is JsonValue existing && existing.TryGetValue(out long number)) value = number; + else if (root[word.Key]?.ToString() is { Length: > 0 } text && long.TryParse(text, out long parsed)) value = parsed; + long updated = value; + foreach ((string name, _, int bit) in word) + updated = features.Contains(name, StringComparer.OrdinalIgnoreCase) ? updated | (1L << bit) : updated & ~(1L << bit); + if (updated != value || root.ContainsKey(word.Key)) root[word.Key] = updated; + } + } + + private static void SetTitle(JsonObject root, string title) + { + if (root["localizedParameters"] is not JsonObject localized) + { + root["localizedParameters"] = new JsonObject + { + ["defaultLanguage"] = "en-US", + ["en-US"] = new JsonObject { ["titleName"] = title } + }; + return; + } + bool any = false; + foreach ((string name, JsonNode? value) in localized.ToList()) + { + if (name.Equals("defaultLanguage", StringComparison.OrdinalIgnoreCase) || value is not JsonObject language) continue; + language["titleName"] = title; + any = true; + } + if (!any) + { + string defaultLanguage = localized["defaultLanguage"]?.ToString() is { Length: > 0 } configured ? configured : "en-US"; + localized["defaultLanguage"] = defaultLanguage; + localized[defaultLanguage] = new JsonObject { ["titleName"] = title }; + } + } + + /// "4.50" becomes "0x0450000000000000". A value that already starts with 0x is used as written. + internal static string FirmwareToParam(string text, string? existing) + { + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return text; + string[] parts = text.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int major)) + throw new InvalidDataException($"'{text}' is not a firmware version such as 4.50."); + string minor = parts.Length > 1 ? parts[1] : "00"; + if (minor.Length == 1) minor += "0"; + if (minor.Length != 2 || !minor.All(char.IsAsciiHexDigit)) + throw new InvalidDataException($"'{text}' is not a firmware version such as 4.50."); + string head = $"{major:D2}{minor}"; + int total = existing is { Length: > 2 } && existing.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? existing.Length - 2 + : 16; + return "0x" + head.PadRight(Math.Max(total, 4), '0'); + } + + // ---------------------------------------------------------------- entry point + + /// + /// Applies to . When is empty or the + /// same as the source the file is changed in place, otherwise a new file or folder is written. + /// + public static async Task ApplyAsync(string source, string output, bool fromPackage, MetadataEdit edit, + string passcode, string backendId, IProgress progress, CancellationToken token) + { + if (edit.IsEmpty) throw new InvalidOperationException("There is nothing to write."); + bool inPlace = output.Length == 0 || + string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), StringComparison.OrdinalIgnoreCase); + string target = inPlace ? Path.GetFullPath(source) : Path.GetFullPath(output); + if (!inPlace && (File.Exists(target) || Directory.Exists(target))) + throw new IOException("The output already exists: " + target); + + string work = Path.Combine(Path.GetTempPath(), "Packwright-edit-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(work); + try + { + byte[]? icon = edit.IconPath.Length > 0 ? await File.ReadAllBytesAsync(edit.IconPath, token).ConfigureAwait(false) : null; + Logger.Info($"Writing edited details into {source}" + (inPlace ? string.Empty : " -> " + target)); + if (Directory.Exists(source)) + { + await EditFolderAsync(source, target, inPlace, edit, icon, progress, token).ConfigureAwait(false); + } + else if (fromPackage) + { + await EditPackageAsync(source, target, edit, icon, passcode, backendId, work, progress, token).ConfigureAwait(false); + } + else + { + switch (Ps5ImageFormatProbe.Detect(source)) + { + case Ps5ImageFormat.Exfat: + await EditCopyOrInPlaceAsync(source, target, inPlace, progress, + path => EditExfatAsync(path, edit, icon, work, progress, token)).ConfigureAwait(false); + break; + case Ps5ImageFormat.Ufs2: + await EditCopyOrInPlaceAsync(source, target, inPlace, progress, + path => EditUfs2Async(path, edit, icon, work, progress, token)).ConfigureAwait(false); + break; + case Ps5ImageFormat.Pfs: + await EditPfsAsync(source, target, edit, icon, work, progress, token).ConfigureAwait(false); + break; + case Ps5ImageFormat.ZArchive: + await EditZArchiveAsync(source, target, edit, icon, progress, token).ConfigureAwait(false); + break; + default: + throw new InvalidDataException("This file type cannot be edited."); + } + } + Logger.Info("Edited details written."); + } + finally + { + try { Directory.Delete(work, true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + } + + private static void Stage(IProgress progress, string text) => + progress.Report(new PackageTaskProgress(text, 0, 0, 0, 0, 0, 0, string.Empty)); + + // ---------------------------------------------------------------- dump folder + + private static async Task EditFolderAsync(string source, string target, bool inPlace, MetadataEdit edit, + byte[]? icon, IProgress progress, CancellationToken token) + { + string root = source; + if (!inPlace) + { + Stage(progress, "Copying folder"); + await Task.Run(() => CopyDirectory(source, target, token), token).ConfigureAwait(false); + root = target; + } + try + { + Stage(progress, "Writing details"); + string param = Path.Combine(root, "sce_sys", "param.json"); + byte[]? existing = File.Exists(param) ? await File.ReadAllBytesAsync(param, token).ConfigureAwait(false) : null; + byte[] patched = PatchParam(existing, edit); + await WriteFileAsync(param, patched, token).ConfigureAwait(false); + if (icon is not null) + await WriteFileAsync(Path.Combine(root, "sce_sys", "icon0.png"), icon, token).ConfigureAwait(false); + } + catch when (!inPlace) + { + try { Directory.Delete(target, true); } catch (IOException) { } + throw; + } + } + + private static async Task WriteFileAsync(string path, byte[] data, CancellationToken token) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + string temp = path + ".packwright-tmp"; + await File.WriteAllBytesAsync(temp, data, token).ConfigureAwait(false); + File.Move(temp, path, true); + } + + private static void CopyDirectory(string source, string target, CancellationToken token) + { + Directory.CreateDirectory(target); + foreach (string file in Directory.EnumerateFiles(source)) + { + token.ThrowIfCancellationRequested(); + File.Copy(file, Path.Combine(target, Path.GetFileName(file))); + } + foreach (string directory in Directory.EnumerateDirectories(source)) + CopyDirectory(directory, Path.Combine(target, Path.GetFileName(directory)), token); + } + + // ---------------------------------------------------------------- exFAT and FFPKG (UFS2) + + private static async Task EditCopyOrInPlaceAsync(string source, string target, bool inPlace, + IProgress progress, Func edit) + { + if (!inPlace) + { + Stage(progress, "Copying image"); + await Task.Run(() => File.Copy(source, target)).ConfigureAwait(false); + } + try + { + await edit(target).ConfigureAwait(false); + } + catch when (!inPlace) + { + try { File.Delete(target); } catch (IOException) { } + throw; + } + } + + private static async Task EditExfatAsync(string image, MetadataEdit edit, byte[]? icon, string work, + IProgress progress, CancellationToken token) + { + byte[]? param; + bool hasIcon, hasSys; + using (var stream = File.OpenRead(image)) + using (var volume = new ExfatVolume(stream)) + { + param = volume.Find(ParamPath) is null ? null : volume.ReadAllBytes(ParamPath, MaximumParamBytes); + hasIcon = volume.Find(IconPath) is not null; + hasSys = volume.Find("sce_sys") is not null; + } + string paramFile = Path.Combine(work, "param.json"); + await File.WriteAllBytesAsync(paramFile, PatchParam(param, edit), token).ConfigureAwait(false); + var operations = new List(); + if (!hasSys) operations.Add(ExfatEditOperation.AddDirectory("sce_sys")); + operations.Add(param is null ? ExfatEditOperation.AddFile(ParamPath, paramFile) : ExfatEditOperation.Replace(ParamPath, paramFile)); + if (icon is not null) + { + string iconFile = Path.Combine(work, "icon0.png"); + await File.WriteAllBytesAsync(iconFile, icon, token).ConfigureAwait(false); + operations.Add(hasIcon ? ExfatEditOperation.Replace(IconPath, iconFile) : ExfatEditOperation.AddFile(IconPath, iconFile)); + } + await ExfatImageMaintenance.ApplyEditsAsync(image, operations, Jobs.Adapt(progress), token).ConfigureAwait(false); + } + + private static async Task EditUfs2Async(string image, MetadataEdit edit, byte[]? icon, string work, + IProgress progress, CancellationToken token) + { + byte[]? param; + bool hasIcon, hasSys; + using (var volume = new Ufs2Volume(image)) + { + bool Has(string path, bool directory) => volume.Entries.Any(entry => entry.IsDirectory == directory && + entry.Path.Trim('/').Equals(path, StringComparison.OrdinalIgnoreCase)); + param = Has(ParamPath, false) ? volume.ReadAllBytes(ParamPath, MaximumParamBytes) : null; + hasIcon = Has(IconPath, false); + hasSys = Has("sce_sys", true); + } + string paramFile = Path.Combine(work, "param.json"); + await File.WriteAllBytesAsync(paramFile, PatchParam(param, edit), token).ConfigureAwait(false); + var operations = new List(); + if (!hasSys) operations.Add(Ufs2EditOperation.AddDirectory("sce_sys")); + operations.Add(param is null ? Ufs2EditOperation.AddFile(ParamPath, paramFile) : Ufs2EditOperation.Replace(ParamPath, paramFile)); + if (icon is not null) + { + string iconFile = Path.Combine(work, "icon0.png"); + await File.WriteAllBytesAsync(iconFile, icon, token).ConfigureAwait(false); + operations.Add(hasIcon ? Ufs2EditOperation.Replace(IconPath, iconFile) : Ufs2EditOperation.AddFile(IconPath, iconFile)); + } + await Ufs2Operations.ApplyEditsAsync(image, operations, Jobs.AdaptUfs2(progress), token).ConfigureAwait(false); + } + + // ---------------------------------------------------------------- FFPFSC + + private static async Task EditPfsAsync(string source, string target, MetadataEdit edit, byte[]? icon, string work, + IProgress progress, CancellationToken token) + { + string temporary = Path.Combine(work, "image.exfat"); + Stage(progress, "Unpacking FFPFSC"); + await Jobs.ConvertAsync(source, temporary, Ps5ImageConversionTarget.Exfat, false, false, progress, token, + new ExfatBuildOptions { GenerateAmprIndex = true }).ConfigureAwait(false); + await EditExfatAsync(temporary, edit, icon, work, progress, token).ConfigureAwait(false); + + string partial = target + ".packwright-tmp"; + try + { + Stage(progress, "Building FFPFSC"); + await Jobs.ConvertAsync(temporary, partial, Ps5ImageConversionTarget.Ffpfsc, true, false, progress, token, + null, new FfpfscBuildOptions + { + Compression = new PfscCompressionOptions { CompressionLevel = 7, MinimumGainPercent = 1 } + }).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + await Jobs.VerifyImageAsync(partial, Ps5ImageFormat.Pfs, token).ConfigureAwait(false); + File.Move(partial, target, true); + } + finally + { + try { File.Delete(partial); } catch (IOException) { } + } + } + + // ---------------------------------------------------------------- ZArchive + + private static async Task EditZArchiveAsync(string source, string target, MetadataEdit edit, byte[]? icon, + IProgress progress, CancellationToken token) + { + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var volume = new ZArchiveVolume(source)) + { + ZArchiveEntry[] found = volume.Entries.Where(entry => !entry.IsDirectory && + (entry.Path.Equals(ParamPath, StringComparison.OrdinalIgnoreCase) || + entry.Path.EndsWith("/" + ParamPath, StringComparison.OrdinalIgnoreCase))).ToArray(); + if (found.Length > 1) + throw new InvalidDataException("The archive contains several game roots; it cannot be edited as one title."); + string prefix; + byte[]? existing = null; + if (found.Length == 1) + { + prefix = found[0].Path[..^ParamPath.Length]; + existing = volume.ReadAllBytes(found[0].Path, MaximumParamBytes); + } + else + { + // No param.json yet: put one in the archive's sce_sys folder, or at the top. + string? sys = volume.Entries.Where(entry => entry.IsDirectory && + (entry.Path.Equals("sce_sys", StringComparison.OrdinalIgnoreCase) || + entry.Path.EndsWith("/sce_sys", StringComparison.OrdinalIgnoreCase))) + .OrderBy(entry => entry.Path.Length).FirstOrDefault()?.Path; + prefix = sys is null ? string.Empty : sys[..^"sce_sys".Length]; + } + replacements[prefix + ParamPath] = PatchParam(existing, edit); + if (icon is not null) replacements[prefix + IconPath] = icon; + } + + string partial = target + ".packwright-tmp"; + try + { + await ZArchiveImage.RewriteWithReplacementsAsync(source, partial, replacements, Jobs.Adapt(progress), token) + .ConfigureAwait(false); + Stage(progress, "Verifying ZArchive"); + await ZArchiveImage.VerifyAsync(partial, null, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + File.Move(partial, target, true); + } + finally + { + try { File.Delete(partial); } catch (IOException) { } + } + } + + // ---------------------------------------------------------------- packages + + private static async Task EditPackageAsync(string source, string target, MetadataEdit edit, byte[]? icon, + string passcode, string backendId, string work, IProgress progress, CancellationToken token) + { + string temporary = Path.Combine(work, "package.exfat"); + Stage(progress, "Unpacking package"); + await Jobs.ConvertAsync(source, temporary, Ps5ImageConversionTarget.Exfat, false, true, progress, token, + new ExfatBuildOptions { GenerateAmprIndex = true }).ConfigureAwait(false); + await EditExfatAsync(temporary, edit, icon, work, progress, token).ConfigureAwait(false); + + string contentId = Jobs.ReadContentId(temporary, Ps5ImageFormat.Exfat); + if (contentId.Length == 0) throw new InvalidDataException("The package has no content ID, so it cannot be rebuilt."); + IPackageBackend backend = Jobs.ResolveBackend(backendId); + string code = passcode.Length > 0 ? passcode : Packwright.Core.Builders.SonyDebugPackageCredentials.DefaultPasscode; + Stage(progress, "Rebuilding package"); + await Jobs.BuildPackageAsync(temporary, target, contentId, code, true, new PackageBuildSettings(), backend, + progress, token).ConfigureAwait(false); + } +} diff --git a/src/Packwright.App/Services/MetadataOverrides.cs b/src/Packwright.App/Services/MetadataOverrides.cs new file mode 100644 index 0000000..1c3501c --- /dev/null +++ b/src/Packwright.App/Services/MetadataOverrides.cs @@ -0,0 +1,117 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// +/// User-supplied details for one library item (a package, image or dump). Empty fields mean "use what the +/// file says". The file itself is never modified. +/// +public sealed class MetadataOverride +{ + public string Title { get; set; } = string.Empty; + public string TitleId { get; set; } = string.Empty; + public string ContentId { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Firmware { get; set; } = string.Empty; + /// Game, Patch, DLC or App. Empty keeps the detected category. + public string Category { get; set; } = string.Empty; + public string ConceptId { get; set; } = string.Empty; + public string MasterVersion { get; set; } = string.Empty; + public string Sdk { get; set; } = string.Empty; + public string Drm { get; set; } = string.Empty; + public string DefaultLanguage { get; set; } = string.Empty; + public string Created { get; set; } = string.Empty; + public string ToolVersion { get; set; } = string.Empty; + /// The declared features (HDR, VRR, ...). Null keeps what the file declares; an empty list means none. + public List? Features { get; set; } + /// Name of the custom icon file inside the overrides folder (empty = none). + public string IconFile { get; set; } = string.Empty; + + public bool IsEmpty => + Title.Length == 0 && TitleId.Length == 0 && ContentId.Length == 0 && Version.Length == 0 && + Firmware.Length == 0 && Category.Length == 0 && IconFile.Length == 0 && ConceptId.Length == 0 && + MasterVersion.Length == 0 && Sdk.Length == 0 && Drm.Length == 0 && DefaultLanguage.Length == 0 && + Created.Length == 0 && ToolVersion.Length == 0 && Features is null; +} + +/// Per-item detail overrides, saved next to the settings so they survive rescans and restarts. +public static class MetadataOverrides +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly object Gate = new(); + private static Dictionary? _items; + + private static string Folder => Path.Combine(AppServices.Instance.Store.AppDataDirectory, "overrides"); + private static string FilePath => Path.Combine(AppServices.Instance.Store.AppDataDirectory, "metadata-overrides.json"); + + private static Dictionary Items + { + get + { + if (_items is not null) return _items; + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + if (File.Exists(FilePath)) + loaded = new Dictionary( + JsonSerializer.Deserialize>(File.ReadAllText(FilePath)) ?? [], + StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Logger.Warn("Edited details could not be read: " + ex.Message); + } + return _items = loaded; + } + } + + public static MetadataOverride? Get(string path) + { + lock (Gate) return Items.TryGetValue(path, out MetadataOverride? value) ? value : null; + } + + /// Saves (or, when empty, removes) the override for . + public static void Set(string path, MetadataOverride value) + { + lock (Gate) + { + if (value.IsEmpty) Items.Remove(path); + else Items[path] = value; + try + { + File.WriteAllText(FilePath, JsonSerializer.Serialize(Items, JsonOptions)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn("Edited details could not be saved: " + ex.Message); + } + } + } + + public static string? IconPath(MetadataOverride? value) + { + if (value is null || value.IconFile.Length == 0) return null; + string path = Path.Combine(Folder, value.IconFile); + return File.Exists(path) ? path : null; + } + + /// Copies an image into the overrides folder and returns the stored file name. + public static string StoreIcon(string itemPath, byte[] imageBytes) + { + Directory.CreateDirectory(Folder); + string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(itemPath.ToLowerInvariant())))[..24] + + "-" + DateTime.UtcNow.Ticks + ".png"; + File.WriteAllBytes(Path.Combine(Folder, name), imageBytes); + return name; + } + + public static void DeleteIcon(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) return; + try { File.Delete(Path.Combine(Folder, fileName)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } +} diff --git a/src/Packwright.App/Services/SourceInfo.cs b/src/Packwright.App/Services/SourceInfo.cs new file mode 100644 index 0000000..1b78e26 --- /dev/null +++ b/src/Packwright.App/Services/SourceInfo.cs @@ -0,0 +1,84 @@ +using Avalonia.Media.Imaging; +using Packwright.Core.Models; +using Packwright.Core.Parsers; +using Packwright.Core.Services; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// A game's display name and a small icon, used to label tasks and the selected source. +public sealed record SourceSummary(string Title, byte[]? Icon) +{ + public static SourceSummary Empty { get; } = new(string.Empty, null); +} + +/// Finds the title and artwork of a dump folder, image or package. +public static class SourceInfo +{ + private const int IconWidth = 96; + + public static Task DescribeAsync(string path) => Task.Run(() => Describe(path)); + + private static SourceSummary Describe(string path) + { + try + { + Ps5GameInfo? game = FromLibrary(path) ?? Read(path); + if (game is null) return new SourceSummary(NameOf(path), null); + string title = string.IsNullOrWhiteSpace(game.Title) ? NameOf(path) : game.Title; + return new SourceSummary(title, ReadIcon(game)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or + NotSupportedException or ArgumentException or System.Text.Json.JsonException) + { + Logger.Warn($"Could not describe {path}: {ex.Message}"); + return new SourceSummary(NameOf(path), null); + } + } + + private static string NameOf(string path) => + Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + private static Ps5GameInfo? FromLibrary(string path) => + AppServices.Instance.Games.FirstOrDefault(game => + string.Equals(game.RootPath, path, StringComparison.OrdinalIgnoreCase)); + + internal static Ps5GameInfo? Read(string path) + { + if (Directory.Exists(path)) + { + string param = Path.Combine(path, "sce_sys", "param.json"); + return File.Exists(param) ? new Ps5ParamReader().Read(param) : null; + } + if (!File.Exists(path)) return null; + if (path.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) return new SonyPkgGameReader().Read(path); + return Ps5ImageFormatProbeLookup(path); + } + + private static Ps5GameInfo? Ps5ImageFormatProbeLookup(string path) + { + Packwright.Containers.Ps5ImageFormat format = Packwright.Containers.Ps5ImageFormatProbe.Detect(path); + return format switch + { + Packwright.Containers.Ps5ImageFormat.Exfat => new FilesystemImageGameReader().Read(path), + Packwright.Containers.Ps5ImageFormat.Ufs2 => new FfpkgGameReader().Read(path), + Packwright.Containers.Ps5ImageFormat.Pfs => new FfpfscGameReader().Read(path), + Packwright.Containers.Ps5ImageFormat.ZArchive => new ZArchiveGameReader().Read(path), + _ => null + }; + } + + private static byte[]? ReadIcon(Ps5GameInfo game) + { + using IReadOnlyGameFileSystem files = GameFileSystem.Open(game); + if (!files.FileExists("sce_sys/icon0.png")) return null; + using Stream stream = files.OpenRead("sce_sys/icon0.png"); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + memory.Position = 0; + using Bitmap small = Bitmap.DecodeToWidth(memory, IconWidth, BitmapInterpolationMode.HighQuality); + using var output = new MemoryStream(); + small.Save(output); + return output.ToArray(); + } +} diff --git a/src/Packwright.App/Services/ThumbnailService.cs b/src/Packwright.App/Services/ThumbnailService.cs new file mode 100644 index 0000000..0a92f2f --- /dev/null +++ b/src/Packwright.App/Services/ThumbnailService.cs @@ -0,0 +1,80 @@ +using System.Collections.Concurrent; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Packwright.App.Models; +using Packwright.Core.Services; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +/// +/// Loads game artwork (sce_sys/icon0.png) in the background, a couple at a time, decoded to a small +/// size. The number of cached bitmaps is capped; the oldest are released and loaded again on demand. +/// +public static class ThumbnailService +{ + private const int DecodeWidth = 168; + private static readonly SemaphoreSlim Gate = new(2); + private static readonly ConcurrentQueue Loaded = new(); + private static int _loadedCount; + + public static void Request(LibraryItem item) + { + if (!AppServices.Instance.Settings.ShowThumbnails) return; + _ = Task.Run(() => LoadAsync(item)); + } + + private static async Task LoadAsync(LibraryItem item) + { + await Gate.WaitAsync().ConfigureAwait(false); + try + { + byte[]? bytes = ReadIcon(item); + if (bytes is null || bytes.Length == 0) return; + Bitmap? bitmap = Decode(bytes); + if (bitmap is null) return; + await Dispatcher.UIThread.InvokeAsync(() => item.SetThumbnail(bitmap)); + Loaded.Enqueue(item); + if (Interlocked.Increment(ref _loadedCount) > AppServices.Instance.Settings.ThumbnailCacheCount && + Loaded.TryDequeue(out LibraryItem? oldest)) + { + Interlocked.Decrement(ref _loadedCount); + await Dispatcher.UIThread.InvokeAsync(oldest.ResetThumbnail); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException + or NotSupportedException or ArgumentException or InvalidOperationException) + { + Logger.Warn($"No artwork for {item.FileName}: {ex.Message}"); + } + finally + { + Gate.Release(); + } + } + + internal static byte[]? ReadIcon(LibraryItem item) + { + if (MetadataOverrides.IconPath(MetadataOverrides.Get(item.Path)) is { } custom) + return File.ReadAllBytes(custom); + using IReadOnlyGameFileSystem files = GameFileSystem.Open(item.Game); + if (!files.FileExists("sce_sys/icon0.png")) return null; + using Stream stream = files.OpenRead("sce_sys/icon0.png"); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private static Bitmap? Decode(byte[] png) + { + try + { + using var stream = new MemoryStream(png); + return Bitmap.DecodeToWidth(stream, DecodeWidth, BitmapInterpolationMode.MediumQuality); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException) + { + return null; + } + } +} diff --git a/src/Packwright.App/Services/UpdateService.cs b/src/Packwright.App/Services/UpdateService.cs new file mode 100644 index 0000000..d722759 --- /dev/null +++ b/src/Packwright.App/Services/UpdateService.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Packwright.Core.Services; +using Packwright.Infrastructure; + +namespace Packwright.App.Services; + +public enum UpdateState { UpToDate, Available, NoReleases, Failed } + +public sealed record UpdateResult(UpdateState State, ReleaseInfo? Release, string Message); + +/// +/// Remembers whether a newer Packwright is available. A check happens only when the user asks, or once a day when +/// they allowed automatic checks; it talks to GitHub and nowhere else. +/// +public static class UpdateService +{ + public static string CurrentVersion { get; } = + Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0"; + + /// The newer release found by the last check, or null. + public static ReleaseInfo? Available { get; private set; } + + public static event Action? Changed; + + /// True when a newer release is known and the user has not chosen to skip it. + public static bool HasVisibleUpdate => + Available is { } release && + !string.Equals(AppServices.Instance.Settings.SkippedUpdateVersion, release.Version, StringComparison.OrdinalIgnoreCase); + + public static bool ShouldCheckOnStartup() + { + AppSettings settings = AppServices.Instance.Settings; + return settings.CheckForUpdates && DateTime.UtcNow - settings.LastUpdateCheckUtc > TimeSpan.FromHours(20); + } + + public static async Task CheckAsync(CancellationToken cancellationToken = default) + { + AppServices services = AppServices.Instance; + try + { + ReleaseInfo? latest = await UpdateChecker.GetLatestAsync(cancellationToken).ConfigureAwait(false); + services.Settings.LastUpdateCheckUtc = DateTime.UtcNow; + services.SaveSettings(); + if (latest is null) + { + Available = null; + Changed?.Invoke(); + return new UpdateResult(UpdateState.NoReleases, null, "No release has been published yet."); + } + if (!UpdateChecker.IsNewer(latest.Version, CurrentVersion)) + { + Available = null; + Changed?.Invoke(); + return new UpdateResult(UpdateState.UpToDate, latest, $"You have the latest version ({CurrentVersion})."); + } + Available = latest; + Logger.Info($"Update available: {latest.Version} (you have {CurrentVersion})."); + Changed?.Invoke(); + return new UpdateResult(UpdateState.Available, latest, $"Version {latest.Version} is available (you have {CurrentVersion})."); + } + catch (OnlineLookupException ex) + { + Logger.Warn("Update check: " + ex.Message); + return new UpdateResult(UpdateState.Failed, null, ex.Message); + } + } + + public static void Skip(ReleaseInfo release) + { + AppServices.Instance.Settings.SkippedUpdateVersion = release.Version; + AppServices.Instance.SaveSettings(); + Changed?.Invoke(); + } +} diff --git a/src/Packwright.App/Styles/Theme.axaml b/src/Packwright.App/Styles/Theme.axaml new file mode 100644 index 0000000..57b77dc --- /dev/null +++ b/src/Packwright.App/Styles/Theme.axaml @@ -0,0 +1,457 @@ + + + + + + + + #0D0F17 + #131622 + #1A1E2E + #222741 + #10131D + #2A2F47 + #8E94AE + #EEF0FA + #7C8CFF + #5B6BF0 + #267C8CFF + #3DD68C + #F5B642 + #FF6B7A + + + + #F3F4FA + #FFFFFF + #FFFFFF + #EEF0FB + #F6F7FC + #DDE0EE + #6A7090 + #161A2B + #4F5FE8 + #3B4BD6 + #1F4F5FE8 + #1FA46A + #C98A10 + #D9414F + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Packwright.App/Views/AboutView.axaml b/src/Packwright.App/Views/AboutView.axaml new file mode 100644 index 0000000..8f94377 --- /dev/null +++ b/src/Packwright.App/Views/AboutView.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + +