mirror of
https://github.com/itsmemac/Packwright.git
synced 2026-10-06 04:04:14 +02:00
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.
This commit is contained in:
183 files changed
+38228
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <Version> 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:.*<Version>\(.*\)</Version>.*:\1:p' "$PROJECT" | head -n1)
|
||||
if [ -z "$version" ]; then echo "No <Version> 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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>Packwright</string>
|
||||
<key>CFBundleDisplayName</key><string>Packwright</string>
|
||||
<key>CFBundleIdentifier</key><string>io.github.itsmemac.packwright</string>
|
||||
<key>CFBundleVersion</key><string>${version}</string>
|
||||
<key>CFBundleShortVersionString</key><string>${version}</string>
|
||||
<key>CFBundleExecutable</key><string>${APP}</string>
|
||||
<key>CFBundleIconFile</key><string>icon</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
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 <<NOTES
|
||||
## Packwright v${version}
|
||||
|
||||
Pick the installer for your system. Everything is self-contained, nothing else needs to be installed.
|
||||
|
||||
| System | Download |
|
||||
| --- | --- |
|
||||
| Windows 10/11 (64-bit) | \`$APP-Setup-v${version}-windows-x64.exe\` |
|
||||
| macOS, Apple silicon | \`$APP-v${version}-macos-arm64.dmg\` |
|
||||
| macOS, Intel | \`$APP-v${version}-macos-x64.dmg\` |
|
||||
| Linux (64-bit), Debian, Ubuntu, Mint | \`$APP-v${version}-linux-x64.deb\` |
|
||||
| Linux (64-bit), Fedora, openSUSE, RHEL | \`$APP-v${version}-linux-x64.rpm\` |
|
||||
|
||||
- **Windows:** run the installer. It installs for you without administrator rights, adds an uninstaller (Settings, Apps) and lets Packwright update itself. Windows may warn that the publisher is unknown because it is not code-signed.
|
||||
- **macOS:** open the disk image and drag **Packwright** onto **Applications**. The app is not notarized, so the first time you open it go to **System Settings, Privacy & Security** and click **Open Anyway** (step by step in the README under *macOS help*). To uninstall, delete it from Applications.
|
||||
- **Linux:** install with your software installer, or \`sudo apt install ./<file>.deb\` / \`sudo dnf install ./<file>.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
|
||||
@@ -0,0 +1,9 @@
|
||||
.vs/
|
||||
**/bin/
|
||||
**/obj/
|
||||
*.user
|
||||
*.suo
|
||||
publish/
|
||||
|
||||
# Locally built installers
|
||||
installer/out/
|
||||
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="src/Packwright.App/Packwright.App.csproj" />
|
||||
<Project Path="src/Packwright.Core/Packwright.Core.csproj" />
|
||||
<Project Path="src/Packwright.Containers/Packwright.Containers.csproj" />
|
||||
<Project Path="src/Packwright.Ufs2/Packwright.Ufs2.csproj" />
|
||||
</Solution>
|
||||
@@ -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 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<version>-windows-x64.exe` | run it (no administrator rights needed) |
|
||||
| macOS, Apple silicon | `Packwright-v<version>-macos-arm64.dmg` | open it, drag **Packwright** onto **Applications** |
|
||||
| macOS, Intel | `Packwright-v<version>-macos-x64.dmg` | open it, drag **Packwright** onto **Applications** |
|
||||
| Linux (64-bit), Debian, Ubuntu, Mint | `Packwright-v<version>-linux-x64.deb` | your software installer, or `sudo apt install ./<file>.deb` |
|
||||
| Linux (64-bit), Fedora, openSUSE, RHEL | `Packwright-v<version>-linux-x64.rpm` | your software installer, or `sudo dnf install ./<file>.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<version>-macos-arm64.dmg`
|
||||
- **Intel**: `Packwright-v<version>-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 <file>.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 `<Version>` 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 `<Version>` 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
|
||||
@@ -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.
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,58 @@
|
||||
; Packwright installer (Inno Setup 6). Built by the release workflow:
|
||||
; iscc /DAppVersion=1.0.0 /DSourceDir=<publish folder> /O<output folder> 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
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the Linux installers from a linux-x64 publish folder:
|
||||
# Packwright-v<version>-linux-x64.deb (Debian, Ubuntu, Mint, ...)
|
||||
# Packwright-v<version>-linux-x64.rpm (Fedora, RHEL family, openSUSE, ...)
|
||||
#
|
||||
# Usage: installer/linux/build-packages.sh <version> <publish folder> <output folder>
|
||||
# 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" <<CONTROL
|
||||
Package: packwright
|
||||
Version: ${version}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Installed-Size: ${size_kb}
|
||||
Depends: libc6, libstdc++6, libx11-6, libice6, libsm6, libfontconfig1, libssl3 | libssl3t64 | libssl1.1
|
||||
Maintainer: itsmemac <noreply@users.noreply.github.com>
|
||||
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" <<SPEC
|
||||
Name: packwright
|
||||
Version: ${version}
|
||||
Release: 1
|
||||
Summary: PS5 package, dump and image tool
|
||||
License: GPL-3.0-only
|
||||
URL: https://github.com/itsmemac/Packwright
|
||||
AutoReqProv: no
|
||||
Requires: libstdc++.so.6()(64bit), libX11.so.6()(64bit), libICE.so.6()(64bit), libSM.so.6()(64bit), libfontconfig.so.1()(64bit), libssl.so.3()(64bit)
|
||||
|
||||
%global debug_package %{nil}
|
||||
%global __os_install_post %{nil}
|
||||
%global _build_id_links none
|
||||
%define _binary_payload w9.xzdio
|
||||
|
||||
%description
|
||||
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").
|
||||
|
||||
%install
|
||||
cp -a ${stage}/. %{buildroot}/
|
||||
|
||||
%files
|
||||
%defattr(-,root,root,-)
|
||||
/opt/packwright
|
||||
/usr/bin/packwright
|
||||
/usr/share/applications/packwright.desktop
|
||||
/usr/share/icons/hicolor/512x512/apps/packwright.png
|
||||
/usr/share/doc/packwright
|
||||
SPEC
|
||||
rpmbuild -bb --target x86_64 --define "_topdir $top" "$top/SPECS/packwright.spec"
|
||||
cp "$top/RPMS/x86_64/packwright-${version}-1.x86_64.rpm" "$out/$name.rpm"
|
||||
|
||||
echo "Built:"
|
||||
ls -l "$out/$name.deb" "$out/$name.rpm"
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Packwright
|
||||
GenericName=PS5 package tool
|
||||
Comment=Manage PS5 dumps, images and packages
|
||||
Exec=/opt/packwright/Packwright %F
|
||||
Icon=packwright
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=true
|
||||
StartupWMClass=Packwright
|
||||
@@ -0,0 +1,10 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Packwright/Styles/Theme.axaml" />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Command-line mode: <c>Packwright info|scan|convert|extract|verify|health ...</c>. It runs the same jobs as the
|
||||
/// window, without starting the interface, and never touches the network.
|
||||
/// </summary>
|
||||
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<string> 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<string> Positional { get; } = [];
|
||||
public Dictionary<string, string> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public HashSet<string> 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 <command> [options]
|
||||
(On Windows use the packwright.cmd next to Packwright.exe so the prompt waits for the result.)
|
||||
|
||||
Commands:
|
||||
info <path> Show the title, IDs, version and size of a dump folder, image or package.
|
||||
scan <folder>... List every title found. --json | --csv | --no-recursive | -o <file>
|
||||
health <folder>... Check a library for problems. --md | --json | -o <file>
|
||||
convert <source> --to <t> Convert or build. <t> is exfat, ffpkg, ffpfsc, zar or pkg.
|
||||
extract <source> Extract an image or package to a folder. -o <folder>
|
||||
verify <source> Check an image or package for damage.
|
||||
send <source> --to <name> Send to a console on your network (FTP). --folder <path> | --install (a .pkg: the
|
||||
console downloads it from this PC) | --host <ip> --port <n> instead of --to
|
||||
consoles List the consoles saved in Settings.
|
||||
version Print the version.
|
||||
|
||||
convert options:
|
||||
-o, --output <file> Where to write it (default: next to the source). --overwrite replaces an existing file.
|
||||
--preset <name> 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 <id> --compression auto|kraken|stored --kraken-level N --threads N --playgo N
|
||||
--deterministic --no-fake-sign --no-right-sprx --drm upgradable|free|standard|keep --workspace <folder>
|
||||
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<List<LibraryItem>> 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<Ps5ScanProgress>(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<int> 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<int> ScanAsync(Options options, CancellationToken token)
|
||||
{
|
||||
List<LibraryItem> 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<int> HealthAsync(Options options, CancellationToken token)
|
||||
{
|
||||
List<LibraryItem> 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<ConsoleProfile> 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<int> 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 <console name> or --host <address>.", 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<int> 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<IProgress<PackageTaskProgress>, 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);
|
||||
}
|
||||
|
||||
/// <summary>Fills a convert or build job from the options; returns a message when they do not make sense.</summary>
|
||||
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 <other file>.";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>Prints the current step and a percentage to the error stream, a line at a time when it is redirected.</summary>
|
||||
private sealed class ConsoleProgress : IProgress<PackageTaskProgress>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
namespace Packwright.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Grouping keys the library understands; anything else normalizes to "None".</summary>
|
||||
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<string> CleanList(List<string>? values, int max = int.MaxValue)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (values is null) return result;
|
||||
var seen = new HashSet<string>(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<string> CleanPaths(List<string>? values)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (values is null) return result;
|
||||
var seen = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a full path when possible, otherwise the trimmed original string.</summary>
|
||||
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<string, float> CleanWeights(Dictionary<string, float>? weights)
|
||||
{
|
||||
var result = new Dictionary<string, float>(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<string> CleanSortKeys(List<string>? values) => CleanList(values);
|
||||
|
||||
private static List<ConsoleProfile> CleanConsoles(List<ConsoleProfile>? consoles)
|
||||
{
|
||||
var result = new List<ConsoleProfile>();
|
||||
var seen = new HashSet<string>(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<ToolPreset> CleanPresets(List<ToolPreset>? presets)
|
||||
{
|
||||
var result = new List<ToolPreset>();
|
||||
var seen = new HashSet<string>(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<SavedLibraryView> CleanViews(List<SavedLibraryView>? views)
|
||||
{
|
||||
var result = new List<SavedLibraryView>();
|
||||
if (views is null) return result;
|
||||
var seen = new HashSet<string>(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Text.Json;
|
||||
using Packwright.Core.Models;
|
||||
|
||||
namespace Packwright.Infrastructure;
|
||||
|
||||
/// <summary>A named snapshot of the library layout: filters, grouping, sorting and columns.</summary>
|
||||
public sealed class SavedLibraryView
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Query { get; set; } = string.Empty;
|
||||
public List<string> Categories { get; set; } = [];
|
||||
public List<string> Regions { get; set; } = [];
|
||||
public List<string> Formats { get; set; } = [];
|
||||
public string GroupBy { get; set; } = string.Empty;
|
||||
public List<string> SortKeys { get; set; } = [];
|
||||
public List<string> HiddenColumns { get; set; } = [];
|
||||
public List<string> ColumnOrder { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A named set of Image tools options (target format and its settings). The source and output paths are never part of it.</summary>
|
||||
public sealed class ToolPreset
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Exfat, Ffpkg, Ffpfsc, ZArchive or Fpkg; empty keeps whatever is chosen.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A console on the local network that files can be sent to. Nothing is contacted until the user sends something.</summary>
|
||||
public sealed class ConsoleProfile
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>The console's address on the local network, such as 192.168.1.50.</summary>
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int FtpPort { get; set; } = 1337;
|
||||
/// <summary>Where files go on the console unless another folder is chosen when sending.</summary>
|
||||
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<string> LibraryFolders { get; set; } = [];
|
||||
public bool RecursiveScan { get; set; } = true;
|
||||
public List<string> RecentFolders { get; set; } = [];
|
||||
public List<string> 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;
|
||||
/// <summary>Ordered sort keys ("Title:asc", "Size:desc") for multi-column sorting.</summary>
|
||||
public List<string> LibrarySortKeys { get; set; } = [];
|
||||
/// <summary>Named saved library views (filters, grouping, sorting and column layout).</summary>
|
||||
public List<SavedLibraryView> SavedViews { get; set; } = [];
|
||||
/// <summary>Named Image tools presets saved by the user.</summary>
|
||||
public List<ToolPreset> Presets { get; set; } = [];
|
||||
/// <summary>Consoles that files can be sent to.</summary>
|
||||
public List<ConsoleProfile> Consoles { get; set; } = [];
|
||||
public List<string> LibraryColumnOrder { get; set; } = [];
|
||||
public List<string> LibraryHiddenColumns { get; set; } = [];
|
||||
/// <summary>Remembered column fill weights by column name so user-resized widths survive a restart.</summary>
|
||||
public Dictionary<string, float> LibraryColumnWeights { get; set; } = [];
|
||||
|
||||
// Appearance
|
||||
public string Theme { get; set; } = "Default (Charcoal)";
|
||||
/// <summary>Library presentation: "Cards" (artwork tiles) or "List".</summary>
|
||||
public string LibraryLayout { get; set; } = "Cards";
|
||||
public int GridRowHeight { get; set; } = 22;
|
||||
public bool ShowThumbnails { get; set; } = true;
|
||||
public bool ShowGridLines { get; set; } = true;
|
||||
/// <summary>Show the inline file preview pane in the Files tab (list-only when disabled).</summary>
|
||||
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
|
||||
/// <summary>Remembered vertical split between the task list and the details panel.</summary>
|
||||
public int TaskSplitterDistance { get; set; }
|
||||
/// <summary>True when the details panel is collapsed to give the list more height.</summary>
|
||||
public bool TaskDetailsCollapsed { get; set; }
|
||||
|
||||
// Build defaults
|
||||
public string DebugPasscode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Selected package backend id ("lpp" or "ppt"). Empty/unknown falls back to the default.</summary>
|
||||
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<Ps5GameInfo> Games { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Result of loading settings, including a non-fatal warning worth surfacing to the user.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public SettingsLoadResult LoadSettingsWithDiagnostics()
|
||||
{
|
||||
if (!File.Exists(SettingsPath)) return new SettingsLoadResult(new AppSettings(), null);
|
||||
try
|
||||
{
|
||||
AppSettings settings = JsonSerializer.Deserialize<AppSettings>(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<LibraryManifest>(
|
||||
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<Ps5GameInfo> games) => Save(ManifestPath, new LibraryManifest
|
||||
{
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
Games = games.ToList()
|
||||
});
|
||||
|
||||
private static void Save<T>(string path, T value)
|
||||
{
|
||||
string tempPath = path + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonSerializer.Serialize(value, JsonOptions));
|
||||
File.Move(tempPath, path, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Infrastructure;
|
||||
|
||||
public enum LogLevel
|
||||
{
|
||||
Info,
|
||||
Warn,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>One structured log record, raised through <see cref="Logger.Logged"/>.</summary>
|
||||
public readonly record struct LogEntry(DateTime Time, LogLevel Level, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal thread-safe file logger. Writes to <c>%LocalAppData%\Packwright\logs</c>, 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; <see cref="Logged"/> feeds the in-app Log tab.
|
||||
/// </summary>
|
||||
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<LogEntry>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Formats an entry exactly as it is written to the log file.</summary>
|
||||
public static string Format(LogEntry entry) =>
|
||||
$"{entry.Time:yyyy-MM-dd HH:mm:ss.fff} [{LevelText(entry.Level)}] {entry.Message}";
|
||||
|
||||
/// <summary>The last lines of the log file, oldest first, for showing history in the app.</summary>
|
||||
public static IReadOnlyList<string> 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<string> 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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Packwright.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>--open "<path>"</c>. Windows only.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Returns the lock when this is the first copy, or null after passing <paramref name="args"/> on.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Calls <paramref name="onActivate"/> (with any paths given) each time another launch is attempted.</summary>
|
||||
public void Listen(Action<string[]> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia.Media;
|
||||
using Packwright.Core.Models;
|
||||
|
||||
namespace Packwright.App.Models;
|
||||
|
||||
/// <summary>One folder or file in the game file browser tree.</summary>
|
||||
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<string> ImageTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".dds", ".tga", ".ico" };
|
||||
private static readonly HashSet<string> 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<string> 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<FileNode> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Builds the folder tree for a set of files and returns its top-level nodes.</summary>
|
||||
public static List<FileNode> BuildTree(IEnumerable<Ps5FileInfo> files, bool expandAll)
|
||||
{
|
||||
var roots = new List<FileNode>();
|
||||
var folders = new Dictionary<string, FileNode>(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<FileNode> 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<FileNode> SelfAndDescendants()
|
||||
{
|
||||
yield return this;
|
||||
foreach (FileNode child in Children)
|
||||
foreach (FileNode inner in child.SelfAndDescendants())
|
||||
yield return inner;
|
||||
}
|
||||
|
||||
public IEnumerable<FileNode> Files() => SelfAndDescendants().Where(node => !node.IsDirectory);
|
||||
|
||||
public static void SetExpanded(IEnumerable<FileNode> nodes, bool expanded)
|
||||
{
|
||||
foreach (FileNode node in nodes)
|
||||
foreach (FileNode inner in node.SelfAndDescendants().Where(inner => inner.IsDirectory))
|
||||
inner.IsExpanded = expanded;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A library row: display-ready values derived from a scanned <see cref="Ps5GameInfo"/>.</summary>
|
||||
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<string> Features { get; private set; } = [];
|
||||
/// <summary>True when the user edited this item's details.</summary>
|
||||
public bool IsEdited { get; private set; }
|
||||
|
||||
/// <summary>Details the title still lacks (after the user's edits), such as "Title ID" or "Firmware".</summary>
|
||||
public IReadOnlyList<string> 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));
|
||||
|
||||
/// <summary>Recomputes the displayed values from the file's own metadata plus the user's edits.</summary>
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The item's role in its title family: Base, Update, DLC or App.</summary>
|
||||
public string Role
|
||||
{
|
||||
get => _role;
|
||||
set { if (_role != value) { _role = value; Raise(); } }
|
||||
}
|
||||
|
||||
/// <summary>Key of the group this row currently sits in (empty when grouping is off).</summary>
|
||||
public string GroupKey
|
||||
{
|
||||
get => _groupKey;
|
||||
set { if (_groupKey != value) { _groupKey = value; Raise(); } }
|
||||
}
|
||||
|
||||
public string Subtitle
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (TitleId.Length > 0) parts.Add(TitleId);
|
||||
if (Version.Length > 0) parts.Add("v" + Version.TrimStart('v', 'V'));
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Artwork, loaded lazily the first time a view asks for it.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Forgets a cached bitmap so it can be loaded again after eviction.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>The regions a Content ID can name, with the two letters it starts with.</summary>
|
||||
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]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>Packwright</AssemblyName>
|
||||
<RootNamespace>Packwright.App</RootNamespace>
|
||||
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<Version>1.0.0</Version>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>Packwright</Product>
|
||||
<Authors>itsmemac</Authors>
|
||||
<Company>itsmemac</Company>
|
||||
<Copyright>Copyright (c) 2026 itsmemac</Copyright>
|
||||
<Description>Cross-platform PS5 dump, image and package manager. Based on PS5 PKG Tool by pearlxcore (GPL-3.0).</Description>
|
||||
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<!-- Lower memory use: no background GC thread, no ICU data, no PGO instrumentation -->
|
||||
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<TieredPGO>false</TieredPGO>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<RuntimeHostConfigurationOption Include="System.GC.ConserveMemory" Value="5" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.22" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.22" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.22" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.22" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Packwright.Core\Packwright.Core.csproj" />
|
||||
<ProjectReference Include="..\Packwright.Containers\Packwright.Containers.csproj" />
|
||||
<ProjectReference Include="..\Packwright.Ufs2\Packwright.Ufs2.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or ('$(RuntimeIdentifier)' == '' And $([MSBuild]::IsOSPlatform('Windows')))">
|
||||
<!-- Command-line launcher for Windows: waits for the window-less exe so output and exit code reach the prompt. -->
|
||||
<Content Include="packwright.cmd" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\third-party\LibProsperoPkg12\**\*" Condition="'$(RuntimeIdentifier)' == 'win-x64' Or ('$(RuntimeIdentifier)' == '' And $([MSBuild]::IsOSPlatform('Windows')))">
|
||||
<Link>LibProsperoPkg12\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
/// <summary>The lock that keeps this the only running copy.</summary>
|
||||
internal static SingleInstance? Current { get; private set; }
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>().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 });
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Core.Tasks;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Services;
|
||||
|
||||
/// <summary>Process-wide state shared by every view: settings, the library and the task queue.</summary>
|
||||
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<string, PackageTaskStatus> _loggedStatus = [];
|
||||
|
||||
/// <summary>Writes one log line whenever a task starts, finishes, fails or is cancelled.</summary>
|
||||
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<Ps5GameInfo> Games { get; private set; }
|
||||
|
||||
public event Action? LibraryChanged;
|
||||
|
||||
/// <summary>Files the last scan found but could not read ("path: reason").</summary>
|
||||
public IReadOnlyList<string> ScanProblems { get; private set; } = [];
|
||||
|
||||
/// <summary>Raised when another page wants the Library to show (and optionally edit) one title.</summary>
|
||||
public event Action<string, bool>? ShowTitleRequested;
|
||||
|
||||
public void RequestShowTitle(string path, bool edit) => ShowTitleRequested?.Invoke(path, edit);
|
||||
|
||||
/// <summary>Raised after settings were saved so views can re-read appearance options.</summary>
|
||||
public event Action? SettingsChanged;
|
||||
|
||||
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
|
||||
|
||||
/// <summary>Raised when another view wants the Tools tab to work on a path.</summary>
|
||||
public event Action<string>? ToolsSourceRequested;
|
||||
|
||||
public void RequestToolsSource(string path) => ToolsSourceRequested?.Invoke(path);
|
||||
|
||||
/// <summary>Queues a job described by <paramref name="spec"/>; it is saved and restored across restarts.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Library folders plus single items added by path (manual sources).</summary>
|
||||
public IReadOnlyList<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>Rescans every library folder (reusing cached entries for unchanged sources).</summary>
|
||||
public async Task<Ps5ScanResult> RefreshLibraryAsync(IProgress<Ps5ScanProgress>? 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ConsoleSender
|
||||
{
|
||||
private const int Attempts = 3;
|
||||
|
||||
/// <summary>The remote folder a source will end up in: the chosen folder plus the file or dump folder name.</summary>
|
||||
public static string RemotePathFor(string remoteFolder, string source) =>
|
||||
FtpClient.Normalize(FtpClient.Normalize(remoteFolder) + "/" +
|
||||
Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)));
|
||||
|
||||
/// <summary>Connects and reports what the console's FTP server says, so a profile can be checked before sending.</summary>
|
||||
public static async Task<string> 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<FtpClient> 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<PackageTaskProgress> 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}.");
|
||||
}
|
||||
|
||||
/// <summary>Dropped links and busy servers are worth another try; refusals (5xx) are not.</summary>
|
||||
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) };
|
||||
|
||||
/// <summary>Fills the placeholders of the install request. {url} is escaped for JSON bodies.</summary>
|
||||
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<PackageTaskProgress> 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)}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace Packwright.App.Services;
|
||||
|
||||
/// <summary>Small helpers for message boxes and the platform file/folder pickers.</summary>
|
||||
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<bool> 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<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>Shows long text in a scrollable box; returns true when the user picks the first button.</summary>
|
||||
public static async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>Asks for one line of text; returns null when cancelled.</summary>
|
||||
public static async Task<string?> 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();
|
||||
}
|
||||
|
||||
/// <summary>Shows a list of checkboxes; returns the new states or null when cancelled.</summary>
|
||||
public static async Task<bool[]?> 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<string?> PickFolderAsync(Visual anchor, string title)
|
||||
{
|
||||
TopLevel? top = TopLevel.GetTopLevel(anchor);
|
||||
if (top is null) return null;
|
||||
IReadOnlyList<IStorageFolder> folders = await top.StorageProvider.OpenFolderPickerAsync(
|
||||
new FolderPickerOpenOptions { Title = title, AllowMultiple = false });
|
||||
return folders.Count > 0 ? folders[0].TryGetLocalPath() : null;
|
||||
}
|
||||
|
||||
public static async Task<string?> PickFileAsync(Visual anchor, string title, string[]? patterns = null)
|
||||
{
|
||||
TopLevel? top = TopLevel.GetTopLevel(anchor);
|
||||
if (top is null) return null;
|
||||
IReadOnlyList<IStorageFile> 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<string?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shows a file or folder in the platform file manager.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Turns artwork into the 512x512 PNG a title uses as its icon (sce_sys/icon0.png).</summary>
|
||||
public static class IconTools
|
||||
{
|
||||
public const int IconSize = 512;
|
||||
|
||||
/// <summary>Centre-crops <paramref name="source"/> to a square and scales it to 512x512. Runs on the UI thread.</summary>
|
||||
public static Task<byte[]?> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>The game's name and a small PNG icon (base64), shown on the task card.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>For the send kinds: a copy of the console's settings at the time, and the folder on the console.</summary>
|
||||
public ConsoleProfile? Console { get; set; }
|
||||
public string RemoteFolder { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>For <see cref="EditDetails"/>: the details to write. Output empty means edit in place.</summary>
|
||||
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<JobSpec>(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;
|
||||
|
||||
/// <summary>The delegate that runs this job inside the task queue.</summary>
|
||||
public Func<IProgress<PackageTaskProgress>, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Settings for a debug package (FPKG) build.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// The long-running operations behind the Tools tab. Each method is UI-free and reports progress
|
||||
/// through <see cref="PackageTaskProgress"/>, so it can run inside the shared task queue.
|
||||
/// </summary>
|
||||
public static class Jobs
|
||||
{
|
||||
public static IProgress<PackageTaskProgress> NoProgress { get; } = new Progress<PackageTaskProgress>();
|
||||
|
||||
public static IProgress<FfpfscProgress> Adapt(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<FfpfscProgress>(value => target.Report(new PackageTaskProgress(
|
||||
value.Stage, 0, 0, value.BytesProcessed, value.TotalBytes, 0, 0, string.Empty)));
|
||||
|
||||
public static IProgress<Ufs2Progress> AdaptUfs2(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<Ufs2Progress>(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<Ps5ImageConversionProgress> AdaptConversion(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<Ps5ImageConversionProgress>(value => target.Report(new PackageTaskProgress(
|
||||
value.Stage, 0, 0, value.Completed, value.Total, 0, 0, string.Empty)));
|
||||
|
||||
public static IProgress<SonyPackageExtractProgress> AdaptExtract(IProgress<PackageTaskProgress> target) =>
|
||||
new Progress<SonyPackageExtractProgress>(value => target.Report(new PackageTaskProgress(
|
||||
"Extracting", 0, 0, value.CompletedBytes, value.TotalBytes, 0, 0, value.CurrentPath)));
|
||||
|
||||
/// <summary>The builder to use: the saved choice, falling back when LibProsperoPkg is not usable on this OS.</summary>
|
||||
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<PackageTaskProgress> progress, CancellationToken token,
|
||||
ExfatBuildOptions? exfatOptions = null, FfpfscBuildOptions? ffpfscOptions = null,
|
||||
FfpkgBuildOptions? ffpkgOptions = null)
|
||||
{
|
||||
IProgress<Ps5ImageConversionProgress> 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<PackageTaskProgress> 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<SonyPackageExtractResult> ExtractPackageAsync(string source, string output,
|
||||
string passcode, IProgress<PackageTaskProgress> 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);
|
||||
|
||||
/// <summary>Reads the content ID from a dump folder or image so a package can be built from it.</summary>
|
||||
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<PackageTaskProgress> 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<SonyDebugPackageProgress>(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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One planned rename: the source, where it would land and whether a numbered name was needed.</summary>
|
||||
public sealed record RenamePlan(Ps5GameInfo Game, string Source, string Target, bool Conflict, string? Error)
|
||||
{
|
||||
public bool Unchanged => Error is null && Target.Length == 0;
|
||||
}
|
||||
|
||||
/// <summary>File operations behind the library context menu: rename, move, delete, export and duplicates.</summary>
|
||||
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<RenamePlan> PlanRenames(IEnumerable<Ps5GameInfo> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<RenamePlan> PlanInstallOrder(IEnumerable<Ps5GameInfo> games, string format)
|
||||
{
|
||||
var plans = new List<RenamePlan>();
|
||||
foreach (IGrouping<string, Ps5GameInfo> 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<Ps5GameInfo> 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
|
||||
};
|
||||
|
||||
/// <summary>Lists updates and add-ons whose Title ID has no base game in the library.</summary>
|
||||
public static string MissingBaseReport(IEnumerable<LibraryItem> items)
|
||||
{
|
||||
List<LibraryItem> all = items.ToList();
|
||||
HashSet<string> 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<RenamePlan> 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();
|
||||
}
|
||||
|
||||
/// <summary>Applies the plans and returns how many items were renamed.</summary>
|
||||
public static int ApplyRenames(IEnumerable<RenamePlan> 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<Ps5GameInfo> 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<Ps5GameInfo> 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<LibraryItem> 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;
|
||||
}
|
||||
|
||||
/// <summary>Groups items that share the same param.json bytes (exact copies) or the same content ID.</summary>
|
||||
public static string DuplicatesReport(IEnumerable<LibraryItem> items)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
int groups = 0;
|
||||
foreach (IGrouping<string, LibraryItem> 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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (IGrouping<string, LibraryItem> 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();
|
||||
}
|
||||
|
||||
/// <summary>Renders a parsed model as readable "name: value" lines (lists are summarised and truncated).</summary>
|
||||
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<object> list = sequence.Cast<object>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Writes the library as a JSON file or as one self-contained web page. Nothing here uses the network.</summary>
|
||||
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<LibraryItem> 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);
|
||||
}
|
||||
|
||||
/// <summary>A small PNG of the title's artwork for the web page, or null when it has none.</summary>
|
||||
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<LibraryItem> items, bool includePaths,
|
||||
IReadOnlyDictionary<string, byte[]>? 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("""
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<title>PS5 library</title>
|
||||
<style>
|
||||
:root{--bg:#f4f5f8;--card:#fff;--text:#15171c;--muted:#666d7a;--line:#e1e4ea;--accent:#2f6fed;--chip:#eef1f7}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#14161a;--card:#1d2026;--text:#e8eaee;--muted:#9aa1ad;--line:#2b3038;--accent:#6b9bff;--chip:#272b33}}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,Segoe UI,Roboto,sans-serif}
|
||||
main{max-width:1280px;margin:0 auto;padding:28px 18px 48px}h1{margin:0 0 4px;font-size:26px}
|
||||
.muted{color:var(--muted)}.stats{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 16px}
|
||||
.stat b{display:block;font-size:20px}.bar{display:flex;gap:10px;margin:0 0 12px}
|
||||
input{flex:1;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--text);font:inherit}
|
||||
.wrap{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:auto}
|
||||
table{border-collapse:collapse;width:100%}th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:middle}
|
||||
th{position:sticky;top:0;background:var(--card);cursor:pointer;user-select:none;white-space:nowrap;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
||||
th.asc::after{content:" \25B2"}th.desc::after{content:" \25BC"}tr:last-child td{border-bottom:0}
|
||||
td.icon{width:56px;padding-right:0}td.icon img{width:44px;height:44px;border-radius:9px;display:block}
|
||||
td.icon .ph{width:44px;height:44px;border-radius:9px;background:var(--chip)}
|
||||
.t{font-weight:600}.sub{color:var(--muted);font-size:12px}.num{text-align:right;white-space:nowrap}
|
||||
.pill{display:inline-block;padding:1px 8px;border-radius:99px;background:var(--chip);font-size:12px}
|
||||
.warn{color:#c47a00}footer{margin-top:18px;font-size:12px;color:var(--muted)}
|
||||
</style></head><body><main>
|
||||
""");
|
||||
html.Append("<h1>PS5 library</h1><div class=\"muted\">")
|
||||
.Append(H($"Exported {DateTime.Now:d MMMM yyyy, HH:mm} with Packwright {UpdateService.CurrentVersion}"))
|
||||
.Append("</div><div class=\"stats\">");
|
||||
Stat(html, items.Count.ToString("N0"), "titles");
|
||||
Stat(html, Ps5LibraryHealth.FormatBytes(total), "total size");
|
||||
foreach (IGrouping<string, LibraryItem> 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("</div><div class=\"bar\"><input id=\"q\" type=\"search\" placeholder=\"Search title, ID, version...\" autofocus></div>");
|
||||
html.Append("<div class=\"wrap\"><table id=\"t\"><thead><tr><th data-n=\"0\" style=\"cursor:default\"></th>")
|
||||
.Append("<th>Title</th><th>Category</th><th>Region</th><th>Format</th><th>Version</th><th>Firmware</th><th class=\"num\" data-num=\"1\">Size</th>");
|
||||
if (includePaths) html.Append("<th>Path</th>");
|
||||
html.Append("</tr></thead><tbody>");
|
||||
foreach (LibraryItem item in items.OrderBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
html.Append("<tr><td class=\"icon\">");
|
||||
if (icons is not null && icons.TryGetValue(item.Path, out byte[]? png))
|
||||
html.Append("<img alt=\"\" src=\"data:image/png;base64,").Append(Convert.ToBase64String(png)).Append("\">");
|
||||
else html.Append("<div class=\"ph\"></div>");
|
||||
html.Append("</td><td><div class=\"t\">").Append(H(item.Title)).Append("</div><div class=\"sub\">")
|
||||
.Append(H(item.TitleId.Length > 0 ? item.TitleId : item.FileName));
|
||||
if (item.ContentId.Length > 0) html.Append(" · ").Append(H(item.ContentId));
|
||||
html.Append("</div>");
|
||||
if (item.HasMissing) html.Append("<div class=\"sub warn\">").Append(H(item.MissingText)).Append("</div>");
|
||||
html.Append("</td><td><span class=\"pill\">").Append(H(item.Category)).Append("</span></td><td>").Append(H(item.Region))
|
||||
.Append("</td><td>").Append(H(item.Format)).Append("</td><td>").Append(H(item.Version)).Append("</td><td>")
|
||||
.Append(H(item.Firmware)).Append("</td><td class=\"num\" data-v=\"").Append(item.SizeBytes).Append("\">")
|
||||
.Append(H(item.SizeText)).Append("</td>");
|
||||
if (includePaths) html.Append("<td class=\"sub\">").Append(H(item.Path)).Append("</td>");
|
||||
html.Append("</tr>");
|
||||
}
|
||||
html.Append("""
|
||||
</tbody></table></div>
|
||||
<footer>Made with Packwright. This page is a single file; it loads nothing from the internet.</footer>
|
||||
</main><script>
|
||||
(function(){var t=document.getElementById('t'),b=t.tBodies[0],q=document.getElementById('q');
|
||||
q.addEventListener('input',function(){var s=q.value.toLowerCase();
|
||||
for(var i=0;i<b.rows.length;i++)b.rows[i].style.display=b.rows[i].textContent.toLowerCase().indexOf(s)<0?'none':'';});
|
||||
var heads=t.tHead.rows[0].cells;
|
||||
for(let c=1;c<heads.length;c++)heads[c].addEventListener('click',function(){
|
||||
var asc=!heads[c].classList.contains('asc');
|
||||
for(var k=0;k<heads.length;k++)heads[k].classList.remove('asc','desc');
|
||||
heads[c].classList.add(asc?'asc':'desc');
|
||||
var rows=[].slice.call(b.rows),num=heads[c].hasAttribute('data-num');
|
||||
function key(r){var x=r.cells[c];return num?+x.getAttribute('data-v'):x.textContent.trim().toLowerCase();}
|
||||
rows.sort(function(a,z){var p=key(a),r=key(z);return(p<r?-1:p>r?1:0)*(asc?1:-1);});
|
||||
rows.forEach(function(r){b.appendChild(r);});});})();
|
||||
</script></body></html>
|
||||
""");
|
||||
return html.ToString();
|
||||
}
|
||||
|
||||
private static void Stat(StringBuilder html, string value, string label) =>
|
||||
html.Append("<div class=\"stat\"><b>").Append(WebUtility.HtmlEncode(value)).Append("</b><span class=\"muted\">")
|
||||
.Append(WebUtility.HtmlEncode(label)).Append("</span></div>");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using Packwright.App.Models;
|
||||
|
||||
namespace Packwright.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The library search grammar: plain words, quoted phrases, <c>field:value</c> terms
|
||||
/// (<c>title id content category role region version fw feature path size</c>), <c>-exclude</c>,
|
||||
/// <c>|</c> for OR and <c>=</c> after the colon for an exact match (<c>id:=PPSA12345</c>).
|
||||
/// Comparisons work on size and version (<c>size:>50GB</c>, <c>version:>=01.05</c>).
|
||||
/// </summary>
|
||||
public sealed class LibraryQuery
|
||||
{
|
||||
private sealed record Term(string Field, string Value, bool Exclude, bool Exact, string Op);
|
||||
|
||||
private readonly List<List<Term>> _alternatives = [];
|
||||
|
||||
public List<string> 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<Term>();
|
||||
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<string> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Runtime;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Packwright.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class MemoryTrimmer
|
||||
{
|
||||
private static DispatcherTimer? _timer;
|
||||
|
||||
/// <summary>Trims once the app has been quiet for <paramref name="seconds"/>; repeated calls restart the wait.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The values to write into a title's param.json and icon. Empty values are left as they are.</summary>
|
||||
/// <summary>The feature flags a title can declare, and which bit of param.json each one is.</summary>
|
||||
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<string> 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;
|
||||
/// <summary>A PNG file that becomes sce_sys/icon0.png.</summary>
|
||||
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;
|
||||
/// <summary>The features to declare (see <see cref="DeclaredFeatureFlags"/>). Null leaves them as they are.</summary>
|
||||
public List<string>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="original"/> with the edited fields changed and everything else kept, or a new
|
||||
/// minimal param.json when there is none.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The number the app reads back as the category (the same mapping the library uses).</summary>
|
||||
private static int CategoryToParam(string category) => category switch { "DLC" => 1, "Patch" => 2, "App" => 3, _ => 0 };
|
||||
|
||||
/// <summary>Makes <paramref name="language"/> the default language, adding an entry for it (with the title) when it has none.</summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>Sets or clears the known feature bits and leaves every other bit as it was.</summary>
|
||||
private static void ApplyFeatures(JsonObject root, IReadOnlyCollection<string> features)
|
||||
{
|
||||
foreach (IGrouping<string, (string Name, string Key, int Bit)> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"4.50" becomes "0x0450000000000000". A value that already starts with 0x is used as written.</summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="edit"/> to <paramref name="source"/>. When <paramref name="output"/> is empty or the
|
||||
/// same as the source the file is changed in place, otherwise a new file or folder is written.
|
||||
/// </summary>
|
||||
public static async Task ApplyAsync(string source, string output, bool fromPackage, MetadataEdit edit,
|
||||
string passcode, string backendId, IProgress<PackageTaskProgress> 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<PackageTaskProgress> 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<PackageTaskProgress> 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<PackageTaskProgress> progress, Func<string, Task> 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<PackageTaskProgress> 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<ExfatEditOperation>();
|
||||
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<PackageTaskProgress> 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<Ufs2EditOperation>();
|
||||
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<PackageTaskProgress> 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<PackageTaskProgress> progress, CancellationToken token)
|
||||
{
|
||||
var replacements = new Dictionary<string, byte[]>(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<PackageTaskProgress> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
/// <summary>Game, Patch, DLC or App. Empty keeps the detected category.</summary>
|
||||
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;
|
||||
/// <summary>The declared features (HDR, VRR, ...). Null keeps what the file declares; an empty list means none.</summary>
|
||||
public List<string>? Features { get; set; }
|
||||
/// <summary>Name of the custom icon file inside the overrides folder (empty = none).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Per-item detail overrides, saved next to the settings so they survive rescans and restarts.</summary>
|
||||
public static class MetadataOverrides
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
private static readonly object Gate = new();
|
||||
private static Dictionary<string, MetadataOverride>? _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<string, MetadataOverride> Items
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_items is not null) return _items;
|
||||
var loaded = new Dictionary<string, MetadataOverride>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
loaded = new Dictionary<string, MetadataOverride>(
|
||||
JsonSerializer.Deserialize<Dictionary<string, MetadataOverride>>(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;
|
||||
}
|
||||
|
||||
/// <summary>Saves (or, when empty, removes) the override for <paramref name="path"/>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Copies an image into the overrides folder and returns the stored file name.</summary>
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A game's display name and a small icon, used to label tasks and the selected source.</summary>
|
||||
public sealed record SourceSummary(string Title, byte[]? Icon)
|
||||
{
|
||||
public static SourceSummary Empty { get; } = new(string.Empty, null);
|
||||
}
|
||||
|
||||
/// <summary>Finds the title and artwork of a dump folder, image or package.</summary>
|
||||
public static class SourceInfo
|
||||
{
|
||||
private const int IconWidth = 96;
|
||||
|
||||
public static Task<SourceSummary> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ThumbnailService
|
||||
{
|
||||
private const int DecodeWidth = 168;
|
||||
private static readonly SemaphoreSlim Gate = new(2);
|
||||
private static readonly ConcurrentQueue<LibraryItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class UpdateService
|
||||
{
|
||||
public static string CurrentVersion { get; } =
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0";
|
||||
|
||||
/// <summary>The newer release found by the last check, or null.</summary>
|
||||
public static ReleaseInfo? Available { get; private set; }
|
||||
|
||||
public static event Action? Changed;
|
||||
|
||||
/// <summary>True when a newer release is known and the user has not chosen to skip it.</summary>
|
||||
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<UpdateResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<Styles.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<Color x:Key="AppBgColor">#0D0F17</Color>
|
||||
<Color x:Key="PanelBgColor">#131622</Color>
|
||||
<Color x:Key="CardBgColor">#1A1E2E</Color>
|
||||
<Color x:Key="CardHoverColor">#222741</Color>
|
||||
<Color x:Key="InputBgColor">#10131D</Color>
|
||||
<Color x:Key="LineColor">#2A2F47</Color>
|
||||
<Color x:Key="MutedColor">#8E94AE</Color>
|
||||
<Color x:Key="TextColor">#EEF0FA</Color>
|
||||
<Color x:Key="AccentColor">#7C8CFF</Color>
|
||||
<Color x:Key="AccentDeepColor">#5B6BF0</Color>
|
||||
<Color x:Key="AccentSoftColor">#267C8CFF</Color>
|
||||
<Color x:Key="SuccessColor">#3DD68C</Color>
|
||||
<Color x:Key="WarnColor">#F5B642</Color>
|
||||
<Color x:Key="DangerColor">#FF6B7A</Color>
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<Color x:Key="AppBgColor">#F3F4FA</Color>
|
||||
<Color x:Key="PanelBgColor">#FFFFFF</Color>
|
||||
<Color x:Key="CardBgColor">#FFFFFF</Color>
|
||||
<Color x:Key="CardHoverColor">#EEF0FB</Color>
|
||||
<Color x:Key="InputBgColor">#F6F7FC</Color>
|
||||
<Color x:Key="LineColor">#DDE0EE</Color>
|
||||
<Color x:Key="MutedColor">#6A7090</Color>
|
||||
<Color x:Key="TextColor">#161A2B</Color>
|
||||
<Color x:Key="AccentColor">#4F5FE8</Color>
|
||||
<Color x:Key="AccentDeepColor">#3B4BD6</Color>
|
||||
<Color x:Key="AccentSoftColor">#1F4F5FE8</Color>
|
||||
<Color x:Key="SuccessColor">#1FA46A</Color>
|
||||
<Color x:Key="WarnColor">#C98A10</Color>
|
||||
<Color x:Key="DangerColor">#D9414F</Color>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<SolidColorBrush x:Key="AppBg" Color="{DynamicResource AppBgColor}" />
|
||||
<SolidColorBrush x:Key="PanelBg" Color="{DynamicResource PanelBgColor}" />
|
||||
<SolidColorBrush x:Key="CardBg" Color="{DynamicResource CardBgColor}" />
|
||||
<SolidColorBrush x:Key="CardHover" Color="{DynamicResource CardHoverColor}" />
|
||||
<SolidColorBrush x:Key="InputBg" Color="{DynamicResource InputBgColor}" />
|
||||
<SolidColorBrush x:Key="Line" Color="{DynamicResource LineColor}" />
|
||||
<SolidColorBrush x:Key="Muted" Color="{DynamicResource MutedColor}" />
|
||||
<SolidColorBrush x:Key="Text" Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush x:Key="Accent" Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="AccentDeep" Color="{DynamicResource AccentDeepColor}" />
|
||||
<SolidColorBrush x:Key="AccentSoft" Color="{DynamicResource AccentSoftColor}" />
|
||||
<SolidColorBrush x:Key="Success" Color="{DynamicResource SuccessColor}" />
|
||||
<SolidColorBrush x:Key="Warn" Color="{DynamicResource WarnColor}" />
|
||||
<SolidColorBrush x:Key="Danger" Color="{DynamicResource DangerColor}" />
|
||||
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="{DynamicResource AccentColor}" />
|
||||
<GradientStop Offset="1" Color="{DynamicResource AccentDeepColor}" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- Fluent control resources, pointed at the palette above -->
|
||||
<StaticResource x:Key="TextControlBackground" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="TextControlBackgroundPointerOver" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="TextControlBackgroundFocused" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="TextControlBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="TextControlBorderBrushPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="TextControlBorderBrushFocused" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ComboBoxBackground" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPointerOver" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPressed" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushFocused" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ButtonBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ButtonBackgroundPointerOver" ResourceKey="CardHover" />
|
||||
<StaticResource x:Key="ButtonBackgroundPressed" ResourceKey="AccentSoft" />
|
||||
<StaticResource x:Key="ButtonBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ButtonBorderBrushPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ButtonBorderBrushPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ButtonForeground" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ButtonForegroundPointerOver" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ButtonForegroundPressed" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillChecked" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" ResourceKey="AccentDeep" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeChecked" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOn" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ProgressBarForeground" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ProgressBarBackground" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ExpanderBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ExpanderHeaderBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ExpanderContentBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ExpanderBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ExpanderContentBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ListBoxBackground" ResourceKey="CardBg" />
|
||||
<!-- No white/black focus box around the current row or cell: selection is shown by colour instead -->
|
||||
<SolidColorBrush x:Key="DataGridCellFocusVisualPrimaryBrush" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="DataGridCellFocusVisualSecondaryBrush" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="DataGridCurrencyVisualPrimaryBrush" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="Selection" Color="{DynamicResource AccentColor}" Opacity="0.26" />
|
||||
<SolidColorBrush x:Key="ScrollThumb" Color="{DynamicResource AccentColor}" Opacity="0.45" />
|
||||
<SolidColorBrush x:Key="ScrollThumbHover" Color="{DynamicResource AccentColor}" Opacity="0.75" />
|
||||
<StaticResource x:Key="ScrollBarThumbFill" ResourceKey="ScrollThumb" />
|
||||
<StaticResource x:Key="ScrollBarThumbFillPointerOver" ResourceKey="ScrollThumbHover" />
|
||||
<StaticResource x:Key="ScrollBarThumbFillPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ScrollBarTrackFill" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="ScrollBarTrackFillPointerOver" ResourceKey="InputBg" />
|
||||
<StaticResource x:Key="ScrollBarTrackStroke" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ScrollBarTrackStrokePointerOver" ResourceKey="Line" />
|
||||
<StaticResource x:Key="ContextMenuBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="ContextMenuBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="MenuFlyoutPresenterBackground" ResourceKey="CardBg" />
|
||||
<StaticResource x:Key="MenuFlyoutPresenterBorderBrush" ResourceKey="Line" />
|
||||
<StaticResource x:Key="TabItemHeaderForegroundSelected" ResourceKey="Text" />
|
||||
</ResourceDictionary>
|
||||
</Styles.Resources>
|
||||
|
||||
<!-- Scroll bars stay in view (no auto-hide), with a rounded thumb in the accent colour. -->
|
||||
<Style Selector="ScrollBar">
|
||||
<Setter Property="AllowAutoHide" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="ScrollBar:horizontal">
|
||||
<Setter Property="Height" Value="14" />
|
||||
</Style>
|
||||
<Style Selector="ScrollBar:vertical">
|
||||
<Setter Property="Width" Value="14" />
|
||||
</Style>
|
||||
<Style Selector="ScrollBar /template/ Thumb">
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ScrollBar /template/ Thumb /template/ Border">
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="Margin" Value="2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource AppBg}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<!-- Typography -->
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontSize" Value="22" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.h">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
<Setter Property="LetterSpacing" Value="1" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
</Style>
|
||||
|
||||
<!-- Surfaces -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBg}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Line}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
<!-- The details panel when it slides over a narrow library -->
|
||||
<Style Selector="Border.drawer">
|
||||
<Setter Property="BoxShadow" Value="-10 0 30 0 #80000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.panel">
|
||||
<Setter Property="Background" Value="{DynamicResource PanelBg}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Line}" />
|
||||
</Style>
|
||||
<Style Selector="Border.pill">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="10,3" />
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}" />
|
||||
</Style>
|
||||
<Style Selector="Border.pill > TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- Buttons -->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentGradient}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentDeep}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentDeep}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource CardHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
<Style Selector="RadioButton.nav">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Margin" Value="0,2" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="Root" Background="{TemplateBinding Background}" CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border x:Name="Bar" Width="3" Height="18" CornerRadius="2" Margin="-12,0,9,0" Background="Transparent" />
|
||||
<ContentPresenter Grid.Column="1" Content="{TemplateBinding Content}" Foreground="{TemplateBinding Foreground}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="RadioButton.nav:pointerover /template/ Border#Root">
|
||||
<Setter Property="Background" Value="{DynamicResource CardHover}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.nav:checked /template/ Border#Root">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.nav:checked /template/ Border#Bar">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.nav:checked">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Style>
|
||||
<!-- Icons are Material paths drawn on a 24x24 grid. Drawing them at their native position in a fixed 24x24 box
|
||||
(then scaled down a little) keeps every icon the same size and optically centred, whatever its shape. -->
|
||||
<Style Selector="Path.icon">
|
||||
<Setter Property="Fill" Value="{Binding $parent[TemplatedControl].Foreground}" />
|
||||
<Setter Property="Stretch" Value="None" />
|
||||
<Setter Property="Width" Value="24" />
|
||||
<Setter Property="Height" Value="24" />
|
||||
<Setter Property="RenderTransform" Value="scale(0.8)" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Inputs -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
</Style>
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="11,7" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
</Style>
|
||||
<Style Selector="NumericUpDown">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
</Style>
|
||||
|
||||
<!-- Segmented pill tabs (detail panel) -->
|
||||
<Style Selector="TabControl.pills">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="TabControl.pills TabItem">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="11,6" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="0,0,4,4" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
</Style>
|
||||
<Style Selector="TabControl.pills TabItem:selected">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="TabControl.pills TabItem /template/ Border#PART_SelectedPipe">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<Style Selector="RadioButton.chip, ToggleButton.chip">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Line}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="13,6" />
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="Root" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter Content="{TemplateBinding Content}" Foreground="{TemplateBinding Foreground}"
|
||||
HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="RadioButton.chip:pointerover /template/ Border#Root, ToggleButton.chip:pointerover /template/ Border#Root">
|
||||
<Setter Property="Background" Value="{DynamicResource CardHover}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.chip:checked /template/ Border#Root, ToggleButton.chip:checked /template/ Border#Root">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.chip:checked, ToggleButton.chip:checked">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- Artwork cards -->
|
||||
<Style Selector="ListBox.cards">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.cards > ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0,0,14,14" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Background" Value="{DynamicResource CardBg}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Line}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.cards > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource CardHover}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.cards > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- Progress bars: visible track, filled indicator -->
|
||||
<Style Selector="ProgressBar">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Line}" />
|
||||
<Setter Property="MinHeight" Value="8" />
|
||||
<Setter Property="Height" Value="8" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar /template/ Border#PART_Indicator">
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
</Style>
|
||||
|
||||
<!-- Lists and grid -->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Margin" Value="0,1" />
|
||||
</Style>
|
||||
<Style Selector="ListBox, ListBoxItem, TreeView, TreeViewItem, DataGrid, DataGridRow, DataGridCell">
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Selection}" />
|
||||
</Style>
|
||||
<Style Selector="TreeViewItem:selected /template/ Border#PART_LayoutRoot">
|
||||
<Setter Property="Background" Value="{DynamicResource Selection}" />
|
||||
</Style>
|
||||
<Style Selector="TreeViewItem:pointerover /template/ Border#PART_LayoutRoot">
|
||||
<Setter Property="Background" Value="{DynamicResource CardHover}" />
|
||||
</Style>
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="RowHeight" Value="44" />
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Line}" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
|
||||
<Setter Property="Fill" Value="{DynamicResource Selection}" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell /template/ Rectangle#CurrencyVisual">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell /template/ Grid#FocusVisual">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow /template/ Rectangle#CurrencyVisual, DataGridRow /template/ Grid#FocusVisual">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow /template/ Rectangle#InvalidVisualElement">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow:pointerover /template/ Rectangle#BackgroundRectangle">
|
||||
<Setter Property="Fill" Value="{DynamicResource CardHover}" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ScrollViewer">
|
||||
<Setter Property="AllowAutoHide" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="ToolTip">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,55 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.AboutView">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,26,32,28" Spacing="18" MaxWidth="860" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card" Padding="28">
|
||||
<StackPanel Orientation="Horizontal" Spacing="22">
|
||||
<Image Width="92" Height="92" Source="avares://Packwright/Assets/icon.png" />
|
||||
<StackPanel VerticalAlignment="Center" Spacing="4">
|
||||
<TextBlock Text="Packwright" FontSize="28" FontWeight="Bold" />
|
||||
<TextBlock x:Name="VersionText" Classes="muted" />
|
||||
<TextBlock Classes="muted" Text="Windows · Linux · macOS" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="UPDATES" Classes="section" />
|
||||
<TextBlock x:Name="UpdateText" TextWrapping="Wrap" Text="Check whether a newer version has been released on GitHub. Only this request is sent; nothing about you or your library." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="UpdateButton" Content="Check for updates" />
|
||||
<Button x:Name="UpdateOpenButton" Content="View the update" Classes="accent" IsVisible="False" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Text="Manage your PS5 dump and image collection, read PS5 packages, and build or convert images: exFAT, FFPKG, FFPFSC, ZArchive and debug packages (FPKG)." />
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="BUILT WITH" Classes="section" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Packwright grew out of PS5 PKG Tool by pearlxcore (GPL-3.0). Its image and package engines still power parts of this app. Thank you, pearlxcore." />
|
||||
<TextBlock TextWrapping="Wrap" Text="ProsperoPkgTool by pearlxcore (MIT), the clean room package engine" />
|
||||
<TextBlock TextWrapping="Wrap" Text="LibProsperoPkg and UFS2Tool by SvenGDK (GPL-3.0-or-later / BSD 2-Clause)" />
|
||||
<TextBlock TextWrapping="Wrap" Text="MkPFS by PSBrew / Renan Barreto, ps5-exfat-builder by kerrdec97, PS5-Dump-Image-Converter by strongt1me" />
|
||||
<TextBlock TextWrapping="Wrap" Text="ZArchive format by Exzap (MIT), zstd via ZstdSharp (MIT)" />
|
||||
<TextBlock TextWrapping="Wrap" Text="PS5 title list by andshrew (MIT), used for Title ID lookups" />
|
||||
<TextBlock TextWrapping="Wrap" Text="Avalonia UI (MIT), ImageSharp, BCnEncoder.Net, Magick.NET" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock TextWrapping="Wrap" Classes="muted" FontSize="12"
|
||||
Text="Copyright (c) 2026 itsmemac. Licensed under the GNU General Public License v3.0, with no warranty. See LICENSE and THIRD_PARTY_NOTICES.md for every component and its terms. PlayStation and PS5 are trademarks of Sony Interactive Entertainment Inc.; this project is independent and not affiliated with Sony. This is not software for obtaining free PS5 games." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="RepoButton" Content="Project page" Classes="accent" />
|
||||
<Button x:Name="IssuesButton" Content="Report a bug" />
|
||||
<Button x:Name="LicenseButton" Content="License" />
|
||||
<Button x:Name="NoticesButton" Content="Open notices" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using Packwright.App.Services;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class AboutView : UserControl
|
||||
{
|
||||
public const string RepositoryUrl = "https://github.com/itsmemac/Packwright";
|
||||
public const string OriginalUrl = "https://github.com/pearlxcore/PS5PkgTool";
|
||||
|
||||
public AboutView()
|
||||
{
|
||||
InitializeComponent();
|
||||
string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0";
|
||||
VersionText.Text = "Version " + version;
|
||||
UpdateButton.Click += async (_, _) => await CheckNowAsync();
|
||||
UpdateOpenButton.Click += async (_, _) =>
|
||||
{
|
||||
if (UpdateService.Available is { } release) await UpdateWindow.ShowAsync(Dialogs.OwnerOf(this), release);
|
||||
};
|
||||
RepoButton.Click += (_, _) => Dialogs.OpenPath(RepositoryUrl);
|
||||
IssuesButton.Click += (_, _) => Dialogs.OpenPath(RepositoryUrl + "/issues");
|
||||
LicenseButton.Click += (_, _) => OpenBundled("LICENSE");
|
||||
NoticesButton.Click += (_, _) => OpenBundled("THIRD_PARTY_NOTICES.md");
|
||||
}
|
||||
|
||||
private async Task CheckNowAsync()
|
||||
{
|
||||
UpdateButton.IsEnabled = false;
|
||||
UpdateText.Text = "Checking GitHub...";
|
||||
UpdateOpenButton.IsVisible = false;
|
||||
try
|
||||
{
|
||||
UpdateResult result = await UpdateService.CheckAsync();
|
||||
UpdateText.Text = result.Message;
|
||||
UpdateOpenButton.IsVisible = result.State == UpdateState.Available;
|
||||
if (result.State == UpdateState.Available && result.Release is { } release)
|
||||
await UpdateWindow.ShowAsync(Dialogs.OwnerOf(this), release);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UpdateButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a license file shipped with the app (next to it, or in the macOS bundle's Resources folder),
|
||||
/// or the same file on GitHub when it is not found.
|
||||
/// </summary>
|
||||
private static void OpenBundled(string fileName)
|
||||
{
|
||||
foreach (string folder in new[] { AppContext.BaseDirectory, Path.Combine(AppContext.BaseDirectory, "..", "Resources") })
|
||||
{
|
||||
string path = Path.GetFullPath(Path.Combine(folder, fileName));
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Dialogs.OpenPath(path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Dialogs.OpenPath(RepositoryUrl + "/blob/main/" + fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>Add, edit and test the consoles that files can be sent to.</summary>
|
||||
public sealed class ConsolesWindow : Window
|
||||
{
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private readonly List<ConsoleProfile> _profiles;
|
||||
private readonly System.Collections.ObjectModel.ObservableCollection<string> _names = [];
|
||||
private readonly ListBox _list = new() { MinHeight = 360, MinWidth = 190, Background = Avalonia.Media.Brushes.Transparent };
|
||||
private bool _busy;
|
||||
private readonly TextBox _name = new();
|
||||
private readonly TextBox _host = new() { Watermark = "For example 192.168.1.50" };
|
||||
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, FormatString = "0", Value = 1337 };
|
||||
private readonly TextBox _folder = new() { Watermark = "/data" };
|
||||
private readonly TextBox _installUrl = new();
|
||||
private readonly TextBox _installBody = new() { AcceptsReturn = true, TextWrapping = Avalonia.Media.TextWrapping.Wrap, MinHeight = 64 };
|
||||
private readonly TextBox _contentType = new();
|
||||
private readonly Button _test = new() { Content = "Test connection" };
|
||||
private readonly TextBlock _result = new() { Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap };
|
||||
private readonly StackPanel _form = new() { Spacing = 8, IsEnabled = false };
|
||||
private CancellationTokenSource? _testing;
|
||||
private int _shown = -1;
|
||||
private bool _saved;
|
||||
|
||||
private ConsolesWindow()
|
||||
{
|
||||
_profiles = _services.Settings.Consoles.Select(Clone).ToList();
|
||||
Title = "Consoles";
|
||||
Width = 760;
|
||||
Height = 640;
|
||||
MinWidth = 640;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
var add = new Button { Content = "Add console" };
|
||||
var remove = new Button { Content = "Remove" };
|
||||
add.Click += (_, _) =>
|
||||
{
|
||||
Commit();
|
||||
_profiles.Add(new ConsoleProfile { Name = UniqueName("My PS5") });
|
||||
Refresh(_profiles.Count - 1);
|
||||
};
|
||||
remove.Click += (_, _) =>
|
||||
{
|
||||
if (_list.SelectedIndex < 0) return;
|
||||
int index = _list.SelectedIndex;
|
||||
_shown = -1;
|
||||
_profiles.RemoveAt(index);
|
||||
Refresh(Math.Min(index, _profiles.Count - 1));
|
||||
};
|
||||
_list.ItemsSource = _names;
|
||||
_list.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_busy || _list.SelectedIndex == _shown) return;
|
||||
Commit();
|
||||
Show(_list.SelectedIndex);
|
||||
};
|
||||
_test.Click += async (_, _) => await TestAsync();
|
||||
|
||||
_form.Children.Add(Field("Name", _name));
|
||||
_form.Children.Add(Field("Address on your network", _host));
|
||||
_form.Children.Add(Field("FTP port", _port));
|
||||
_form.Children.Add(Field("Default folder on the console", _folder));
|
||||
_form.Children.Add(new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Children = { _test } });
|
||||
_form.Children.Add(_result);
|
||||
_form.Children.Add(new Expander
|
||||
{
|
||||
Header = "Install by URL (advanced)",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 8, Margin = new Thickness(0, 8, 0, 0),
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Classes = { "muted" }, FontSize = 12, TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||
Text = "Some installer payloads on the console download a package from this PC when asked. They each expect their own " +
|
||||
"request, so check your payload's documentation and adjust these to match. {host} is the console's address and " +
|
||||
"{url} is the address of the file. The defaults follow the common \"remote package installer\" style and are untested."
|
||||
},
|
||||
Field("Request address (POST)", _installUrl),
|
||||
Field("Request body", _installBody),
|
||||
Field("Content type", _contentType)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var save = new Button { Content = "Save", MinWidth = 100 };
|
||||
save.Classes.Add("accent");
|
||||
save.Click += (_, _) =>
|
||||
{
|
||||
Commit();
|
||||
if (_profiles.Any(profile => string.IsNullOrWhiteSpace(profile.Host)))
|
||||
{
|
||||
_result.Text = "Enter an address for every console, or remove the ones you do not use.";
|
||||
return;
|
||||
}
|
||||
_services.Settings.Consoles = _profiles;
|
||||
_services.ReplaceSettings(_services.Settings);
|
||||
_saved = true;
|
||||
Close();
|
||||
};
|
||||
var cancel = new Button { Content = "Cancel" };
|
||||
cancel.Click += (_, _) => Close();
|
||||
|
||||
var grid = new Grid { Margin = new Thickness(20), RowDefinitions = new RowDefinitions("Auto,*,Auto"), ColumnDefinitions = new ColumnDefinitions("220,18,*") };
|
||||
var intro = new TextBlock
|
||||
{
|
||||
Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 14),
|
||||
Text = "Packwright sends files only to devices on your own network, and only when you press Send. No account is used: console FTP payloads accept anyone on the network."
|
||||
};
|
||||
Grid.SetColumnSpan(intro, 3);
|
||||
grid.Children.Add(intro);
|
||||
var left = new StackPanel { Spacing = 8, Children = { _list, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { add, remove } } } };
|
||||
Grid.SetRow(left, 1);
|
||||
var right = new ScrollViewer { Content = _form };
|
||||
Grid.SetRow(right, 1);
|
||||
Grid.SetColumn(right, 2);
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 14, 0, 0), Children = { save, cancel } };
|
||||
Grid.SetRow(buttons, 2);
|
||||
Grid.SetColumnSpan(buttons, 3);
|
||||
grid.Children.Add(left);
|
||||
grid.Children.Add(right);
|
||||
grid.Children.Add(buttons);
|
||||
Content = grid;
|
||||
Closing += (_, _) => _testing?.Cancel();
|
||||
Refresh(_profiles.Count > 0 ? 0 : -1);
|
||||
}
|
||||
|
||||
/// <summary>Opens the window; true when the list was saved.</summary>
|
||||
public static async Task<bool> ShowAsync(Window? owner)
|
||||
{
|
||||
var window = new ConsolesWindow();
|
||||
if (owner is not null) await window.ShowDialog(owner);
|
||||
else window.Show();
|
||||
return window._saved;
|
||||
}
|
||||
|
||||
private static StackPanel Field(string label, Control input) => new()
|
||||
{
|
||||
Spacing = 4,
|
||||
Children = { new TextBlock { Text = label, Classes = { "h" } }, input }
|
||||
};
|
||||
|
||||
private static ConsoleProfile Clone(ConsoleProfile source) => new()
|
||||
{
|
||||
Name = source.Name, Host = source.Host, FtpPort = source.FtpPort, DefaultFolder = source.DefaultFolder,
|
||||
InstallUrl = source.InstallUrl, InstallBody = source.InstallBody, InstallContentType = source.InstallContentType
|
||||
};
|
||||
|
||||
private string UniqueName(string baseName)
|
||||
{
|
||||
string name = baseName;
|
||||
for (int index = 2; _profiles.Any(profile => profile.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); index++)
|
||||
name = $"{baseName} {index}";
|
||||
return name;
|
||||
}
|
||||
|
||||
private void Refresh(int select)
|
||||
{
|
||||
_busy = true;
|
||||
_shown = -1;
|
||||
_names.Clear();
|
||||
foreach (ConsoleProfile profile in _profiles) _names.Add(profile.Name);
|
||||
_list.SelectedIndex = select;
|
||||
_busy = false;
|
||||
Show(select);
|
||||
}
|
||||
|
||||
private void Show(int index)
|
||||
{
|
||||
_shown = index;
|
||||
_result.Text = string.Empty;
|
||||
_form.IsEnabled = index >= 0 && index < _profiles.Count;
|
||||
if (!_form.IsEnabled)
|
||||
{
|
||||
foreach (TextBox box in new[] { _name, _host, _folder, _installUrl, _installBody, _contentType }) box.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
ConsoleProfile profile = _profiles[index];
|
||||
_name.Text = profile.Name;
|
||||
_host.Text = profile.Host;
|
||||
_port.Value = profile.FtpPort;
|
||||
_folder.Text = profile.DefaultFolder;
|
||||
_installUrl.Text = profile.InstallUrl;
|
||||
_installBody.Text = profile.InstallBody;
|
||||
_contentType.Text = profile.InstallContentType;
|
||||
}
|
||||
|
||||
/// <summary>Writes the form back into the profile that was being shown.</summary>
|
||||
private void Commit()
|
||||
{
|
||||
if (_shown < 0 || _shown >= _profiles.Count) return;
|
||||
ConsoleProfile profile = _profiles[_shown];
|
||||
string name = (_name.Text ?? string.Empty).Trim();
|
||||
if (name.Length > 0 && !_profiles.Where((other, index) => index != _shown).Any(other => other.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
profile.Name = name;
|
||||
profile.Host = (_host.Text ?? string.Empty).Trim();
|
||||
profile.FtpPort = (int)(_port.Value ?? 1337);
|
||||
profile.DefaultFolder = string.IsNullOrWhiteSpace(_folder.Text) ? "/data" : _folder.Text.Trim();
|
||||
profile.InstallUrl = (_installUrl.Text ?? string.Empty).Trim();
|
||||
profile.InstallBody = _installBody.Text ?? string.Empty;
|
||||
profile.InstallContentType = string.IsNullOrWhiteSpace(_contentType.Text) ? "application/json" : _contentType.Text.Trim();
|
||||
if (_shown < _names.Count && _names[_shown] != profile.Name)
|
||||
{
|
||||
_busy = true;
|
||||
_names[_shown] = profile.Name;
|
||||
_list.SelectedIndex = _shown;
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestAsync()
|
||||
{
|
||||
if (_shown < 0) return;
|
||||
Commit();
|
||||
ConsoleProfile profile = _profiles[_shown];
|
||||
_test.IsEnabled = false;
|
||||
_result.Text = "Connecting...";
|
||||
_testing = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
_result.Text = await ConsoleSender.TestFtpAsync(profile, _testing.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_result.Text = "Cancelled.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ArgumentException or System.Net.Sockets.SocketException)
|
||||
{
|
||||
_result.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_test.IsEnabled = true;
|
||||
_testing?.Dispose();
|
||||
_testing = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public enum EditTarget { Library, InPlace, NewCopy }
|
||||
|
||||
/// <summary>What the dialog decided: the values to write and where. Empty output means in place.</summary>
|
||||
public sealed record EditOutcome(EditTarget Target, MetadataEdit? Edit, string Output);
|
||||
|
||||
/// <summary>
|
||||
/// Lets the user fill in or correct what the library shows for an item: title, IDs, version, required
|
||||
/// firmware, category and icon. They can be kept in the library only, or written into the file itself
|
||||
/// (or a new copy of it).
|
||||
/// </summary>
|
||||
public sealed class EditDetailsWindow : Window
|
||||
{
|
||||
private static readonly string[] Categories = ["(detected)", "Game", "Patch", "DLC", "App"];
|
||||
private static readonly string[] DrmTypes = ["standard", "upgradable", "free"];
|
||||
private static readonly string[] Languages =
|
||||
[
|
||||
"en-US", "en-GB", "ja-JP", "fr-FR", "fr-CA", "es-ES", "es-419", "de-DE", "it-IT", "nl-NL", "pt-PT", "pt-BR", "ru-RU",
|
||||
"ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO", "pl-PL", "tr-TR", "ar-AE", "cs-CZ", "hu-HU",
|
||||
"el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"
|
||||
];
|
||||
|
||||
private readonly LibraryItem _item;
|
||||
private readonly MetadataOverride _edit;
|
||||
private readonly TextBox _title = new();
|
||||
private readonly TextBox _titleId = new();
|
||||
private readonly TextBox _contentId = new();
|
||||
private readonly TextBox _version = new();
|
||||
private readonly TextBox _firmware = new();
|
||||
private readonly TextBox _concept = new();
|
||||
private readonly TextBox _master = new();
|
||||
private readonly TextBox _sdk = new();
|
||||
private readonly TextBox _created = new() { Watermark = "For example 2025-03-14 12:00:00" };
|
||||
private readonly TextBox _toolVersion = new();
|
||||
private readonly ComboBox _region = new() { HorizontalAlignment = HorizontalAlignment.Stretch };
|
||||
private readonly ComboBox _drm = new() { HorizontalAlignment = HorizontalAlignment.Stretch, IsEditable = true, ItemsSource = DrmTypes };
|
||||
private readonly ComboBox _language = new() { HorizontalAlignment = HorizontalAlignment.Stretch, IsEditable = true, ItemsSource = Languages };
|
||||
private readonly List<CheckBox> _features = [];
|
||||
private bool _syncingRegion;
|
||||
private readonly ComboBox _category = new() { ItemsSource = Categories, SelectedIndex = 0, HorizontalAlignment = HorizontalAlignment.Stretch };
|
||||
private readonly Image _preview = new() { Stretch = Stretch.UniformToFill };
|
||||
private readonly TextBlock _iconNote = new() { Classes = { "muted" }, TextWrapping = TextWrapping.Wrap };
|
||||
private readonly ComboBox _mode = new()
|
||||
{
|
||||
ItemsSource = new[]
|
||||
{
|
||||
"Library only (the file is not changed)",
|
||||
"Write into the file itself",
|
||||
"Write into a new copy of the file..."
|
||||
},
|
||||
SelectedIndex = 0,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
private readonly TextBox _query = new() { Watermark = "Or search by game name" };
|
||||
private readonly Button _searchOnline = new() { Content = "Search" };
|
||||
private readonly Button _searchById = new() { Content = "Search PlayStation Network" };
|
||||
private Expander? _onlineExpander;
|
||||
private readonly Button _useMatch = new() { Content = "Use this match", IsEnabled = false };
|
||||
private readonly ListBox _results = new() { MaxHeight = 170 };
|
||||
private readonly TextBlock _onlineNote = new() { Classes = { "muted" }, TextWrapping = TextWrapping.Wrap, FontSize = 12 };
|
||||
private readonly Button _save;
|
||||
private readonly Button _autofill;
|
||||
private readonly TextBlock _autofillNote = new() { Classes = { "muted" }, TextWrapping = TextWrapping.Wrap, FontSize = 12 };
|
||||
private readonly TextBlock _modeNote = new() { Classes = { "muted" }, TextWrapping = TextWrapping.Wrap };
|
||||
private byte[]? _newIcon;
|
||||
private bool _removeIcon;
|
||||
private EditOutcome? _outcome;
|
||||
|
||||
private EditDetailsWindow(LibraryItem item)
|
||||
{
|
||||
_item = item;
|
||||
MetadataOverride existing = MetadataOverrides.Get(item.Path) ?? new MetadataOverride();
|
||||
_edit = new MetadataOverride
|
||||
{
|
||||
Title = existing.Title, TitleId = existing.TitleId, ContentId = existing.ContentId,
|
||||
Version = existing.Version, Firmware = existing.Firmware, Category = existing.Category,
|
||||
IconFile = existing.IconFile, ConceptId = existing.ConceptId, MasterVersion = existing.MasterVersion,
|
||||
Sdk = existing.Sdk, Drm = existing.Drm, DefaultLanguage = existing.DefaultLanguage, Created = existing.Created,
|
||||
ToolVersion = existing.ToolVersion, Features = existing.Features?.ToList()
|
||||
};
|
||||
|
||||
Title = "Edit details";
|
||||
Width = 1060;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
CanResize = false;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
var game = item.Game;
|
||||
Fill(_title, _edit.Title, string.IsNullOrWhiteSpace(game.Title) ? item.FileName : game.Title);
|
||||
Fill(_titleId, _edit.TitleId, game.TitleId);
|
||||
Fill(_contentId, _edit.ContentId, game.ContentId);
|
||||
Fill(_version, _edit.Version, game.DisplayVersion);
|
||||
Fill(_firmware, _edit.Firmware, game.RequiredSystemSoftware);
|
||||
Fill(_concept, _edit.ConceptId, game.ConceptId);
|
||||
Fill(_master, _edit.MasterVersion, game.MasterVersion);
|
||||
Fill(_sdk, _edit.Sdk, game.SdkVersion);
|
||||
Fill(_created, _edit.Created, game.CreationDate);
|
||||
Fill(_toolVersion, _edit.ToolVersion, game.ToolVersion);
|
||||
_drm.Text = _edit.Drm;
|
||||
_drm.PlaceholderText = string.IsNullOrWhiteSpace(game.DrmType) ? "(not available)" : game.DrmType;
|
||||
_language.Text = _edit.DefaultLanguage;
|
||||
_language.PlaceholderText = string.IsNullOrWhiteSpace(game.DefaultLanguage) ? "(not available)" : game.DefaultLanguage;
|
||||
int category = Array.FindIndex(Categories, c => string.Equals(c, _edit.Category, StringComparison.OrdinalIgnoreCase));
|
||||
_category.SelectedIndex = category < 0 ? 0 : category;
|
||||
_region.ItemsSource = LibraryItem.Regions.Select(region => $"{region.Name} ({region.Prefix})").ToList();
|
||||
_contentId.TextChanged += (_, _) => SyncRegionFromContentId();
|
||||
_region.SelectionChanged += (_, _) => ApplyRegionToContentId();
|
||||
SyncRegionFromContentId();
|
||||
|
||||
var choose = new Button { Content = "Choose image..." };
|
||||
choose.Click += async (_, _) => await ChooseIconAsync();
|
||||
var remove = new Button { Content = "Use original icon" };
|
||||
remove.Click += (_, _) => { _newIcon = null; _removeIcon = true; ShowPreview(null); };
|
||||
|
||||
var iconFrame = new Border
|
||||
{
|
||||
Width = 112, Height = 112, CornerRadius = new CornerRadius(16), ClipToBounds = true,
|
||||
Background = Brushes.Black, Child = _preview
|
||||
};
|
||||
_save = new Button { Content = "Save in library", MinWidth = 130 };
|
||||
_save.Classes.Add("accent");
|
||||
_save.Click += (_, _) => Save();
|
||||
var save = _save;
|
||||
var reset = new Button { Content = "Reset all" };
|
||||
reset.Click += (_, _) => ResetAll();
|
||||
_searchById.Classes.Add("accent");
|
||||
_searchById.Click += async (_, _) => await SearchByIdAsync();
|
||||
ToolTip.SetTip(_searchById, "Looks this title's Title ID up in a public list of PS5 titles, then shows the matching entries to pick from.");
|
||||
_autofill = new Button { Content = "Autofill missing" };
|
||||
_autofill.Click += async (_, _) => await AutofillAsync();
|
||||
ToolTip.SetTip(_autofill, "Fill what is missing from the file's own data and its file or folder name. Nothing is sent anywhere.");
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
cancel.Click += (_, _) => Close();
|
||||
|
||||
// Two columns of fields so the window is wide rather than tall.
|
||||
var form = new Grid { ColumnDefinitions = new ColumnDefinitions("125,*,24,125,*"), RowSpacing = 10 };
|
||||
int row = -1, slot = 0; // slot 0 = left pair, 1 = right pair
|
||||
void Add(string label, Control control, bool wide = false)
|
||||
{
|
||||
if (wide || slot == 0)
|
||||
{
|
||||
row++;
|
||||
form.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
slot = 0;
|
||||
}
|
||||
int labelColumn = wide || slot == 0 ? 0 : 3;
|
||||
var text = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center, Classes = { "muted" } };
|
||||
Grid.SetRow(text, row);
|
||||
Grid.SetColumn(text, labelColumn);
|
||||
Grid.SetRow(control, row);
|
||||
Grid.SetColumn(control, labelColumn + 1);
|
||||
if (wide) Grid.SetColumnSpan(control, 4);
|
||||
form.Children.Add(text);
|
||||
form.Children.Add(control);
|
||||
slot = wide ? 0 : 1 - slot;
|
||||
}
|
||||
Add("Title", _title, wide: true);
|
||||
Add("Title ID", _titleId);
|
||||
Add("Concept ID", _concept);
|
||||
Add("Content ID", _contentId, wide: true);
|
||||
Add("Region", _region);
|
||||
Add("Category", _category);
|
||||
Add("Version", _version);
|
||||
Add("Master version", _master);
|
||||
Add("Required firmware", _firmware);
|
||||
Add("SDK version", _sdk);
|
||||
Add("DRM", _drm);
|
||||
Add("Default language", _language);
|
||||
Add("Created", _created);
|
||||
Add("Tool version", _toolVersion);
|
||||
Add("Features", BuildFeatures(game), wide: true);
|
||||
Add("Apply to", _mode, wide: true);
|
||||
_mode.SelectionChanged += (_, _) => UpdateModeNote();
|
||||
UpdateModeNote();
|
||||
|
||||
choose.HorizontalAlignment = remove.HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
choose.HorizontalContentAlignment = remove.HorizontalContentAlignment = HorizontalAlignment.Center;
|
||||
_searchById.HorizontalAlignment = _autofill.HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
_searchById.HorizontalContentAlignment = _autofill.HorizontalContentAlignment = HorizontalAlignment.Center;
|
||||
iconFrame.Width = iconFrame.Height = 150;
|
||||
iconFrame.HorizontalAlignment = HorizontalAlignment.Center;
|
||||
_iconNote.TextAlignment = TextAlignment.Center;
|
||||
var side = new StackPanel
|
||||
{
|
||||
Spacing = 8, Width = 230,
|
||||
Children =
|
||||
{
|
||||
iconFrame, choose, remove, _iconNote,
|
||||
new Border { Height = 1, Margin = new Thickness(0, 6), Background = Brushes.Gray, Opacity = 0.3 },
|
||||
_searchById, _autofill, _autofillNote
|
||||
}
|
||||
};
|
||||
var body = new Grid { ColumnDefinitions = new ColumnDefinitions("230,28,*") };
|
||||
Grid.SetColumn(form, 2);
|
||||
body.Children.Add(side);
|
||||
body.Children.Add(form);
|
||||
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(22),
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.FileName, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Leave a field empty to keep what the file reports (shown greyed out).",
|
||||
Classes = { "muted" }, TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
body,
|
||||
BuildOnlineSection(),
|
||||
_modeNote,
|
||||
new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
Children =
|
||||
{
|
||||
reset,
|
||||
new StackPanel
|
||||
{
|
||||
[Grid.ColumnProperty] = 1, Orientation = Orientation.Horizontal, Spacing = 8, Children = { save, cancel }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Taller than the screen on small displays? Scroll instead of cutting the buttons off.
|
||||
Control inner = (Control)Content!;
|
||||
Content = null;
|
||||
Content = new ScrollViewer { Content = inner, VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto };
|
||||
Opened += (_, _) =>
|
||||
{
|
||||
if ((Screens.ScreenFromWindow(this) ?? Screens.Primary) is { } screen)
|
||||
{
|
||||
double scale = RenderScaling > 0 ? RenderScaling : 1;
|
||||
MaxHeight = Math.Max(480, screen.WorkingArea.Height / scale - 40);
|
||||
MaxWidth = Math.Max(640, screen.WorkingArea.Width / scale - 40);
|
||||
}
|
||||
};
|
||||
|
||||
ShowPreview(MetadataOverrides.IconPath(_edit) is { } path ? new Bitmap(path) : item.Thumbnail,
|
||||
MetadataOverrides.IconPath(_edit) is not null);
|
||||
}
|
||||
|
||||
/// <summary>Shows the dialog. Returns null when cancelled.</summary>
|
||||
public static async Task<EditOutcome?> ShowAsync(Window? owner, LibraryItem item)
|
||||
{
|
||||
var window = new EditDetailsWindow(item);
|
||||
if (owner is not null) await window.ShowDialog(owner);
|
||||
else window.Show();
|
||||
return window._outcome;
|
||||
}
|
||||
|
||||
/// <summary>The "Find online" section: search the PlayStation Store by name and pick the match.</summary>
|
||||
private Control BuildOnlineSection()
|
||||
{
|
||||
string guess = Ps5MetadataSuggester.CleanName(_item.Title);
|
||||
_query.Text = guess.Length > 0 ? guess : Ps5MetadataSuggester.CleanName(Path.GetFileNameWithoutExtension(_item.FileName));
|
||||
_query.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key != Avalonia.Input.Key.Enter) return;
|
||||
e.Handled = true;
|
||||
_ = SearchOnlineAsync();
|
||||
};
|
||||
_searchOnline.Click += async (_, _) => await SearchOnlineAsync();
|
||||
_useMatch.Click += async (_, _) => await UseMatchAsync();
|
||||
_results.SelectionChanged += (_, _) => _useMatch.IsEnabled = _results.SelectedItem is StoreResult;
|
||||
_results.ItemTemplate = new Avalonia.Controls.Templates.FuncDataTemplate<StoreResult>((result, _) =>
|
||||
result is null
|
||||
? new TextBlock()
|
||||
: new StackPanel
|
||||
{
|
||||
Spacing = 1, Margin = new Thickness(2, 4),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = result.Name, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis },
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"{result.TitleId} · {result.Region} · {result.Publisher}", Classes = { "muted" }, FontSize = 11
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var searchRow = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8 };
|
||||
Grid.SetColumn(_searchOnline, 1);
|
||||
searchRow.Children.Add(_query);
|
||||
searchRow.Children.Add(_searchOnline);
|
||||
_onlineNote.Text = "Press \"Search PlayStation Network\" above to look up this title's Title ID. Or search by name here: " +
|
||||
"only the name above is sent to the PlayStation Store, and only when you press Search.";
|
||||
var panel = new StackPanel
|
||||
{
|
||||
Spacing = 8, Margin = new Thickness(0, 6, 0, 0),
|
||||
Children = { searchRow, _results, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _useMatch } }, _onlineNote }
|
||||
};
|
||||
_onlineExpander = new Expander
|
||||
{
|
||||
Header = "Results, and search by game name", Content = panel, IsExpanded = false,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
return _onlineExpander;
|
||||
}
|
||||
|
||||
/// <summary>The Title ID to look up: what is typed, else what the file or its name shows.</summary>
|
||||
private async Task<string> KnownTitleIdAsync()
|
||||
{
|
||||
string typed = (_titleId.Text ?? string.Empty).Trim();
|
||||
if (typed.Length > 0) return typed;
|
||||
if (_item.TitleId.Length > 0) return _item.TitleId;
|
||||
try { return (await Task.Run(() => Ps5MetadataSuggester.Suggest(_item.Game))).TitleId; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||
ArgumentException or InvalidOperationException) { return string.Empty; }
|
||||
}
|
||||
|
||||
/// <summary>Looks the title's Title ID up in the public title list and shows the matching entries.</summary>
|
||||
private async Task SearchByIdAsync()
|
||||
{
|
||||
if (_onlineExpander is not null) _onlineExpander.IsExpanded = true;
|
||||
_searchById.IsEnabled = false;
|
||||
_searchOnline.IsEnabled = false;
|
||||
_useMatch.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
string id = await KnownTitleIdAsync();
|
||||
if (id.Length == 0)
|
||||
{
|
||||
_onlineNote.Text = "No Title ID could be found for this file, so there is nothing to look up. " +
|
||||
"Type the game's name in the box and press Search instead.";
|
||||
return;
|
||||
}
|
||||
string cache = AppServices.Instance.Store.AppDataDirectory;
|
||||
_onlineNote.Text = Ps5TitleDatabase.IsCached(cache)
|
||||
? $"Looking up {id}..."
|
||||
: $"Downloading the public PS5 title list (about 2 MB, once) and looking up {id}...";
|
||||
IReadOnlyList<DbTitle> rows = await Ps5TitleDatabase.LookupAsync(id, cache);
|
||||
var results = rows.Select(row => new StoreResult(row.Name, row.ContentId, row.TitleId, row.PublisherId,
|
||||
string.Empty, string.Empty, string.Empty, 1.0)).ToList();
|
||||
_results.ItemsSource = results;
|
||||
if (results.Count > 0) _results.SelectedIndex = 0;
|
||||
_onlineNote.Text = results.Count == 0
|
||||
? $"{id} is not in the public title list (new or unusual titles can be missing). Try searching by name."
|
||||
: $"Found {results.Count:N0} entr{(results.Count == 1 ? "y" : "ies")} for {id}. Select the right one and press \"Use this match\".";
|
||||
}
|
||||
catch (OnlineLookupException ex)
|
||||
{
|
||||
_onlineNote.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchById.IsEnabled = true;
|
||||
_searchOnline.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SearchOnlineAsync()
|
||||
{
|
||||
string name = _query.Text?.Trim() ?? string.Empty;
|
||||
if (name.Length < 2)
|
||||
{
|
||||
_onlineNote.Text = "Type the game's name first.";
|
||||
return;
|
||||
}
|
||||
string known = (_titleId.Text ?? string.Empty).Trim();
|
||||
if (known.Length == 0) known = _item.TitleId;
|
||||
_searchOnline.IsEnabled = false;
|
||||
_useMatch.IsEnabled = false;
|
||||
_onlineNote.Text = "Searching the PlayStation Store...";
|
||||
try
|
||||
{
|
||||
IReadOnlyList<StoreResult> results = await Ps5StoreLookup.FindAsync(name, known);
|
||||
_results.ItemsSource = results;
|
||||
if (results.Count > 0) _results.SelectedIndex = 0;
|
||||
_onlineNote.Text = results.Count == 0
|
||||
? $"No PS5 game named \"{name}\" was found. Try another spelling or a shorter name."
|
||||
: "Pick the entry that matches your copy. The same game has a different Title ID in each region" +
|
||||
(known.Length > 0 ? $" (yours is {known})." : ".");
|
||||
}
|
||||
catch (OnlineLookupException ex)
|
||||
{
|
||||
_onlineNote.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchOnline.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fills what is empty from the chosen store entry. Values already typed are kept.</summary>
|
||||
private async Task UseMatchAsync()
|
||||
{
|
||||
if (_results.SelectedItem is not StoreResult match) return;
|
||||
_useMatch.IsEnabled = false;
|
||||
var filled = new List<string>();
|
||||
var notes = new List<string>();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_title.Text))
|
||||
{
|
||||
_title.Text = match.Name;
|
||||
filled.Add("Title");
|
||||
}
|
||||
string known = (_titleId.Text ?? string.Empty).Trim();
|
||||
if (known.Length == 0) known = _item.Game.TitleId;
|
||||
if (known.Length == 0 || known.Equals(match.TitleId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_titleId.Text) && _item.Game.TitleId.Length == 0)
|
||||
{
|
||||
_titleId.Text = match.TitleId;
|
||||
filled.Add("Title ID");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(_contentId.Text) && _item.Game.ContentId.Length == 0)
|
||||
{
|
||||
_contentId.Text = match.ContentId;
|
||||
filled.Add("Content ID");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
notes.Add($"Kept your Title ID {known}: this entry is {match.TitleId} ({match.Region}), a different region's release.");
|
||||
}
|
||||
|
||||
string iconUrl = match.IconUrl;
|
||||
string about = match.Publisher;
|
||||
bool needIcon = !_item.HasThumbnail && _newIcon is null && _edit.IconFile.Length == 0;
|
||||
if (iconUrl.Length == 0 && needIcon)
|
||||
{
|
||||
// The title list has no artwork; the store's search, by the name found, does.
|
||||
try
|
||||
{
|
||||
StoreResult? hit = await Ps5StoreLookup.FindArtworkAsync(match.Name, match.TitleId);
|
||||
if (hit is not null)
|
||||
{
|
||||
iconUrl = hit.IconUrl;
|
||||
about = hit.Publisher + (hit.ReleaseDate.Length >= 10 ? $", released {hit.ReleaseDate[..10]}" : string.Empty);
|
||||
}
|
||||
}
|
||||
catch (OnlineLookupException) { notes.Add("The icon could not be fetched."); }
|
||||
}
|
||||
if (iconUrl.Length > 0 && needIcon)
|
||||
{
|
||||
byte[]? downloaded = await Ps5StoreLookup.DownloadImageAsync(iconUrl);
|
||||
byte[]? png = downloaded is null ? null : await IconTools.SquarePngAsync(Ps5ImageData.FromPng(downloaded));
|
||||
if (png is not null)
|
||||
{
|
||||
_newIcon = png;
|
||||
_removeIcon = false;
|
||||
ShowPreview(new Bitmap(new MemoryStream(png)));
|
||||
filled.Add("Icon");
|
||||
}
|
||||
}
|
||||
if (match.ReleaseDate.Length >= 10 && !about.Contains("released", StringComparison.Ordinal))
|
||||
about += (about.Length > 0 ? ", " : string.Empty) + $"released {match.ReleaseDate[..10]}";
|
||||
_onlineNote.Text = (filled.Count > 0 ? "Filled: " + string.Join(", ", filled) + ". " : "Nothing new to fill in. ") +
|
||||
string.Join(" ", notes) + (about.Length > 0 ? $" ({about}.)" : string.Empty) +
|
||||
" Check the fields above, then save.";
|
||||
}
|
||||
catch (OnlineLookupException ex)
|
||||
{
|
||||
_onlineNote.Text = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_useMatch.IsEnabled = _results.SelectedItem is StoreResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fills the empty fields with what can be worked out offline about this title.</summary>
|
||||
private async Task AutofillAsync()
|
||||
{
|
||||
_autofill.IsEnabled = false;
|
||||
_autofillNote.Text = "Looking...";
|
||||
try
|
||||
{
|
||||
MetadataSuggestion s = await Task.Run(() => Ps5MetadataSuggester.Suggest(_item.Game));
|
||||
var filled = new List<string>();
|
||||
void Fill(TextBox box, string value, string field)
|
||||
{
|
||||
if (value.Length == 0 || !string.IsNullOrWhiteSpace(box.Text)) return;
|
||||
box.Text = value;
|
||||
filled.Add($"{field} ({s.Sources.GetValueOrDefault(field, "found")})");
|
||||
}
|
||||
Fill(_title, s.Title, "Title");
|
||||
Fill(_titleId, s.TitleId, "Title ID");
|
||||
Fill(_contentId, s.ContentId, "Content ID");
|
||||
Fill(_version, s.Version, "Version");
|
||||
Fill(_firmware, s.Firmware, "Firmware");
|
||||
if (s.Icon is not null && _newIcon is null && _edit.IconFile.Length == 0 &&
|
||||
await IconTools.SquarePngAsync(s.Icon) is { } png)
|
||||
{
|
||||
_newIcon = png;
|
||||
_removeIcon = false;
|
||||
ShowPreview(new Bitmap(new MemoryStream(png)));
|
||||
filled.Add($"Icon (made from sce_sys/{s.IconSource})");
|
||||
}
|
||||
_autofillNote.Text = filled.Count > 0
|
||||
? "Filled: " + string.Join("; ", filled) + ". Check them, then save."
|
||||
: "Nothing more could be worked out from this title's files and its file or folder name.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||
ArgumentException or InvalidOperationException)
|
||||
{
|
||||
_autofillNote.Text = "Could not read the title: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_autofill.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- region and features
|
||||
|
||||
/// <summary>The Content ID starts with two letters that name the region, so the region shown follows the Content ID.</summary>
|
||||
private void SyncRegionFromContentId()
|
||||
{
|
||||
string id = (_contentId.Text ?? string.Empty).Trim();
|
||||
if (id.Length == 0) id = _item.ContentId.Length > 0 ? _item.ContentId : _item.TitleId;
|
||||
string region = LibraryItem.RegionOfId(id);
|
||||
int index = Array.FindIndex(LibraryItem.Regions, entry => entry.Name == region);
|
||||
_syncingRegion = true;
|
||||
_region.SelectedIndex = index;
|
||||
_syncingRegion = false;
|
||||
}
|
||||
|
||||
/// <summary>Choosing a region rewrites the first two letters of the Content ID (it is the only place a region is stored).</summary>
|
||||
private void ApplyRegionToContentId()
|
||||
{
|
||||
if (_syncingRegion || _region.SelectedIndex < 0) return;
|
||||
string prefix = LibraryItem.Regions[_region.SelectedIndex].Prefix;
|
||||
string id = (_contentId.Text ?? string.Empty).Trim();
|
||||
if (id.Length == 0 && _item.ContentId.Length > 0) id = _item.ContentId;
|
||||
if (id.Length >= 2)
|
||||
_contentId.Text = prefix + id[2..];
|
||||
else
|
||||
{
|
||||
string titleId = (_titleId.Text ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (titleId.Length == 0) titleId = _item.TitleId;
|
||||
if (titleId.Length == 0)
|
||||
{
|
||||
_autofillNote.Text = "Enter a Content ID or Title ID first: the region is the first two letters of the Content ID.";
|
||||
SyncRegionFromContentId();
|
||||
return;
|
||||
}
|
||||
_contentId.Text = $"{prefix}0000-{titleId}_00-0000000000000000";
|
||||
}
|
||||
}
|
||||
|
||||
private Control BuildFeatures(Ps5GameInfo game)
|
||||
{
|
||||
var panel = new WrapPanel { Orientation = Orientation.Horizontal };
|
||||
foreach (string name in DeclaredFeatureFlags.Names)
|
||||
{
|
||||
var box = new CheckBox { Content = name, Margin = new Thickness(0, 0, 14, 0), Tag = name };
|
||||
_features.Add(box);
|
||||
panel.Children.Add(box);
|
||||
}
|
||||
SetFeatureBoxes(_edit.Features ?? game.DeclaredFeatures);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void SetFeatureBoxes(IEnumerable<string> features)
|
||||
{
|
||||
var on = new HashSet<string>(features, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (CheckBox box in _features) box.IsChecked = on.Contains((string)box.Tag!);
|
||||
}
|
||||
|
||||
/// <summary>The ticked features, or null when they match what the file declares (nothing to change).</summary>
|
||||
private List<string>? ChosenFeatures()
|
||||
{
|
||||
List<string> chosen = _features.Where(box => box.IsChecked == true).Select(box => (string)box.Tag!).ToList();
|
||||
var detected = new HashSet<string>(_item.Game.DeclaredFeatures.Where(name => DeclaredFeatureFlags.Names.Contains(name)), StringComparer.OrdinalIgnoreCase);
|
||||
return detected.SetEquals(chosen) ? null : chosen;
|
||||
}
|
||||
|
||||
private void UpdateModeNote()
|
||||
{
|
||||
// The button says exactly what will happen, so "Save" is never mistaken for changing the file.
|
||||
_save.Content = _mode.SelectedIndex switch
|
||||
{
|
||||
1 => "Write into the file",
|
||||
2 => "Write a new copy...",
|
||||
_ => "Save in library"
|
||||
};
|
||||
_modeNote.Text = _mode.SelectedIndex switch
|
||||
{
|
||||
1 => _item.Game.SourceKind == Ps5SourceKind.SonyPackage
|
||||
? "The package is unpacked, changed and rebuilt as a debug package (FPKG), as a queued task. This needs free " +
|
||||
"space for a temporary copy and works only for packages Packwright can read (not retail or encrypted ones). " +
|
||||
"The builder sets the required firmware from the game's SDK, so a firmware edit does not stick on packages."
|
||||
: "The change is made as a queued task: images are rebuilt beside the original and swapped in only after " +
|
||||
"they verify.",
|
||||
2 => "Same as above, but the original is left untouched and the result goes to a new file.",
|
||||
_ => "Only Packwright's library shows these values. The file itself is NOT changed: to update the " +
|
||||
"file, choose \"Write into the file itself\" (or a new copy) above."
|
||||
};
|
||||
}
|
||||
|
||||
private static void Fill(TextBox box, string value, string detected)
|
||||
{
|
||||
box.Text = value;
|
||||
box.Watermark = string.IsNullOrWhiteSpace(detected) ? "(not available)" : detected;
|
||||
}
|
||||
|
||||
private void ShowPreview(Bitmap? bitmap, bool custom = false)
|
||||
{
|
||||
_preview.Source = bitmap;
|
||||
_iconNote.Text = bitmap is null
|
||||
? "No icon found in the file. Choose a PNG or JPG image (square, 512x512 or larger works best)."
|
||||
: custom || _newIcon is not null
|
||||
? "Custom icon. It is shown in the library and the details panel."
|
||||
: "Icon from the file. Choose an image to replace it.";
|
||||
}
|
||||
|
||||
private async Task ChooseIconAsync()
|
||||
{
|
||||
string? file = await Dialogs.PickFileAsync(this, "Choose an icon image", ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.webp"]);
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
byte[] png = IconTools.SquarePng(file) ?? throw new InvalidOperationException("The image could not be decoded.");
|
||||
_newIcon = png;
|
||||
_removeIcon = false;
|
||||
ShowPreview(new Bitmap(new MemoryStream(png)));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(this, "Edit details", "That image could not be read: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async void Save()
|
||||
{
|
||||
_edit.Title = (_title.Text ?? string.Empty).Trim();
|
||||
_edit.TitleId = (_titleId.Text ?? string.Empty).Trim().ToUpperInvariant();
|
||||
_edit.ContentId = (_contentId.Text ?? string.Empty).Trim().ToUpperInvariant();
|
||||
_edit.Version = (_version.Text ?? string.Empty).Trim();
|
||||
_edit.Firmware = (_firmware.Text ?? string.Empty).Trim();
|
||||
_edit.Category = _category.SelectedIndex <= 0 ? string.Empty : Categories[_category.SelectedIndex];
|
||||
_edit.ConceptId = (_concept.Text ?? string.Empty).Trim();
|
||||
_edit.MasterVersion = (_master.Text ?? string.Empty).Trim();
|
||||
_edit.Sdk = (_sdk.Text ?? string.Empty).Trim();
|
||||
_edit.Drm = (_drm.Text ?? string.Empty).Trim();
|
||||
_edit.DefaultLanguage = (_language.Text ?? string.Empty).Trim();
|
||||
_edit.Created = (_created.Text ?? string.Empty).Trim();
|
||||
_edit.ToolVersion = (_toolVersion.Text ?? string.Empty).Trim();
|
||||
_edit.Features = ChosenFeatures();
|
||||
|
||||
string oldIcon = _edit.IconFile;
|
||||
if (_newIcon is not null)
|
||||
_edit.IconFile = MetadataOverrides.StoreIcon(_item.Path, _newIcon);
|
||||
else if (_removeIcon)
|
||||
_edit.IconFile = string.Empty;
|
||||
if (oldIcon.Length > 0 && oldIcon != _edit.IconFile) MetadataOverrides.DeleteIcon(oldIcon);
|
||||
|
||||
if (_mode.SelectedIndex == 0)
|
||||
{
|
||||
MetadataOverrides.Set(_item.Path, _edit);
|
||||
_item.ApplyOverride(_edit.IsEmpty ? null : _edit);
|
||||
Logger.Info($"Edited library details for {_item.FileName}.");
|
||||
_outcome = new EditOutcome(EditTarget.Library, null, string.Empty);
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Written into the file: only the fields that were filled in, plus the icon.
|
||||
var write = new MetadataEdit
|
||||
{
|
||||
Title = _edit.Title, TitleId = _edit.TitleId, ContentId = _edit.ContentId, Version = _edit.Version,
|
||||
Firmware = _edit.Firmware, Category = _edit.Category, ConceptId = _edit.ConceptId, MasterVersion = _edit.MasterVersion,
|
||||
Sdk = _edit.Sdk, Drm = _edit.Drm, DefaultLanguage = _edit.DefaultLanguage, Created = _edit.Created,
|
||||
ToolVersion = _edit.ToolVersion, Features = _edit.Features?.ToList(),
|
||||
IconPath = MetadataOverrides.IconPath(_edit) ?? string.Empty
|
||||
};
|
||||
if (write.IsEmpty)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(this, "Edit details", "Fill in at least one field or choose an icon to write.");
|
||||
return;
|
||||
}
|
||||
string output = string.Empty;
|
||||
if (_mode.SelectedIndex == 2)
|
||||
{
|
||||
bool folder = Directory.Exists(_item.Path);
|
||||
string stem = folder ? _item.FileName : Path.GetFileNameWithoutExtension(_item.FileName);
|
||||
string extension = folder ? string.Empty : Path.GetExtension(_item.FileName);
|
||||
string? picked = folder
|
||||
? await Dialogs.PickFolderAsync(this, "Folder to create the edited copy in")
|
||||
: await Dialogs.SaveFileAsync(this, "Save the edited copy", stem + " (edited)" + extension, extension);
|
||||
if (picked is null) return;
|
||||
output = folder ? Path.Combine(picked, stem + " (edited)") : picked;
|
||||
}
|
||||
else if (!await Dialogs.ConfirmAsync(this, "Write into the file",
|
||||
$"Change {_item.FileName} itself?\n\nIt is rebuilt beside the original and replaced only after the result " +
|
||||
"verifies. Large images need free space and time.", "Write"))
|
||||
return;
|
||||
_outcome = new EditOutcome(_mode.SelectedIndex == 2 ? EditTarget.NewCopy : EditTarget.InPlace, write, output);
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ResetAll()
|
||||
{
|
||||
_title.Text = _titleId.Text = _contentId.Text = _version.Text = _firmware.Text = string.Empty;
|
||||
_concept.Text = _master.Text = _sdk.Text = _created.Text = _toolVersion.Text = string.Empty;
|
||||
_drm.Text = _language.Text = string.Empty;
|
||||
_category.SelectedIndex = 0;
|
||||
SetFeatureBoxes(_item.Game.DeclaredFeatures);
|
||||
SyncRegionFromContentId();
|
||||
_newIcon = null;
|
||||
_removeIcon = true;
|
||||
ShowPreview(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:v="using:Packwright.App.Views"
|
||||
x:Class="Packwright.App.Views.FileBrowserWindow"
|
||||
Title="File browser" Width="1180" Height="760" MinWidth="760" MinHeight="480"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="avares://Packwright/Assets/icon.png">
|
||||
<Grid RowDefinitions="Auto,*" Margin="22,18,22,18">
|
||||
<StackPanel Spacing="3" Margin="0,0,0,14">
|
||||
<TextBlock x:Name="TitleText" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SubText" Classes="muted" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<v:FilesPanel x:Name="Files" Grid.Row="1" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Avalonia.Controls;
|
||||
using Packwright.Core.Models;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>A roomy, separate window for browsing one game's files with the preview beside the tree.</summary>
|
||||
public partial class FileBrowserWindow : Window
|
||||
{
|
||||
public FileBrowserWindow() : this(new Ps5GameInfo(), new Ps5FileInventory()) { }
|
||||
|
||||
public FileBrowserWindow(Ps5GameInfo game, Ps5FileInventory inventory)
|
||||
{
|
||||
InitializeComponent();
|
||||
string title = string.IsNullOrWhiteSpace(game.Title) ? Path.GetFileName(game.RootPath) : game.Title;
|
||||
Title = "Files · " + title;
|
||||
TitleText.Text = title;
|
||||
SubText.Text = game.RootPath;
|
||||
Files.UseSideBySideLayout();
|
||||
Files.Active = true;
|
||||
Files.Load(game, inventory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.FilesPanel">
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Arc.spin">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.9" IterationCount="Infinite">
|
||||
<KeyFrame Cue="0%"><Setter Property="RotateTransform.Angle" Value="0" /></KeyFrame>
|
||||
<KeyFrame Cue="100%"><Setter Property="RotateTransform.Angle" Value="360" /></KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
<Style Selector="TreeViewItem">
|
||||
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="Padding" Value="2" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="0,4,0,0">
|
||||
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,8">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button x:Name="ExpandButton" Content="Expand" Classes="ghost" Padding="10,6" />
|
||||
<Button x:Name="CollapseButton" Content="Collapse" Classes="ghost" Padding="10,6" />
|
||||
<Button x:Name="PopOutButton" Content="Open in window" Classes="ghost" Padding="10,6" />
|
||||
</StackPanel>
|
||||
<TextBox x:Name="FilterBox" Watermark="Filter files and folders..." />
|
||||
</DockPanel>
|
||||
|
||||
<Grid x:Name="Body" Grid.Row="1">
|
||||
<Border x:Name="TreeHost" Classes="card" Padding="4" ClipToBounds="True">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<TreeView x:Name="Tree" SelectionMode="Multiple" Background="Transparent">
|
||||
<TreeView.ItemTemplate>
|
||||
<TreeDataTemplate ItemsSource="{Binding Children}">
|
||||
<Grid ColumnDefinitions="26,*,Auto" Height="26">
|
||||
<Path Data="{Binding Icon}" Fill="{Binding IconBrush}" Width="16" Height="16" Stretch="Uniform"
|
||||
HorizontalAlignment="Left" Margin="2,0,0,0" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Detail}" Classes="muted" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="14,0,6,0" />
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</TreeView>
|
||||
<TextBlock x:Name="EmptyText" Text="No files match." Classes="muted" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" IsVisible="False" />
|
||||
<Border x:Name="BusyOverlay" Grid.RowSpan="2" IsVisible="False" Background="#99000000" CornerRadius="10">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
|
||||
<Arc Classes="spin" Width="34" Height="34" StartAngle="0" SweepAngle="270" StrokeThickness="4"
|
||||
Stroke="{DynamicResource Accent}" StrokeLineCap="Round" RenderTransform="rotate(0deg)"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="BusyText" Text="Working..." HorizontalAlignment="Center" />
|
||||
<Button x:Name="BusyCancel" Content="Stop" Classes="ghost" HorizontalAlignment="Center" Padding="12,5" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter x:Name="Splitter" Background="Transparent" />
|
||||
|
||||
<Border x:Name="PreviewHost" Classes="card" Padding="0" ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<DockPanel Margin="14,12,10,4">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="2">
|
||||
<Button x:Name="CopyPathButton" Content="Copy path" Classes="ghost" Padding="8,4" />
|
||||
<Button x:Name="PrevPageButton" Content="◀" Classes="ghost" Padding="8,4" IsEnabled="False" />
|
||||
<Button x:Name="NextPageButton" Content="▶" Classes="ghost" Padding="8,4" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PreviewTitle" Text="Preview" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<TextBlock x:Name="PreviewInfo" Grid.Row="1" Classes="muted" FontSize="11" Margin="14,0,14,8" TextTrimming="CharacterEllipsis" />
|
||||
<Panel Grid.Row="2" Background="{DynamicResource InputBg}">
|
||||
<Image x:Name="PreviewImage" Stretch="Uniform" IsVisible="False" Margin="8" />
|
||||
<TextBox x:Name="PreviewText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="11" />
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<DockPanel Grid.Row="2" Margin="0,8,0,0">
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="ExtractFileButton" Content="Extract selected..." />
|
||||
<Button x:Name="ExtractAllButton" Content="Extract all..." />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="FilesSummary" VerticalAlignment="Center" HorizontalAlignment="Right" Classes="muted" FontSize="12" />
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// A file explorer for a game: folder tree with sizes and counts, filtering, folder or file extraction
|
||||
/// and a preview for images, text and raw bytes.
|
||||
/// </summary>
|
||||
public partial class FilesPanel : UserControl
|
||||
{
|
||||
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".txt", ".json", ".xml", ".cfg", ".ini", ".log", ".lua", ".js", ".md", ".csv", ".yml", ".yaml", ".html",
|
||||
".htm", ".css", ".py", ".sh", ".conf", ".properties", ".sfo", ".tsv", ".meta"
|
||||
};
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private Ps5GameInfo? _game;
|
||||
private Ps5FileInventory? _inventory;
|
||||
private List<FileNode> _roots = [];
|
||||
private List<FileNode> _selected = [];
|
||||
private string? _previewPath;
|
||||
private long _pageOffset;
|
||||
private int _pageSize;
|
||||
private long _previewSize;
|
||||
private bool _sideBySide;
|
||||
private CancellationTokenSource? _previewCts;
|
||||
private bool _treePending;
|
||||
private bool _active;
|
||||
|
||||
/// <summary>
|
||||
/// The tree (one row object per file) is only built while the panel is shown, so selecting a game with
|
||||
/// 100,000+ files does not build it for every click.
|
||||
/// </summary>
|
||||
public bool Active
|
||||
{
|
||||
get => _active;
|
||||
set
|
||||
{
|
||||
_active = value;
|
||||
if (value && _treePending) BuildTree();
|
||||
}
|
||||
}
|
||||
|
||||
public FilesPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ApplyLayout(sideBySide: false);
|
||||
FilterBox.TextChanged += (_, _) => BuildTree();
|
||||
Tree.SelectionChanged += async (_, _) => await OnSelectionAsync();
|
||||
ExpandButton.Click += async (_, _) => await ExpandAllAsync();
|
||||
BusyCancel.Click += (_, _) => _expandCts?.Cancel();
|
||||
CollapseButton.Click += (_, _) => FileNode.SetExpanded(_roots, false);
|
||||
PopOutButton.Click += (_, _) => PopOut();
|
||||
CopyPathButton.Click += async (_, _) => await CopyPathAsync();
|
||||
ExtractFileButton.Click += async (_, _) => await ExtractAsync(all: false);
|
||||
ExtractAllButton.Click += async (_, _) => await ExtractAsync(all: true);
|
||||
PrevPageButton.Click += async (_, _) => await ShowPageAsync(_pageOffset - _pageSize);
|
||||
NextPageButton.Click += async (_, _) => await ShowPageAsync(_pageOffset + _pageSize);
|
||||
ResetPreview();
|
||||
}
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
// The tree is not virtualized, so opening every folder of a game with 100,000+ files would build far too
|
||||
// many rows at once. Folders are opened level by level, in small batches so the window keeps painting
|
||||
// (with a spinner and a Stop button), up to a number of rows that stays responsive.
|
||||
private const int MaximumExpandedRows = 4000;
|
||||
private CancellationTokenSource? _expandCts;
|
||||
|
||||
private async Task ExpandAllAsync()
|
||||
{
|
||||
if (_roots.Count == 0) return;
|
||||
_expandCts?.Cancel();
|
||||
var cts = _expandCts = new CancellationTokenSource();
|
||||
BusyText.Text = "Opening folders...";
|
||||
BusyOverlay.IsVisible = true;
|
||||
bool limited = false;
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
int rows = _roots.Count;
|
||||
int batch = 0;
|
||||
List<FileNode> level = _roots.Where(node => node.IsDirectory).ToList();
|
||||
while (level.Count > 0 && !limited)
|
||||
{
|
||||
var next = new List<FileNode>();
|
||||
foreach (FileNode node in level)
|
||||
{
|
||||
if (rows >= MaximumExpandedRows) { limited = true; break; }
|
||||
node.IsExpanded = true;
|
||||
rows += node.Children.Count;
|
||||
next.AddRange(node.Children.Where(child => child.IsDirectory));
|
||||
if (++batch % 25 != 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
StatusChanged?.Invoke(limited
|
||||
? "Opened folders as far as the list stays responsive. Use the filter to find a specific file."
|
||||
: "All folders opened.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusChanged?.Invoke("Stopped opening folders.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_expandCts, cts))
|
||||
{
|
||||
BusyOverlay.IsVisible = false;
|
||||
_expandCts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tree on the left and preview on the right, for use in a wide window.</summary>
|
||||
public void UseSideBySideLayout() => ApplyLayout(sideBySide: true);
|
||||
|
||||
private void ApplyLayout(bool sideBySide)
|
||||
{
|
||||
_sideBySide = sideBySide;
|
||||
PopOutButton.IsVisible = !sideBySide;
|
||||
Body.RowDefinitions.Clear();
|
||||
Body.ColumnDefinitions.Clear();
|
||||
if (sideBySide)
|
||||
{
|
||||
Body.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star) { MinWidth = 240 });
|
||||
Body.ColumnDefinitions.Add(new ColumnDefinition(10, GridUnitType.Pixel));
|
||||
Body.ColumnDefinitions.Add(new ColumnDefinition(1.25, GridUnitType.Star) { MinWidth = 240 });
|
||||
Grid.SetColumn(TreeHost, 0); Grid.SetRow(TreeHost, 0);
|
||||
Grid.SetColumn(Splitter, 1); Grid.SetRow(Splitter, 0);
|
||||
Grid.SetColumn(PreviewHost, 2); Grid.SetRow(PreviewHost, 0);
|
||||
Splitter.ResizeDirection = GridResizeDirection.Columns;
|
||||
Splitter.Width = 10; Splitter.Height = double.NaN;
|
||||
}
|
||||
else
|
||||
{
|
||||
Body.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Star) { MinHeight = 120 });
|
||||
Body.RowDefinitions.Add(new RowDefinition(10, GridUnitType.Pixel));
|
||||
Body.RowDefinitions.Add(new RowDefinition(250, GridUnitType.Pixel) { MinHeight = 120 });
|
||||
Grid.SetColumn(TreeHost, 0); Grid.SetRow(TreeHost, 0);
|
||||
Grid.SetColumn(Splitter, 0); Grid.SetRow(Splitter, 1);
|
||||
Grid.SetColumn(PreviewHost, 0); Grid.SetRow(PreviewHost, 2);
|
||||
Splitter.ResizeDirection = GridResizeDirection.Rows;
|
||||
Splitter.Height = 10; Splitter.Width = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_game = null;
|
||||
_inventory = null;
|
||||
_treePending = false;
|
||||
_roots = [];
|
||||
_selected = [];
|
||||
Tree.ItemsSource = null;
|
||||
FilesSummary.Text = string.Empty;
|
||||
EmptyText.IsVisible = false;
|
||||
ResetPreview();
|
||||
}
|
||||
|
||||
public void Load(Ps5GameInfo game, Ps5FileInventory inventory)
|
||||
{
|
||||
_game = game;
|
||||
_inventory = inventory;
|
||||
FilesSummary.Text = $"{inventory.FileCount:N0} file(s) · {LibraryItem.FormatBytes(inventory.TotalSize)}";
|
||||
FilterBox.Text = string.Empty;
|
||||
BuildTree();
|
||||
ResetPreview();
|
||||
}
|
||||
|
||||
private void BuildTree()
|
||||
{
|
||||
if (_inventory is null) return;
|
||||
if (!_active)
|
||||
{
|
||||
_treePending = true;
|
||||
return;
|
||||
}
|
||||
_treePending = false;
|
||||
string filter = FilterBox.Text?.Trim() ?? string.Empty;
|
||||
IEnumerable<Ps5FileInfo> files = filter.Length == 0
|
||||
? _inventory.Files
|
||||
: _inventory.Files.Where(file => file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase));
|
||||
_roots = FileNode.BuildTree(files, expandAll: filter.Length > 0);
|
||||
Tree.ItemsSource = _roots;
|
||||
EmptyText.IsVisible = _roots.Count == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- preview
|
||||
|
||||
private void ResetPreview()
|
||||
{
|
||||
_previewCts?.Cancel();
|
||||
_previewPath = null;
|
||||
PreviewImage.IsVisible = false;
|
||||
PreviewImage.Source = null;
|
||||
PreviewText.IsVisible = true;
|
||||
PreviewText.Text = "Select a file to preview it.";
|
||||
PreviewTitle.Text = "Preview";
|
||||
PreviewInfo.Text = string.Empty;
|
||||
PrevPageButton.IsEnabled = NextPageButton.IsEnabled = false;
|
||||
CopyPathButton.IsEnabled = false;
|
||||
}
|
||||
|
||||
private async Task OnSelectionAsync()
|
||||
{
|
||||
_selected = Tree.SelectedItems?.OfType<FileNode>().ToList() ?? [];
|
||||
CopyPathButton.IsEnabled = _selected.Count > 0;
|
||||
if (_selected.Count == 0) { ResetPreview(); return; }
|
||||
if (_selected.Count > 1)
|
||||
{
|
||||
ResetPreview();
|
||||
CopyPathButton.IsEnabled = true;
|
||||
int files = _selected.SelectMany(node => node.Files()).Distinct().Count();
|
||||
PreviewTitle.Text = $"{_selected.Count:N0} items selected";
|
||||
PreviewText.Text = $"{files:N0} file(s) would be extracted.";
|
||||
return;
|
||||
}
|
||||
|
||||
FileNode node = _selected[0];
|
||||
PreviewInfo.Text = DescribeNode(node);
|
||||
if (node.IsDirectory)
|
||||
{
|
||||
_previewCts?.Cancel();
|
||||
_previewPath = null;
|
||||
PreviewImage.IsVisible = false;
|
||||
PreviewText.IsVisible = true;
|
||||
PreviewTitle.Text = node.Name;
|
||||
PreviewText.Text = $"Folder\n{node.FullPath}\n\n{node.FileCount:N0} file(s)\n{LibraryItem.FormatBytes(node.Size)}" +
|
||||
$"\n\nUse \"Extract selected\" to extract the whole folder.";
|
||||
PrevPageButton.IsEnabled = NextPageButton.IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_services.Settings.ShowFilePreview)
|
||||
{
|
||||
PreviewTitle.Text = node.Name;
|
||||
PreviewText.Text = "File preview is turned off in Settings.";
|
||||
return;
|
||||
}
|
||||
_previewPath = node.FullPath;
|
||||
_previewSize = node.Size;
|
||||
_pageSize = Math.Max(1, _services.Settings.HexPageKb) * 1024;
|
||||
PreviewTitle.Text = node.Name;
|
||||
if (_previewSize == 0)
|
||||
{
|
||||
PreviewImage.IsVisible = false;
|
||||
PreviewText.IsVisible = true;
|
||||
PreviewText.Text = "(empty file)";
|
||||
PrevPageButton.IsEnabled = NextPageButton.IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
string extension = Path.GetExtension(node.Name);
|
||||
long limit = (long)Math.Max(1, _services.Settings.MaxPreviewMb) * 1024 * 1024;
|
||||
if ((ImageExtensions.Contains(extension) || extension.Equals(".dds", StringComparison.OrdinalIgnoreCase)) &&
|
||||
_previewSize <= limit)
|
||||
{
|
||||
await ShowImageAsync(node.FullPath, extension);
|
||||
return;
|
||||
}
|
||||
await ShowPageAsync(0);
|
||||
}
|
||||
|
||||
private static string DescribeNode(FileNode node)
|
||||
{
|
||||
if (node.IsDirectory) return node.FullPath;
|
||||
var parts = new List<string> { node.FullPath, LibraryItem.FormatBytes(node.Size) };
|
||||
if (node.Info is { } info)
|
||||
{
|
||||
if (info.Origin.Length > 0) parts.Add(info.Origin);
|
||||
if (info.IsEncrypted) parts.Add("encrypted");
|
||||
}
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private async Task ShowImageAsync(string path, string extension)
|
||||
{
|
||||
Ps5GameInfo game = _game!;
|
||||
_previewCts?.Cancel();
|
||||
_previewCts = new CancellationTokenSource();
|
||||
CancellationToken token = _previewCts.Token;
|
||||
PrevPageButton.IsEnabled = NextPageButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
Bitmap? bitmap = await Task.Run(() =>
|
||||
{
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(game, token);
|
||||
using Stream stream = files.OpenRead(GameFileSystem.NormalizePath(path));
|
||||
using var memory = new MemoryStream();
|
||||
stream.CopyTo(memory);
|
||||
memory.Position = 0;
|
||||
if (extension.Equals(".dds", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
(byte[] rgba, int width, int height) = Ps5ImageCodec.DecodeDdsToRgba(memory);
|
||||
var bmp = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96),
|
||||
PixelFormat.Rgba8888, AlphaFormat.Unpremul);
|
||||
using ILockedFramebuffer buffer = bmp.Lock();
|
||||
for (int y = 0; y < height; y++)
|
||||
System.Runtime.InteropServices.Marshal.Copy(rgba, y * width * 4,
|
||||
buffer.Address + y * buffer.RowBytes, width * 4);
|
||||
return (Bitmap)bmp;
|
||||
}
|
||||
return new Bitmap(memory);
|
||||
}, token);
|
||||
if (token.IsCancellationRequested || _previewPath != path) return;
|
||||
PreviewImage.Source = bitmap;
|
||||
PreviewImage.IsVisible = true;
|
||||
PreviewText.IsVisible = false;
|
||||
if (bitmap is not null)
|
||||
PreviewInfo.Text = DescribeNode(_selected[0]) + $" · {bitmap.PixelSize.Width}x{bitmap.PixelSize.Height}";
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
PreviewImage.IsVisible = false;
|
||||
PreviewText.IsVisible = true;
|
||||
PreviewText.Text = "Could not show this image: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowPageAsync(long offset)
|
||||
{
|
||||
if (_game is null || _previewPath is null) return;
|
||||
Ps5GameInfo game = _game;
|
||||
string path = _previewPath;
|
||||
offset = Math.Clamp(offset, 0, Math.Max(0, _previewSize - 1));
|
||||
offset -= offset % _pageSize;
|
||||
_previewCts?.Cancel();
|
||||
_previewCts = new CancellationTokenSource();
|
||||
CancellationToken token = _previewCts.Token;
|
||||
try
|
||||
{
|
||||
GameFileChunk chunk = await Task.Run(() => GameFileSystem.ReadFileChunk(game, path, offset, _pageSize, token), token);
|
||||
if (token.IsCancellationRequested || _previewPath != path) return;
|
||||
_pageOffset = chunk.Offset;
|
||||
bool text = TextExtensions.Contains(Path.GetExtension(path)) && LooksLikeText(chunk.Data);
|
||||
PreviewText.Text = text ? Encoding.UTF8.GetString(chunk.Data) : HexDump(chunk.Data, chunk.Offset);
|
||||
PreviewImage.IsVisible = false;
|
||||
PreviewText.IsVisible = true;
|
||||
PrevPageButton.IsEnabled = chunk.HasPrevious;
|
||||
NextPageButton.IsEnabled = chunk.HasNext;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
PreviewText.Text = "Could not read this file: " + ex.Message;
|
||||
PreviewText.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeText(byte[] data)
|
||||
{
|
||||
int control = 0;
|
||||
foreach (byte value in data.Take(4096))
|
||||
if (value < 0x09 || (value > 0x0D && value < 0x20)) control++;
|
||||
return control * 50 < Math.Min(data.Length, 4096) + 1;
|
||||
}
|
||||
|
||||
private static string HexDump(byte[] data, long baseOffset)
|
||||
{
|
||||
var text = new StringBuilder(data.Length * 4);
|
||||
for (int row = 0; row < data.Length; row += 16)
|
||||
{
|
||||
text.Append((baseOffset + row).ToString("X8")).Append(" ");
|
||||
var ascii = new StringBuilder(16);
|
||||
for (int column = 0; column < 16; column++)
|
||||
{
|
||||
if (row + column < data.Length)
|
||||
{
|
||||
byte value = data[row + column];
|
||||
text.Append(value.ToString("X2")).Append(' ');
|
||||
ascii.Append(value is >= 0x20 and < 0x7F ? (char)value : '.');
|
||||
}
|
||||
else text.Append(" ");
|
||||
if (column == 7) text.Append(' ');
|
||||
}
|
||||
text.Append(" |").Append(ascii).Append("|\n");
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
private async Task CopyPathAsync()
|
||||
{
|
||||
if (_selected.Count == 0) return;
|
||||
string text = string.Join(Environment.NewLine, _selected.Select(node => node.FullPath));
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
await clipboard.SetTextAsync(text);
|
||||
StatusChanged?.Invoke("Copied " + (_selected.Count == 1 ? _selected[0].FullPath : $"{_selected.Count:N0} paths") + ".");
|
||||
}
|
||||
|
||||
private void PopOut()
|
||||
{
|
||||
if (_game is null || _inventory is null) return;
|
||||
var window = new FileBrowserWindow(_game, _inventory);
|
||||
if (Dialogs.OwnerOf(this) is { } owner) window.Show(owner);
|
||||
else window.Show();
|
||||
}
|
||||
|
||||
private async Task ExtractAsync(bool all)
|
||||
{
|
||||
Ps5GameInfo? game = _game;
|
||||
if (game is null || _inventory is null) return;
|
||||
List<string> wanted = all
|
||||
? _inventory.Files.Select(file => file.RelativePath).ToList()
|
||||
: _selected.SelectMany(node => node.Files()).Select(node => node.FullPath).Distinct().ToList();
|
||||
if (wanted.Count == 0)
|
||||
{
|
||||
StatusChanged?.Invoke("Select files or folders to extract first.");
|
||||
return;
|
||||
}
|
||||
string? folder = await Dialogs.PickFolderAsync(this, "Extract to...");
|
||||
if (folder is null) return;
|
||||
StatusChanged?.Invoke($"Extracting {wanted.Count:N0} file(s)...");
|
||||
try
|
||||
{
|
||||
string root = Path.GetFullPath(folder);
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
foreach (string relative in wanted)
|
||||
{
|
||||
string target = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!target.StartsWith(root, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("A path escapes the extraction folder: " + relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
await GameFileSystem.ExtractFileAsync(game, relative, target);
|
||||
}
|
||||
});
|
||||
StatusChanged?.Invoke($"Extracted {wanted.Count:N0} file(s) to {folder}.");
|
||||
if (_services.Settings.OpenOutputAfterTask) Dialogs.OpenPath(folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Extract files", ex);
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Extract", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.HealthView">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<DockPanel Margin="32,26,24,10">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button x:Name="ExportButton" Content="Export report..." />
|
||||
<Button x:Name="DeepButton" Content="Check files too"
|
||||
ToolTip.Tip="Also looks inside every title for a missing icon, and checks unpacked dumps for launch problems. Slower." />
|
||||
<Button x:Name="RunButton" Content="Check now" Classes="accent" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Library health" Classes="title" />
|
||||
<TextBlock x:Name="SubText" Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<WrapPanel Grid.Row="1" Margin="32,0,24,4">
|
||||
<Border Classes="card" Padding="16,12" Margin="0,0,12,12" MinWidth="130">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="CardTitles" FontSize="24" FontWeight="Bold" Text="0" />
|
||||
<TextBlock Text="titles" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" Padding="16,12" Margin="0,0,12,12" MinWidth="130">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="CardSize" FontSize="24" FontWeight="Bold" Text="0 B" />
|
||||
<TextBlock Text="in the library" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" Padding="16,12" Margin="0,0,12,12" MinWidth="130">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="CardProblems" FontSize="24" FontWeight="Bold" Text="0" />
|
||||
<TextBlock Text="problems" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" Padding="16,12" Margin="0,0,12,12" MinWidth="130">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="CardWarnings" FontSize="24" FontWeight="Bold" Text="0" />
|
||||
<TextBlock Text="warnings" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" Padding="16,12" Margin="0,0,12,12" MinWidth="130"
|
||||
ToolTip.Tip="Space taken by exact duplicates, older copies and superseded updates. Nothing is deleted for you.">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="CardReclaim" FontSize="24" FontWeight="Bold" Text="0 B" />
|
||||
<TextBlock Text="in duplicates and older copies" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
|
||||
<Border x:Name="ProgressHost" Grid.Row="2" Classes="card" Margin="32,0,24,8" Padding="14,10" IsVisible="False">
|
||||
<DockPanel>
|
||||
<Button x:Name="StopButton" DockPanel.Dock="Right" Content="Stop" Classes="ghost" Margin="12,0,0,0" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ProgressText" Classes="muted" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||
<ProgressBar x:Name="ProgressBar" Minimum="0" Maximum="100" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="32,0,24,8">
|
||||
<RadioButton x:Name="FilterAll" Classes="chip" GroupName="healthfilter" Content="All" IsChecked="True" />
|
||||
<RadioButton x:Name="FilterProblems" Classes="chip" GroupName="healthfilter" Content="Problems" />
|
||||
<RadioButton x:Name="FilterWarnings" Classes="chip" GroupName="healthfilter" Content="Warnings" />
|
||||
<RadioButton x:Name="FilterNotes" Classes="chip" GroupName="healthfilter" Content="Notes" />
|
||||
</StackPanel>
|
||||
|
||||
<Panel Grid.Row="4">
|
||||
<ListBox x:Name="IssueList" Margin="32,0,24,16" SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="2,6">
|
||||
<Ellipse Width="10" Height="10" Fill="{Binding Dot}" VerticalAlignment="Top" Margin="0,7,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="pill" VerticalAlignment="Center"><TextBlock Text="{Binding Kind}" FontSize="11" /></Border>
|
||||
<Border Classes="pill" VerticalAlignment="Center" IsVisible="{Binding HasReclaim}">
|
||||
<TextBlock Text="{Binding ReclaimText}" FontSize="11" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Path}" Classes="muted" FontSize="11" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" Margin="12,0,16,0">
|
||||
<Button Content="Fix..." Classes="accent" IsVisible="{Binding CanFix}" Click="OnFix" Padding="12,5" />
|
||||
<Button Content="Show" IsVisible="{Binding CanShow}" Click="OnShow" Padding="12,5" />
|
||||
<Button Content="Folder" IsVisible="{Binding CanReveal}" Click="OnReveal" Padding="12,5" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<StackPanel x:Name="EmptyState" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8" IsVisible="False">
|
||||
<TextBlock x:Name="EmptyTitle" Text="Nothing to fix" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyHint" Classes="muted" HorizontalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>The library health page: a quick check of everything the library already knows, and an optional slower one.</summary>
|
||||
public partial class HealthView : UserControl
|
||||
{
|
||||
private sealed class IssueRow
|
||||
{
|
||||
public required HealthIssue Issue { get; init; }
|
||||
public IBrush Dot { get; init; } = Brushes.Gray;
|
||||
public string Title => Issue.Title;
|
||||
public string Kind => Issue.KindLabel;
|
||||
public string Message => Issue.Message;
|
||||
public string Path => Issue.ItemPath;
|
||||
public bool HasReclaim => Issue.ReclaimableBytes > 0;
|
||||
public string ReclaimText => "takes " + Ps5LibraryHealth.FormatBytes(Issue.ReclaimableBytes);
|
||||
public bool CanFix { get; init; }
|
||||
public bool CanShow { get; init; }
|
||||
public bool CanReveal { get; init; }
|
||||
}
|
||||
|
||||
private static readonly IBrush Red = new SolidColorBrush(Color.Parse("#F0626B"));
|
||||
private static readonly IBrush Amber = new SolidColorBrush(Color.Parse("#F5B642"));
|
||||
private static readonly IBrush Blue = new SolidColorBrush(Color.Parse("#7C8CFF"));
|
||||
private static readonly IBrush Green = new SolidColorBrush(Color.Parse("#3DD68C"));
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private HealthReport? _report;
|
||||
private HealthSeverity? _filter;
|
||||
private CancellationTokenSource? _cancellation;
|
||||
private bool _ranOnce;
|
||||
|
||||
public HealthView()
|
||||
{
|
||||
InitializeComponent();
|
||||
RunButton.Click += async (_, _) => await RunAsync(deep: false);
|
||||
DeepButton.Click += async (_, _) => await RunAsync(deep: true);
|
||||
StopButton.Click += (_, _) => _cancellation?.Cancel();
|
||||
ExportButton.Click += async (_, _) => await ExportAsync();
|
||||
foreach ((RadioButton button, HealthSeverity? severity) in new (RadioButton, HealthSeverity?)[]
|
||||
{ (FilterAll, null), (FilterProblems, HealthSeverity.Problem), (FilterWarnings, HealthSeverity.Warning), (FilterNotes, HealthSeverity.Info) })
|
||||
button.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
if (button.IsChecked != true) return;
|
||||
_filter = severity;
|
||||
ShowIssues();
|
||||
};
|
||||
// A refreshed library changes the answers, so a page that is on screen re-checks itself.
|
||||
_services.LibraryChanged += () => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (IsEffectivelyVisible && _cancellation is null) _ = RunAsync(deep: false);
|
||||
});
|
||||
}
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
/// <summary>Called when the page is opened: the quick check is cheap, so it always shows current results.</summary>
|
||||
public void OnShown()
|
||||
{
|
||||
if (_cancellation is null) _ = RunAsync(deep: false);
|
||||
}
|
||||
|
||||
internal static HealthItem ToHealthItem(LibraryItem item) => new(
|
||||
item.Path, item.FileName, item.Title, item.TitleId, item.ContentId, item.Version, item.Category,
|
||||
item.SizeBytes, item.Format,
|
||||
item.Game.RawParamJson.Length == 0
|
||||
? string.Empty
|
||||
: Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(item.Game.RawParamJson))),
|
||||
item.MissingFields,
|
||||
item.Game.DataWarnings.FirstOrDefault(warning => warning.Contains("could not be read", StringComparison.OrdinalIgnoreCase)) ?? string.Empty);
|
||||
|
||||
private async Task RunAsync(bool deep)
|
||||
{
|
||||
if (_cancellation is not null) return;
|
||||
_cancellation = new CancellationTokenSource();
|
||||
CancellationToken token = _cancellation.Token;
|
||||
SetBusy(true, deep ? "Checking the library..." : "Checking...");
|
||||
try
|
||||
{
|
||||
List<LibraryItem> items = _services.Games.Select(game => new LibraryItem(game)).ToList();
|
||||
List<HealthItem> health = items.Select(ToHealthItem).ToList();
|
||||
IReadOnlyList<string> problems = _services.ScanProblems;
|
||||
HealthReport report = await Task.Run(() => Ps5LibraryHealth.Analyze(health, problems), token);
|
||||
|
||||
if (deep)
|
||||
{
|
||||
int done = 0;
|
||||
foreach (LibraryItem item in items)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ProgressText.Text = $"Looking inside {item.Title}... {++done:N0} of {items.Count:N0}";
|
||||
ProgressBar.Value = done * 100.0 / Math.Max(1, items.Count);
|
||||
HealthItem healthItem = ToHealthItem(item);
|
||||
List<HealthIssue> found = await Task.Run(() => DeepCheck(item, healthItem), token);
|
||||
report.Issues.AddRange(found);
|
||||
}
|
||||
Ps5LibraryHealth.Sort(report);
|
||||
}
|
||||
|
||||
_report = report;
|
||||
_ranOnce = true;
|
||||
ShowReport(report, deep);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusChanged?.Invoke("Health check stopped.");
|
||||
if (_report is not null) ShowReport(_report, deep: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
|
||||
{
|
||||
Logger.Exception("Health check", ex);
|
||||
SubText.Text = "The check could not finish: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cancellation?.Dispose();
|
||||
_cancellation = null;
|
||||
SetBusy(false, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The slower checks for one title: is there an icon, and (for an unpacked dump) can it launch.</summary>
|
||||
private static List<HealthIssue> DeepCheck(LibraryItem item, HealthItem healthItem)
|
||||
{
|
||||
var issues = new List<HealthIssue>();
|
||||
if (!File.Exists(item.Path) && !Directory.Exists(item.Path)) return issues;
|
||||
bool hasIcon = MetadataOverrides.IconPath(MetadataOverrides.Get(item.Path)) is not null;
|
||||
if (!hasIcon)
|
||||
{
|
||||
try
|
||||
{
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(item.Game);
|
||||
hasIcon = files.FileExists("sce_sys/icon0.png");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||
NotSupportedException or ArgumentException)
|
||||
{
|
||||
// Unreadable titles are already reported by the quick check.
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
if (Ps5LibraryHealth.IconIssue(healthItem, hasIcon) is { } iconIssue) issues.Add(iconIssue);
|
||||
if (item.Game.SourceKind == Ps5SourceKind.LooseDump) issues.AddRange(Ps5LibraryHealth.LaunchIssues(healthItem));
|
||||
return issues;
|
||||
}
|
||||
|
||||
private void SetBusy(bool busy, string text)
|
||||
{
|
||||
ProgressHost.IsVisible = busy && text.Length > 0;
|
||||
ProgressText.Text = text;
|
||||
ProgressBar.IsIndeterminate = busy && text == "Checking...";
|
||||
if (!busy) ProgressBar.Value = 0;
|
||||
RunButton.IsEnabled = DeepButton.IsEnabled = !busy;
|
||||
StopButton.IsVisible = busy && text != "Checking...";
|
||||
}
|
||||
|
||||
private void ShowReport(HealthReport report, bool deep)
|
||||
{
|
||||
int problems = report.Count(HealthSeverity.Problem), warnings = report.Count(HealthSeverity.Warning);
|
||||
CardTitles.Text = report.ItemCount.ToString("N0");
|
||||
CardSize.Text = Ps5LibraryHealth.FormatBytes(report.TotalBytes);
|
||||
CardProblems.Text = problems.ToString("N0");
|
||||
CardProblems.Foreground = problems > 0 ? Red : Green;
|
||||
CardWarnings.Text = warnings.ToString("N0");
|
||||
CardWarnings.Foreground = warnings > 0 ? Amber : Green;
|
||||
CardReclaim.Text = Ps5LibraryHealth.FormatBytes(report.ReclaimableBytes);
|
||||
SubText.Text = $"Checked {DateTime.Now:HH:mm}" + (deep ? ", including the inside of every title." : ". ") +
|
||||
(deep ? string.Empty : "\"Check files too\" also looks for missing icons and launch problems.") +
|
||||
(report.Drives.Count > 0
|
||||
? " " + string.Join(" ", report.Drives.Select(drive =>
|
||||
$"{drive.Root.TrimEnd('\\', '/')} {Ps5LibraryHealth.FormatBytes(drive.FreeBytes)} free"))
|
||||
: string.Empty);
|
||||
ShowIssues();
|
||||
StatusChanged?.Invoke(report.Issues.Count == 0
|
||||
? "Library health: nothing to fix."
|
||||
: $"Library health: {problems:N0} problem(s), {warnings:N0} warning(s).");
|
||||
}
|
||||
|
||||
private void ShowIssues()
|
||||
{
|
||||
if (_report is null) return;
|
||||
List<IssueRow> rows = _report.Issues
|
||||
.Where(issue => _filter is null || issue.Severity == _filter)
|
||||
.Select(issue => new IssueRow
|
||||
{
|
||||
Issue = issue,
|
||||
Dot = issue.Severity switch { HealthSeverity.Problem => Red, HealthSeverity.Warning => Amber, _ => Blue },
|
||||
CanFix = issue.Kind is HealthKind.MissingDetails or HealthKind.NoIcon && IsInLibrary(issue.ItemPath),
|
||||
CanShow = issue.Kind != HealthKind.Unreadable && issue.Kind != HealthKind.LowDiskSpace && IsInLibrary(issue.ItemPath),
|
||||
CanReveal = issue.Kind != HealthKind.NotFound && (File.Exists(issue.ItemPath) || Directory.Exists(issue.ItemPath))
|
||||
}).ToList();
|
||||
IssueList.ItemsSource = rows;
|
||||
EmptyState.IsVisible = rows.Count == 0;
|
||||
IssueList.IsVisible = rows.Count > 0;
|
||||
EmptyTitle.Text = _report.Issues.Count == 0 ? "Nothing to fix" : "Nothing in this group";
|
||||
EmptyHint.Text = _report.Issues.Count == 0
|
||||
? "The library looks healthy."
|
||||
: "Choose All, or another group, to see the rest.";
|
||||
}
|
||||
|
||||
private bool IsInLibrary(string path) =>
|
||||
_services.Games.Any(game => string.Equals(game.RootPath, path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IssueRow? RowOf(object? sender) => (sender as Control)?.DataContext as IssueRow;
|
||||
|
||||
private void OnShow(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (RowOf(sender) is { } row) _services.RequestShowTitle(row.Path, edit: false);
|
||||
}
|
||||
|
||||
private void OnFix(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (RowOf(sender) is { } row) _services.RequestShowTitle(row.Path, edit: true);
|
||||
}
|
||||
|
||||
private void OnReveal(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (RowOf(sender) is { } row) Dialogs.Reveal(row.Path);
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
if (!_ranOnce || _report is null) await RunAsync(deep: false);
|
||||
if (_report is null) return;
|
||||
string? file = await Dialogs.SaveFileAsync(this, "Export the health report", "library-health.md", ".md");
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(file, Ps5LibraryHealth.ToMarkdown(_report, issue => issue.ItemPath), Encoding.UTF8);
|
||||
StatusChanged?.Invoke($"Saved the health report to {file}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.ImageEditorWindow"
|
||||
Title="Edit image files" Width="980" Height="680" MinWidth="720" MinHeight="480"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,*,6,150,Auto" Margin="12">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,8">
|
||||
<TextBlock x:Name="ImageText" FontWeight="SemiBold" />
|
||||
<WrapPanel>
|
||||
<Button x:Name="ReplaceButton" Content="Replace file..." Margin="0,0,8,4" />
|
||||
<Button x:Name="AddFileButton" Content="Add file..." Margin="0,0,8,4" />
|
||||
<Button x:Name="AddTreeButton" Content="Add folder..." Margin="0,0,8,4" />
|
||||
<Button x:Name="NewDirButton" Content="New directory" Margin="0,0,8,4" />
|
||||
<Button x:Name="DeleteButton" Content="Delete selected" Margin="0,0,8,4" />
|
||||
<Button x:Name="UndoButton" Content="Undo last" Margin="0,0,8,4" />
|
||||
<TextBlock Text="Target folder:" VerticalAlignment="Center" Margin="12,0,6,4" />
|
||||
<TextBox x:Name="TargetBox" Width="260" Margin="0,0,0,4" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
<DataGrid x:Name="EntryGrid" Grid.Row="1" IsReadOnly="True" AutoGenerateColumns="False" SelectionMode="Single">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Type" Binding="{Binding Type}" Width="90" />
|
||||
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText}" SortMemberPath="Size" Width="110" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<GridSplitter Grid.Row="2" />
|
||||
<ListBox x:Name="ChangesList" Grid.Row="3" />
|
||||
<DockPanel Grid.Row="4" Margin="0,8,0,0">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="ApplyButton" Content="Apply changes" Classes="accent" />
|
||||
<Button x:Name="CancelOpButton" Content="Cancel operation" IsEnabled="False" />
|
||||
<Button x:Name="CloseButton" Content="Close" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,12,0">
|
||||
<ProgressBar x:Name="EditProgress" Minimum="0" Maximum="100" />
|
||||
<TextBlock x:Name="StatusText" Classes="muted" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Containers;
|
||||
using Packwright.Infrastructure;
|
||||
using UFS2Tool;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Queues file changes for an exFAT or FFPKG image and applies them transactionally: the engines
|
||||
/// rebuild beside the original and swap it in only after the result has been verified.
|
||||
/// </summary>
|
||||
public partial class ImageEditorWindow : Window
|
||||
{
|
||||
private sealed record Entry(string Type, string Path, long Size)
|
||||
{
|
||||
public string SizeText => Type == "Directory" ? string.Empty : LibraryItem.FormatBytes(Size);
|
||||
}
|
||||
|
||||
private sealed record Change(string Description, ExfatEditOperation? Exfat, Ufs2EditOperation? Ufs2);
|
||||
|
||||
private readonly string _imagePath;
|
||||
private readonly Ps5ImageFormat _format;
|
||||
private readonly List<Change> _changes = [];
|
||||
private readonly ObservableCollection<string> _changeText = [];
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
public ImageEditorWindow() : this(string.Empty, Ps5ImageFormat.Exfat) { }
|
||||
|
||||
public ImageEditorWindow(string imagePath, Ps5ImageFormat format)
|
||||
{
|
||||
InitializeComponent();
|
||||
_imagePath = imagePath;
|
||||
_format = format;
|
||||
ImageText.Text = imagePath;
|
||||
ChangesList.ItemsSource = _changeText;
|
||||
ReplaceButton.Click += async (_, _) => await ReplaceAsync();
|
||||
AddFileButton.Click += async (_, _) => await AddFileAsync();
|
||||
AddTreeButton.Click += async (_, _) => await AddTreeAsync();
|
||||
NewDirButton.Click += async (_, _) => await NewDirectoryAsync();
|
||||
DeleteButton.Click += async (_, _) => await DeleteAsync();
|
||||
UndoButton.Click += (_, _) => Undo();
|
||||
ApplyButton.Click += async (_, _) => await ApplyAsync();
|
||||
CancelOpButton.Click += (_, _) => _cancellation?.Cancel();
|
||||
CloseButton.Click += (_, _) => Close();
|
||||
EntryGrid.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (EntryGrid.SelectedItem is Entry entry)
|
||||
TargetBox.Text = entry.Type == "Directory" ? entry.Path : ParentPath(entry.Path);
|
||||
};
|
||||
if (imagePath.Length > 0) LoadEntries();
|
||||
}
|
||||
|
||||
private void LoadEntries()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_format == Ps5ImageFormat.Exfat)
|
||||
{
|
||||
using FileStream stream = File.OpenRead(_imagePath);
|
||||
using var volume = new ExfatVolume(stream);
|
||||
EntryGrid.ItemsSource = volume.Entries.OrderBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new Entry(entry.IsDirectory ? "Directory" : "File", entry.Path, entry.Size)).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
using var volume = new Ufs2Volume(_imagePath);
|
||||
EntryGrid.ItemsSource = volume.Entries.OrderBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new Entry(entry.IsDirectory ? "Directory" : "File", entry.Path, entry.Size)).ToList();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Image editor", ex);
|
||||
StatusText.Text = "Could not read the image: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParentPath(string path)
|
||||
{
|
||||
int slash = path.LastIndexOf('/');
|
||||
return slash <= 0 ? string.Empty : path[..slash];
|
||||
}
|
||||
|
||||
private string Normalize(string path) => _format == Ps5ImageFormat.Exfat
|
||||
? ExfatImageMaintenance.NormalizeImagePath(path)
|
||||
: Ufs2Operations.NormalizeImagePath(path);
|
||||
|
||||
private string Join(string name)
|
||||
{
|
||||
string parent = string.IsNullOrWhiteSpace(TargetBox.Text) ? string.Empty : Normalize(TargetBox.Text!);
|
||||
return Normalize(parent.Length == 0 ? name : parent + "/" + name);
|
||||
}
|
||||
|
||||
private void Queue(string description, ExfatEditOperation? exfat, Ufs2EditOperation? ufs2)
|
||||
{
|
||||
_changes.Add(new Change(description, exfat, ufs2));
|
||||
_changeText.Add(description);
|
||||
StatusText.Text = $"{_changes.Count:N0} change(s) queued.";
|
||||
}
|
||||
|
||||
private async Task<string?> PickSourceFileAsync(string title)
|
||||
{
|
||||
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(
|
||||
new FilePickerOpenOptions { Title = title, AllowMultiple = false });
|
||||
return files.Count > 0 ? files[0].TryGetLocalPath() : null;
|
||||
}
|
||||
|
||||
private async Task ReplaceAsync()
|
||||
{
|
||||
if (EntryGrid.SelectedItem is not Entry { Type: "File" } entry)
|
||||
{
|
||||
StatusText.Text = "Select a file to replace.";
|
||||
return;
|
||||
}
|
||||
if (await PickSourceFileAsync("Replacement file") is not { } source) return;
|
||||
Queue($"Replace {entry.Path} <- {source}",
|
||||
_format == Ps5ImageFormat.Exfat ? ExfatEditOperation.Replace(entry.Path, source) : null,
|
||||
_format == Ps5ImageFormat.Ufs2 ? Ufs2EditOperation.Replace(entry.Path, source) : null);
|
||||
}
|
||||
|
||||
private async Task AddFileAsync()
|
||||
{
|
||||
if (await PickSourceFileAsync("File to add") is not { } source) return;
|
||||
string target = Join(Path.GetFileName(source));
|
||||
Queue($"Add file {target} <- {source}",
|
||||
_format == Ps5ImageFormat.Exfat ? ExfatEditOperation.AddFile(target, source) : null,
|
||||
_format == Ps5ImageFormat.Ufs2 ? Ufs2EditOperation.AddFile(target, source) : null);
|
||||
}
|
||||
|
||||
private async Task AddTreeAsync()
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Folder to import") is not { } source) return;
|
||||
string target = Join(new DirectoryInfo(source).Name);
|
||||
Queue($"Add tree {target} <- {source}",
|
||||
_format == Ps5ImageFormat.Exfat ? ExfatEditOperation.AddDirectoryTree(target, source) : null,
|
||||
_format == Ps5ImageFormat.Ufs2 ? Ufs2EditOperation.AddDirectoryTree(target, source) : null);
|
||||
}
|
||||
|
||||
private async Task NewDirectoryAsync()
|
||||
{
|
||||
string path = Normalize(TargetBox.Text ?? string.Empty);
|
||||
if (path.Length == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(this, "New directory", "Type the full directory path in the target box.");
|
||||
return;
|
||||
}
|
||||
Queue($"New directory {path}",
|
||||
_format == Ps5ImageFormat.Exfat ? ExfatEditOperation.AddDirectory(path) : null,
|
||||
_format == Ps5ImageFormat.Ufs2 ? Ufs2EditOperation.AddDirectory(path) : null);
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (EntryGrid.SelectedItem is not Entry entry) return;
|
||||
if (!await Dialogs.ConfirmAsync(this, "Delete", $"Queue deletion of {entry.Path}?", "Queue delete")) return;
|
||||
Queue($"Delete {entry.Path}",
|
||||
_format == Ps5ImageFormat.Exfat ? ExfatEditOperation.Delete(entry.Path) : null,
|
||||
_format == Ps5ImageFormat.Ufs2 ? Ufs2EditOperation.Delete(entry.Path) : null);
|
||||
}
|
||||
|
||||
private void Undo()
|
||||
{
|
||||
if (_changes.Count == 0) return;
|
||||
_changes.RemoveAt(_changes.Count - 1);
|
||||
_changeText.RemoveAt(_changeText.Count - 1);
|
||||
StatusText.Text = $"{_changes.Count:N0} change(s) queued.";
|
||||
}
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
foreach (Button button in new[] { ReplaceButton, AddFileButton, AddTreeButton, NewDirButton, DeleteButton,
|
||||
UndoButton, ApplyButton, CloseButton })
|
||||
button.IsEnabled = !busy;
|
||||
CancelOpButton.IsEnabled = busy;
|
||||
}
|
||||
|
||||
private async Task ApplyAsync()
|
||||
{
|
||||
if (_changes.Count == 0) return;
|
||||
if (!await Dialogs.ConfirmAsync(this, "Apply changes?",
|
||||
$"Apply {_changes.Count:N0} queued change(s)?\n\nThe image is rebuilt beside the original and replaced only after " +
|
||||
"full verification. Large game images need substantial free space and time.", "Apply"))
|
||||
return;
|
||||
|
||||
_cancellation = new CancellationTokenSource();
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
int files;
|
||||
if (_format == Ps5ImageFormat.Exfat)
|
||||
{
|
||||
var progress = new Progress<FfpfscProgress>(value =>
|
||||
Report(value.Stage, value.BytesProcessed, value.TotalBytes));
|
||||
ExfatEditResult result = await ExfatImageMaintenance.ApplyEditsAsync(_imagePath,
|
||||
_changes.Select(change => change.Exfat!).ToList(), progress, _cancellation.Token);
|
||||
files = result.Verification.FileCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
var progress = new Progress<Ufs2Progress>(value =>
|
||||
Report(value.Stage, value.BytesProcessed, value.TotalBytes));
|
||||
Ufs2EditResult result = await Ufs2Operations.ApplyEditsAsync(_imagePath,
|
||||
_changes.Select(change => change.Ufs2!).ToList(), progress, _cancellation.Token);
|
||||
files = result.Verification.FileCount;
|
||||
}
|
||||
EditProgress.Value = 100;
|
||||
StatusText.Text = $"Completed and verified {files:N0} files.";
|
||||
_changes.Clear();
|
||||
_changeText.Clear();
|
||||
LoadEntries();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusText.Text = "Operation cancelled; the original image was preserved.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or
|
||||
ArgumentException)
|
||||
{
|
||||
StatusText.Text = "No unverified changes were kept.";
|
||||
await Dialogs.ShowMessageAsync(this, "Edit failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cancellation?.Dispose();
|
||||
_cancellation = null;
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void Report(string stage, long done, long total)
|
||||
{
|
||||
EditProgress.Value = total <= 0 ? 0 : Math.Clamp(done * 100.0 / total, 0, 100);
|
||||
StatusText.Text = $"{stage} - {LibraryItem.FormatBytes(done)} / {LibraryItem.FormatBytes(total)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using Avalonia.Interactivity;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Core.Tasks;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class LibraryView
|
||||
{
|
||||
private sealed record Proposal(LibraryItem Item, MetadataSuggestion Suggestion, byte[]? IconPng);
|
||||
|
||||
/// <summary>
|
||||
/// Looks through the library for titles with missing details, works out what it can offline, shows what it
|
||||
/// found, and only then saves it (in the library, and optionally into the files themselves).
|
||||
/// </summary>
|
||||
private async Task FixMissingDetailsAsync()
|
||||
{
|
||||
Avalonia.Controls.Window? owner = Dialogs.OwnerOf(this);
|
||||
List<LibraryItem> targets = _items.Where(item => item.HasMissing).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(owner, "Fix missing details", "No title in the library is missing details.");
|
||||
return;
|
||||
}
|
||||
|
||||
var proposals = new List<Proposal>();
|
||||
int done = 0;
|
||||
foreach (LibraryItem item in targets)
|
||||
{
|
||||
Status($"Looking for missing details... {++done:N0} of {targets.Count:N0}");
|
||||
MetadataSuggestion suggestion;
|
||||
try { suggestion = await Task.Run(() => Ps5MetadataSuggester.Suggest(item.Game)); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.Warn($"Could not look for details of {item.FileName}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
Trim(suggestion, item);
|
||||
byte[]? icon = suggestion.Icon is null ? null : await IconTools.SquarePngAsync(suggestion.Icon);
|
||||
if (suggestion.Icon is not null && icon is null) suggestion.Icon = null;
|
||||
if (suggestion.HasAnything) proposals.Add(new Proposal(item, suggestion, icon));
|
||||
}
|
||||
|
||||
if (proposals.Count == 0)
|
||||
{
|
||||
Status("Nothing more could be worked out.");
|
||||
await Dialogs.ShowMessageAsync(owner, "Fix missing details",
|
||||
$"{targets.Count:N0} title(s) are missing details, but nothing more could be worked out from the files " +
|
||||
"and their names. Their metadata was probably stripped when they were dumped.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ReviewSaveAndOfferWriteAsync(owner, "Fix missing details", proposals,
|
||||
$"{proposals.Count:N0} of {targets.Count:N0} title(s) with missing details can be improved.");
|
||||
}
|
||||
|
||||
/// <summary>Shows what was found, saves it in the library on confirmation, then offers to write it into the files.</summary>
|
||||
private async Task ReviewSaveAndOfferWriteAsync(Avalonia.Controls.Window? owner, string title,
|
||||
List<Proposal> proposals, string header)
|
||||
{
|
||||
if (!await Dialogs.ConfirmTextAsync(owner, title, header + " Nothing changes until you save.",
|
||||
Review(proposals), "Save in library"))
|
||||
{
|
||||
Status("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Proposal proposal in proposals) SaveProposal(proposal);
|
||||
AutoSizeColumns();
|
||||
ApplyFilters();
|
||||
await ShowSelectionAsync();
|
||||
Status($"Saved details for {proposals.Count:N0} title(s) in the library.");
|
||||
|
||||
// Writing into a file rebuilds it; for big archives that takes a while, so it is a separate, explicit choice.
|
||||
if (await Dialogs.ConfirmAsync(owner, "Write into the files?",
|
||||
$"The details were saved in the library only. Also write them into the {proposals.Count:N0} file(s) " +
|
||||
"themselves?\n\nEach file is rebuilt in the background and swapped in only after it verifies. Large " +
|
||||
"archives take a while. You can follow it on the Tasks page.", "Write into files", "Not now"))
|
||||
foreach (Proposal proposal in proposals) QueueFileWrite(proposal.Item);
|
||||
}
|
||||
|
||||
/// <summary>The name to search the store for: the title without file-name noise, or nothing when there is none.</summary>
|
||||
private static string OnlineQueryName(LibraryItem item)
|
||||
{
|
||||
string name = Ps5MetadataSuggester.CleanName(item.Title);
|
||||
return name.Length > 0 ? name : Ps5MetadataSuggester.CleanName(Path.GetFileNameWithoutExtension(item.FileName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks missing titles up in the PlayStation Store by name. Only titles that already have a Title ID are
|
||||
/// filled in, and only from a store entry with that same ID, because the same game has a different ID in each
|
||||
/// region. Each name is sent on request, one at a time.
|
||||
/// </summary>
|
||||
private async Task LookUpOnlineAsync()
|
||||
{
|
||||
Avalonia.Controls.Window? owner = Dialogs.OwnerOf(this);
|
||||
List<LibraryItem> missing = _items.Where(item => item.HasMissing).ToList();
|
||||
List<LibraryItem> candidates = missing.Where(item => item.TitleId.Length > 0).ToList();
|
||||
int noId = missing.Count(item => item.TitleId.Length == 0);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(owner, "Look up online",
|
||||
missing.Count == 0
|
||||
? "No title in the library is missing details."
|
||||
: $"{missing.Count:N0} title(s) are missing details, but none can be looked up in bulk.\n\n" +
|
||||
"A bulk lookup goes by Title ID, and none of them has one yet. Run Fix missing details first " +
|
||||
"(it reads the ID from the file or folder name), or open a title, choose Edit details and " +
|
||||
"search by name.");
|
||||
return;
|
||||
}
|
||||
if (!await Dialogs.ConfirmAsync(owner, "Look up online",
|
||||
$"Look up {candidates.Count:N0} title(s) by their Title ID?\n\n" +
|
||||
"The names and content IDs come from a public PS5 title list (a 2 MB file downloaded once from GitHub " +
|
||||
"and kept on this computer). Icons come from the PlayStation Store: only the game's name is sent, one " +
|
||||
"request about every second. Nothing is saved until you confirm the result." +
|
||||
(noId > 0 ? $"\n\n{noId:N0} other title(s) have no Title ID and are skipped." : string.Empty),
|
||||
"Look up", "Cancel"))
|
||||
return;
|
||||
|
||||
var proposals = new List<Proposal>();
|
||||
var unmatched = new List<string>();
|
||||
int done = 0;
|
||||
try
|
||||
{
|
||||
foreach (LibraryItem item in candidates)
|
||||
{
|
||||
Status($"Looking up {item.TitleId}... {++done:N0} of {candidates.Count:N0}");
|
||||
IReadOnlyList<DbTitle> rows = await Ps5TitleDatabase.LookupAsync(item.TitleId,
|
||||
_services.Store.AppDataDirectory);
|
||||
DbTitle? row = rows.FirstOrDefault();
|
||||
if (row is null)
|
||||
{
|
||||
unmatched.Add(item.FileName);
|
||||
continue;
|
||||
}
|
||||
var match = new StoreResult(row.Name, row.ContentId, row.TitleId, row.PublisherId, string.Empty,
|
||||
string.Empty, string.Empty, 1.0);
|
||||
Proposal? proposal = await BuildOnlineProposalAsync(item, match);
|
||||
if (proposal is not null) proposals.Add(proposal);
|
||||
}
|
||||
}
|
||||
catch (OnlineLookupException ex)
|
||||
{
|
||||
Status(ex.Message);
|
||||
await Dialogs.ShowMessageAsync(owner, "Look up online", ex.Message);
|
||||
if (proposals.Count == 0) return;
|
||||
}
|
||||
|
||||
if (proposals.Count == 0)
|
||||
{
|
||||
Status("Nothing new was found online.");
|
||||
await Dialogs.ShowMessageAsync(owner, "Look up online",
|
||||
"Nothing new was found." + (unmatched.Count > 0
|
||||
? $"\n\nNo store entry with the same Title ID was found for:\n{string.Join("\n", unmatched.Take(15))}" : string.Empty));
|
||||
return;
|
||||
}
|
||||
await ReviewSaveAndOfferWriteAsync(owner, "Look up online", proposals,
|
||||
$"{proposals.Count:N0} of {candidates.Count:N0} title(s) were found by Title ID." +
|
||||
(unmatched.Count > 0 ? $" {unmatched.Count:N0} had no entry with the same Title ID." : string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>Turns a confirmed store match into the fields that are still missing for the item.</summary>
|
||||
private static async Task<Proposal?> BuildOnlineProposalAsync(LibraryItem item, StoreResult match)
|
||||
{
|
||||
MetadataOverride? existing = MetadataOverrides.Get(item.Path);
|
||||
var suggestion = new MetadataSuggestion();
|
||||
// The file's real name beats a name guessed from the file name, but never replaces one the user typed.
|
||||
if (item.Game.LocalizedTitles.Count == 0 && string.IsNullOrWhiteSpace(existing?.Title))
|
||||
{
|
||||
suggestion.Title = match.Name;
|
||||
suggestion.Sources["Title"] = "public PS5 title list";
|
||||
}
|
||||
// A content ID the file really has, or one the user typed, stays. The offline guess (IV0000-...) does not.
|
||||
bool generated = existing?.ContentId.StartsWith("IV0000-", StringComparison.OrdinalIgnoreCase) == true;
|
||||
if (string.IsNullOrWhiteSpace(item.Game.ContentId) && (string.IsNullOrWhiteSpace(existing?.ContentId) || generated))
|
||||
{
|
||||
suggestion.ContentId = match.ContentId;
|
||||
suggestion.Sources["Content ID"] = $"public PS5 title list ({new DbTitle(match.TitleId, "", "", match.ContentId, match.ContentId.Length >= 2 ? match.ContentId[..2] : "", "").Region})";
|
||||
}
|
||||
|
||||
byte[]? icon = null;
|
||||
if (existing is not { IconFile.Length: > 0 } && !await HasIconFileAsync(item.Game))
|
||||
{
|
||||
// The title list has no artwork: ask the store for it by the name found. A miss just means no icon.
|
||||
string iconUrl = match.IconUrl;
|
||||
if (iconUrl.Length == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
iconUrl = (await Ps5StoreLookup.FindArtworkAsync(match.Name, match.TitleId))?.IconUrl ?? string.Empty;
|
||||
}
|
||||
catch (OnlineLookupException) { iconUrl = string.Empty; }
|
||||
}
|
||||
byte[]? downloaded = iconUrl.Length == 0 ? null : await Ps5StoreLookup.DownloadImageAsync(iconUrl);
|
||||
if (downloaded is not null)
|
||||
{
|
||||
icon = await IconTools.SquarePngAsync(Ps5ImageData.FromPng(downloaded));
|
||||
if (icon is not null) suggestion.IconSource = "the PlayStation Store";
|
||||
}
|
||||
}
|
||||
return suggestion.HasAnything || icon is not null ? new Proposal(item, suggestion, icon) : null;
|
||||
}
|
||||
|
||||
private static Task<bool> HasIconFileAsync(Ps5GameInfo game) => Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using IReadOnlyGameFileSystem files = GameFileSystem.Open(game);
|
||||
return files.FileExists("sce_sys/icon0.png");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||
NotSupportedException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
private async void OnMoreLookUpOnline(object? sender, RoutedEventArgs e) => await LookUpOnlineAsync();
|
||||
|
||||
/// <summary>Keeps only what is actually new for the item (the suggester already skips what the file has).</summary>
|
||||
private static void Trim(MetadataSuggestion suggestion, LibraryItem item)
|
||||
{
|
||||
MetadataOverride? existing = MetadataOverrides.Get(item.Path);
|
||||
// A value the user already entered by hand always wins.
|
||||
if (!string.IsNullOrWhiteSpace(existing?.Title)) { suggestion.Title = string.Empty; suggestion.Sources.Remove("Title"); }
|
||||
if (!string.IsNullOrWhiteSpace(existing?.TitleId)) { suggestion.TitleId = string.Empty; suggestion.Sources.Remove("Title ID"); }
|
||||
if (!string.IsNullOrWhiteSpace(existing?.ContentId)) { suggestion.ContentId = string.Empty; suggestion.Sources.Remove("Content ID"); }
|
||||
if (!string.IsNullOrWhiteSpace(existing?.Version)) { suggestion.Version = string.Empty; suggestion.Sources.Remove("Version"); }
|
||||
if (!string.IsNullOrWhiteSpace(existing?.Firmware)) { suggestion.Firmware = string.Empty; suggestion.Sources.Remove("Firmware"); }
|
||||
if (existing is { IconFile.Length: > 0 }) suggestion.Icon = null;
|
||||
}
|
||||
|
||||
private static string Review(List<Proposal> proposals)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (Proposal proposal in proposals)
|
||||
{
|
||||
MetadataSuggestion s = proposal.Suggestion;
|
||||
text.AppendLine(proposal.Item.FileName);
|
||||
void Line(string label, string value)
|
||||
{
|
||||
if (value.Length == 0) return;
|
||||
text.AppendLine($" {label,-12} {value} ({s.Sources.GetValueOrDefault(label, "found")})");
|
||||
}
|
||||
Line("Title", s.Title);
|
||||
Line("Title ID", s.TitleId);
|
||||
Line("Content ID", s.ContentId);
|
||||
Line("Version", s.Version);
|
||||
Line("Firmware", s.Firmware);
|
||||
if (proposal.IconPng is not null)
|
||||
text.AppendLine($" {"Icon",-12} " + (s.IconSource.Contains('.') ? $"made from sce_sys/{s.IconSource}" : $"from {s.IconSource}"));
|
||||
text.AppendLine();
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static void SaveProposal(Proposal proposal)
|
||||
{
|
||||
LibraryItem item = proposal.Item;
|
||||
MetadataSuggestion s = proposal.Suggestion;
|
||||
MetadataOverride edit = MetadataOverrides.Get(item.Path) ?? new MetadataOverride();
|
||||
if (s.Title.Length > 0) edit.Title = s.Title;
|
||||
if (s.TitleId.Length > 0) edit.TitleId = s.TitleId;
|
||||
if (s.ContentId.Length > 0) edit.ContentId = s.ContentId;
|
||||
if (s.Version.Length > 0) edit.Version = s.Version;
|
||||
if (s.Firmware.Length > 0) edit.Firmware = s.Firmware;
|
||||
if (proposal.IconPng is not null)
|
||||
{
|
||||
MetadataOverrides.DeleteIcon(edit.IconFile);
|
||||
edit.IconFile = MetadataOverrides.StoreIcon(item.Path, proposal.IconPng);
|
||||
}
|
||||
MetadataOverrides.Set(item.Path, edit);
|
||||
item.ApplyOverride(edit);
|
||||
item.ResetThumbnail();
|
||||
_ = item.Thumbnail;
|
||||
}
|
||||
|
||||
/// <summary>Queues the item's saved details to be written into its file, replacing it in place.</summary>
|
||||
private void QueueFileWrite(LibraryItem item)
|
||||
{
|
||||
MetadataOverride? saved = MetadataOverrides.Get(item.Path);
|
||||
if (saved is null) return;
|
||||
var write = new MetadataEdit
|
||||
{
|
||||
Title = saved.Title, TitleId = saved.TitleId, ContentId = saved.ContentId,
|
||||
Version = saved.Version, Firmware = saved.Firmware,
|
||||
IconPath = MetadataOverrides.IconPath(saved) ?? string.Empty
|
||||
};
|
||||
if (write.IsEmpty) return;
|
||||
var spec = new JobSpec
|
||||
{
|
||||
Kind = JobSpec.EditDetails,
|
||||
Title = item.Title,
|
||||
Source = item.Path,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.SonyPackage,
|
||||
Passcode = _services.Settings.DebugPasscode,
|
||||
Backend = _services.Settings.BuildBackend,
|
||||
Edit = write,
|
||||
Icon = write.IconPath.Length > 0 ? Convert.ToBase64String(File.ReadAllBytes(write.IconPath)) : null
|
||||
};
|
||||
string iconFile = saved.IconFile;
|
||||
QueuedPackageTask task = _services.EnqueueJob(spec, item.Title, "Edit details", item.Format, "In place");
|
||||
task.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (task.Status != PackageTaskStatus.Completed) return;
|
||||
// The file now holds these details, so the library-only copy is no longer needed.
|
||||
MetadataOverrides.Set(item.Path, new MetadataOverride());
|
||||
MetadataOverrides.DeleteIcon(iconFile);
|
||||
_ = RefreshAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnMoreFixMissing(object? sender, RoutedEventArgs e) => await FixMissingDetailsAsync();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.Core.Services;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>The list view's column headers: click to sort, right-click to choose which columns are shown.</summary>
|
||||
public partial class LibraryView
|
||||
{
|
||||
/// <summary>The column's real name. The header text also carries the sort arrow, so the name lives in the tag.</summary>
|
||||
private static string ColumnName(DataGridColumn column) => column.Tag as string ?? column.Header?.ToString() ?? string.Empty;
|
||||
|
||||
private void InitializeColumnHeaders()
|
||||
{
|
||||
foreach (DataGridColumn column in ListGrid.Columns) column.Tag = column.Header?.ToString() ?? string.Empty;
|
||||
ListGrid.Sorting += OnGridSorting;
|
||||
ListGrid.AddHandler(ContextRequestedEvent, OnGridContextRequested, RoutingStrategies.Tunnel);
|
||||
UpdateSortHeaders();
|
||||
}
|
||||
|
||||
/// <summary>A click on a column header sorts by it; a second click reverses the order.</summary>
|
||||
private void OnGridSorting(object? sender, DataGridColumnEventArgs e)
|
||||
{
|
||||
e.Handled = true; // the grid's own sorting is not used: the library sorts its list itself
|
||||
string name = ColumnName(e.Column);
|
||||
if (!SortOptions.Contains(name)) return;
|
||||
_suppress = true;
|
||||
if (SortBox.SelectedItem as string == name)
|
||||
SortDirection.IsChecked = SortDirection.IsChecked != true;
|
||||
else
|
||||
{
|
||||
SortBox.SelectedItem = name;
|
||||
SortDirection.IsChecked = false;
|
||||
}
|
||||
_suppress = false;
|
||||
PersistView();
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
/// <summary>Shows the sort arrow on the column the list is sorted by.</summary>
|
||||
private void UpdateSortHeaders()
|
||||
{
|
||||
string sorted = SortBox.SelectedItem as string ?? "Title";
|
||||
string arrow = SortDirection.IsChecked == true ? " ▼" : " ▲";
|
||||
foreach (DataGridColumn column in ListGrid.Columns)
|
||||
{
|
||||
string name = ColumnName(column);
|
||||
column.Header = name == sorted ? name + arrow : name;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGridContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (e.Source is not Visual source) return;
|
||||
DataGridColumnHeader? header = source.FindAncestorOfType<DataGridColumnHeader>(includeSelf: true);
|
||||
if (header is null) return;
|
||||
e.Handled = true; // not the row menu
|
||||
BuildColumnMenu().Open(header);
|
||||
}
|
||||
|
||||
private ContextMenu BuildColumnMenu()
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
foreach (DataGridColumn column in ListGrid.Columns.OrderBy(column => column.DisplayIndex))
|
||||
{
|
||||
string name = ColumnName(column);
|
||||
var item = new MenuItem
|
||||
{
|
||||
Header = name,
|
||||
ToggleType = MenuItemToggleType.CheckBox,
|
||||
IsChecked = column.IsVisible,
|
||||
IsEnabled = name != "Title", // the title is what identifies a row
|
||||
StaysOpenOnClick = true
|
||||
};
|
||||
item.Click += (_, _) => SetColumnVisible(name, item.IsChecked);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
menu.Items.Add(new Separator());
|
||||
var all = new MenuItem { Header = "Show all columns" };
|
||||
all.Click += (_, _) =>
|
||||
{
|
||||
_services.Settings.LibraryHiddenColumns = [];
|
||||
_services.SaveSettings();
|
||||
ApplyHiddenColumns();
|
||||
};
|
||||
menu.Items.Add(all);
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void SetColumnVisible(string name, bool visible)
|
||||
{
|
||||
List<string> hidden = _services.Settings.LibraryHiddenColumns.ToList();
|
||||
hidden.RemoveAll(existing => existing == name);
|
||||
if (!visible) hidden.Add(name);
|
||||
_services.Settings.LibraryHiddenColumns = hidden;
|
||||
_services.SaveSettings();
|
||||
ApplyHiddenColumns();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.Core.Tasks;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class LibraryView
|
||||
{
|
||||
private sealed record Field(string Label, string Value);
|
||||
|
||||
private sealed class TrophyRow
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
public Bitmap? Icon { get; init; }
|
||||
}
|
||||
|
||||
private readonly List<Bitmap> _detailBitmaps = [];
|
||||
private readonly List<Bitmap> _trophyBitmaps = [];
|
||||
private CancellationTokenSource? _detailsCts;
|
||||
private Ps5GameInfo? _detailGame;
|
||||
private Ps5TrophySet? _trophySet;
|
||||
private Ps5Artwork? _artwork;
|
||||
|
||||
// Below this width the details panel stops taking space and slides over the list instead, so the list
|
||||
// (and its scroll bar) always have room. The header buttons wrap under the title when the list is narrow.
|
||||
private const double DrawerBelowWidth = 1100;
|
||||
private const double DetailsWidth = 470;
|
||||
private const double WrapHeaderBelow = 600;
|
||||
private bool _drawerMode;
|
||||
|
||||
private void UpdateAdaptive()
|
||||
{
|
||||
double width = LibraryRoot.Bounds.Width;
|
||||
if (width <= 0) return;
|
||||
bool drawer = width < DrawerBelowWidth;
|
||||
_drawerMode = drawer;
|
||||
if (drawer)
|
||||
{
|
||||
Grid.SetColumn(DetailHost, 0);
|
||||
Grid.SetColumnSpan(DetailHost, 2);
|
||||
DetailHost.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||
DetailHost.Width = Math.Min(DetailsWidth, Math.Max(320, width - 48));
|
||||
DetailHost.ZIndex = 10;
|
||||
DetailHost.IsVisible = _selection.Count > 0;
|
||||
// Start below the header so its buttons stay reachable while the panel is open.
|
||||
DetailHost.Margin = new Thickness(0, Math.Max(0, HeaderGrid.Bounds.Bottom), 0, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Grid.SetColumn(DetailHost, 1);
|
||||
Grid.SetColumnSpan(DetailHost, 1);
|
||||
DetailHost.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch;
|
||||
DetailHost.Width = DetailsWidth;
|
||||
DetailHost.ZIndex = 0;
|
||||
DetailHost.IsVisible = true;
|
||||
DetailHost.Margin = default;
|
||||
}
|
||||
DetailHost.Classes.Set("drawer", drawer);
|
||||
DetailClose.IsVisible = drawer;
|
||||
|
||||
bool wrap = (drawer ? width : width - DetailsWidth) < WrapHeaderBelow;
|
||||
Grid.SetColumn(HeaderActions, wrap ? 0 : 1);
|
||||
Grid.SetRow(HeaderActions, wrap ? 1 : 0);
|
||||
Grid.SetColumnSpan(HeaderActions, wrap ? 2 : 1);
|
||||
HeaderActions.HorizontalAlignment = wrap
|
||||
? Avalonia.Layout.HorizontalAlignment.Left
|
||||
: Avalonia.Layout.HorizontalAlignment.Right;
|
||||
HeaderActions.Margin = wrap ? new Thickness(0, 12, 0, 0) : default;
|
||||
}
|
||||
|
||||
private void InitializeDetails()
|
||||
{
|
||||
LibraryRoot.SizeChanged += (_, _) => UpdateAdaptive();
|
||||
HeaderGrid.SizeChanged += (_, _) => UpdateAdaptive();
|
||||
DetailClose.Click += (_, _) => ClearSelection();
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key != Avalonia.Input.Key.Escape || !_drawerMode || _selection.Count == 0) return;
|
||||
ClearSelection();
|
||||
e.Handled = true;
|
||||
};
|
||||
ExportTrophiesButton.Click += async (_, _) => await ExportTrophiesAsync();
|
||||
FilesPane.StatusChanged += Status;
|
||||
DetailTabs.SelectionChanged += (_, e) =>
|
||||
{
|
||||
if (e.Source == DetailTabs) UpdateFilesActive();
|
||||
};
|
||||
EditDetailsButton.Click += async (_, _) => await EditSelectedAsync();
|
||||
}
|
||||
|
||||
private async Task ShowSelectionAsync()
|
||||
{
|
||||
_detailsCts?.Cancel();
|
||||
UpdateAdaptive();
|
||||
LibraryItem? item = _selection.FirstOrDefault();
|
||||
ClearDetails();
|
||||
DetailEmpty.IsVisible = item is null;
|
||||
DetailContent.IsVisible = item is not null;
|
||||
if (item is null) return;
|
||||
if (_selection.Count > 1) Status($"{_selection.Count:N0} items selected.");
|
||||
|
||||
Ps5GameInfo game = item.Game;
|
||||
_detailGame = game;
|
||||
DetailTitle.Text = item.Title;
|
||||
DetailSub.Text = string.Join(" · ", new[] { item.TitleId, item.Category, item.Format, item.SizeText }
|
||||
.Where(part => part.Length > 0));
|
||||
IconImage.Source = item.Thumbnail;
|
||||
OverviewList.ItemsSource = BuildOverview(item);
|
||||
RawText.Text = game.RawParamJson;
|
||||
PackageText.Text = game.Package is null ? "Not a package." : LibraryActions.Describe(game.Package);
|
||||
|
||||
_detailsCts = new CancellationTokenSource();
|
||||
CancellationToken token = _detailsCts.Token;
|
||||
try
|
||||
{
|
||||
var artwork = new Progress<Ps5Artwork>(value =>
|
||||
{
|
||||
if (!token.IsCancellationRequested) ShowArtwork(value);
|
||||
});
|
||||
Ps5GameDetails details = await new Ps5DetailsLoader().LoadAsync(game, token, artwork);
|
||||
if (token.IsCancellationRequested) return;
|
||||
ShowArtwork(new Ps5Artwork(details.Icon, details.Background, details.Background1, details.Background2));
|
||||
ShowTrophies(details.TrophySet);
|
||||
FilesPane.Load(game, details.Files);
|
||||
UpdateFilesActive();
|
||||
StatsText.Text = BuildStats(details.Files);
|
||||
ExecutableText.Text = LibraryActions.Describe(details.Executable);
|
||||
ActivitiesText.Text = details.Uds is null ? "No activity data." : LibraryActions.Describe(details.Uds);
|
||||
if (details.Errors.Count > 0)
|
||||
{
|
||||
// Missing or unreadable trophy, activity or executable data is normal for stripped or retail dumps. It
|
||||
// is shown with the title (and logged), not in the status bar, which is for what you just did.
|
||||
Logger.Info($"Details of {item.FileName}: {string.Join("; ", details.Errors)}");
|
||||
List<Field> overview = BuildOverview(item);
|
||||
overview.Add(new Field("Read notes", string.Join("\n", details.Errors)));
|
||||
OverviewList.ItemsSource = overview;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Details", ex);
|
||||
Status("Could not read details: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MemoryTrimmer.Schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisposeAll(List<Bitmap> bitmaps)
|
||||
{
|
||||
foreach (Bitmap bitmap in bitmaps) bitmap.Dispose();
|
||||
bitmaps.Clear();
|
||||
}
|
||||
|
||||
private static Bitmap? Track(Bitmap? bitmap, List<Bitmap> list)
|
||||
{
|
||||
if (bitmap is not null) list.Add(bitmap);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
/// <summary>The file tree is built only while the Files tab is the one on screen.</summary>
|
||||
private void UpdateFilesActive() =>
|
||||
FilesPane.Active = DetailTabs.SelectedItem is TabItem { Header: string header } && header == "Files";
|
||||
|
||||
private void ClearDetails()
|
||||
{
|
||||
DetailTitle.Text = DetailSub.Text = string.Empty;
|
||||
IconImage.Source = null;
|
||||
BannerImage.Source = null;
|
||||
OverviewList.ItemsSource = null;
|
||||
ArtworkPanel.Children.Clear();
|
||||
TrophyList.ItemsSource = null;
|
||||
DisposeAll(_detailBitmaps);
|
||||
DisposeAll(_trophyBitmaps);
|
||||
TrophySummary.Text = string.Empty;
|
||||
FilesPane.Clear();
|
||||
PackageText.Text = ExecutableText.Text = ActivitiesText.Text = StatsText.Text = RawText.Text = string.Empty;
|
||||
_detailGame = null;
|
||||
_trophySet = null;
|
||||
_artwork = null;
|
||||
}
|
||||
|
||||
private static List<Field> BuildOverview(LibraryItem item)
|
||||
{
|
||||
Ps5GameInfo game = item.Game;
|
||||
var fields = new List<Field>();
|
||||
if (item.IsEdited) fields.Add(new Field("Edited", "Some details below were changed by you (Edit details)."));
|
||||
void Add(string label, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) fields.Add(new Field(label, value));
|
||||
}
|
||||
Add("Title", item.Title);
|
||||
Add("Title ID", item.TitleId);
|
||||
Add("Content ID", item.ContentId);
|
||||
Add("Concept ID", item.ConceptId);
|
||||
Add("Category", item.Category);
|
||||
Add("Region", item.Region);
|
||||
Add("Version", item.Version);
|
||||
Add("Master version", item.MasterVersion);
|
||||
Add("Required firmware", item.Firmware);
|
||||
Add("SDK version", item.SdkVersion);
|
||||
Add("DRM", item.Drm);
|
||||
Add("Default language", item.DefaultLanguage);
|
||||
Add("Created", item.Created);
|
||||
Add("Tool version", item.ToolVersion);
|
||||
Add("Format", game.SourceDescription);
|
||||
Add("Size", game.SourceSize > 0 ? LibraryItem.FormatBytes(game.SourceSize) : null);
|
||||
Add("Path", game.RootPath);
|
||||
if (item.Features.Count > 0) Add("Features", string.Join(", ", item.Features));
|
||||
if (game.DataWarnings.Count > 0) Add("Warnings", string.Join("\n", game.DataWarnings));
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string BuildStats(Ps5FileInventory files)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine($"Files {files.FileCount:N0}");
|
||||
text.AppendLine($"Total size {LibraryItem.FormatBytes(files.TotalSize)}");
|
||||
text.AppendLine();
|
||||
text.AppendLine("By type");
|
||||
foreach (var group in files.Files
|
||||
.GroupBy(file => string.IsNullOrEmpty(file.Extension) ? "(none)" : file.Extension.ToLowerInvariant())
|
||||
.Select(group => (Name: group.Key, Count: group.Count(), Size: group.Sum(file => file.Size)))
|
||||
.OrderByDescending(group => group.Size).Take(12))
|
||||
text.AppendLine($" {group.Name,-10} {group.Count,7:N0} files {LibraryItem.FormatBytes(group.Size),12}");
|
||||
text.AppendLine();
|
||||
text.AppendLine("Largest files");
|
||||
foreach (Ps5FileInfo file in files.Files.OrderByDescending(file => file.Size).Take(15))
|
||||
text.AppendLine($" {LibraryItem.FormatBytes(file.Size),10} {file.RelativePath}");
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- artwork and trophies
|
||||
|
||||
private void ShowArtwork(Ps5Artwork art)
|
||||
{
|
||||
_artwork = art;
|
||||
// Artwork can be 4K. Previous bitmaps are released before new ones are made, and everything is shown at
|
||||
// display size, so a big game does not hold hundreds of megabytes of pixels.
|
||||
BannerImage.Source = null;
|
||||
ArtworkPanel.Children.Clear();
|
||||
IconImage.Source = null;
|
||||
DisposeAll(_detailBitmaps);
|
||||
if (MetadataOverrides.IconPath(MetadataOverrides.Get(_detailGame?.RootPath ?? string.Empty)) is { } custom)
|
||||
IconImage.Source = Track(new Bitmap(custom), _detailBitmaps);
|
||||
else if (Track(ToBitmap(art.Icon), _detailBitmaps) is { } icon) IconImage.Source = icon;
|
||||
Ps5ImageData? banner = art.Background ?? art.Background1 ?? art.Background2;
|
||||
if (Track(ToBitmap(banner), _detailBitmaps) is { } bannerBitmap) BannerImage.Source = bannerBitmap;
|
||||
foreach ((string name, string fileName, Ps5ImageData? data) in new[]
|
||||
{
|
||||
("Icon", "icon0.png", art.Icon), ("Background", "pic0.png", art.Background),
|
||||
("Background 1", "pic1.png", art.Background1), ("Background 2", "pic2.png", art.Background2)
|
||||
})
|
||||
{
|
||||
if (Track(ToBitmap(data), _detailBitmaps) is not { } bitmap) continue;
|
||||
var header = new DockPanel();
|
||||
var save = new Button { Content = "Save...", Classes = { "ghost" }, Padding = new Thickness(10, 3) };
|
||||
DockPanel.SetDock(save, Avalonia.Controls.Dock.Right);
|
||||
string captured = fileName;
|
||||
Ps5ImageData image = data!;
|
||||
save.Click += async (_, _) => await SaveArtworkAsync(image, captured);
|
||||
header.Children.Add(save);
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{name} ({bitmap.PixelSize.Width}x{bitmap.PixelSize.Height})",
|
||||
Classes = { "muted" },
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
});
|
||||
ArtworkPanel.Children.Add(header);
|
||||
ArtworkPanel.Children.Add(new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(10),
|
||||
ClipToBounds = true,
|
||||
Child = new Image { Source = bitmap, MaxWidth = 420, Stretch = Avalonia.Media.Stretch.Uniform }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveArtworkAsync(Ps5ImageData data, string fileName)
|
||||
{
|
||||
string? file = await Dialogs.SaveFileAsync(this, "Save artwork", fileName, ".png");
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
if (!data.IsRgba) await File.WriteAllBytesAsync(file, data.Bytes);
|
||||
else
|
||||
{
|
||||
// Converted artwork is shown reduced; read the original again so the saved file is full size.
|
||||
Ps5ImageData source = data;
|
||||
Ps5GameInfo? game = _detailGame;
|
||||
if (game is not null && fileName.StartsWith("pic", StringComparison.OrdinalIgnoreCase) &&
|
||||
int.TryParse(fileName.AsSpan(3, 1), out int index))
|
||||
source = await Task.Run(() => Ps5DetailsLoader.LoadFullBackground(game, index)) ?? data;
|
||||
using Bitmap full = ToBitmap(source, int.MaxValue)!;
|
||||
await using FileStream stream = File.Create(file);
|
||||
full.Save(stream);
|
||||
}
|
||||
Status($"Saved {Path.GetFileName(file)}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Save artwork", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaximumDisplayWidth = 1280;
|
||||
|
||||
private static Bitmap? ToBitmap(Ps5ImageData? data, int maximumWidth = MaximumDisplayWidth)
|
||||
{
|
||||
if (data is null || data.IsEmpty) return null;
|
||||
try
|
||||
{
|
||||
if (!data.IsRgba)
|
||||
{
|
||||
using var encoded = new MemoryStream(data.Bytes, writable: false);
|
||||
return data.Width > maximumWidth
|
||||
? Bitmap.DecodeToWidth(encoded, maximumWidth, BitmapInterpolationMode.MediumQuality)
|
||||
: new Bitmap(encoded);
|
||||
}
|
||||
|
||||
// Average whole blocks of pixels down to display size (a plain copy for small images).
|
||||
int factor = Math.Max(1, (int)Math.Ceiling(data.Width / (double)Math.Max(1, maximumWidth)));
|
||||
int width = data.Width / factor, height = data.Height / factor;
|
||||
var bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96),
|
||||
PixelFormat.Rgba8888, AlphaFormat.Unpremul);
|
||||
using ILockedFramebuffer buffer = bitmap.Lock();
|
||||
if (factor == 1)
|
||||
{
|
||||
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;
|
||||
}
|
||||
byte[] row = new byte[width * 4];
|
||||
int area = factor * factor;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int r = 0, g = 0, b = 0, a = 0;
|
||||
for (int dy = 0; dy < factor; dy++)
|
||||
{
|
||||
int index = ((y * factor + dy) * data.Width + x * factor) * 4;
|
||||
for (int dx = 0; dx < factor; dx++, index += 4)
|
||||
{
|
||||
r += data.Bytes[index]; g += data.Bytes[index + 1];
|
||||
b += data.Bytes[index + 2]; a += data.Bytes[index + 3];
|
||||
}
|
||||
}
|
||||
int target = x * 4;
|
||||
row[target] = (byte)(r / area); row[target + 1] = (byte)(g / area);
|
||||
row[target + 2] = (byte)(b / area); row[target + 3] = (byte)(a / area);
|
||||
}
|
||||
System.Runtime.InteropServices.Marshal.Copy(row, 0, buffer.Address + y * buffer.RowBytes, row.Length);
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowTrophies(Ps5TrophySet? set)
|
||||
{
|
||||
_trophySet = set;
|
||||
TrophyList.ItemsSource = null;
|
||||
DisposeAll(_trophyBitmaps);
|
||||
TrophyList.ItemsSource = set?.Trophies.Select(trophy => new TrophyRow
|
||||
{
|
||||
Name = trophy.Hidden ? trophy.Name + " (hidden)" : trophy.Name,
|
||||
Description = trophy.Description,
|
||||
Grade = trophy.Grade,
|
||||
Icon = Track(ToBitmap(trophy.Icon), _trophyBitmaps)
|
||||
}).ToList();
|
||||
TrophySummary.Text = set is null
|
||||
? "No trophy data."
|
||||
: $"{set.Trophies.Count:N0} trophies" + (set.IntegrityValid ? string.Empty : " · integrity check failed");
|
||||
}
|
||||
|
||||
private async Task ExportTrophiesAsync()
|
||||
{
|
||||
if (_trophySet is null || _trophySet.Trophies.Count == 0) return;
|
||||
string? folder = await Dialogs.PickFolderAsync(this, "Export trophy icons to...");
|
||||
if (folder is null) return;
|
||||
int written = 0;
|
||||
try
|
||||
{
|
||||
foreach (Ps5Trophy trophy in _trophySet.Trophies.Where(trophy => trophy.IconPng is { Length: > 0 }))
|
||||
{
|
||||
await File.WriteAllBytesAsync(Path.Combine(folder, $"TROP{trophy.Id:D3}.png"), trophy.IconPng!);
|
||||
written++;
|
||||
}
|
||||
Status($"Exported {written:N0} trophy icon(s).");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export trophies", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- context menu actions
|
||||
|
||||
private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (_selection.Count == 0) { e.Cancel = true; return; }
|
||||
if (sender is not ContextMenu menu) return;
|
||||
foreach (MenuItem item in menu.Items.OfType<MenuItem>().Where(item => item.Header as string == "Rename"))
|
||||
{
|
||||
item.Items.Clear();
|
||||
foreach (Ps5RenameFormat preset in Ps5RenameFormats.Presets)
|
||||
{
|
||||
string format = preset.Format;
|
||||
var entry = new MenuItem { Header = preset.Label };
|
||||
entry.Click += async (_, _) => await RenameSelectedAsync(format);
|
||||
item.Items.Add(entry);
|
||||
}
|
||||
item.Items.Add(new Separator());
|
||||
var custom = new MenuItem { Header = Ps5RenameFormats.CustomLabel };
|
||||
custom.Click += async (_, _) => await RenameSelectedAsync(LibraryActions.DefaultRenameFormat(_services.Settings));
|
||||
item.Items.Add(custom);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMenuConvert(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selection.FirstOrDefault() is { } item) _services.RequestToolsSource(item.Path);
|
||||
}
|
||||
|
||||
private async void OnMenuEdit(object? sender, RoutedEventArgs e) => await EditSelectedAsync();
|
||||
|
||||
private async void OnMenuSend(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selection.FirstOrDefault() is not { } item) return;
|
||||
if (await SendToConsoleWindow.ShowAsync(Dialogs.OwnerOf(this), item.Path, item.Title))
|
||||
Status($"Queued: send {item.Title} to a console. See the Tasks page.");
|
||||
}
|
||||
|
||||
private async Task EditSelectedAsync()
|
||||
{
|
||||
if (_selection.FirstOrDefault() is not { } item) return;
|
||||
EditOutcome? outcome = await EditDetailsWindow.ShowAsync(Dialogs.OwnerOf(this), item);
|
||||
if (outcome is null) return;
|
||||
if (outcome.Target == EditTarget.Library)
|
||||
{
|
||||
item.ResetThumbnail();
|
||||
_ = item.Thumbnail;
|
||||
AutoSizeColumns();
|
||||
ApplyFilters();
|
||||
await ShowSelectionAsync();
|
||||
Status($"Saved {item.Title} in the library only; {item.FileName} was not changed. " +
|
||||
"Open Edit details and choose \"Write into the file itself\" to update the file.");
|
||||
return;
|
||||
}
|
||||
|
||||
var spec = new JobSpec
|
||||
{
|
||||
Kind = JobSpec.EditDetails,
|
||||
Title = item.Title,
|
||||
Source = item.Path,
|
||||
Output = outcome.Output,
|
||||
FromPackage = item.Game.SourceKind == Ps5SourceKind.SonyPackage,
|
||||
Passcode = _services.Settings.DebugPasscode,
|
||||
Backend = _services.Settings.BuildBackend,
|
||||
Edit = outcome.Edit,
|
||||
Icon = outcome.Edit is { IconPath.Length: > 0 } edit
|
||||
? Convert.ToBase64String(await File.ReadAllBytesAsync(edit.IconPath))
|
||||
: null
|
||||
};
|
||||
QueuedPackageTask task = _services.EnqueueJob(spec, item.Title, "Edit details", item.Format,
|
||||
outcome.Target == EditTarget.InPlace ? "In place" : "New copy");
|
||||
string written = outcome.Output;
|
||||
task.Changed += (_, _) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (task.Status != PackageTaskStatus.Completed) return;
|
||||
if (written.Length > 0) _services.AddManualSource(written);
|
||||
_ = RefreshAsync();
|
||||
});
|
||||
Status("Edit queued. Follow it on the Tasks page.");
|
||||
}
|
||||
|
||||
private void OnMenuReveal(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selection.FirstOrDefault() is { } item) Dialogs.Reveal(item.Path);
|
||||
}
|
||||
|
||||
private async void OnMenuCopyId(object? sender, RoutedEventArgs e) => await CopyAsync(_selection.FirstOrDefault()?.TitleId);
|
||||
private async void OnMenuCopyContent(object? sender, RoutedEventArgs e) => await CopyAsync(_selection.FirstOrDefault()?.ContentId);
|
||||
private async void OnMenuCopyPath(object? sender, RoutedEventArgs e) => await CopyAsync(_selection.FirstOrDefault()?.Path);
|
||||
private async void OnMenuMove(object? sender, RoutedEventArgs e) => await MoveSelectedAsync();
|
||||
private async void OnMenuDelete(object? sender, RoutedEventArgs e) => await DeleteSelectedAsync();
|
||||
|
||||
private async Task CopyAsync(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
await clipboard.SetTextAsync(text);
|
||||
}
|
||||
|
||||
private async Task RenameSelectedAsync(string format) =>
|
||||
await ApplyRenamePlansAsync(LibraryActions.PlanRenames(SelectedGames(), format));
|
||||
|
||||
private async Task RenameByInstallOrderAsync()
|
||||
{
|
||||
List<Ps5GameInfo> packages = SelectedGames().Where(game => game.SourceKind == Ps5SourceKind.SonyPackage).ToList();
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Rename by install order", "Select one or more package (.pkg) files first.");
|
||||
return;
|
||||
}
|
||||
await ApplyRenamePlansAsync(LibraryActions.PlanInstallOrder(packages, LibraryActions.DefaultRenameFormat(_services.Settings)));
|
||||
}
|
||||
|
||||
private async Task ApplyRenamePlansAsync(IReadOnlyList<RenamePlan> plans)
|
||||
{
|
||||
if (plans.Count == 0) return;
|
||||
int moves = plans.Count(plan => plan.Error is null && plan.Target.Length > 0);
|
||||
string header = $"{moves:N0} of {plans.Count:N0} item(s) will be renamed.";
|
||||
if (moves == 0)
|
||||
{
|
||||
await Dialogs.ConfirmTextAsync(Dialogs.OwnerOf(this), "Rename", header, LibraryActions.PreviewText(plans), "", "Close");
|
||||
return;
|
||||
}
|
||||
if (!await Dialogs.ConfirmTextAsync(Dialogs.OwnerOf(this), "Rename", header, LibraryActions.PreviewText(plans), "Rename"))
|
||||
return;
|
||||
int renamed = LibraryActions.ApplyRenames(plans);
|
||||
Status($"Renamed {renamed:N0} item(s).");
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task MoveSelectedAsync()
|
||||
{
|
||||
List<Ps5GameInfo> games = SelectedGames();
|
||||
if (games.Count == 0) return;
|
||||
string? folder = await Dialogs.PickFolderAsync(this, "Move to folder...");
|
||||
if (folder is null) return;
|
||||
if (_services.Settings.ConfirmMove && !await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Move",
|
||||
$"Move {games.Count:N0} item(s) to {folder}?", "Move"))
|
||||
return;
|
||||
Status($"Moving {games.Count:N0} item(s)...");
|
||||
try
|
||||
{
|
||||
int moved = await Task.Run(() => LibraryActions.MoveAll(games, folder, CancellationToken.None));
|
||||
Status($"Moved {moved:N0} item(s) to {folder}.");
|
||||
_services.AddLibraryFolder(folder);
|
||||
await RefreshAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Move", ex);
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Move", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedAsync()
|
||||
{
|
||||
List<Ps5GameInfo> games = SelectedGames();
|
||||
if (games.Count == 0) return;
|
||||
if (!await Dialogs.ConfirmTextAsync(Dialogs.OwnerOf(this), "Delete",
|
||||
$"Permanently delete {games.Count:N0} item(s) from disk? This cannot be undone.",
|
||||
string.Join("\n", games.Select(game => game.RootPath)), "Delete permanently"))
|
||||
return;
|
||||
int deleted = await Task.Run(() => LibraryActions.DeleteAll(games));
|
||||
Status($"Deleted {deleted:N0} item(s).");
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
/// <summary>Writes what is currently shown as a JSON file or a single web page.</summary>
|
||||
private async Task ExportReportAsync(bool html)
|
||||
{
|
||||
List<LibraryItem> items = _shown.ToList();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export", "There is nothing to export. Add a folder or clear the filters first.");
|
||||
return;
|
||||
}
|
||||
bool paths = !await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Include file paths?",
|
||||
"The file paths contain your folder and user names. Leave them out if you plan to share the file.",
|
||||
"Leave paths out", "Include paths");
|
||||
string? file = await Dialogs.SaveFileAsync(this, html ? "Export a web page" : "Export JSON",
|
||||
html ? "ps5-library.html" : "ps5-library.json", html ? ".html" : ".json");
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
string text;
|
||||
if (html)
|
||||
{
|
||||
var icons = new System.Collections.Concurrent.ConcurrentDictionary<string, byte[]>();
|
||||
int done = 0;
|
||||
await Task.Run(() => Parallel.ForEach(items, new ParallelOptions { MaxDegreeOfParallelism = 3 }, item =>
|
||||
{
|
||||
if (LibraryExport.ReadSmallIcon(item) is { } png) icons[item.Path] = png;
|
||||
int count = Interlocked.Increment(ref done);
|
||||
if (count % 10 == 0) Dispatcher.UIThread.Post(() => Status($"Preparing artwork... {count:N0} of {items.Count:N0}"));
|
||||
}));
|
||||
text = LibraryExport.ToHtml(items, paths, icons);
|
||||
}
|
||||
else text = LibraryExport.ToJson(items, paths);
|
||||
await File.WriteAllTextAsync(file, text, new UTF8Encoding(false));
|
||||
Status($"Exported {items.Count:N0} title(s) to {file}.");
|
||||
if (html && await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Export finished", "Open the page now?", "Open", "Close"))
|
||||
Dialogs.OpenPath(file);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
string? file = await Dialogs.SaveFileAsync(this, "Export CSV", "ps5-library.csv", ".csv");
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(file, LibraryActions.ToCsv(_shown), Encoding.UTF8);
|
||||
Status($"Exported {_shown.Count:N0} row(s) to {file}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Packwright.App.Models;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class LibraryView
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the title stored at <paramref name="path"/>: clears the search and filters so it is listed, selects it
|
||||
/// (which opens its details) and scrolls to it. Returns false when the library does not hold it.
|
||||
/// </summary>
|
||||
public async Task<bool> ShowTitleAsync(string path)
|
||||
{
|
||||
if (!_items.Any(candidate => string.Equals(candidate.Path, path, StringComparison.OrdinalIgnoreCase))) return false;
|
||||
ClearFilters();
|
||||
// Let the list rebuild before selecting in it.
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
LibraryItem? item = _items.FirstOrDefault(candidate => string.Equals(candidate.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||
if (item is null) return false;
|
||||
|
||||
if (ListScroll.IsVisible)
|
||||
{
|
||||
ListGrid.SelectedItem = item;
|
||||
ListGrid.ScrollIntoView(item, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (ListBox box in GroupsHost.GetVisualDescendants().OfType<ListBox>())
|
||||
{
|
||||
if (box.ItemsSource is not System.Collections.IEnumerable source || !source.Cast<object>().Contains(item)) continue;
|
||||
box.SelectedItem = item;
|
||||
box.ScrollIntoView(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Shows the title and opens its Edit details window.</summary>
|
||||
public async Task EditTitleAsync(string path)
|
||||
{
|
||||
if (await ShowTitleAsync(path)) await EditSelectedAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:v="using:Packwright.App.Views"
|
||||
x:Class="Packwright.App.Views.LibraryView">
|
||||
<Grid x:Name="LibraryRoot" ColumnDefinitions="*,Auto" ClipToBounds="True">
|
||||
|
||||
<!-- Main column -->
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<!-- Header -->
|
||||
<Grid x:Name="HeaderGrid" Margin="32,26,24,10" ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||
<StackPanel x:Name="HeaderActions" Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,10,0">
|
||||
<RadioButton x:Name="CardsToggle" Classes="chip" GroupName="layout" Padding="7,6" Margin="0" CornerRadius="10"
|
||||
ToolTip.Tip="Cards">
|
||||
<Path Classes="icon" Data="M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z" />
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="ListToggle" Classes="chip" GroupName="layout" Padding="7,6" Margin="0" CornerRadius="10"
|
||||
ToolTip.Tip="List">
|
||||
<Path Classes="icon" Data="M3 14h4v-4H3v4zm0 5h4v-4H3v4zM3 9h4V5H3v4zm5 5h13v-4H8v4zm0 5h13v-4H8v4zM8 5v4h13V5H8z" />
|
||||
</RadioButton>
|
||||
</StackPanel>
|
||||
<Button x:Name="MoreButton" Content="More ▾">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem x:Name="MoreAddItem" Header="Add a package or image file..." />
|
||||
<MenuItem Header="Export what is shown">
|
||||
<MenuItem x:Name="MoreExport" Header="As a CSV spreadsheet..." />
|
||||
<MenuItem x:Name="MoreExportJson" Header="As a JSON file..." />
|
||||
<MenuItem x:Name="MoreExportHtml" Header="As a web page with artwork..." />
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="MoreFixMissing" Header="Fix missing details..." />
|
||||
<MenuItem x:Name="MoreLookUpOnline" Header="Look up missing details online..." />
|
||||
<MenuItem x:Name="MoreDuplicates" Header="Find duplicates" />
|
||||
<MenuItem x:Name="MoreMissingBase" Header="Patches and DLC missing a base game" />
|
||||
<MenuItem x:Name="MoreInstallOrder" Header="Rename packages by install order" />
|
||||
<Separator />
|
||||
<MenuItem x:Name="MoreColumns" Header="Columns (list view)" />
|
||||
<MenuItem x:Name="MoreDeleteView" Header="Delete the current saved view" />
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button x:Name="RefreshButton" Content="Refresh" />
|
||||
<Button x:Name="AddFolderButton" Content="Add folder" Classes="accent" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Library" Classes="title" />
|
||||
<TextBlock x:Name="CountText" Classes="muted" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Filters -->
|
||||
<Border Grid.Row="1" Classes="card" Margin="32,0,24,14" Padding="14">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="SearchBox" Watermark="Search title, ID, path... try id:PPSA01 size:>20GB -dlc" />
|
||||
<Button x:Name="ClearFiltersButton" Grid.Column="1" Content="Reset" Classes="ghost" Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<WrapPanel>
|
||||
<RadioButton x:Name="PresetAll" Classes="chip" GroupName="preset" Content="All" IsChecked="True" Margin="0,0,6,8" />
|
||||
<RadioButton x:Name="PresetGames" Classes="chip" GroupName="preset" Content="Games" Margin="0,0,6,8" />
|
||||
<RadioButton x:Name="PresetUpdates" Classes="chip" GroupName="preset" Content="Updates" Margin="0,0,6,8" />
|
||||
<RadioButton x:Name="PresetDlc" Classes="chip" GroupName="preset" Content="DLC" Margin="0,0,14,8" />
|
||||
<ComboBox x:Name="FormatFilter" Width="136" Margin="0,0,8,8" />
|
||||
<ComboBox x:Name="RegionFilter" Width="136" Margin="0,0,8,8" />
|
||||
<ComboBox x:Name="GroupBox" Width="150" Margin="0,0,8,8" />
|
||||
<ComboBox x:Name="SortBox" Width="124" Margin="0,0,6,8" />
|
||||
<ToggleButton x:Name="SortDirection" Classes="chip" Content="A → Z" MinWidth="64" Margin="0,0,14,8" />
|
||||
<ComboBox x:Name="ViewsBox" Width="150" Margin="0,0,4,8" />
|
||||
<Button x:Name="SaveViewButton" Content="Save view" Classes="ghost" Margin="0,0,0,8" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<Panel Grid.Row="2">
|
||||
<ScrollViewer x:Name="CardsScroll" Margin="32,0,10,0">
|
||||
<ItemsControl x:Name="GroupsHost" Margin="0,0,14,24">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="0,0,0,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="2,4,0,12" IsVisible="{Binding ShowHeader}">
|
||||
<TextBlock Text="{Binding Title}" FontSize="15" FontWeight="SemiBold" />
|
||||
<Border Classes="pill"><TextBlock Text="{Binding Count}" /></Border>
|
||||
</StackPanel>
|
||||
<ListBox Classes="cards" ItemsSource="{Binding Items}" SelectionMode="Multiple"
|
||||
SelectionChanged="OnCardsSelectionChanged" DoubleTapped="OnItemDoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="178">
|
||||
<Border Height="178" CornerRadius="13,13,0,0" ClipToBounds="True" Background="{DynamicResource InputBg}">
|
||||
<Panel>
|
||||
<Path Data="M12 2 3 7v10l9 5 9-5V7l-9-5zm0 2.2L18.6 8 12 11.8 5.4 8 12 4.2zM5 9.7l6 3.4v6.6l-6-3.4V9.7zm14 0v6.6l-6 3.4v-6.6l6-3.4z"
|
||||
Fill="{DynamicResource Muted}" Opacity="0.35" Width="46" Height="46" Stretch="Uniform"
|
||||
IsVisible="{Binding !HasThumbnail}" />
|
||||
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill" />
|
||||
</Panel>
|
||||
</Border>
|
||||
<StackPanel Margin="12,10,12,12" Spacing="5">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" TextWrapping="Wrap" MaxLines="2"
|
||||
TextTrimming="CharacterEllipsis" MinHeight="34" />
|
||||
<TextBlock Text="{Binding Subtitle}" Classes="muted" FontSize="11" TextTrimming="CharacterEllipsis" />
|
||||
<WrapPanel>
|
||||
<Border Classes="pill" Margin="0,2,6,0"><TextBlock Text="{Binding Role}" /></Border>
|
||||
<Border Classes="pill" Margin="0,2,6,0"><TextBlock Text="{Binding Format}" /></Border>
|
||||
<Border Classes="pill" Margin="0,2,6,0" Background="#33F5B642" IsVisible="{Binding HasMissing}"
|
||||
ToolTip.Tip="{Binding MissingText}">
|
||||
<TextBlock Text="Missing details" Foreground="#F5B642" />
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding SizeText}" Classes="muted" FontSize="11" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu Opening="OnContextMenuOpening">
|
||||
<MenuItem Header="Convert / Build in Tools..." Click="OnMenuConvert" />
|
||||
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
||||
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
||||
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
||||
<MenuItem Header="Rename" />
|
||||
<MenuItem Header="Move to folder..." Click="OnMenuMove" />
|
||||
<MenuItem Header="Delete..." Click="OnMenuDelete" />
|
||||
<Separator />
|
||||
<MenuItem Header="Copy Title ID" Click="OnMenuCopyId" />
|
||||
<MenuItem Header="Copy Content ID" Click="OnMenuCopyContent" />
|
||||
<MenuItem Header="Copy path" Click="OnMenuCopyPath" />
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="ListScroll" Margin="24,0,10,0" IsVisible="False" HorizontalScrollBarVisibility="Visible"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<DataGrid x:Name="ListGrid" Margin="0,0,0,16" IsReadOnly="True" AutoGenerateColumns="False"
|
||||
CanUserSortColumns="True" CanUserResizeColumns="True" CanUserReorderColumns="True"
|
||||
SelectionMode="Extended" GridLinesVisibility="Horizontal" RowHeight="52"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Header="Title" Width="320" SortMemberPath="Title">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" Margin="6,0">
|
||||
<Border Width="38" Height="38" CornerRadius="8" ClipToBounds="True" Background="{DynamicResource InputBg}">
|
||||
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding TitleId}" Classes="muted" FontSize="11" IsVisible="{Binding HasTitleId}" />
|
||||
<TextBlock Text="Missing details" Foreground="#F5B642" FontSize="11" IsVisible="{Binding !HasTitleId}"
|
||||
ToolTip.Tip="{Binding MissingText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Role" Binding="{Binding Role}" Width="110" />
|
||||
<DataGridTextColumn Header="Category" Binding="{Binding Category}" Width="100" />
|
||||
<DataGridTextColumn Header="Version" Binding="{Binding Version}" Width="100" />
|
||||
<DataGridTextColumn Header="Region" Binding="{Binding Region}" Width="110" />
|
||||
<DataGridTextColumn Header="Format" Binding="{Binding Format}" Width="110" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText}" Width="100" />
|
||||
<DataGridTextColumn Header="Firmware" Binding="{Binding Firmware}" Width="100" />
|
||||
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" MinWidth="240" />
|
||||
</DataGrid.Columns>
|
||||
<DataGrid.ContextMenu>
|
||||
<ContextMenu Opening="OnContextMenuOpening">
|
||||
<MenuItem Header="Convert / Build in Tools..." Click="OnMenuConvert" />
|
||||
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
||||
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
||||
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
||||
<MenuItem Header="Rename" />
|
||||
<MenuItem Header="Move to folder..." Click="OnMenuMove" />
|
||||
<MenuItem Header="Delete..." Click="OnMenuDelete" />
|
||||
<Separator />
|
||||
<MenuItem Header="Copy Title ID" Click="OnMenuCopyId" />
|
||||
<MenuItem Header="Copy Content ID" Click="OnMenuCopyContent" />
|
||||
<MenuItem Header="Copy path" Click="OnMenuCopyPath" />
|
||||
</ContextMenu>
|
||||
</DataGrid.ContextMenu>
|
||||
</DataGrid>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel x:Name="EmptyState" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10" IsVisible="False">
|
||||
<TextBlock x:Name="EmptyTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyHint" Text="Add a folder with PS5 dumps, images or packages to get started." Classes="muted" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<!-- Details -->
|
||||
<Border x:Name="DetailHost" Grid.Column="1" Width="470" Classes="panel" BorderThickness="1,0,0,0">
|
||||
<Panel>
|
||||
<Button x:Name="DetailClose" ZIndex="5" IsVisible="False" HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Margin="0,10,10,0" Padding="9" CornerRadius="10" Background="#99000000" BorderThickness="0"
|
||||
ToolTip.Tip="Close (Esc)">
|
||||
<Path Data="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
|
||||
Fill="White" Width="12" Height="12" Stretch="Uniform" />
|
||||
</Button>
|
||||
<StackPanel x:Name="DetailEmpty" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8" Width="300">
|
||||
<TextBlock Text="Select a game" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Artwork, trophies, files and raw metadata show up here." Classes="muted"
|
||||
TextWrapping="Wrap" TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid x:Name="DetailContent" RowDefinitions="Auto,*" IsVisible="False">
|
||||
<Grid Height="190">
|
||||
<Image x:Name="BannerImage" Stretch="UniformToFill" Opacity="0.55" />
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#00000000" />
|
||||
<GradientStop Offset="1" Color="{DynamicResource PanelBgColor}" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom" Margin="20,0,20,0" Spacing="16">
|
||||
<Border Width="104" Height="104" CornerRadius="16" ClipToBounds="True" Background="{DynamicResource InputBg}"
|
||||
BorderBrush="{DynamicResource Line}" BorderThickness="1">
|
||||
<Image x:Name="IconImage" Stretch="UniformToFill" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Bottom" Spacing="4" Margin="0,0,0,4" MaxWidth="290">
|
||||
<TextBlock x:Name="DetailTitle" FontSize="19" FontWeight="Bold" TextWrapping="Wrap" MaxLines="2" />
|
||||
<TextBlock x:Name="DetailSub" Classes="muted" TextWrapping="Wrap" />
|
||||
<Button x:Name="EditDetailsButton" HorizontalAlignment="Left" Padding="12,5" Margin="0,4,0,0" CornerRadius="9"
|
||||
Background="{DynamicResource AccentSoft}" BorderBrush="{DynamicResource Accent}" BorderThickness="1"
|
||||
ToolTip.Tip="Fix the title, IDs, firmware or icon">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Path Data="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||
Fill="{DynamicResource Accent}" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Edit details" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TabControl Grid.Row="1" x:Name="DetailTabs" Classes="pills" Margin="16,12,16,12">
|
||||
<TabItem Header="Overview">
|
||||
<ScrollViewer>
|
||||
<ItemsControl x:Name="OverviewList" Margin="2,8,8,8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid ColumnDefinitions="130,*" Margin="0,4">
|
||||
<TextBlock Text="{Binding Label}" Classes="muted" />
|
||||
<SelectableTextBlock Grid.Column="1" Text="{Binding Value}" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Artwork">
|
||||
<ScrollViewer><StackPanel x:Name="ArtworkPanel" Margin="2,8,8,8" Spacing="10" /></ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Trophies">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8" Margin="0,8,0,0">
|
||||
<Button x:Name="ExportTrophiesButton" Content="Export trophy icons..." />
|
||||
<TextBlock x:Name="TrophySummary" VerticalAlignment="Center" Classes="muted" />
|
||||
</StackPanel>
|
||||
<ListBox x:Name="TrophyList">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid ColumnDefinitions="48,*" Margin="0,4">
|
||||
<Image Source="{Binding Icon}" Width="40" Height="40" />
|
||||
<StackPanel Grid.Column="1" Margin="8,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Description}" Classes="muted" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Grade}" Foreground="{DynamicResource Accent}" FontSize="11" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem Header="Files">
|
||||
<v:FilesPanel x:Name="FilesPane" />
|
||||
</TabItem>
|
||||
<TabItem Header="Stats">
|
||||
<TextBox x:Name="StatsText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</TabItem>
|
||||
<TabItem Header="Executable">
|
||||
<TextBox x:Name="ExecutableText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</TabItem>
|
||||
<TabItem Header="Activities">
|
||||
<TextBox x:Name="ActivitiesText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</TabItem>
|
||||
<TabItem Header="Package">
|
||||
<TextBox x:Name="PackageText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</TabItem>
|
||||
<TabItem Header="Raw">
|
||||
<TextBox x:Name="RawText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,616 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Packwright.App.Models;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Models;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class LibraryView : UserControl
|
||||
{
|
||||
private sealed class GroupVm
|
||||
{
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public int Count => Items.Count;
|
||||
public bool ShowHeader { get; init; }
|
||||
public List<LibraryItem> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
private static readonly (string Label, string Key)[] GroupOptions =
|
||||
[
|
||||
("No grouping", ""), ("Family (title)", "family"), ("Title ID", "titleid"), ("Category", "category"),
|
||||
("Region", "region"), ("Format", "source"), ("Firmware", "firmware")
|
||||
];
|
||||
|
||||
private static readonly string[] SortOptions = ["Title", "Title ID", "Role", "Version", "Size", "Category", "Region", "Format", "Firmware", "Path"];
|
||||
private const string AnyFormat = "All formats";
|
||||
private const string AnyRegion = "All regions";
|
||||
private const string NoView = "Saved views";
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private List<LibraryItem> _items = [];
|
||||
private List<LibraryItem> _shown = [];
|
||||
private List<LibraryItem> _selection = [];
|
||||
private CancellationTokenSource? _scanCts;
|
||||
private bool _suppress;
|
||||
private bool _updatingSelection;
|
||||
private string _preset = "all";
|
||||
|
||||
public LibraryView()
|
||||
{
|
||||
InitializeComponent();
|
||||
GroupBox.ItemsSource = GroupOptions.Select(option => option.Label).ToList();
|
||||
SortBox.ItemsSource = SortOptions;
|
||||
_suppress = true;
|
||||
GroupBox.SelectedIndex = Math.Max(0, Array.FindIndex(GroupOptions, option => option.Key == _services.Settings.DefaultGroupBy));
|
||||
SortBox.SelectedItem = SortOptions.Contains(_services.Settings.LibrarySortColumn) ? _services.Settings.LibrarySortColumn : "Title";
|
||||
SortDirection.IsChecked = !_services.Settings.LibrarySortAscending;
|
||||
SortDirection.Content = _services.Settings.LibrarySortAscending ? "A → Z" : "Z → A";
|
||||
SetLayout(_services.Settings.LibraryLayout != "List", persist: false);
|
||||
_suppress = false;
|
||||
|
||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
RefreshButton.Click += async (_, _) => await RefreshAsync();
|
||||
MoreFixMissing.Click += OnMoreFixMissing;
|
||||
MoreLookUpOnline.Click += OnMoreLookUpOnline;
|
||||
SearchBox.TextChanged += (_, _) => ApplyFilters();
|
||||
ClearFiltersButton.Click += (_, _) => ClearFilters();
|
||||
foreach (ComboBox combo in new[] { FormatFilter, RegionFilter, GroupBox, SortBox })
|
||||
combo.SelectionChanged += (_, _) => { if (!_suppress) { PersistView(); ApplyFilters(); } };
|
||||
SortDirection.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
SortDirection.Content = SortDirection.IsChecked == true ? "Z → A" : "A → Z";
|
||||
if (!_suppress) { PersistView(); ApplyFilters(); }
|
||||
};
|
||||
foreach ((RadioButton button, string preset) in new[]
|
||||
{ (PresetAll, "all"), (PresetGames, "game"), (PresetUpdates, "patch"), (PresetDlc, "dlc") })
|
||||
button.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
if (button.IsChecked != true) return;
|
||||
_preset = preset;
|
||||
if (!_suppress) ApplyFilters();
|
||||
};
|
||||
CardsToggle.IsCheckedChanged += (_, _) => { if (CardsToggle.IsChecked == true && !_suppress) SetLayout(true, persist: true); };
|
||||
ListToggle.IsCheckedChanged += (_, _) => { if (ListToggle.IsChecked == true && !_suppress) SetLayout(false, persist: true); };
|
||||
|
||||
ListGrid.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_updatingSelection) return;
|
||||
_selection = ListGrid.SelectedItems.Cast<LibraryItem>().ToList();
|
||||
_ = ShowSelectionAsync();
|
||||
};
|
||||
ListGrid.DoubleTapped += (_, e) =>
|
||||
{
|
||||
if (IsOnItem(e.Source) && _selection.FirstOrDefault() is { } item) Dialogs.Reveal(item.Path);
|
||||
};
|
||||
// A click on empty space (not on a row, header or scroll bar) clears the selection.
|
||||
EventHandler<PointerPressedEventArgs> clearOnEmpty = (_, e) =>
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && e.KeyModifiers == KeyModifiers.None &&
|
||||
!IsOnItem(e.Source) && !IsOn<ScrollBar>(e.Source) && !IsOn<DataGridColumnHeader>(e.Source))
|
||||
ClearSelection();
|
||||
};
|
||||
ListGrid.AddHandler(PointerPressedEvent, clearOnEmpty, RoutingStrategies.Bubble, handledEventsToo: true);
|
||||
CardsScroll.AddHandler(PointerPressedEvent, clearOnEmpty, RoutingStrategies.Bubble, handledEventsToo: true);
|
||||
|
||||
ViewsBox.SelectionChanged += (_, _) => { if (!_suppress) ApplySavedView(); };
|
||||
SaveViewButton.Click += async (_, _) => await SaveViewAsync();
|
||||
MoreAddItem.Click += async (_, _) => await AddItemAsync();
|
||||
MoreExport.Click += async (_, _) => await ExportAsync();
|
||||
MoreExportJson.Click += async (_, _) => await ExportReportAsync(html: false);
|
||||
MoreExportHtml.Click += async (_, _) => await ExportReportAsync(html: true);
|
||||
MoreDuplicates.Click += async (_, _) => await Dialogs.ConfirmTextAsync(Dialogs.OwnerOf(this), "Duplicates",
|
||||
"Exact copies share the same param.json; the second list shares a content ID and version.",
|
||||
LibraryActions.DuplicatesReport(_items), "", "Close");
|
||||
MoreMissingBase.Click += async (_, _) => await Dialogs.ConfirmTextAsync(Dialogs.OwnerOf(this),
|
||||
"Missing base games", "Updates and add-ons without a base game:",
|
||||
LibraryActions.MissingBaseReport(_items), "", "Close");
|
||||
MoreInstallOrder.Click += async (_, _) => await RenameByInstallOrderAsync();
|
||||
MoreColumns.Click += async (_, _) => await ChooseColumnsAsync();
|
||||
MoreDeleteView.Click += (_, _) => DeleteSavedView();
|
||||
|
||||
InitializeDetails();
|
||||
InitializeColumnHeaders();
|
||||
ApplyHiddenColumns();
|
||||
RebuildViewsBox();
|
||||
_services.LibraryChanged += () => Dispatcher.UIThread.Post(Rebuild);
|
||||
_services.SettingsChanged += () => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
SetLayout(_services.Settings.LibraryLayout != "List", persist: false);
|
||||
ApplyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Rebuild();
|
||||
if (_services.Settings.RefreshOnStartup || (_items.Count == 0 && _services.ScanSources.Count > 0))
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private void Status(string text) => StatusChanged?.Invoke(text);
|
||||
|
||||
// ---------------------------------------------------------------- layout and scanning
|
||||
|
||||
private void SetLayout(bool cards, bool persist)
|
||||
{
|
||||
bool wasSuppressed = _suppress;
|
||||
_suppress = true;
|
||||
CardsToggle.IsChecked = cards;
|
||||
ListToggle.IsChecked = !cards;
|
||||
_suppress = wasSuppressed;
|
||||
CardsScroll.IsVisible = cards;
|
||||
ListScroll.IsVisible = !cards;
|
||||
if (persist)
|
||||
{
|
||||
_services.Settings.LibraryLayout = cards ? "Cards" : "List";
|
||||
_services.SaveSettings();
|
||||
ApplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
string? folder = await Dialogs.PickFolderAsync(this, "Select a folder with PS5 dumps, images or packages");
|
||||
if (folder is null) return;
|
||||
_services.AddLibraryFolder(folder);
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
string? file = await Dialogs.PickFileAsync(this, "Add a package or image to the library");
|
||||
if (file is null) return;
|
||||
_services.AddManualSource(file);
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_services.ScanSources.Count == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Library",
|
||||
"Add a folder first. Packwright scans it for dumps, packages and images.");
|
||||
return;
|
||||
}
|
||||
_scanCts?.Cancel();
|
||||
_scanCts = new CancellationTokenSource();
|
||||
CancellationToken token = _scanCts.Token;
|
||||
RefreshButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var progress = new Progress<Ps5ScanProgress>(value =>
|
||||
Status($"Scanning {value.Processed}/{value.Total}: {Path.GetFileName(value.CurrentPath)}"));
|
||||
Ps5ScanResult result = await _services.RefreshLibraryAsync(progress, token);
|
||||
Status($"Library: {result.Games.Count:N0} item(s)" +
|
||||
(result.Errors.Count > 0 ? $", {result.Errors.Count} folder error(s), see Log." : "."));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status("Scan cancelled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Library scan", ex);
|
||||
Status("Scan failed: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RefreshButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filtering, sorting, grouping
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
_items = _services.Games.Select(game => new LibraryItem(game)).ToList();
|
||||
MarkSupersededUpdates();
|
||||
bool wasSuppressed = _suppress;
|
||||
_suppress = true;
|
||||
try
|
||||
{
|
||||
Fill(FormatFilter, AnyFormat, _items.Select(item => item.Format));
|
||||
Fill(RegionFilter, AnyRegion, _items.Select(item => item.Region));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppress = wasSuppressed;
|
||||
}
|
||||
AutoSizeColumns();
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private static double TextWidth(string text, double size, FontWeight weight, FontFamily family) =>
|
||||
new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight,
|
||||
new Typeface(family, FontStyle.Normal, weight), size, Brushes.White).Width;
|
||||
|
||||
/// <summary>
|
||||
/// Sizes every column to its longest value (or its header when that is wider), so nothing is cut off and
|
||||
/// the list scrolls sideways when the window is narrower than the columns together.
|
||||
/// </summary>
|
||||
private void AutoSizeColumns()
|
||||
{
|
||||
FontFamily family = ListGrid.FontFamily;
|
||||
double size = ListGrid.FontSize > 0 ? ListGrid.FontSize : 14;
|
||||
const int Sample = 150; // the longest values by length are enough, and keeps this fast for huge libraries
|
||||
|
||||
double Widest(Func<LibraryItem, string> value, FontWeight weight, double fontSize) => _items
|
||||
.Select(value).Where(text => text.Length > 0).Distinct()
|
||||
.OrderByDescending(text => text.Length).Take(Sample)
|
||||
.Select(text => TextWidth(text, fontSize, weight, family)).DefaultIfEmpty(0).Max();
|
||||
|
||||
var selectors = new Dictionary<string, Func<LibraryItem, string>>
|
||||
{
|
||||
["Role"] = item => item.Role, ["Category"] = item => item.Category, ["Version"] = item => item.Version,
|
||||
["Region"] = item => item.Region, ["Format"] = item => item.Format, ["Size"] = item => item.SizeText,
|
||||
["Firmware"] = item => item.Firmware, ["Path"] = item => item.Path
|
||||
};
|
||||
foreach (DataGridColumn column in ListGrid.Columns)
|
||||
{
|
||||
string header = ColumnName(column);
|
||||
double headerWidth = TextWidth(header, 11, FontWeight.SemiBold, family) + 40;
|
||||
double width;
|
||||
if (header == "Title")
|
||||
{
|
||||
// thumbnail + gaps, then the wider of the title (semi-bold) and the title ID line
|
||||
double text = Math.Max(Widest(item => item.Title, FontWeight.SemiBold, size),
|
||||
Widest(item => item.TitleId, FontWeight.Normal, 11));
|
||||
width = 6 + 38 + 12 + text + 24;
|
||||
}
|
||||
else if (selectors.TryGetValue(header, out Func<LibraryItem, string>? selector))
|
||||
width = Widest(selector, FontWeight.Normal, size) + 36;
|
||||
else
|
||||
continue;
|
||||
column.MinWidth = 60;
|
||||
column.Width = new DataGridLength(Math.Clamp(Math.Max(width, headerWidth), 60, 2400));
|
||||
}
|
||||
UpdateGridWidth();
|
||||
}
|
||||
|
||||
private void MarkSupersededUpdates()
|
||||
{
|
||||
foreach (IGrouping<string, LibraryItem> family in _items
|
||||
.Where(item => item.Category == "Patch" && item.TitleId.Length > 0)
|
||||
.GroupBy(item => item.TitleId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
LibraryItem? newest = family.OrderByDescending(item => VersionKey(item.Version)).FirstOrDefault();
|
||||
foreach (LibraryItem item in family)
|
||||
item.Role = item == newest ? "Update" : "Update (older)";
|
||||
}
|
||||
}
|
||||
|
||||
private static string VersionKey(string version) =>
|
||||
string.Join(".", version.Split('.', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part => int.TryParse(part, out int number) ? number.ToString("D8") : part));
|
||||
|
||||
private static void Fill(ComboBox combo, string any, IEnumerable<string> values)
|
||||
{
|
||||
string? previous = combo.SelectedItem as string;
|
||||
List<string> options = [any];
|
||||
options.AddRange(values.Where(value => value.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase));
|
||||
combo.ItemsSource = options;
|
||||
combo.SelectedItem = previous is not null && options.Contains(previous) ? previous : options[0];
|
||||
}
|
||||
|
||||
private static string? Chosen(ComboBox combo, string any) =>
|
||||
combo.SelectedItem is string value && value != any ? value : null;
|
||||
|
||||
private string GroupKeyName => GroupOptions[Math.Max(0, GroupBox.SelectedIndex)].Key;
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
_suppress = true;
|
||||
SearchBox.Text = string.Empty;
|
||||
FormatFilter.SelectedIndex = RegionFilter.SelectedIndex = 0;
|
||||
PresetAll.IsChecked = true;
|
||||
_preset = "all";
|
||||
ViewsBox.SelectedIndex = 0;
|
||||
_suppress = false;
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
if (_suppress) return;
|
||||
LibraryQuery query = LibraryQuery.Parse(SearchBox.Text);
|
||||
string? format = Chosen(FormatFilter, AnyFormat), region = Chosen(RegionFilter, AnyRegion);
|
||||
IEnumerable<LibraryItem> filtered = _items.Where(item =>
|
||||
(format is null || item.Format == format) &&
|
||||
(region is null || item.Region == region) &&
|
||||
PresetMatches(item) &&
|
||||
query.Matches(item));
|
||||
|
||||
Comparison<LibraryItem> compare = SortComparison(SortBox.SelectedItem as string ?? "Title");
|
||||
bool descending = SortDirection.IsChecked == true;
|
||||
List<LibraryItem> sorted = filtered.ToList();
|
||||
sorted.Sort((a, b) => descending ? compare(b, a) : compare(a, b));
|
||||
|
||||
string groupBy = GroupKeyName;
|
||||
foreach (LibraryItem item in sorted) item.GroupKey = GroupOf(item, groupBy);
|
||||
if (groupBy.Length > 0)
|
||||
{
|
||||
Dictionary<string, string> order = sorted.GroupBy(item => item.GroupKey)
|
||||
.ToDictionary(group => group.Key, group => GroupSortKey(group.ToList(), groupBy));
|
||||
sorted = sorted.OrderBy(item => order[item.GroupKey], StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.GroupKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => groupBy == "family" ? RolePriority(item) : 0)
|
||||
.ThenBy(item => groupBy == "family" ? VersionKey(item.Version) : string.Empty, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
_shown = sorted;
|
||||
|
||||
BuildViews(sorted, groupBy);
|
||||
CountText.Text = _items.Count == 0
|
||||
? "No items yet"
|
||||
: $"{sorted.Count:N0} of {_items.Count:N0} item(s)" + (query.Problems.Count > 0
|
||||
? $" · check the query: {string.Join(", ", query.Problems.Distinct())}: is not a field" : string.Empty);
|
||||
bool empty = sorted.Count == 0;
|
||||
EmptyState.IsVisible = empty;
|
||||
if (empty)
|
||||
{
|
||||
bool none = _items.Count == 0;
|
||||
EmptyTitle.Text = none ? "Your library is empty" : "Nothing matches";
|
||||
EmptyHint.Text = none
|
||||
? "Add a folder with PS5 dumps, images or packages to get started."
|
||||
: "Try a different search or reset the filters.";
|
||||
}
|
||||
UpdateSortHeaders();
|
||||
Status(CountText.Text);
|
||||
}
|
||||
|
||||
private bool PresetMatches(LibraryItem item) => _preset switch
|
||||
{
|
||||
"game" => item.Category == "Game",
|
||||
"patch" => item.Category == "Patch",
|
||||
"dlc" => item.Category is "DLC" or "Add-on",
|
||||
_ => true
|
||||
};
|
||||
|
||||
private static int RolePriority(LibraryItem item) => item.Category switch
|
||||
{
|
||||
"Game" => 0,
|
||||
"Patch" => 1,
|
||||
"DLC" or "Add-on" => 2,
|
||||
"App" => 3,
|
||||
_ => 4
|
||||
};
|
||||
|
||||
private static Comparison<LibraryItem> SortComparison(string column) => column switch
|
||||
{
|
||||
"Title ID" => (a, b) => string.Compare(a.TitleId, b.TitleId, StringComparison.OrdinalIgnoreCase),
|
||||
"Role" => (a, b) => RolePriority(a).CompareTo(RolePriority(b)),
|
||||
"Version" => (a, b) => string.CompareOrdinal(VersionKey(a.Version), VersionKey(b.Version)),
|
||||
"Size" => (a, b) => a.SizeBytes.CompareTo(b.SizeBytes),
|
||||
"Category" => (a, b) => string.Compare(a.Category, b.Category, StringComparison.OrdinalIgnoreCase),
|
||||
"Region" => (a, b) => string.Compare(a.Region, b.Region, StringComparison.OrdinalIgnoreCase),
|
||||
"Format" => (a, b) => string.Compare(a.Format, b.Format, StringComparison.OrdinalIgnoreCase),
|
||||
"Firmware" => (a, b) => Ps5LibraryHealth.CompareVersions(a.Firmware, b.Firmware),
|
||||
"Path" => (a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase),
|
||||
_ => (a, b) => string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
private static string GroupOf(LibraryItem item, string key) => key switch
|
||||
{
|
||||
"family" => item.TitleId.Length > 0 ? item.TitleId : item.ContentId.Length > 0 ? item.ContentId : item.FileName,
|
||||
"titleid" => item.TitleId,
|
||||
"category" => item.Category,
|
||||
"region" => item.Region,
|
||||
"source" => item.Format,
|
||||
"firmware" => item.Firmware,
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
/// <summary>Families order by their base title so related items stay together; others by the key.</summary>
|
||||
private static string GroupSortKey(List<LibraryItem> group, string key)
|
||||
{
|
||||
if (key != "family") return group[0].GroupKey;
|
||||
return (group.FirstOrDefault(item => item.Category == "Game") ?? group[0]).Title;
|
||||
}
|
||||
|
||||
private static string GroupTitle(List<LibraryItem> items, string key)
|
||||
{
|
||||
string groupKey = items[0].GroupKey;
|
||||
if (key == "family")
|
||||
{
|
||||
LibraryItem lead = items.FirstOrDefault(item => item.Category == "Game") ?? items[0];
|
||||
return groupKey.Length > 0 && lead.Title != groupKey ? $"{lead.Title} · {groupKey}" : lead.Title;
|
||||
}
|
||||
return groupKey.Length == 0 ? "Unknown" : groupKey;
|
||||
}
|
||||
|
||||
private void BuildViews(List<LibraryItem> sorted, string groupBy)
|
||||
{
|
||||
_updatingSelection = true;
|
||||
try
|
||||
{
|
||||
if (CardsScroll.IsVisible)
|
||||
{
|
||||
List<GroupVm> groups = groupBy.Length == 0
|
||||
? [new GroupVm { Items = sorted, ShowHeader = false }]
|
||||
: sorted.GroupBy(item => item.GroupKey).Select(group => group.ToList())
|
||||
.Select(list => new GroupVm { Title = GroupTitle(list, groupBy), Items = list, ShowHeader = true }).ToList();
|
||||
GroupsHost.ItemsSource = groups;
|
||||
}
|
||||
else
|
||||
{
|
||||
var view = new DataGridCollectionView(sorted);
|
||||
if (groupBy.Length > 0) view.GroupDescriptions.Add(new DataGridPathGroupDescription(nameof(LibraryItem.GroupKey)));
|
||||
ListGrid.ItemsSource = view;
|
||||
UpdateGridWidth();
|
||||
}
|
||||
_selection = [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingSelection = false;
|
||||
}
|
||||
_ = ShowSelectionAsync();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
private void OnCardsSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_updatingSelection || sender is not ListBox box) return;
|
||||
_updatingSelection = true;
|
||||
try
|
||||
{
|
||||
if (box.SelectedItems?.Count > 0)
|
||||
foreach (ListBox other in GroupsHost.GetVisualDescendants().OfType<ListBox>().Where(other => other != box))
|
||||
other.SelectedItems?.Clear();
|
||||
_selection = GroupsHost.GetVisualDescendants().OfType<ListBox>()
|
||||
.SelectMany(list => list.SelectedItems?.Cast<LibraryItem>() ?? []).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingSelection = false;
|
||||
}
|
||||
_ = ShowSelectionAsync();
|
||||
}
|
||||
|
||||
private void OnItemDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (IsOnItem(e.Source) && _selection.FirstOrDefault() is { } item) Dialogs.Reveal(item.Path);
|
||||
}
|
||||
|
||||
private static bool IsOn<T>(object? source) where T : class
|
||||
{
|
||||
for (Visual? visual = source as Visual; visual is not null; visual = visual.GetVisualParent())
|
||||
if (visual is T) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOnItem(object? source) => IsOn<ListBoxItem>(source) || IsOn<DataGridRow>(source);
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
if (_selection.Count == 0) return;
|
||||
ListGrid.SelectedItems.Clear();
|
||||
foreach (ListBox list in GroupsHost.GetVisualDescendants().OfType<ListBox>())
|
||||
list.SelectedItems?.Clear();
|
||||
}
|
||||
|
||||
private List<Ps5GameInfo> SelectedGames() => _selection.Select(item => item.Game).ToList();
|
||||
|
||||
// ---------------------------------------------------------------- saved views
|
||||
|
||||
private void RebuildViewsBox()
|
||||
{
|
||||
bool wasSuppressed = _suppress;
|
||||
_suppress = true;
|
||||
List<string> names = [NoView];
|
||||
names.AddRange(_services.Settings.SavedViews.Select(view => view.Name));
|
||||
ViewsBox.ItemsSource = names;
|
||||
ViewsBox.SelectedIndex = 0;
|
||||
_suppress = wasSuppressed;
|
||||
}
|
||||
|
||||
private void PersistView()
|
||||
{
|
||||
_services.Settings.DefaultGroupBy = GroupKeyName;
|
||||
_services.Settings.LibrarySortColumn = SortBox.SelectedItem as string ?? "Title";
|
||||
_services.Settings.LibrarySortAscending = SortDirection.IsChecked != true;
|
||||
_services.SaveSettings();
|
||||
}
|
||||
|
||||
private async Task SaveViewAsync()
|
||||
{
|
||||
string? name = await Dialogs.PromptAsync(Dialogs.OwnerOf(this), "Save view", "Name this view:");
|
||||
if (name is null) return;
|
||||
var view = new SavedLibraryView
|
||||
{
|
||||
Name = name,
|
||||
Query = (SearchBox.Text ?? string.Empty) + (_preset == "all" ? string.Empty : "\u0001" + _preset),
|
||||
Formats = Chosen(FormatFilter, AnyFormat) is { } format ? [format] : [],
|
||||
Regions = Chosen(RegionFilter, AnyRegion) is { } region ? [region] : [],
|
||||
GroupBy = GroupKeyName,
|
||||
SortKeys = [$"{SortBox.SelectedItem}:{(SortDirection.IsChecked == true ? "desc" : "asc")}"],
|
||||
HiddenColumns = _services.Settings.LibraryHiddenColumns.ToList()
|
||||
};
|
||||
_services.Settings.SavedViews.RemoveAll(existing => existing.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
_services.Settings.SavedViews.Add(view);
|
||||
_services.SaveSettings();
|
||||
RebuildViewsBox();
|
||||
ViewsBox.SelectedItem = name;
|
||||
Status($"Saved view '{name}'.");
|
||||
}
|
||||
|
||||
private void ApplySavedView()
|
||||
{
|
||||
if (ViewsBox.SelectedIndex <= 0) return;
|
||||
SavedLibraryView? view = _services.Settings.SavedViews.FirstOrDefault(saved => saved.Name == ViewsBox.SelectedItem as string);
|
||||
if (view is null) return;
|
||||
_suppress = true;
|
||||
string[] parts = view.Query.Split('\u0001');
|
||||
SearchBox.Text = parts[0];
|
||||
_preset = parts.Length > 1 ? parts[1] : "all";
|
||||
(_preset switch { "game" => PresetGames, "patch" => PresetUpdates, "dlc" => PresetDlc, _ => PresetAll }).IsChecked = true;
|
||||
FormatFilter.SelectedItem = view.Formats.FirstOrDefault() is { } format && FormatFilter.Items.Contains(format) ? format : AnyFormat;
|
||||
RegionFilter.SelectedItem = view.Regions.FirstOrDefault() is { } region && RegionFilter.Items.Contains(region) ? region : AnyRegion;
|
||||
GroupBox.SelectedIndex = Math.Max(0, Array.FindIndex(GroupOptions, option => option.Key == view.GroupBy));
|
||||
if (view.SortKeys.FirstOrDefault()?.Split(':') is { Length: 2 } sort)
|
||||
{
|
||||
SortBox.SelectedItem = SortOptions.Contains(sort[0]) ? sort[0] : "Title";
|
||||
SortDirection.IsChecked = sort[1] == "desc";
|
||||
}
|
||||
_services.Settings.LibraryHiddenColumns = view.HiddenColumns.ToList();
|
||||
ApplyHiddenColumns();
|
||||
_suppress = false;
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void DeleteSavedView()
|
||||
{
|
||||
if (ViewsBox.SelectedItem is not string name || ViewsBox.SelectedIndex <= 0) return;
|
||||
_services.Settings.SavedViews.RemoveAll(view => view.Name == name);
|
||||
_services.SaveSettings();
|
||||
RebuildViewsBox();
|
||||
Status($"Deleted view '{name}'.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- list columns
|
||||
|
||||
/// <summary>
|
||||
/// Gives the list the width of its visible columns, so a window that is too narrow shows a horizontal scroll
|
||||
/// bar instead of squeezing the columns together.
|
||||
/// </summary>
|
||||
private void UpdateGridWidth()
|
||||
{
|
||||
double total = 0;
|
||||
foreach (DataGridColumn column in ListGrid.Columns.Where(column => column.IsVisible))
|
||||
total += column.Width.IsStar || column.Width.IsAuto ? Math.Max(column.MinWidth, 120) : column.Width.Value;
|
||||
ListGrid.MinWidth = total + 24;
|
||||
}
|
||||
|
||||
private void ApplyHiddenColumns()
|
||||
{
|
||||
foreach (DataGridColumn column in ListGrid.Columns)
|
||||
column.IsVisible = !_services.Settings.LibraryHiddenColumns.Contains(ColumnName(column));
|
||||
UpdateGridWidth();
|
||||
}
|
||||
|
||||
private async Task ChooseColumnsAsync()
|
||||
{
|
||||
List<DataGridColumn> columns = ListGrid.Columns.ToList();
|
||||
bool[]? result = await Dialogs.ChecklistAsync(Dialogs.OwnerOf(this), "Visible columns",
|
||||
columns.Select(column => (ColumnName(column), column.IsVisible)).ToList());
|
||||
if (result is null) return;
|
||||
_services.Settings.LibraryHiddenColumns = columns.Where((_, index) => !result[index])
|
||||
.Select(column => ColumnName(column)).ToList();
|
||||
_services.SaveSettings();
|
||||
ApplyHiddenColumns();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.LogView">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<DockPanel Margin="32,26,32,12">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<CheckBox x:Name="AutoScrollBox" Content="Auto-scroll" IsChecked="True" Margin="0,0,8,0" />
|
||||
<Button x:Name="ClearButton" Content="Clear view" />
|
||||
<Button x:Name="OpenLogButton" Content="Open log folder" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Log" Classes="title" />
|
||||
<TextBlock Text="Live application log. The full file is kept on disk." Classes="muted" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<Border Grid.Row="1" Classes="card" Margin="32,0,32,24" Padding="4">
|
||||
<TextBox x:Name="LogText" IsReadOnly="True" AcceptsReturn="True" BorderThickness="0" Background="Transparent"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace" FontSize="12" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class LogView : UserControl
|
||||
{
|
||||
private const int MaximumLines = 5000;
|
||||
private readonly StringBuilder _text = new();
|
||||
private int _lines;
|
||||
|
||||
public LogView()
|
||||
{
|
||||
InitializeComponent();
|
||||
// Show what was logged before this page existed (startup, earlier sessions), then follow new entries.
|
||||
foreach (string line in Logger.ReadTail(1000))
|
||||
{
|
||||
_text.Append(line).Append('\n');
|
||||
_lines++;
|
||||
}
|
||||
LogText.Text = _text.ToString();
|
||||
LogText.CaretIndex = LogText.Text.Length;
|
||||
Logger.Logged += entry => Dispatcher.UIThread.Post(() => Append(entry));
|
||||
ClearButton.Click += (_, _) => { _text.Clear(); _lines = 0; LogText.Text = string.Empty; };
|
||||
OpenLogButton.Click += (_, _) => Dialogs.OpenPath(Logger.LogDirectory);
|
||||
}
|
||||
|
||||
private void Append(LogEntry entry)
|
||||
{
|
||||
_text.Append(Logger.Format(entry)).Append('\n');
|
||||
if (++_lines > MaximumLines)
|
||||
{
|
||||
int cut = _text.ToString().IndexOf('\n') + 1;
|
||||
_text.Remove(0, cut);
|
||||
_lines--;
|
||||
}
|
||||
LogText.Text = _text.ToString();
|
||||
if (AutoScrollBox.IsChecked == true) LogText.CaretIndex = LogText.Text.Length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:v="using:Packwright.App.Views"
|
||||
x:Class="Packwright.App.Views.MainWindow"
|
||||
Title="Packwright" Width="1360" Height="860" MinWidth="900" MinHeight="620"
|
||||
Icon="avares://Packwright/Assets/icon.png">
|
||||
<Grid x:Name="RootGrid" ColumnDefinitions="236,*">
|
||||
<!-- Sidebar -->
|
||||
<Border x:Name="Sidebar" Classes="panel" BorderThickness="0,0,1,0" Padding="16,18,16,14">
|
||||
<DockPanel LastChildFill="False">
|
||||
<Grid x:Name="BrandRow" DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto" Margin="4,0,0,26">
|
||||
<Image x:Name="BrandLogo" Width="42" Height="42" Source="avares://Packwright/Assets/icon.png" HorizontalAlignment="Left" />
|
||||
<StackPanel x:Name="BrandText" Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="Packwright" FontWeight="Bold" FontSize="15" />
|
||||
<TextBlock x:Name="VersionLabel" Classes="muted" FontSize="11" />
|
||||
</StackPanel>
|
||||
<Button x:Name="ThemeButton" Grid.Column="2" Classes="ghost" Padding="5" VerticalAlignment="Center" CornerRadius="10">
|
||||
<Path x:Name="ThemeIcon" Classes="icon" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top">
|
||||
<TextBlock x:Name="WorkspaceLabel" Text="WORKSPACE" Classes="section" Margin="6,0,0,8" />
|
||||
<RadioButton x:Name="NavLibrary" Classes="nav" GroupName="nav" IsChecked="True">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z" />
|
||||
<TextBlock x:Name="NavLibraryText" Text="Library" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="NavTools" Classes="nav" GroupName="nav">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z" />
|
||||
<TextBlock x:Name="NavToolsText" Text="Tools" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="NavTasks" Classes="nav" GroupName="nav">
|
||||
<Grid x:Name="TasksRow" ColumnDefinitions="Auto,*,Auto">
|
||||
<Path Classes="icon" Data="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
|
||||
<TextBlock x:Name="NavTasksText" Grid.Column="1" Text="Tasks" Margin="12,0,0,0" VerticalAlignment="Center" />
|
||||
<Border x:Name="TaskBadge" Grid.Column="2" Classes="pill" IsVisible="False" Padding="8,1">
|
||||
<TextBlock x:Name="TaskBadgeText" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="NavHealth" Classes="nav" GroupName="nav">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M10.5 13H8v-3h2.5V7.5h3V10H16v3h-2.5v2.5h-3V13zM12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" />
|
||||
<TextBlock x:Name="NavHealthText" Text="Health" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="NavLog" Classes="nav" GroupName="nav">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10zm-2-1h-6v-2h6v2zM7.5 17l-1.41-1.41L8.67 13l-2.58-2.59L7.5 9l4 4-4 4z" />
|
||||
<TextBlock x:Name="NavLogText" Text="Log" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom">
|
||||
<RadioButton x:Name="NavSettings" Classes="nav" GroupName="nav">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z" />
|
||||
<TextBlock x:Name="NavSettingsText" Text="Settings" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
<RadioButton x:Name="NavAbout" Classes="nav" GroupName="nav">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Path Classes="icon" Data="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
|
||||
<TextBlock x:Name="NavAboutText" Text="About" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto">
|
||||
<Panel>
|
||||
<v:LibraryView x:Name="LibraryPane" />
|
||||
<v:ToolsView x:Name="ToolsPane" IsVisible="False" />
|
||||
<v:TasksView x:Name="TasksPane" IsVisible="False" />
|
||||
<v:HealthView x:Name="HealthPane" IsVisible="False" />
|
||||
<v:LogView x:Name="LogPane" IsVisible="False" />
|
||||
<v:SettingsView x:Name="SettingsPane" IsVisible="False" />
|
||||
<v:AboutView x:Name="AboutPane" IsVisible="False" />
|
||||
</Panel>
|
||||
<Border Grid.Row="1" Classes="panel" BorderThickness="0,1,0,0" Padding="18,7">
|
||||
<DockPanel>
|
||||
<Button x:Name="AuthorLink" DockPanel.Dock="Right" Classes="ghost" Padding="8,2" Margin="14,-3,-8,-3"
|
||||
ToolTip.Tip="github.com/itsmemac/Packwright">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="Built with" Classes="muted" FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBlock Text="♥" Foreground="{DynamicResource Danger}" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="by Mac" Classes="muted" FontSize="12" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="UpdateLink" DockPanel.Dock="Right" Classes="ghost" Padding="8,2" Margin="14,-3,0,-3" IsVisible="False">
|
||||
<TextBlock x:Name="UpdateLinkText" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource Accent}" />
|
||||
</Button>
|
||||
<TextBlock x:Name="TaskSummary" DockPanel.Dock="Right" Classes="muted" FontSize="12" />
|
||||
<TextBlock x:Name="StatusText" Text="Ready" Classes="muted" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Tasks;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
private readonly Control[] _pages;
|
||||
private readonly RadioButton[] _nav;
|
||||
|
||||
public MainWindow() : this([]) { }
|
||||
|
||||
public MainWindow(string[] args)
|
||||
{
|
||||
InitializeComponent();
|
||||
AppServices services = AppServices.Instance;
|
||||
VersionLabel.Text = "Version " + (Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0");
|
||||
_pages = [LibraryPane, ToolsPane, TasksPane, HealthPane, LogPane, SettingsPane, AboutPane];
|
||||
_nav = [NavLibrary, NavTools, NavTasks, NavHealth, NavLog, NavSettings, NavAbout];
|
||||
for (int i = 0; i < _nav.Length; i++)
|
||||
{
|
||||
int index = i;
|
||||
_nav[i].IsCheckedChanged += (_, _) => { if (_nav[index].IsChecked == true) ShowPage(index); };
|
||||
}
|
||||
|
||||
InitializeThemeButton(services);
|
||||
RootGrid.SizeChanged += (_, e) => ApplySidebarMode(e.NewSize.Width);
|
||||
RestoreWindowBounds(services);
|
||||
Closing += (_, _) => SaveWindowBounds(services);
|
||||
// Remember maximize as soon as it changes, so it survives even if the app is not closed cleanly.
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == WindowStateProperty && IsLoaded) SaveWindowBounds(services);
|
||||
};
|
||||
// Hand memory back when the user switches to another program.
|
||||
Deactivated += (_, _) => MemoryTrimmer.Schedule(5);
|
||||
|
||||
AuthorLink.Click += (_, _) => Dialogs.OpenPath(AboutView.RepositoryUrl);
|
||||
UpdateService.Changed += () => Dispatcher.UIThread.Post(RefreshUpdateLink);
|
||||
UpdateLink.Click += async (_, _) =>
|
||||
{
|
||||
if (UpdateService.Available is { } release) await UpdateWindow.ShowAsync(this, release);
|
||||
};
|
||||
DragDrop.SetAllowDrop(this, true);
|
||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||
services.ToolsSourceRequested += path =>
|
||||
{
|
||||
NavTools.IsChecked = true;
|
||||
ToolsPane.SetSource(path);
|
||||
};
|
||||
LibraryPane.StatusChanged += text => StatusText.Text = text;
|
||||
HealthPane.StatusChanged += text => StatusText.Text = text;
|
||||
services.ShowTitleRequested += async (path, edit) =>
|
||||
{
|
||||
NavLibrary.IsChecked = true;
|
||||
if (edit) await LibraryPane.EditTitleAsync(path);
|
||||
else await LibraryPane.ShowTitleAsync(path);
|
||||
};
|
||||
ToolsPane.StatusChanged += text => StatusText.Text = text;
|
||||
SettingsPane.StatusChanged += text => StatusText.Text = text;
|
||||
_timer.Tick += (_, _) => UpdateTaskSummary();
|
||||
_timer.Start();
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
if (services.SettingsWarning is { } warning)
|
||||
await Dialogs.ShowMessageAsync(this, "Settings", warning);
|
||||
await LibraryPane.InitializeAsync();
|
||||
MemoryTrimmer.Schedule(6);
|
||||
await OfferUpdateChecksAsync(services);
|
||||
string? opened = args.FirstOrDefault(arg => File.Exists(arg) || Directory.Exists(arg));
|
||||
if (opened is not null) services.RequestToolsSource(opened);
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly string[] ThemeModes = ["Dark", "Light", "System"];
|
||||
private bool? _compactSidebar;
|
||||
|
||||
private static string ThemeMode(string? saved) =>
|
||||
ThemeModes.FirstOrDefault(mode => string.Equals(mode, saved, StringComparison.OrdinalIgnoreCase)) ?? "Dark";
|
||||
|
||||
private void InitializeThemeButton(AppServices services)
|
||||
{
|
||||
UpdateThemeButton(services);
|
||||
ThemeButton.Click += (_, _) =>
|
||||
{
|
||||
string next = ThemeModes[(Array.IndexOf(ThemeModes, ThemeMode(services.Settings.Theme)) + 1) % ThemeModes.Length];
|
||||
services.Settings.Theme = next;
|
||||
SettingsView.ApplyTheme(next);
|
||||
services.SaveSettings();
|
||||
UpdateThemeButton(services);
|
||||
services.NotifySettingsChanged();
|
||||
};
|
||||
// Other places that change the theme (importing settings, for example) keep the button in step.
|
||||
services.SettingsChanged += () => Dispatcher.UIThread.Post(() => UpdateThemeButton(services));
|
||||
}
|
||||
|
||||
private void UpdateThemeButton(AppServices services)
|
||||
{
|
||||
string mode = ThemeMode(services.Settings.Theme);
|
||||
ThemeIcon.Data = Avalonia.Media.Geometry.Parse(mode switch
|
||||
{
|
||||
// Material icons: sun, moon, monitor
|
||||
"Light" => "M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm9-9.95h-2V3.5h2V.55zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41 1.79-1.79zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79-1.4 1.4zM20 10.5v2h3v-2h-3zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2v2.95zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41-1.79 1.8z",
|
||||
"System" => "M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H3V4h18v12z",
|
||||
_ => "M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"
|
||||
});
|
||||
ToolTip.SetTip(ThemeButton, $"Theme: {mode}. Click to switch (Dark, Light, System).");
|
||||
}
|
||||
|
||||
/// <summary>Below about 1100 px the sidebar shrinks to icons so pages keep room to work.</summary>
|
||||
private void ApplySidebarMode(double width)
|
||||
{
|
||||
if (width <= 0) return;
|
||||
bool compact = _compactSidebar == true ? width < 1140 : width < 1100;
|
||||
if (_compactSidebar == compact) return;
|
||||
_compactSidebar = compact;
|
||||
|
||||
RootGrid.ColumnDefinitions[0].Width = new GridLength(compact ? 68 : 236);
|
||||
Sidebar.Padding = compact ? new Thickness(8, 18, 8, 14) : new Thickness(16, 18, 16, 14);
|
||||
BrandText.IsVisible = WorkspaceLabel.IsVisible = !compact;
|
||||
BrandLogo.HorizontalAlignment = compact ? Avalonia.Layout.HorizontalAlignment.Center : Avalonia.Layout.HorizontalAlignment.Left;
|
||||
BrandRow.Margin = compact ? new Thickness(0, 0, 0, 18) : new Thickness(4, 0, 0, 26);
|
||||
BrandRow.ColumnDefinitions = compact
|
||||
? new ColumnDefinitions("*,0,0")
|
||||
: new ColumnDefinitions("Auto,*,Auto");
|
||||
Grid.SetColumn(ThemeButton, compact ? 0 : 2);
|
||||
Grid.SetRow(ThemeButton, compact ? 1 : 0);
|
||||
ThemeButton.HorizontalAlignment = compact ? Avalonia.Layout.HorizontalAlignment.Center : Avalonia.Layout.HorizontalAlignment.Stretch;
|
||||
ThemeButton.Margin = compact ? new Thickness(0, 10, 0, 0) : default;
|
||||
|
||||
(RadioButton Button, TextBlock Label)[] items =
|
||||
[
|
||||
(NavLibrary, NavLibraryText), (NavTools, NavToolsText), (NavTasks, NavTasksText),
|
||||
(NavHealth, NavHealthText), (NavLog, NavLogText), (NavSettings, NavSettingsText), (NavAbout, NavAboutText)
|
||||
];
|
||||
foreach ((RadioButton button, TextBlock label) in items)
|
||||
{
|
||||
label.IsVisible = !compact;
|
||||
ToolTip.SetTip(button, compact ? label.Text : null);
|
||||
if (button.Content is Control content)
|
||||
content.HorizontalAlignment = compact
|
||||
? Avalonia.Layout.HorizontalAlignment.Center
|
||||
: Avalonia.Layout.HorizontalAlignment.Stretch;
|
||||
button.HorizontalContentAlignment = compact
|
||||
? Avalonia.Layout.HorizontalAlignment.Center
|
||||
: Avalonia.Layout.HorizontalAlignment.Left;
|
||||
if (compact) button.Padding = new Thickness(0, 10);
|
||||
else button.ClearValue(Avalonia.Controls.Primitives.TemplatedControl.PaddingProperty);
|
||||
}
|
||||
// The task count badge sits right next to the icon when the label is hidden.
|
||||
TasksRow.ColumnDefinitions = compact ? new ColumnDefinitions("Auto,Auto") : new ColumnDefinitions("Auto,*,Auto");
|
||||
Grid.SetColumn(TaskBadge, compact ? 1 : 2);
|
||||
TaskBadge.Margin = compact ? new Thickness(6, 0, 0, 0) : default;
|
||||
}
|
||||
|
||||
/// <summary>Puts the window back where, and as big as, the user left it.</summary>
|
||||
private void RestoreWindowBounds(AppServices services)
|
||||
{
|
||||
var settings = services.Settings;
|
||||
if (settings.WindowWidth >= MinWidth && settings.WindowHeight >= MinHeight)
|
||||
{
|
||||
Width = settings.WindowWidth;
|
||||
Height = settings.WindowHeight;
|
||||
}
|
||||
if (settings.WindowHasPosition)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
Position = new PixelPoint(settings.WindowX, settings.WindowY);
|
||||
}
|
||||
if (settings.WindowMaximized) WindowState = WindowState.Maximized;
|
||||
// A monitor may have been unplugged since: if the saved spot is off every screen, centre instead.
|
||||
Opened += (_, _) =>
|
||||
{
|
||||
if (WindowState != WindowState.Normal) return;
|
||||
// The display may be smaller than when the size was saved: never open bigger than the screen.
|
||||
if ((Screens.ScreenFromWindow(this) ?? Screens.Primary) is { } current)
|
||||
{
|
||||
// Sizes here are the client area; the title bar and borders come on top (FrameSize).
|
||||
double scale = RenderScaling > 0 ? RenderScaling : 1;
|
||||
Size outer = FrameSize ?? new Size(Bounds.Width, Bounds.Height);
|
||||
double chromeWidth = Math.Max(0, outer.Width - Bounds.Width), chromeHeight = Math.Max(0, outer.Height - Bounds.Height);
|
||||
double maxWidth = current.WorkingArea.Width / scale - chromeWidth;
|
||||
double maxHeight = current.WorkingArea.Height / scale - chromeHeight;
|
||||
if (Bounds.Width > maxWidth) Width = Math.Max(MinWidth, maxWidth);
|
||||
if (Bounds.Height > maxHeight) Height = Math.Max(MinHeight, maxHeight);
|
||||
int frameWidth = (int)((Math.Min(Bounds.Width, maxWidth) + chromeWidth) * scale);
|
||||
int frameHeight = (int)((Math.Min(Bounds.Height, maxHeight) + chromeHeight) * scale);
|
||||
Position = new PixelPoint(
|
||||
Math.Clamp(Position.X, current.WorkingArea.X, Math.Max(current.WorkingArea.X, current.WorkingArea.Right - frameWidth)),
|
||||
Math.Clamp(Position.Y, current.WorkingArea.Y, Math.Max(current.WorkingArea.Y, current.WorkingArea.Bottom - frameHeight)));
|
||||
}
|
||||
if (!settings.WindowHasPosition) return;
|
||||
var frame = new PixelRect(Position, new PixelSize(Math.Max(1, (int)(Bounds.Width * (RenderScaling))),
|
||||
Math.Max(1, (int)(Bounds.Height * RenderScaling))));
|
||||
if (Screens.All.Any(screen => screen.WorkingArea.Intersects(frame))) return;
|
||||
if (Screens.Primary is { } primary)
|
||||
Position = new PixelPoint(
|
||||
primary.WorkingArea.X + Math.Max(0, (primary.WorkingArea.Width - frame.Width) / 2),
|
||||
primary.WorkingArea.Y + Math.Max(0, (primary.WorkingArea.Height - frame.Height) / 2));
|
||||
};
|
||||
}
|
||||
|
||||
private void SaveWindowBounds(AppServices services)
|
||||
{
|
||||
var settings = services.Settings;
|
||||
if (WindowState == WindowState.Minimized) return;
|
||||
settings.WindowMaximized = WindowState == WindowState.Maximized;
|
||||
if (WindowState == WindowState.Normal)
|
||||
{
|
||||
settings.WindowWidth = (int)Math.Round(Bounds.Width);
|
||||
settings.WindowHeight = (int)Math.Round(Bounds.Height);
|
||||
settings.WindowX = Position.X;
|
||||
settings.WindowY = Position.Y;
|
||||
settings.WindowHasPosition = true;
|
||||
}
|
||||
services.SaveSettings();
|
||||
}
|
||||
|
||||
/// <summary>Raises the window (restoring it if minimized) and opens a file the second launch was given.</summary>
|
||||
public void BringToFront(string[] paths)
|
||||
{
|
||||
if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
|
||||
Show();
|
||||
Activate();
|
||||
Topmost = true; // a plain Activate() is ignored by Windows when another app has the focus
|
||||
Topmost = false;
|
||||
string? opened = paths.FirstOrDefault(path => File.Exists(path) || Directory.Exists(path));
|
||||
if (opened is not null) AppServices.Instance.RequestToolsSource(opened);
|
||||
}
|
||||
|
||||
private void RefreshUpdateLink()
|
||||
{
|
||||
UpdateLink.IsVisible = UpdateService.HasVisibleUpdate;
|
||||
if (UpdateService.Available is { } release) UpdateLinkText.Text = $"Update {release.Version} available";
|
||||
}
|
||||
|
||||
/// <summary>Asks once whether to check for updates; after that, checks quietly once a day if the user said yes.</summary>
|
||||
private async Task OfferUpdateChecksAsync(AppServices services)
|
||||
{
|
||||
AppSettings settings = services.Settings;
|
||||
if (!settings.UpdateCheckAsked)
|
||||
{
|
||||
settings.UpdateCheckAsked = true;
|
||||
settings.CheckForUpdates = await Dialogs.ConfirmAsync(this, "Check for updates?",
|
||||
"Packwright can look for new versions on GitHub once a day and tell you when one is out.\n\n" +
|
||||
"It sends nothing but an ordinary web request to github.com (as any website sees your connection). " +
|
||||
"It never installs anything without you pressing a button. You can change this in Settings.",
|
||||
"Yes, check", "No thanks");
|
||||
services.SaveSettings();
|
||||
}
|
||||
if (UpdateService.ShouldCheckOnStartup()) _ = UpdateService.CheckAsync();
|
||||
}
|
||||
|
||||
private void ShowPage(int index)
|
||||
{
|
||||
MemoryTrimmer.Schedule();
|
||||
if (index == Array.IndexOf(_pages, SettingsPane)) SettingsPane.RefreshLayoutChoice();
|
||||
if (index == Array.IndexOf(_pages, HealthPane)) HealthPane.OnShown();
|
||||
for (int i = 0; i < _pages.Length; i++) _pages[i].IsVisible = i == index;
|
||||
}
|
||||
|
||||
private void UpdateTaskSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateTaskSummaryCore();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Packwright.Infrastructure.Logger.Exception("Task summary", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTaskSummaryCore()
|
||||
{
|
||||
IReadOnlyList<QueuedPackageTask> tasks = AppServices.Instance.Queue.Tasks;
|
||||
int running = tasks.Count(task => task.Status == PackageTaskStatus.Running);
|
||||
int queued = tasks.Count(task => task.Status == PackageTaskStatus.Queued);
|
||||
TaskSummary.Text = running + queued == 0 ? string.Empty : $"{running} running, {queued} queued";
|
||||
TaskBadge.IsVisible = running + queued > 0;
|
||||
TaskBadgeText.Text = (running + queued).ToString();
|
||||
}
|
||||
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
IStorageItem? item = e.Data.GetFiles()?.FirstOrDefault();
|
||||
if (item?.TryGetLocalPath() is { } path)
|
||||
AppServices.Instance.RequestToolsSource(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>Sends a game, image or package to a console on the local network, as a background task.</summary>
|
||||
public sealed class SendToConsoleWindow : Window
|
||||
{
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private readonly string _source;
|
||||
private readonly string _title;
|
||||
private readonly bool _isPackage;
|
||||
private readonly ComboBox _console = new() { HorizontalAlignment = HorizontalAlignment.Stretch };
|
||||
private readonly ComboBox _method = new() { HorizontalAlignment = HorizontalAlignment.Stretch };
|
||||
private readonly TextBox _folder = new();
|
||||
private readonly TextBlock _where = new() { Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap };
|
||||
private readonly StackPanel _folderPanel;
|
||||
private readonly Button _send = new() { Content = "Send", MinWidth = 110 };
|
||||
private bool _queued;
|
||||
|
||||
private SendToConsoleWindow(string source, string title)
|
||||
{
|
||||
_source = source;
|
||||
_title = title;
|
||||
_isPackage = File.Exists(source) && source.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase);
|
||||
Title = "Send to console";
|
||||
Width = 560;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
CanResize = false;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
string size = Directory.Exists(source) ? "folder" : Ps5LibraryHealth.FormatBytes(new FileInfo(source).Length);
|
||||
var manage = new Button { Content = "Manage consoles..." };
|
||||
manage.Click += async (_, _) =>
|
||||
{
|
||||
if (await ConsolesWindow.ShowAsync(this)) RefreshConsoles(_console.SelectedItem as string);
|
||||
};
|
||||
DockPanel.SetDock(manage, Dock.Right);
|
||||
manage.Margin = new Thickness(8, 0, 0, 0);
|
||||
_method.ItemsSource = _isPackage
|
||||
? new[] { "Upload the file over FTP", "Install by URL (the console downloads it from this PC)" }
|
||||
: new[] { "Upload over FTP" };
|
||||
_method.SelectedIndex = 0;
|
||||
_console.SelectionChanged += (_, _) => FillFolder();
|
||||
_method.SelectionChanged += (_, _) => UpdateWhere();
|
||||
_folder.TextChanged += (_, _) => UpdateWhere();
|
||||
_send.Classes.Add("accent");
|
||||
_send.Click += async (_, _) => await SendAsync();
|
||||
var cancel = new Button { Content = "Cancel" };
|
||||
cancel.Click += (_, _) => Close();
|
||||
|
||||
_folderPanel = new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children = { new TextBlock { Text = "Folder on the console", Classes = { "h" } }, _folder }
|
||||
};
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(22),
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = _title, FontSize = 18, FontWeight = Avalonia.Media.FontWeight.Bold, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
new TextBlock { Text = $"{source} · {size}", Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap, FontSize = 12 },
|
||||
new StackPanel { Spacing = 4, Children = { new TextBlock { Text = "Console", Classes = { "h" } },
|
||||
new DockPanel { Children = { manage, _console } } } },
|
||||
new StackPanel { Spacing = 4, Children = { new TextBlock { Text = "How", Classes = { "h" } }, _method } },
|
||||
_folderPanel,
|
||||
_where,
|
||||
new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { _send, cancel } }
|
||||
}
|
||||
};
|
||||
RefreshConsoles(null);
|
||||
}
|
||||
|
||||
/// <summary>Opens the dialog; true when a task was queued.</summary>
|
||||
public static async Task<bool> ShowAsync(Window? owner, string source, string title)
|
||||
{
|
||||
var window = new SendToConsoleWindow(source, title);
|
||||
if (owner is not null) await window.ShowDialog(owner);
|
||||
else window.Show();
|
||||
return window._queued;
|
||||
}
|
||||
|
||||
private ConsoleProfile? Chosen => _services.Settings.Consoles.FirstOrDefault(profile => profile.Name == _console.SelectedItem as string);
|
||||
private bool UseInstall => _isPackage && _method.SelectedIndex == 1;
|
||||
|
||||
private void RefreshConsoles(string? select)
|
||||
{
|
||||
List<string> names = _services.Settings.Consoles.Select(profile => profile.Name).ToList();
|
||||
_console.ItemsSource = names.Count > 0 ? names : new List<string> { "(no console yet: use Manage consoles...)" };
|
||||
_console.SelectedIndex = select is not null && names.Contains(select) ? names.IndexOf(select) : 0;
|
||||
_send.IsEnabled = names.Count > 0;
|
||||
FillFolder();
|
||||
}
|
||||
|
||||
private void FillFolder()
|
||||
{
|
||||
_folder.Text = Chosen?.DefaultFolder ?? "/data";
|
||||
UpdateWhere();
|
||||
}
|
||||
|
||||
private void UpdateWhere()
|
||||
{
|
||||
ConsoleProfile? profile = Chosen;
|
||||
_folderPanel.IsVisible = !UseInstall;
|
||||
if (profile is null) { _where.Text = "Add a console first."; return; }
|
||||
_where.Text = UseInstall
|
||||
? $"Packwright will ask {profile.Name} ({profile.Host}) to install this package, and share the file from this PC for the length of the download. Your firewall may ask you to allow it."
|
||||
: $"It will be uploaded to {ConsoleSender.RemotePathFor(_folder.Text ?? profile.DefaultFolder, _source)} on {profile.Name} ({profile.Host}:{profile.FtpPort}).";
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
ConsoleProfile? profile = Chosen;
|
||||
if (profile is null) return;
|
||||
if (string.IsNullOrWhiteSpace(profile.Host))
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(this, "Send to console", "This console has no address yet. Open Manage consoles... and enter it.");
|
||||
return;
|
||||
}
|
||||
if (!await Dialogs.ConfirmAsync(this, "Send to console?", _where.Text ?? string.Empty, "Send", "Cancel")) return;
|
||||
var spec = new JobSpec
|
||||
{
|
||||
Kind = UseInstall ? JobSpec.SendInstall : JobSpec.SendFtp,
|
||||
Source = _source,
|
||||
Title = _title,
|
||||
Console = new ConsoleProfile
|
||||
{
|
||||
Name = profile.Name, Host = profile.Host, FtpPort = profile.FtpPort, DefaultFolder = profile.DefaultFolder,
|
||||
InstallUrl = profile.InstallUrl, InstallBody = profile.InstallBody, InstallContentType = profile.InstallContentType
|
||||
},
|
||||
RemoteFolder = UseInstall ? string.Empty : (_folder.Text ?? string.Empty).Trim()
|
||||
};
|
||||
string name = Path.GetFileName(_source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
_services.EnqueueJob(spec, $"Send {name} to {profile.Name}", "Send", Directory.Exists(_source) ? "dump folder" : Path.GetExtension(_source).TrimStart('.').ToUpperInvariant(),
|
||||
"Console", UseInstall ? "install by URL" : "FTP");
|
||||
_queued = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.SettingsView">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Margin="32,26,32,10" Spacing="4">
|
||||
<TextBlock Text="Settings" Classes="title" />
|
||||
<TextBlock Text="Preferences are saved to your user profile and shared across Windows, Linux and macOS builds." Classes="muted" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="32,8,32,24" Spacing="16" MaxWidth="920" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="LIBRARY" Classes="section" />
|
||||
<ListBox x:Name="FolderList" Height="140" Background="{DynamicResource InputBg}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="AddFolderButton" Content="Add folder..." />
|
||||
<Button x:Name="RemoveFolderButton" Content="Remove selected" />
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="RecursiveBox" Content="Scan sub-folders" />
|
||||
<CheckBox x:Name="RefreshOnStartBox" Content="Refresh the library when the app starts" />
|
||||
<CheckBox x:Name="UpdateCheckBox" Content="Check for new versions once a day (contacts github.com)" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="APPEARANCE" Classes="section" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Library view" Classes="h" />
|
||||
<ComboBox x:Name="ViewBox" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Text="The light, dark and system theme switch is the sun / moon button at the top of the sidebar."
|
||||
Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ThumbnailsBox" Content="Show game artwork in the library" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="OUTPUT AND NAMING" Classes="section" />
|
||||
<TextBlock Text="Default output folder" Classes="h" />
|
||||
<DockPanel>
|
||||
<Button x:Name="BrowseOutputButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
|
||||
<TextBox x:Name="OutputBox" Watermark="Next to the source when empty" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="Custom rename format" Classes="h" Margin="0,6,0,6" />
|
||||
<TextBox x:Name="RenameFormatBox" Watermark="{}{TITLE} [{TITLE_ID}]" />
|
||||
<TextBlock x:Name="RenameTokens" Classes="muted" FontSize="11" TextWrapping="Wrap" />
|
||||
<CheckBox x:Name="OpenOutputBox" Content="Open the output location after a file extraction" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="FILES AND PREVIEW" Classes="section" />
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Largest previewed file (MB)" Classes="h" />
|
||||
<NumericUpDown x:Name="PreviewMbBox" Minimum="1" Maximum="1024" FormatString="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="Hex viewer page (KB)" Classes="h" />
|
||||
<NumericUpDown x:Name="HexPageBox" Minimum="1" Maximum="256" FormatString="0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<CheckBox x:Name="ShowPreviewBox" Content="Show the inline file preview" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="PACKAGE DEFAULTS" Classes="section" />
|
||||
<TextBlock Text="Default passcode" Classes="h" />
|
||||
<TextBox x:Name="PasscodeBox" Watermark="32 characters, blank = all zeros" MaxLength="32" />
|
||||
<TextBlock Text="Preferred builder" Classes="h" Margin="0,6,0,6" />
|
||||
<ComboBox x:Name="BackendBox" MinWidth="320" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="CONSOLES" Classes="section" />
|
||||
<TextBlock Classes="muted" TextWrapping="Wrap"
|
||||
Text="Send games, images and packages to a console on your own network (right-click an item, then Send to console). Packwright only contacts a console when you press Send or Test." />
|
||||
<Button x:Name="ConsolesButton" Content="Manage consoles..." HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="WindowsCard" Classes="card" IsVisible="False">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="WINDOWS INTEGRATION" Classes="section" />
|
||||
<TextBlock Classes="muted" TextWrapping="Wrap"
|
||||
Text="Adds "Open with Packwright" to the Explorer right-click menu for .pkg, .ffpkg, .ffpfsc and .exfat files and for folders. Per-user, no administrator rights needed." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="InstallShellButton" Content="Add to Explorer menu" />
|
||||
<Button x:Name="RemoveShellButton" Content="Remove" />
|
||||
<TextBlock x:Name="ShellStatus" VerticalAlignment="Center" Classes="muted" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="SAFETY" Classes="section" />
|
||||
<CheckBox x:Name="ConfirmDeleteBox" Content="Ask before deleting" />
|
||||
<CheckBox x:Name="ConfirmMoveBox" Content="Ask before moving" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="2" Classes="panel" BorderThickness="0,1,0,0" Padding="32,14">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Button x:Name="SaveButton" Content="Save settings" Classes="accent" MinWidth="140" Padding="18,10" />
|
||||
<Button x:Name="ImportButton" Content="Import..." />
|
||||
<Button x:Name="ExportButton" Content="Export..." />
|
||||
<TextBlock x:Name="SavedText" VerticalAlignment="Center" Classes="muted" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Styling;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Backends;
|
||||
using Packwright.Core.Services;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
private static readonly string[] Layouts = ["Cards", "List"];
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private readonly ObservableCollection<string> _folders = [];
|
||||
private HashSet<string> _manual = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
FolderList.ItemsSource = _folders;
|
||||
ViewBox.ItemsSource = Layouts;
|
||||
BackendBox.ItemsSource = BackendRegistry.All.Select(backend =>
|
||||
backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp
|
||||
? backend.DisplayName + " (Windows only)"
|
||||
: backend.DisplayName).ToList();
|
||||
RenameTokens.Text = "Tokens: " + string.Join(" ", Ps5RenameFormatter.TokenNames);
|
||||
Load();
|
||||
ApplyTheme(_services.Settings.Theme);
|
||||
_services.LibraryChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(Load);
|
||||
AddFolderButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Add a library folder") is { } folder &&
|
||||
!_folders.Contains(folder, StringComparer.OrdinalIgnoreCase))
|
||||
_folders.Add(folder);
|
||||
};
|
||||
RemoveFolderButton.Click += (_, _) =>
|
||||
{
|
||||
if (FolderList.SelectedItem is string folder) _folders.Remove(folder);
|
||||
};
|
||||
BrowseOutputButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Default output folder") is { } folder) OutputBox.Text = folder;
|
||||
};
|
||||
SaveButton.Click += async (_, _) => await SaveAsync();
|
||||
ConsolesButton.Click += async (_, _) => await ConsolesWindow.ShowAsync(Dialogs.OwnerOf(this));
|
||||
WindowsCard.IsVisible = OperatingSystem.IsWindows();
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
UpdateShellStatus();
|
||||
InstallShellButton.Click += (_, _) => ToggleShell(install: true);
|
||||
RemoveShellButton.Click += (_, _) => ToggleShell(install: false);
|
||||
}
|
||||
ExportButton.Click += async (_, _) => await ExportAsync();
|
||||
ImportButton.Click += async (_, _) => await ImportAsync();
|
||||
}
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private void UpdateShellStatus() =>
|
||||
ShellStatus.Text = ShellIntegration.IsInstalled() ? "Installed." : "Not installed.";
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private async void ToggleShell(bool install)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (install) ShellIntegration.Install();
|
||||
else ShellIntegration.Uninstall();
|
||||
UpdateShellStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Shell integration", ex);
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Explorer integration", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The header's Cards/List buttons change this setting, so the page re-reads it when it is opened.</summary>
|
||||
public void RefreshLayoutChoice() =>
|
||||
ViewBox.SelectedItem = Layouts.Contains(_services.Settings.LibraryLayout) ? _services.Settings.LibraryLayout : "Cards";
|
||||
|
||||
public static void ApplyTheme(string? theme)
|
||||
{
|
||||
if (Application.Current is null) return;
|
||||
Application.Current.RequestedThemeVariant = theme switch
|
||||
{
|
||||
"Light" => ThemeVariant.Light,
|
||||
"System" => ThemeVariant.Default,
|
||||
_ => ThemeVariant.Dark
|
||||
};
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
AppSettings settings = _services.Settings;
|
||||
_folders.Clear();
|
||||
_manual = settings.ManualSources.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string folder in settings.LibraryFolders.Concat(settings.ManualSources)) _folders.Add(folder);
|
||||
RecursiveBox.IsChecked = settings.RecursiveScan;
|
||||
RefreshOnStartBox.IsChecked = settings.RefreshOnStartup;
|
||||
UpdateCheckBox.IsChecked = settings.CheckForUpdates;
|
||||
ViewBox.SelectedItem = Layouts.Contains(settings.LibraryLayout) ? settings.LibraryLayout : "Cards";
|
||||
ThumbnailsBox.IsChecked = settings.ShowThumbnails;
|
||||
OutputBox.Text = settings.OutputDirectory;
|
||||
RenameFormatBox.Text = settings.RenameFormat;
|
||||
OpenOutputBox.IsChecked = settings.OpenOutputAfterTask;
|
||||
PreviewMbBox.Value = settings.MaxPreviewMb;
|
||||
HexPageBox.Value = settings.HexPageKb;
|
||||
ShowPreviewBox.IsChecked = settings.ShowFilePreview;
|
||||
PasscodeBox.Text = settings.DebugPasscode;
|
||||
ConfirmDeleteBox.IsChecked = settings.ConfirmDelete;
|
||||
ConfirmMoveBox.IsChecked = settings.ConfirmMove;
|
||||
BackendBox.SelectedIndex = Math.Max(0, BackendRegistry.All.ToList()
|
||||
.FindIndex(backend => backend.Id == Jobs.ResolveBackend(settings.BuildBackend).Id));
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
string passcode = PasscodeBox.Text ?? string.Empty;
|
||||
if (passcode.Length > 0 && passcode.Length != 32)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Settings",
|
||||
"The passcode must be exactly 32 characters, or blank for the default.");
|
||||
return;
|
||||
}
|
||||
AppSettings settings = _services.Settings;
|
||||
// Files and items that were added individually stay manual sources; the rest are library folders.
|
||||
settings.ManualSources = _folders.Where(path => File.Exists(path) || _manual.Contains(path)).ToList();
|
||||
settings.LibraryFolders = _folders.Except(settings.ManualSources, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
settings.RecursiveScan = RecursiveBox.IsChecked == true;
|
||||
settings.RefreshOnStartup = RefreshOnStartBox.IsChecked == true;
|
||||
settings.CheckForUpdates = UpdateCheckBox.IsChecked == true;
|
||||
settings.UpdateCheckAsked = true;
|
||||
settings.LibraryLayout = ViewBox.SelectedItem as string ?? "Cards";
|
||||
settings.ShowThumbnails = ThumbnailsBox.IsChecked == true;
|
||||
settings.OutputDirectory = OutputBox.Text?.Trim() ?? string.Empty;
|
||||
settings.RenameFormat = RenameFormatBox.Text?.Trim() ?? string.Empty;
|
||||
settings.OpenOutputAfterTask = OpenOutputBox.IsChecked == true;
|
||||
settings.MaxPreviewMb = (int)(PreviewMbBox.Value ?? 16);
|
||||
settings.HexPageKb = (int)(HexPageBox.Value ?? 16);
|
||||
settings.ShowFilePreview = ShowPreviewBox.IsChecked == true;
|
||||
settings.DebugPasscode = passcode;
|
||||
settings.ConfirmDelete = ConfirmDeleteBox.IsChecked == true;
|
||||
settings.ConfirmMove = ConfirmMoveBox.IsChecked == true;
|
||||
settings.BuildBackend = BackendRegistry.All[Math.Max(0, BackendBox.SelectedIndex)].Id;
|
||||
_services.ReplaceSettings(settings);
|
||||
ApplyTheme(_services.Settings.Theme);
|
||||
_services.NotifySettingsChanged();
|
||||
SavedText.Text = "Saved.";
|
||||
StatusChanged?.Invoke("Settings saved.");
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
string? file = await Dialogs.SaveFileAsync(this, "Export settings", "packwright-settings.json", ".json");
|
||||
if (file is null) return;
|
||||
// The passcode is left out unless the user chooses to include it.
|
||||
bool includePasscode = !string.IsNullOrEmpty(_services.Settings.DebugPasscode) &&
|
||||
await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Export settings",
|
||||
"Include the saved package passcode in the exported file?", "Include", "Leave out");
|
||||
AppSettings copy = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(_services.Settings)) ?? new();
|
||||
if (!includePasscode) copy.DebugPasscode = string.Empty;
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true }));
|
||||
StatusChanged?.Invoke("Settings exported.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Export settings", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportAsync()
|
||||
{
|
||||
string? file = await Dialogs.PickFileAsync(this, "Import settings", ["*.json"]);
|
||||
if (file is null) return;
|
||||
try
|
||||
{
|
||||
AppSettings? imported = JsonSerializer.Deserialize<AppSettings>(await File.ReadAllTextAsync(file),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (imported is null) throw new InvalidDataException("The file does not contain settings.");
|
||||
if (string.IsNullOrEmpty(imported.DebugPasscode)) imported.DebugPasscode = _services.Settings.DebugPasscode;
|
||||
_services.ReplaceSettings(imported);
|
||||
Load();
|
||||
ApplyTheme(_services.Settings.Theme);
|
||||
_services.NotifySettingsChanged();
|
||||
SavedText.Text = "Imported and saved.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Import settings", "That file could not be imported: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.TasksView">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<DockPanel Margin="32,26,32,12">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<CheckBox x:Name="AutoStartBox" Content="Start automatically" IsChecked="True" Margin="0,0,8,0" />
|
||||
<Button x:Name="RunQueueButton" Content="Run queue" IsVisible="False" />
|
||||
<Button x:Name="CancelButton" Content="Cancel" />
|
||||
<Button x:Name="RetryButton" Content="Retry" />
|
||||
<Button x:Name="OpenButton" Content="Show output" />
|
||||
<Button x:Name="RemoveButton" Content="Remove" />
|
||||
<Button x:Name="ClearButton" Content="Clear completed" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Tasks" Classes="title" />
|
||||
<TextBlock Text="Everything queued runs here, one job at a time." Classes="muted" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
<ListBox x:Name="TaskList" Margin="24,0,24,0" Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Classes="card" Padding="16,14" Margin="8,4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Width="60" Height="60" CornerRadius="12" ClipToBounds="True" Margin="0,0,16,0"
|
||||
VerticalAlignment="Top" Background="{DynamicResource InputBg}">
|
||||
<Panel>
|
||||
<Path Data="M12 2 3 7v10l9 5 9-5V7l-9-5zm0 2.2L18.6 8 12 11.8 5.4 8 12 4.2zM5 9.7l6 3.4v6.6l-6-3.4V9.7zm14 0v6.6l-6 3.4v-6.6l6-3.4z"
|
||||
Fill="{DynamicResource Muted}" Opacity="0.35" Width="26" Height="26" Stretch="Uniform"
|
||||
IsVisible="{Binding !HasIcon}" />
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="3">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="15" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Operation}" Classes="muted" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||
<Grid ColumnDefinitions="44,*" Margin="0,4,0,0">
|
||||
<TextBlock Text="From" Classes="muted" FontSize="11" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Source}" FontSize="11" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Source}" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="44,*" IsVisible="{Binding HasOutput}">
|
||||
<TextBlock Text="To" Classes="muted" FontSize="11" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Output}" FontSize="11" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Output}" />
|
||||
</Grid>
|
||||
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Percent}" Foreground="{Binding BarBrush}"
|
||||
IsIndeterminate="{Binding Indeterminate}" Margin="0,10,0,4" />
|
||||
<TextBlock Text="{Binding Message}" FontSize="11" Classes="muted" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="2" Classes="pill" VerticalAlignment="Top" Background="{Binding StatusBackground}">
|
||||
<TextBlock Text="{Binding StatusText}" Foreground="{Binding StatusForeground}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<StackPanel x:Name="EmptyState" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="6">
|
||||
<TextBlock Text="No tasks yet" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Convert, extract or verify something from the Tools page." Classes="muted" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<Border Grid.Row="2" Classes="panel" BorderThickness="0,1,0,0" Padding="32,10">
|
||||
<TextBlock x:Name="DetailText" TextWrapping="Wrap" Classes="muted" MinHeight="18" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,167 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Infrastructure;
|
||||
using Packwright.Core.Tasks;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class TasksView : UserControl
|
||||
{
|
||||
private sealed class TaskRow
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string Operation { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public bool HasOutput { get; init; }
|
||||
public Bitmap? Icon { get; init; }
|
||||
public bool HasIcon => Icon is not null;
|
||||
public string Route { get; init; } = string.Empty;
|
||||
public string StatusText { get; init; } = string.Empty;
|
||||
public double Percent { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string Output { get; init; } = string.Empty;
|
||||
public string Details { get; init; } = string.Empty;
|
||||
public IBrush BarBrush { get; init; } = Brushes.Transparent;
|
||||
public bool Indeterminate { get; init; }
|
||||
public IBrush StatusBackground { get; init; } = Brushes.Transparent;
|
||||
public IBrush StatusForeground { get; init; } = Brushes.White;
|
||||
}
|
||||
|
||||
private static (IBrush Background, IBrush Foreground) StatusBrushes(PackageTaskStatus status) => status switch
|
||||
{
|
||||
PackageTaskStatus.Completed => (new SolidColorBrush(Color.Parse("#2E3DD68C")), new SolidColorBrush(Color.Parse("#3DD68C"))),
|
||||
PackageTaskStatus.Failed => (new SolidColorBrush(Color.Parse("#2EFF6B7A")), new SolidColorBrush(Color.Parse("#FF6B7A"))),
|
||||
PackageTaskStatus.Cancelled or PackageTaskStatus.Interrupted or PackageTaskStatus.Cancelling =>
|
||||
(new SolidColorBrush(Color.Parse("#2EF5B642")), new SolidColorBrush(Color.Parse("#F5B642"))),
|
||||
PackageTaskStatus.Queued => (new SolidColorBrush(Color.Parse("#26808AA8")), new SolidColorBrush(Color.Parse("#8E94AE"))),
|
||||
_ => (new SolidColorBrush(Color.Parse("#267C8CFF")), new SolidColorBrush(Color.Parse("#7C8CFF")))
|
||||
};
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(400) };
|
||||
private string _signature = string.Empty;
|
||||
private readonly Dictionary<string, Bitmap?> _icons = [];
|
||||
|
||||
private Bitmap? IconFor(string id, JobSpec? spec)
|
||||
{
|
||||
if (_icons.TryGetValue(id, out Bitmap? cached)) return cached;
|
||||
Bitmap? bitmap = null;
|
||||
if (spec?.Icon is { Length: > 0 } encoded)
|
||||
{
|
||||
try { bitmap = new Bitmap(new MemoryStream(Convert.FromBase64String(encoded))); }
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException or IOException) { }
|
||||
}
|
||||
_icons[id] = bitmap;
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public TasksView()
|
||||
{
|
||||
InitializeComponent();
|
||||
_timer.Tick += (_, _) => Refresh();
|
||||
_timer.Start();
|
||||
CancelButton.Click += (_, _) => { if (Selected() is { } row) _services.Queue.Cancel(row.Id); };
|
||||
RetryButton.Click += (_, _) => { if (Selected() is { } row) _services.Queue.Retry(row.Id); };
|
||||
RemoveButton.Click += (_, _) => { if (Selected() is { } row) _services.Queue.Remove(row.Id); Refresh(); };
|
||||
ClearButton.Click += (_, _) => { _services.Queue.ClearCompleted(); Refresh(); };
|
||||
OpenButton.Click += (_, _) =>
|
||||
{
|
||||
if (Selected() is { Output.Length: > 0 } row && (File.Exists(row.Output) || Directory.Exists(row.Output)))
|
||||
Dialogs.Reveal(row.Output);
|
||||
};
|
||||
AutoStartBox.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
bool auto = AutoStartBox.IsChecked == true;
|
||||
_services.Queue.AutoStart = auto;
|
||||
RunQueueButton.IsVisible = !auto;
|
||||
};
|
||||
RunQueueButton.Click += (_, _) => _services.Queue.RunQueue();
|
||||
TaskList.SelectionChanged += (_, _) => DetailText.Text = Selected()?.Details ?? string.Empty;
|
||||
}
|
||||
|
||||
private TaskRow? Selected() => TaskList.SelectedItem as TaskRow;
|
||||
|
||||
private static string StatusOf(PackageTaskStatus status) => status switch
|
||||
{
|
||||
PackageTaskStatus.Queued => "Queued",
|
||||
PackageTaskStatus.Running => "Running",
|
||||
PackageTaskStatus.Cancelling => "Cancelling",
|
||||
PackageTaskStatus.Completed => "Completed",
|
||||
PackageTaskStatus.Failed => "Failed",
|
||||
PackageTaskStatus.Cancelled => "Cancelled",
|
||||
_ => "Interrupted"
|
||||
};
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
try
|
||||
{
|
||||
RefreshCore();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a display problem stop the list from updating on the next tick.
|
||||
Logger.Exception("Tasks page", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshCore()
|
||||
{
|
||||
IReadOnlyList<QueuedPackageTask> tasks = _services.Queue.Tasks;
|
||||
var rows = tasks.Select(task =>
|
||||
{
|
||||
double percent = task.Status == PackageTaskStatus.Completed ? 1d : task.Progress.TaskPercent;
|
||||
string stage = task.IsTerminal ? string.Empty : task.Progress.Stage ?? string.Empty;
|
||||
string current = task.IsTerminal ? string.Empty : task.Progress.CurrentFile ?? string.Empty;
|
||||
string message = string.IsNullOrWhiteSpace(task.Message)
|
||||
? stage
|
||||
: stage.Length > 0 ? stage + ": " + task.Message : task.Message;
|
||||
if (current.Length > 0) message = (message + " " + current).Trim();
|
||||
string details = task.Failure is { } failure
|
||||
? failure.Message
|
||||
: (task.Operation + " " + task.FormatRoute).Trim();
|
||||
(IBrush statusBackground, IBrush statusForeground) = StatusBrushes(task.Status);
|
||||
// Fill the bar in the status colour; animate it until the first real progress arrives.
|
||||
bool starting = task.Status is PackageTaskStatus.Running or PackageTaskStatus.Cancelling && percent <= 0;
|
||||
JobSpec? spec = JobSpec.FromJson(task.PersistencePayload);
|
||||
string route = task.FormatRoute;
|
||||
string operation = route.Length > 0 ? $"{task.Operation} · {route}" : task.Operation;
|
||||
bool hasOutput = task.OutputPath.Length > 0 &&
|
||||
!string.Equals(task.OutputPath, task.SourcePath, StringComparison.OrdinalIgnoreCase);
|
||||
return new TaskRow
|
||||
{
|
||||
Title = string.IsNullOrWhiteSpace(spec?.Title) ? task.DisplayName : spec!.Title,
|
||||
Operation = operation,
|
||||
Source = task.SourcePath,
|
||||
HasOutput = hasOutput,
|
||||
Icon = IconFor(task.Id, spec),
|
||||
BarBrush = statusForeground,
|
||||
Indeterminate = starting,
|
||||
StatusBackground = statusBackground,
|
||||
StatusForeground = statusForeground,
|
||||
Id = task.Id,
|
||||
Name = task.DisplayName,
|
||||
Route = task.FormatRoute,
|
||||
StatusText = task.IsTerminal || task.Status == PackageTaskStatus.Queued
|
||||
? StatusOf(task.Status)
|
||||
: (percent * 100d).ToString("0") + "%",
|
||||
Percent = percent * 100d,
|
||||
Message = message,
|
||||
Output = task.OutputPath,
|
||||
Details = details
|
||||
};
|
||||
}).ToList();
|
||||
EmptyState.IsVisible = rows.Count == 0;
|
||||
string signature = string.Join("|", rows.Select(row => row.Id + row.StatusText + row.Message + row.Percent));
|
||||
if (signature == _signature) return;
|
||||
_signature = signature;
|
||||
string? selectedId = Selected()?.Id;
|
||||
TaskList.ItemsSource = rows;
|
||||
TaskList.SelectedItem = rows.FirstOrDefault(row => row.Id == selectedId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Avalonia.Controls;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Backends;
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>Presets: named sets of conversion and build options that can be applied in one click.</summary>
|
||||
public partial class ToolsView
|
||||
{
|
||||
private const string NoPreset = "Choose a preset...";
|
||||
|
||||
internal static readonly ToolPreset[] BuiltInPresets =
|
||||
[
|
||||
new() { Name = "Smallest FFPFSC (level 9)", Target = nameof(Target.Ffpfsc), Level = 9, Gain = 1 },
|
||||
new() { Name = "Fast FFPFSC (level 3)", Target = nameof(Target.Ffpfsc), Level = 3, Gain = 5 },
|
||||
new() { Name = "exFAT image with AMPR index", Target = nameof(Target.Exfat), GenerateAmpr = true },
|
||||
new() { Name = "Debug package, reproducible", Target = nameof(Target.Fpkg), Deterministic = true }
|
||||
];
|
||||
|
||||
private Target? _wantedTarget;
|
||||
private List<ToolPreset> _presetList = [];
|
||||
|
||||
private void InitializePresets()
|
||||
{
|
||||
PresetBox.SelectionChanged += (_, _) => { if (!_suppress) OnPresetChosen(); };
|
||||
PresetSaveButton.Click += async (_, _) => await SavePresetAsync();
|
||||
PresetDeleteButton.Click += async (_, _) => await DeletePresetAsync();
|
||||
RebuildPresets(null);
|
||||
}
|
||||
|
||||
private void RebuildPresets(string? select)
|
||||
{
|
||||
bool wasSuppressed = _suppress;
|
||||
_suppress = true;
|
||||
_presetList = [.. BuiltInPresets, .. _services.Settings.Presets];
|
||||
var names = new List<string> { NoPreset };
|
||||
names.AddRange(BuiltInPresets.Select(preset => preset.Name));
|
||||
names.AddRange(_services.Settings.Presets.Select(preset => preset.Name + " (yours)"));
|
||||
PresetBox.ItemsSource = names;
|
||||
int index = select is null ? 0 : _presetList.FindIndex(preset => preset.Name == select) + 1;
|
||||
PresetBox.SelectedIndex = Math.Max(0, index);
|
||||
PresetDeleteButton.IsEnabled = PresetBox.SelectedIndex > BuiltInPresets.Length;
|
||||
_suppress = wasSuppressed;
|
||||
}
|
||||
|
||||
private ToolPreset? ChosenPreset =>
|
||||
PresetBox.SelectedIndex > 0 && PresetBox.SelectedIndex <= _presetList.Count ? _presetList[PresetBox.SelectedIndex - 1] : null;
|
||||
|
||||
private void OnPresetChosen()
|
||||
{
|
||||
PresetDeleteButton.IsEnabled = PresetBox.SelectedIndex > BuiltInPresets.Length;
|
||||
if (ChosenPreset is not { } preset) return;
|
||||
ApplyPreset(preset);
|
||||
ResultText.Text = $"Applied the preset '{preset.Name}'.";
|
||||
}
|
||||
|
||||
private ToolPreset CapturePreset(string name)
|
||||
{
|
||||
int krakenIndex = Math.Max(0, KrakenLevelBox.SelectedIndex);
|
||||
return new ToolPreset
|
||||
{
|
||||
Name = name,
|
||||
Target = CurrentTarget?.ToString() ?? string.Empty,
|
||||
ClusterIndex = Math.Max(0, ClusterBox.SelectedIndex),
|
||||
GenerateAmpr = AmprBox.IsChecked == true,
|
||||
Level = (int)(LevelBox.Value ?? 7),
|
||||
Gain = (int)(GainBox.Value ?? 1),
|
||||
BlockIndex = Math.Max(0, BlockBox.SelectedIndex),
|
||||
FragmentIndex = Math.Max(0, FragmentBox.SelectedIndex),
|
||||
DensityIndex = Math.Max(0, DensityBox.SelectedIndex),
|
||||
MinFree = (int)(MinFreeBox.Value ?? 0),
|
||||
Backend = BackendRegistry.All.ElementAtOrDefault(Math.Max(0, BackendBox.SelectedIndex))?.Id ?? string.Empty,
|
||||
CompressionIndex = Math.Max(0, CompressionBox.SelectedIndex),
|
||||
KrakenLevel = KrakenLevels[krakenIndex].Value,
|
||||
KrakenThreads = (int)(KrakenThreadsBox.Value ?? 0),
|
||||
PlayGoChunks = (int)(PlayGoBox.Value ?? 1),
|
||||
Deterministic = DeterministicBox.IsChecked == true,
|
||||
FakeSign = FakeSignBox.IsChecked == true,
|
||||
RightSprx = RightSprxBox.IsChecked == true,
|
||||
DrmIndex = Math.Max(0, DrmBox.SelectedIndex),
|
||||
Sdk = SdkBox.SelectedIndex > 0 ? SdkBox.SelectedItem as string ?? string.Empty : string.Empty,
|
||||
TempDirectory = TempBox.Text?.Trim() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static void Select(ComboBox box, int index) =>
|
||||
box.SelectedIndex = box.Items.Count == 0 ? -1 : Math.Clamp(index, 0, box.Items.Count - 1);
|
||||
|
||||
private void ApplyPreset(ToolPreset preset)
|
||||
{
|
||||
bool wasSuppressed = _suppress;
|
||||
_suppress = true;
|
||||
Select(ClusterBox, preset.ClusterIndex);
|
||||
AmprBox.IsChecked = preset.GenerateAmpr;
|
||||
LevelBox.Value = Math.Clamp(preset.Level, 1, 9);
|
||||
GainBox.Value = Math.Clamp(preset.Gain, 0, 100);
|
||||
Select(BlockBox, preset.BlockIndex);
|
||||
Select(FragmentBox, preset.FragmentIndex);
|
||||
Select(DensityBox, preset.DensityIndex);
|
||||
MinFreeBox.Value = Math.Clamp(preset.MinFree, 0, 50);
|
||||
int backend = BackendRegistry.All.ToList().FindIndex(item => item.Id == preset.Backend);
|
||||
if (backend >= 0) BackendBox.SelectedIndex = backend;
|
||||
Select(CompressionBox, preset.CompressionIndex);
|
||||
int kraken = Array.FindIndex(KrakenLevels, level => level.Value == preset.KrakenLevel);
|
||||
KrakenLevelBox.SelectedIndex = kraken >= 0 ? kraken : Array.FindIndex(KrakenLevels, level => level.Value == 7);
|
||||
KrakenThreadsBox.Value = Math.Clamp(preset.KrakenThreads, 0, 64);
|
||||
PlayGoBox.Value = Math.Clamp(preset.PlayGoChunks, 1, 64);
|
||||
DeterministicBox.IsChecked = preset.Deterministic;
|
||||
FakeSignBox.IsChecked = preset.FakeSign;
|
||||
RightSprxBox.IsChecked = preset.RightSprx;
|
||||
Select(DrmBox, preset.DrmIndex);
|
||||
int sdk = preset.Sdk.Length == 0 ? 0 : SdkBox.Items.OfType<string>().ToList().IndexOf(preset.Sdk);
|
||||
SdkBox.SelectedIndex = Math.Max(0, sdk);
|
||||
TempBox.Text = preset.TempDirectory.Length > 0 && Directory.Exists(preset.TempDirectory) ? preset.TempDirectory : string.Empty;
|
||||
_suppress = wasSuppressed;
|
||||
|
||||
if (Enum.TryParse(preset.Target, out Target target))
|
||||
{
|
||||
_wantedTarget = target;
|
||||
if (ActionBox.ItemsSource is IEnumerable<string> actions && actions.Contains(ActionConvert))
|
||||
{
|
||||
if (CurrentAction != ActionConvert) ActionBox.SelectedItem = ActionConvert;
|
||||
else UpdateTargets();
|
||||
}
|
||||
else if (_source is not null)
|
||||
ResultText.Text = $"The options were applied, but this source cannot be converted to {TargetLabel(target)}.";
|
||||
}
|
||||
else
|
||||
UpdateOptions();
|
||||
}
|
||||
|
||||
private async Task SavePresetAsync()
|
||||
{
|
||||
string? name = await Dialogs.PromptAsync(Dialogs.OwnerOf(this), "Save preset",
|
||||
"Name this preset (it saves the target and every option on this page, not the source or output):");
|
||||
if (name is null) return;
|
||||
if (BuiltInPresets.Any(preset => preset.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Save preset", "That name belongs to a built-in preset. Choose another.");
|
||||
return;
|
||||
}
|
||||
List<ToolPreset> mine = _services.Settings.Presets;
|
||||
if (mine.Any(preset => preset.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) &&
|
||||
!await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Replace preset?", $"A preset named '{name}' already exists. Replace it?", "Replace"))
|
||||
return;
|
||||
mine.RemoveAll(preset => preset.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
mine.Add(CapturePreset(name));
|
||||
_services.SaveSettings();
|
||||
RebuildPresets(name);
|
||||
ResultText.Text = $"Saved the preset '{name}'.";
|
||||
}
|
||||
|
||||
private async Task DeletePresetAsync()
|
||||
{
|
||||
if (ChosenPreset is not { } preset || PresetBox.SelectedIndex <= BuiltInPresets.Length) return;
|
||||
if (!await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Delete preset?", $"Delete the preset '{preset.Name}'?", "Delete")) return;
|
||||
_services.Settings.Presets.RemoveAll(item => item.Name == preset.Name);
|
||||
_services.SaveSettings();
|
||||
RebuildPresets(null);
|
||||
ResultText.Text = $"Deleted the preset '{preset.Name}'.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Packwright.App.Views.ToolsView">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Margin="32,26,32,8" Spacing="4">
|
||||
<TextBlock Text="Image tools" Classes="title" />
|
||||
<TextBlock Classes="muted" TextWrapping="Wrap"
|
||||
Text="Convert, extract, verify and repair dumps, images and packages. Every job runs in the background on the Tasks page." />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="32,10,32,24" Spacing="16">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="1 · SOURCE" Classes="section" />
|
||||
<DockPanel>
|
||||
<Button x:Name="BrowseFolderButton" DockPanel.Dock="Right" Content="Folder..." Margin="8,0,0,0" />
|
||||
<Button x:Name="BrowseFileButton" DockPanel.Dock="Right" Content="File..." Margin="8,0,0,0" />
|
||||
<TextBox x:Name="SourceBox" Watermark="Drop a dump folder, .exfat, .ffpkg, .ffpfsc or .pkg here, or browse" />
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="14" Margin="0,4,0,0">
|
||||
<Border x:Name="SourceIconHost" Width="56" Height="56" CornerRadius="11" ClipToBounds="True"
|
||||
Background="{DynamicResource InputBg}" IsVisible="False">
|
||||
<Image x:Name="SourceIcon" Stretch="UniformToFill" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center" Spacing="5">
|
||||
<TextBlock x:Name="SourceTitle" FontWeight="SemiBold" FontSize="15" IsVisible="False" />
|
||||
<Border Classes="pill" HorizontalAlignment="Left">
|
||||
<TextBlock x:Name="DetectedText" Text="Nothing selected" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="2 · WHAT TO DO" Classes="section" />
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Action" Classes="h" />
|
||||
<ComboBox x:Name="ActionBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="TargetPanel" Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="Convert to" Classes="h" />
|
||||
<ComboBox x:Name="TargetBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel x:Name="PresetPanel" Spacing="6">
|
||||
<TextBlock Text="Preset" Classes="h" />
|
||||
<DockPanel>
|
||||
<Button x:Name="PresetDeleteButton" DockPanel.Dock="Right" Content="Delete" Margin="8,0,0,0" IsEnabled="False" />
|
||||
<Button x:Name="PresetSaveButton" DockPanel.Dock="Right" Content="Save current as..." Margin="8,0,0,0" />
|
||||
<ComboBox x:Name="PresetBox" HorizontalAlignment="Stretch" />
|
||||
</DockPanel>
|
||||
<TextBlock Classes="muted" FontSize="12" TextWrapping="Wrap"
|
||||
Text="A preset fills in the target format and its options. It never touches the source or the output path." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="OutputPanel" Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="3 · OUTPUT" Classes="section" />
|
||||
<TextBlock x:Name="OutputLabel" Text="Output file" Classes="h" />
|
||||
<DockPanel>
|
||||
<Button x:Name="BrowseOutputButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
|
||||
<TextBox x:Name="OutputBox" />
|
||||
</DockPanel>
|
||||
<CheckBox x:Name="OverwriteBox" Content="Overwrite the output if it already exists" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PasscodePanel" Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="PACKAGE PASSCODE" Classes="section" />
|
||||
<TextBox x:Name="PasscodeBox" Watermark="Leave blank for the default (32 zeros)" MaxLength="32" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ImageOptions" Classes="card" IsVisible="False">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="IMAGE OPTIONS" Classes="section" />
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel x:Name="ExfatOptions" Spacing="6">
|
||||
<TextBlock Text="exFAT cluster size" Classes="h" />
|
||||
<ComboBox x:Name="ClusterBox" HorizontalAlignment="Stretch" />
|
||||
<CheckBox x:Name="AmprBox" Content="Generate AMPR index" IsChecked="True" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="FfpfscOptions" Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="FFPFSC compression level (1-9)" Classes="h" />
|
||||
<NumericUpDown x:Name="LevelBox" Minimum="1" Maximum="9" Value="7" FormatString="0" />
|
||||
<TextBlock Text="Minimum gain to compress a block (%)" Classes="h" Margin="0,6,0,6" />
|
||||
<NumericUpDown x:Name="GainBox" Minimum="0" Maximum="100" Value="1" FormatString="0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid x:Name="FfpkgOptions" ColumnDefinitions="*,12,*,12,*,12,*">
|
||||
<StackPanel Spacing="6"><TextBlock Text="Block size" Classes="h" /><ComboBox x:Name="BlockBox" HorizontalAlignment="Stretch" /></StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6"><TextBlock Text="Fragment size" Classes="h" /><ComboBox x:Name="FragmentBox" HorizontalAlignment="Stretch" /></StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="6"><TextBlock Text="Bytes per inode" Classes="h" /><ComboBox x:Name="DensityBox" HorizontalAlignment="Stretch" /></StackPanel>
|
||||
<StackPanel Grid.Column="6" Spacing="6"><TextBlock Text="Minimum free (%)" Classes="h" /><NumericUpDown x:Name="MinFreeBox" Minimum="0" Maximum="50" Value="0" FormatString="0" /></StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BuildOptions" Classes="card" IsVisible="False">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="PACKAGE BUILD OPTIONS" Classes="section" />
|
||||
<Grid ColumnDefinitions="*,16,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Builder" Classes="h" />
|
||||
<ComboBox x:Name="BackendBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="Inner compression" Classes="h" />
|
||||
<ComboBox x:Name="CompressionBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="PlayGo chunks" Classes="h" />
|
||||
<NumericUpDown x:Name="PlayGoBox" Minimum="1" Maximum="64" Value="1" FormatString="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="2" Margin="0,12,0,0" Spacing="8" VerticalAlignment="Bottom">
|
||||
<CheckBox x:Name="DeterministicBox" Content="Reproducible output (fixed seed)" />
|
||||
<CheckBox x:Name="FakeSignBox" Content="Fake-sign modules" IsChecked="True" />
|
||||
<CheckBox x:Name="RightSprxBox" Content="Inject right.sprx" IsChecked="True" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Expander Header="Advanced" Margin="0,4,0,0">
|
||||
<Grid ColumnDefinitions="*,16,*" RowDefinitions="Auto,Auto,Auto" Margin="0,10,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="DRM type" Classes="h" />
|
||||
<ComboBox x:Name="DrmBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
<TextBlock Text="SDK version" Classes="h" />
|
||||
<ComboBox x:Name="SdkBox" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="Kraken level" Classes="h" />
|
||||
<ComboBox x:Name="KrakenLevelBox" HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="The built-in Kraken encoder uses one fixed strategy. The level is recorded in the image header but does not change the compressed size." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="2" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="Kraken threads (0 = automatic)" Classes="h" />
|
||||
<NumericUpDown x:Name="KrakenThreadsBox" Minimum="0" Maximum="64" Value="0" FormatString="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2" Grid.ColumnSpan="3" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock Text="Workspace folder (temporary files)" Classes="h" />
|
||||
<DockPanel>
|
||||
<Button x:Name="BrowseTempButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
|
||||
<TextBox x:Name="TempBox" Watermark="System temporary folder when empty" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="2" Classes="panel" BorderThickness="0,1,0,0" Padding="32,14">
|
||||
<DockPanel>
|
||||
<Button x:Name="RunButton" DockPanel.Dock="Left" Content="Run" Classes="accent" MinWidth="140" Padding="18,10" />
|
||||
<TextBlock x:Name="ResultText" Margin="16,0,0,0" VerticalAlignment="Center" TextWrapping="Wrap" Classes="muted" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,508 @@
|
||||
using Avalonia.Controls;
|
||||
using Packwright.App.Services;
|
||||
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.Containers;
|
||||
using Packwright.Infrastructure;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
public partial class ToolsView : UserControl
|
||||
{
|
||||
private enum Target { Exfat, Ffpkg, Ffpfsc, ZArchive, Fpkg }
|
||||
|
||||
private const string ActionConvert = "Convert / Build";
|
||||
private const string ActionExtract = "Extract";
|
||||
private const string ActionVerify = "Verify";
|
||||
private const string ActionRepair = "Repair exFAT";
|
||||
private const string ActionAmpr = "Refresh AMPR index";
|
||||
private const string ActionEdit = "Edit files...";
|
||||
private const string ActionRebuild = "Rebuild FFPKG metadata";
|
||||
|
||||
private static readonly (int Value, string Name)[] KrakenLevels =
|
||||
[
|
||||
(-4, "-4 HyperFast4"), (-3, "-3 HyperFast3"), (-2, "-2 HyperFast2"), (-1, "-1 HyperFast1"),
|
||||
(0, "0 None"), (1, "1 SuperFast"), (2, "2 VeryFast"), (3, "3 Fast"), (4, "4 Normal"),
|
||||
(5, "5 Optimal1"), (6, "6 Optimal2"), (7, "7 Optimal3"), (8, "8 Optimal4"), (9, "9 Optimal5")
|
||||
];
|
||||
|
||||
private readonly AppServices _services = AppServices.Instance;
|
||||
private readonly List<Target> _targets = [];
|
||||
private bool _suppress;
|
||||
private string? _source;
|
||||
private Ps5ImageFormat _format;
|
||||
private bool _isPackage;
|
||||
private bool _isDirectory;
|
||||
private SonyPkgKind? _packageKind;
|
||||
|
||||
public ToolsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
BackendBox.ItemsSource = BackendRegistry.All.Select(backend =>
|
||||
backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp
|
||||
? backend.DisplayName + " (Windows only)"
|
||||
: backend.DisplayName).ToList();
|
||||
BackendBox.SelectedIndex = Math.Max(0, BackendRegistry.All.ToList()
|
||||
.FindIndex(backend => backend.Id == Jobs.ResolveBackend(_services.Settings.BuildBackend).Id));
|
||||
CompressionBox.ItemsSource = new[] { "Auto (Kraken where it helps)", "Kraken (force)", "Uncompressed" };
|
||||
CompressionBox.SelectedIndex = 0;
|
||||
DrmBox.ItemsSource = new[] { "Upgradable", "Free", "Standard", "Keep source" };
|
||||
DrmBox.SelectedIndex = 0;
|
||||
SdkBox.ItemsSource = new[] { "Auto (from source)" }.Concat(Ps5SdkVersions.Releases.Select(release => release.Version)).ToList();
|
||||
SdkBox.SelectedIndex = 0;
|
||||
KrakenLevelBox.ItemsSource = KrakenLevels.Select(level => level.Name).ToList();
|
||||
KrakenLevelBox.SelectedIndex = Array.FindIndex(KrakenLevels, level => level.Value == 7);
|
||||
BrowseTempButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Workspace folder") is { } folder) TempBox.Text = folder;
|
||||
};
|
||||
ClusterBox.ItemsSource = new[] { "Auto", "32 KB", "64 KB" };
|
||||
ClusterBox.SelectedIndex = 0;
|
||||
BlockBox.ItemsSource = new[] { "32 KB", "64 KB" };
|
||||
BlockBox.SelectedIndex = 0;
|
||||
FragmentBox.ItemsSource = new[] { "4 KB", "64 KB" };
|
||||
FragmentBox.SelectedIndex = 0;
|
||||
DensityBox.ItemsSource = new[] { "256 KiB", "512 KiB", "1 MiB" };
|
||||
DensityBox.SelectedIndex = 0;
|
||||
|
||||
SourceBox.LostFocus += (_, _) => SetSource(SourceBox.Text?.Trim());
|
||||
SourceBox.KeyDown += (_, e) => { if (e.Key == Avalonia.Input.Key.Enter) SetSource(SourceBox.Text?.Trim()); };
|
||||
BrowseFileButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFileAsync(this, "Select an image or package") is { } file) SetSource(file);
|
||||
};
|
||||
BrowseFolderButton.Click += async (_, _) =>
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Select a dump folder") is { } folder) SetSource(folder);
|
||||
};
|
||||
ActionBox.SelectionChanged += (_, _) => { if (!_suppress) UpdateTargets(); };
|
||||
TargetBox.SelectionChanged += (_, _) => { if (!_suppress) UpdateOptions(); };
|
||||
BrowseOutputButton.Click += async (_, _) => await BrowseOutputAsync();
|
||||
RunButton.Click += async (_, _) => await RunAsync();
|
||||
InitializePresets();
|
||||
UpdateActions();
|
||||
}
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
private SourceSummary _summary = SourceSummary.Empty;
|
||||
|
||||
private async Task LoadSummaryAsync(string source)
|
||||
{
|
||||
SourceSummary summary = await SourceInfo.DescribeAsync(source);
|
||||
if (!string.Equals(source, _source, StringComparison.OrdinalIgnoreCase)) return;
|
||||
_summary = summary;
|
||||
SourceTitle.Text = summary.Title;
|
||||
SourceTitle.IsVisible = summary.Title.Length > 0;
|
||||
if (summary.Icon is { Length: > 0 } icon)
|
||||
{
|
||||
SourceIcon.Source = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(icon));
|
||||
SourceIconHost.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private string? CurrentAction => ActionBox.SelectedItem as string;
|
||||
private Target? CurrentTarget =>
|
||||
CurrentAction == ActionConvert && TargetBox.SelectedIndex >= 0 && TargetBox.SelectedIndex < _targets.Count
|
||||
? _targets[TargetBox.SelectedIndex]
|
||||
: null;
|
||||
|
||||
public void SetSource(string? path)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && _source is not null &&
|
||||
(File.Exists(path) || Directory.Exists(path)) &&
|
||||
string.Equals(Path.GetFullPath(path), _source, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SourceBox.Text = _source;
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(path) || (!File.Exists(path) && !Directory.Exists(path)))
|
||||
{
|
||||
_source = null;
|
||||
if (!string.IsNullOrWhiteSpace(path)) ResultText.Text = "That path does not exist.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_source = Path.GetFullPath(path);
|
||||
ResultText.Text = string.Empty;
|
||||
}
|
||||
SourceBox.Text = _source ?? path;
|
||||
_isDirectory = _source is not null && Directory.Exists(_source);
|
||||
_isPackage = _source is not null && !_isDirectory &&
|
||||
Path.GetExtension(_source).Equals(".pkg", StringComparison.OrdinalIgnoreCase);
|
||||
_format = _source is null || _isDirectory || _isPackage
|
||||
? Ps5ImageFormat.Unknown
|
||||
: Ps5ImageFormatProbe.Detect(_source);
|
||||
_packageKind = null;
|
||||
if (_isPackage)
|
||||
{
|
||||
try { _packageKind = new SonyPkgReader().Read(_source!).Kind; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
Logger.Warn("Could not read package " + _source + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
DetectedText.Text = _source is null ? "Nothing selected" : FormatLabel();
|
||||
_summary = SourceSummary.Empty;
|
||||
SourceTitle.IsVisible = SourceIconHost.IsVisible = false;
|
||||
SourceIcon.Source = null;
|
||||
if (_source is not null) _ = LoadSummaryAsync(_source);
|
||||
if (_isPackage && _packageKind is not null and not SonyPkgKind.FinalizedDebug)
|
||||
ResultText.Text = _packageKind == SonyPkgKind.FinalizedPatch
|
||||
? "Patch package: Convert, Extract and Verify need a finalized debug (FPKG) package."
|
||||
: "Retail package: Convert, Extract and Verify need a debug (FPKG) package.";
|
||||
UpdateActions();
|
||||
}
|
||||
|
||||
private string FormatLabel()
|
||||
{
|
||||
if (_isDirectory) return "dump folder";
|
||||
if (_isPackage)
|
||||
return _packageKind switch
|
||||
{
|
||||
SonyPkgKind.FinalizedDebug => "debug package (FPKG)",
|
||||
SonyPkgKind.FinalizedPatch => "patch package (LIH)",
|
||||
SonyPkgKind.FinalizedRetail => "retail package",
|
||||
SonyPkgKind.MetadataContainer => "CNT metadata",
|
||||
_ => "package"
|
||||
};
|
||||
return _format switch
|
||||
{
|
||||
Ps5ImageFormat.Exfat => "exFAT image",
|
||||
Ps5ImageFormat.Ufs2 => "FFPKG image",
|
||||
Ps5ImageFormat.Pfs => "FFPFSC image",
|
||||
Ps5ImageFormat.ZArchive => "ZArchive",
|
||||
_ => "unknown (not a PS5 image)"
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateActions()
|
||||
{
|
||||
var actions = new List<string>();
|
||||
if (_source is not null)
|
||||
{
|
||||
if (_isDirectory) actions.Add(ActionConvert);
|
||||
else if (_isPackage)
|
||||
{
|
||||
if (_packageKind == SonyPkgKind.FinalizedDebug) actions.AddRange([ActionConvert, ActionExtract, ActionVerify]);
|
||||
}
|
||||
else if (_format is Ps5ImageFormat.Exfat or Ps5ImageFormat.Ufs2 or Ps5ImageFormat.Pfs or Ps5ImageFormat.ZArchive)
|
||||
{
|
||||
actions.AddRange([ActionConvert, ActionExtract, ActionVerify]);
|
||||
if (_format == Ps5ImageFormat.Exfat) actions.AddRange([ActionEdit, ActionRepair, ActionAmpr]);
|
||||
else if (_format == Ps5ImageFormat.Ufs2) actions.AddRange([ActionEdit, ActionRebuild]);
|
||||
}
|
||||
}
|
||||
_suppress = true;
|
||||
string? previous = CurrentAction;
|
||||
ActionBox.ItemsSource = actions;
|
||||
ActionBox.SelectedItem = previous is not null && actions.Contains(previous) ? previous : actions.FirstOrDefault();
|
||||
_suppress = false;
|
||||
UpdateTargets();
|
||||
}
|
||||
|
||||
private void UpdateTargets()
|
||||
{
|
||||
Target? previousTarget = _wantedTarget ?? CurrentTarget;
|
||||
_targets.Clear();
|
||||
if (_source is not null && CurrentAction == ActionConvert)
|
||||
{
|
||||
if (_isPackage)
|
||||
_targets.AddRange([Target.Exfat, Target.Ffpkg, Target.Ffpfsc, Target.ZArchive]);
|
||||
else if (_isDirectory)
|
||||
_targets.AddRange([Target.Exfat, Target.Ffpkg, Target.Ffpfsc, Target.ZArchive, Target.Fpkg]);
|
||||
else
|
||||
{
|
||||
foreach (Ps5ImageConversionTarget target in Enum.GetValues<Ps5ImageConversionTarget>())
|
||||
if (Ps5ImageConversionService.IsSupported(_format, target))
|
||||
_targets.Add(target switch
|
||||
{
|
||||
Ps5ImageConversionTarget.Exfat => Target.Exfat,
|
||||
Ps5ImageConversionTarget.Ffpkg => Target.Ffpkg,
|
||||
Ps5ImageConversionTarget.ZArchive => Target.ZArchive,
|
||||
_ => Target.Ffpfsc
|
||||
});
|
||||
_targets.Add(Target.Fpkg);
|
||||
}
|
||||
}
|
||||
_suppress = true;
|
||||
TargetBox.ItemsSource = _targets.Select(TargetLabel).ToList();
|
||||
int keep = previousTarget is { } wanted ? _targets.IndexOf(wanted) : -1;
|
||||
TargetBox.SelectedIndex = _targets.Count == 0 ? -1 : Math.Max(0, keep);
|
||||
_suppress = false;
|
||||
if (_targets.Count > 0) _wantedTarget = null;
|
||||
UpdateOptions();
|
||||
}
|
||||
|
||||
private static string TargetLabel(Target target) => target switch
|
||||
{
|
||||
Target.Exfat => "exFAT image (.exfat)",
|
||||
Target.Ffpkg => "FFPKG image (.ffpkg)",
|
||||
Target.Ffpfsc => "FFPFSC image (.ffpfsc)",
|
||||
Target.ZArchive => "ZArchive (.zar)",
|
||||
_ => "Debug package (.pkg)"
|
||||
};
|
||||
|
||||
private static string Extension(Target target) => target switch
|
||||
{
|
||||
Target.Exfat => ".exfat",
|
||||
Target.Ffpkg => ".ffpkg",
|
||||
Target.Ffpfsc => ".ffpfsc",
|
||||
Target.ZArchive => ".zar",
|
||||
_ => ".pkg"
|
||||
};
|
||||
|
||||
private static string ShortLabel(Target target) => target switch
|
||||
{
|
||||
Target.Exfat => "exFAT",
|
||||
Target.Ffpkg => "FFPKG",
|
||||
Target.Ffpfsc => "FFPFSC",
|
||||
Target.ZArchive => "ZArchive",
|
||||
_ => "FPKG"
|
||||
};
|
||||
|
||||
private void UpdateOptions()
|
||||
{
|
||||
string? action = CurrentAction;
|
||||
bool convert = action == ActionConvert;
|
||||
bool extract = action == ActionExtract;
|
||||
TargetPanel.IsVisible = convert;
|
||||
OutputPanel.IsVisible = convert || extract;
|
||||
OutputLabel.Text = extract ? "Output folder" : "Output file";
|
||||
PasscodePanel.IsVisible = _isPackage && (extract || action == ActionVerify);
|
||||
BuildOptions.IsVisible = convert && CurrentTarget == Target.Fpkg;
|
||||
Target? chosen = convert ? CurrentTarget : null;
|
||||
ExfatOptions.IsVisible = chosen is Target.Exfat or Target.Ffpfsc;
|
||||
FfpfscOptions.IsVisible = chosen == Target.Ffpfsc;
|
||||
FfpkgOptions.IsVisible = chosen == Target.Ffpkg;
|
||||
ImageOptions.IsVisible = chosen is Target.Exfat or Target.Ffpfsc or Target.Ffpkg;
|
||||
RunButton.Content = action == ActionEdit ? "Open editor" : "Run";
|
||||
RunButton.IsEnabled = action is not null;
|
||||
if (convert && CurrentTarget is { } target) SuggestOutput(Extension(target), replaceExtension: true);
|
||||
else if (extract) SuggestOutput("-files", replaceExtension: false);
|
||||
}
|
||||
|
||||
private string _lastSuggestion = string.Empty;
|
||||
|
||||
private void SuggestOutput(string suffix, bool replaceExtension)
|
||||
{
|
||||
if (_source is null) { OutputBox.Text = string.Empty; _lastSuggestion = string.Empty; return; }
|
||||
string current = OutputBox.Text?.Trim() ?? string.Empty;
|
||||
if (current.Length > 0 && current != _lastSuggestion)
|
||||
{
|
||||
// The user typed or picked this path; keep it and only follow the target's file extension.
|
||||
if (replaceExtension && !current.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string next = Path.ChangeExtension(current, suffix);
|
||||
OutputBox.Text = next;
|
||||
_lastSuggestion = next;
|
||||
}
|
||||
return;
|
||||
}
|
||||
string name = _isDirectory
|
||||
? new DirectoryInfo(_source).Name
|
||||
: Path.GetFileNameWithoutExtension(_source);
|
||||
string? parent = !string.IsNullOrWhiteSpace(_services.Settings.OutputDirectory) &&
|
||||
Directory.Exists(_services.Settings.OutputDirectory)
|
||||
? _services.Settings.OutputDirectory
|
||||
: _isDirectory ? Directory.GetParent(_source)?.FullName : Path.GetDirectoryName(_source);
|
||||
_lastSuggestion = Path.Combine(parent ?? _source, name + suffix);
|
||||
OutputBox.Text = _lastSuggestion;
|
||||
}
|
||||
|
||||
private async Task BrowseOutputAsync()
|
||||
{
|
||||
if (CurrentAction == ActionExtract)
|
||||
{
|
||||
if (await Dialogs.PickFolderAsync(this, "Select the output folder") is { } folder) OutputBox.Text = folder;
|
||||
return;
|
||||
}
|
||||
Target target = CurrentTarget ?? Target.Ffpfsc;
|
||||
string? file = await Dialogs.SaveFileAsync(this, "Output file",
|
||||
Path.GetFileName(OutputBox.Text ?? "output" + Extension(target)), Extension(target));
|
||||
if (file is not null) OutputBox.Text = file;
|
||||
}
|
||||
|
||||
private string Passcode() =>
|
||||
!string.IsNullOrEmpty(PasscodeBox.Text) ? PasscodeBox.Text!
|
||||
: !string.IsNullOrEmpty(_services.Settings.DebugPasscode) ? _services.Settings.DebugPasscode
|
||||
: SonyDebugPackageCredentials.DefaultPasscode;
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
if (_source is null || CurrentAction is not { } action) return;
|
||||
string source = _source;
|
||||
string fileName = Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
string sourceLabel = FormatLabel();
|
||||
ResultText.Text = string.Empty;
|
||||
try
|
||||
{
|
||||
if (_summary.Title.Length == 0) _summary = await SourceInfo.DescribeAsync(source);
|
||||
var spec = new JobSpec
|
||||
{
|
||||
Title = _summary.Title,
|
||||
Icon = _summary.Icon is { Length: > 0 } iconBytes ? Convert.ToBase64String(iconBytes) : null,
|
||||
Source = source,
|
||||
Format = _format.ToString(),
|
||||
FromPackage = _isPackage,
|
||||
Passcode = Passcode(),
|
||||
Overwrite = OverwriteBox.IsChecked == true
|
||||
};
|
||||
switch (action)
|
||||
{
|
||||
case ActionConvert:
|
||||
await QueueConvertAsync(spec, fileName, sourceLabel);
|
||||
break;
|
||||
case ActionExtract:
|
||||
spec.Kind = JobSpec.Extract;
|
||||
if (string.IsNullOrWhiteSpace(OutputBox.Text)) return;
|
||||
spec.Output = Jobs.FindAvailablePath(OutputBox.Text.Trim());
|
||||
Enqueue(spec, $"Extract {fileName}", _isPackage ? "Extract package" : "Extract", sourceLabel, "folder");
|
||||
break;
|
||||
case ActionVerify:
|
||||
spec.Kind = JobSpec.Verify;
|
||||
Enqueue(spec, $"Verify {fileName}", _isPackage ? "Verify package" : "Verify", sourceLabel, "");
|
||||
break;
|
||||
case ActionEdit:
|
||||
await new ImageEditorWindow(source, _format).ShowDialog(Dialogs.OwnerOf(this)!);
|
||||
break;
|
||||
case ActionRebuild:
|
||||
if (!await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this), "Rebuild FFPKG image?",
|
||||
"This extracts every readable file, rebuilds all FFPKG metadata beside the original, verifies the " +
|
||||
"replacement and only then swaps it into place. It needs substantial free disk space.\n\n" + source,
|
||||
"Rebuild"))
|
||||
break;
|
||||
spec.Kind = JobSpec.RebuildFfpkg;
|
||||
Enqueue(spec, $"Rebuild {fileName}", "Rebuild", sourceLabel, "FFPKG");
|
||||
break;
|
||||
case ActionRepair:
|
||||
spec.Kind = JobSpec.Repair;
|
||||
Enqueue(spec, $"Repair {fileName}", "Repair", sourceLabel, "");
|
||||
break;
|
||||
case ActionAmpr:
|
||||
spec.Kind = JobSpec.RefreshAmpr;
|
||||
Enqueue(spec, $"Refresh AMPR {fileName}", "Refresh AMPR", sourceLabel, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Exception("Tools", ex);
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Image tools", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void Enqueue(JobSpec spec, string name, string operation, string sourceFormat, string targetFormat,
|
||||
string qualifier = "")
|
||||
{
|
||||
_services.EnqueueJob(spec, name, operation, sourceFormat, targetFormat, qualifier);
|
||||
ResultText.Text = "Queued. See the Tasks page.";
|
||||
StatusChanged?.Invoke($"Queued: {name}");
|
||||
}
|
||||
|
||||
private async Task QueueConvertAsync(JobSpec spec, string fileName, string sourceLabel)
|
||||
{
|
||||
string output = OutputBox.Text?.Trim() ?? string.Empty;
|
||||
if (output.Length == 0 || CurrentTarget is not { } target)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Image tools", "Choose an output file.");
|
||||
return;
|
||||
}
|
||||
spec.Output = output;
|
||||
if (!spec.Overwrite && (File.Exists(output) || Directory.Exists(output)))
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Image tools",
|
||||
"The output already exists:\n" + output + "\n\nTick \"Overwrite the output\" or choose another name.");
|
||||
return;
|
||||
}
|
||||
if (target == Target.Fpkg)
|
||||
{
|
||||
await QueueBuildPackageAsync(spec, fileName, sourceLabel);
|
||||
return;
|
||||
}
|
||||
spec.Kind = JobSpec.Convert;
|
||||
spec.Target = (target switch
|
||||
{
|
||||
Target.Exfat => Ps5ImageConversionTarget.Exfat,
|
||||
Target.Ffpkg => Ps5ImageConversionTarget.Ffpkg,
|
||||
Target.ZArchive => Ps5ImageConversionTarget.ZArchive,
|
||||
_ => Ps5ImageConversionTarget.Ffpfsc
|
||||
}).ToString();
|
||||
spec.ClusterSize = ClusterBox.SelectedIndex switch { 1 => 32768, 2 => 65536, _ => 0 };
|
||||
spec.GenerateAmpr = AmprBox.IsChecked == true;
|
||||
spec.CompressionLevel = (int)(LevelBox.Value ?? 7);
|
||||
spec.MinimumGain = (int)(GainBox.Value ?? 1);
|
||||
spec.BlockSize = BlockBox.SelectedIndex == 1 ? 65536 : 32768;
|
||||
spec.FragmentSize = FragmentBox.SelectedIndex == 1 ? 65536 : 4096;
|
||||
if (spec.FragmentSize > spec.BlockSize) spec.FragmentSize = spec.BlockSize;
|
||||
spec.BytesPerInode = DensityBox.SelectedIndex switch { 1 => 524288, 2 => 1048576, _ => 262144 };
|
||||
spec.MinFreePercent = (int)(MinFreeBox.Value ?? 0);
|
||||
Enqueue(spec, $"Convert {fileName}", "Convert", sourceLabel, ShortLabel(target));
|
||||
}
|
||||
|
||||
private async Task QueueBuildPackageAsync(JobSpec spec, string fileName, string sourceLabel)
|
||||
{
|
||||
string contentId = Jobs.ReadContentId(spec.Source, _format);
|
||||
if (contentId.Length == 0)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Build package",
|
||||
"The source does not contain a content ID (sce_sys/param.json), so a package cannot be built from it.");
|
||||
return;
|
||||
}
|
||||
IPackageBackend backend = BackendRegistry.All[Math.Max(0, BackendBox.SelectedIndex)];
|
||||
if (backend.Id == BackendRegistry.LppId && !Jobs.PlatformSupportsLpp)
|
||||
{
|
||||
backend = BackendRegistry.Get(BackendRegistry.PptId);
|
||||
StatusChanged?.Invoke("LibProsperoPkg is Windows only; using ProsperoPkgTool.");
|
||||
}
|
||||
spec.Kind = JobSpec.BuildPackage;
|
||||
spec.ContentId = contentId;
|
||||
spec.Backend = backend.Id;
|
||||
spec.Compression = (CompressionBox.SelectedIndex switch
|
||||
{
|
||||
1 => Ps5InnerCompression.Kraken,
|
||||
2 => Ps5InnerCompression.Stored,
|
||||
_ => Ps5InnerCompression.Auto
|
||||
}).ToString();
|
||||
spec.KrakenLevel = KrakenLevels[Math.Max(0, KrakenLevelBox.SelectedIndex)].Value;
|
||||
spec.KrakenThreads = (int)(KrakenThreadsBox.Value ?? 0);
|
||||
spec.PlayGoChunks = (int)(PlayGoBox.Value ?? 1);
|
||||
spec.Deterministic = DeterministicBox.IsChecked == true;
|
||||
spec.FakeSign = FakeSignBox.IsChecked == true;
|
||||
spec.RightSprx = RightSprxBox.IsChecked == true;
|
||||
spec.Drm = DrmBox.SelectedIndex switch { 0 => "upgradable", 1 => "free", 2 => "standard", _ => null };
|
||||
spec.Sdk = SdkBox.SelectedIndex > 0 ? Ps5SdkVersions.ExecutableVersionAt(SdkBox.SelectedIndex - 1) : null;
|
||||
spec.TempDirectory = string.IsNullOrWhiteSpace(TempBox.Text) ? null : TempBox.Text!.Trim();
|
||||
|
||||
// Free-space preflight before a long build; the engine re-checks exactly while it runs.
|
||||
Ps5DiskSpaceCheck space = Ps5DiskSpace.Check(EstimatePayload(spec.Source), spec.Output, spec.TempDirectory);
|
||||
if (space.Status == Ps5DiskSpaceStatus.Insufficient)
|
||||
{
|
||||
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Build package",
|
||||
"Not enough free disk space to build this package.\n\n" + space.Message +
|
||||
"\n\nFree space, or choose a different workspace or output volume.");
|
||||
return;
|
||||
}
|
||||
if (space.Status == Ps5DiskSpaceStatus.NearLimit && !await Dialogs.ConfirmAsync(Dialogs.OwnerOf(this),
|
||||
"Low disk space", "Low free disk space for this build.\n\n" + space.Message, "Build anyway"))
|
||||
return;
|
||||
Enqueue(spec, $"Build package from {fileName}", "Build package", sourceLabel, "FPKG", backend.DisplayName);
|
||||
}
|
||||
|
||||
private static long EstimatePayload(string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(source))
|
||||
return Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).Sum(file => new FileInfo(file).Length);
|
||||
if (File.Exists(source)) return new FileInfo(source).Length;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Packwright.App.Services;
|
||||
using Packwright.Core.Services;
|
||||
|
||||
namespace Packwright.App.Views;
|
||||
|
||||
/// <summary>Shows a newer release: what is new, and a download for this system that is checked against the release's checksums.</summary>
|
||||
public sealed class UpdateWindow : Window
|
||||
{
|
||||
private readonly ReleaseInfo _release;
|
||||
private readonly ReleaseAsset? _asset;
|
||||
private readonly Button _download = new() { MinWidth = 150 };
|
||||
private readonly Button _show = new() { Content = "Show in folder", IsVisible = false };
|
||||
private readonly Button _install = new() { Content = "Install now", IsVisible = false, MinWidth = 130 };
|
||||
private readonly ProgressBar _progress = new() { IsVisible = false, Minimum = 0, Maximum = 100 };
|
||||
private readonly TextBlock _status = new() { Classes = { "muted" }, TextWrapping = TextWrapping.Wrap };
|
||||
private CancellationTokenSource? _cancellation;
|
||||
private string? _downloaded;
|
||||
|
||||
private UpdateWindow(ReleaseInfo release)
|
||||
{
|
||||
_release = release;
|
||||
_asset = UpdateChecker.PickAsset(release);
|
||||
Title = "Update available";
|
||||
Width = 600;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
MaxHeight = 760;
|
||||
CanResize = false;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
string when = release.PublishedUtc is { } published ? $" · released {published.ToLocalTime():d MMMM yyyy}" : string.Empty;
|
||||
var notes = new TextBox
|
||||
{
|
||||
Text = release.Notes.Length > 0 ? release.Notes.Replace("\r\n", "\n").Trim() : "No release notes were published.",
|
||||
IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MaxHeight = 240, MinHeight = 110
|
||||
};
|
||||
|
||||
bool installer = _asset is not null && UpdateChecker.IsInstaller(_asset);
|
||||
_download.Content = _asset is null ? "No download for this system"
|
||||
: $"{(installer ? "Download installer" : "Download")} ({FormatBytes(_asset.Size)})";
|
||||
_install.Classes.Add("accent");
|
||||
_install.Click += async (_, _) => await InstallAsync();
|
||||
_download.IsEnabled = _asset is not null;
|
||||
_download.Classes.Add("accent");
|
||||
_download.Click += async (_, _) => await DownloadAsync();
|
||||
_show.Click += (_, _) => { if (_downloaded is not null) Dialogs.Reveal(_downloaded); };
|
||||
var page = new Button { Content = "Release page" };
|
||||
page.Click += (_, _) => Dialogs.OpenPath(release.PageUrl.Length > 0 ? release.PageUrl : AboutView.RepositoryUrl + "/releases");
|
||||
var skip = new Button { Content = "Skip this version" };
|
||||
skip.Click += (_, _) => { UpdateService.Skip(release); Close(); };
|
||||
var later = new Button { Content = "Later" };
|
||||
later.Click += (_, _) => { _cancellation?.Cancel(); Close(); };
|
||||
|
||||
_status.Text = _asset is null
|
||||
? "This release has no file for your system attached. Open the release page to see what is available."
|
||||
: installer
|
||||
? $"{_asset.Name} installs for you without administrator rights. It is downloaded and checked first; then Packwright closes, updates and starts again."
|
||||
: $"The file is {_asset.Name}. It is not installed for you: {InstallHint(_asset)}";
|
||||
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(22),
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = $"Packwright {release.Version} is available", FontSize = 20, FontWeight = FontWeight.Bold },
|
||||
new TextBlock { Text = $"You have {UpdateService.CurrentVersion}{when}", Classes = { "muted" } },
|
||||
notes,
|
||||
_progress,
|
||||
_status,
|
||||
new WrapPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children = { Spaced(_download), Spaced(_install), Spaced(_show), Spaced(page), Spaced(skip), Spaced(later) }
|
||||
}
|
||||
}
|
||||
};
|
||||
Closing += (_, _) => _cancellation?.Cancel();
|
||||
}
|
||||
|
||||
private static Control Spaced(Control control)
|
||||
{
|
||||
control.Margin = new Thickness(0, 0, 8, 8);
|
||||
return control;
|
||||
}
|
||||
|
||||
public static async Task ShowAsync(Window? owner, ReleaseInfo release)
|
||||
{
|
||||
var window = new UpdateWindow(release);
|
||||
if (owner is not null) await window.ShowDialog(owner);
|
||||
else window.Show();
|
||||
}
|
||||
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
if (_asset is null) return;
|
||||
_cancellation = new CancellationTokenSource();
|
||||
_download.IsEnabled = false;
|
||||
_show.IsVisible = false;
|
||||
_progress.IsVisible = true;
|
||||
_progress.Value = 0;
|
||||
_status.Text = "Downloading...";
|
||||
string folder = DownloadFolder();
|
||||
try
|
||||
{
|
||||
var progress = new Progress<(long Done, long Total)>(value =>
|
||||
{
|
||||
if (value.Total > 0) _progress.Value = value.Done * 100.0 / value.Total;
|
||||
_status.Text = $"Downloading... {FormatBytes(value.Done)} of {FormatBytes(value.Total)}";
|
||||
});
|
||||
DownloadResult result = await UpdateChecker.DownloadAsync(_release, _asset, folder, progress, _cancellation.Token);
|
||||
_downloaded = result.Path;
|
||||
_progress.Value = 100;
|
||||
if (result.Verified == false)
|
||||
{
|
||||
// A file that does not match its checksum is damaged or tampered with: it is removed, not kept.
|
||||
try { File.Delete(result.Path); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
_status.Text = result.VerifyMessage + "\nThe file was deleted.";
|
||||
_download.IsEnabled = true;
|
||||
return;
|
||||
}
|
||||
bool isInstaller = UpdateChecker.IsInstaller(_asset);
|
||||
_status.Text = $"Saved to {result.Path}\n{result.VerifyMessage}" +
|
||||
(result.Verified == false ? string.Empty
|
||||
: isInstaller ? "\nPress Install now to update."
|
||||
: "\n" + InstallHint(_asset));
|
||||
_show.IsVisible = true;
|
||||
_install.IsVisible = isInstaller && result.Verified != false && OperatingSystem.IsWindows();
|
||||
_download.IsVisible = !_install.IsVisible;
|
||||
}
|
||||
catch (OnlineLookupException ex)
|
||||
{
|
||||
_status.Text = ex.Message;
|
||||
_download.IsEnabled = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_status.Text = "Download cancelled.";
|
||||
_download.IsEnabled = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_status.Text = "The file could not be saved: " + ex.Message;
|
||||
_download.IsEnabled = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cancellation?.Dispose();
|
||||
_cancellation = null;
|
||||
_progress.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs the downloaded installer silently (it shows its own progress), then closes Packwright so it can replace the files.</summary>
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
if (_downloaded is null) return;
|
||||
AppServices services = AppServices.Instance;
|
||||
if (services.Queue.Tasks.Any(task => task.Status == Packwright.Core.Tasks.PackageTaskStatus.Running) &&
|
||||
!await Dialogs.ConfirmAsync(this, "Tasks are running",
|
||||
"Installing closes Packwright, which stops the tasks that are running now. Install anyway?", "Install", "Wait"))
|
||||
return;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_downloaded,
|
||||
"/SILENT /CLOSEAPPLICATIONS /NORESTART") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
|
||||
{
|
||||
_status.Text = "The installer could not be started: " + ex.Message;
|
||||
return;
|
||||
}
|
||||
Packwright.Infrastructure.Logger.Info("Started the installer " + _downloaded);
|
||||
Close();
|
||||
(Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.Shutdown();
|
||||
}
|
||||
|
||||
/// <summary>How to install a downloaded macOS or Linux package.</summary>
|
||||
private static string InstallHint(ReleaseAsset asset) => asset.Name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|
||||
? "when it has downloaded, close Packwright, open the disk image and drag Packwright onto Applications."
|
||||
: asset.Name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|
||||
? $"when it has downloaded, close Packwright and install it with your software installer, or run: sudo apt install ./{asset.Name}"
|
||||
: asset.Name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|
||||
? $"when it has downloaded, close Packwright and install it with your software installer, or run: sudo dnf install ./{asset.Name}"
|
||||
: "when it has downloaded, close Packwright and install it.";
|
||||
|
||||
private static string DownloadFolder()
|
||||
{
|
||||
string downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
return Directory.Exists(downloads) ? downloads : Path.GetTempPath();
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes) => Ps5LibraryHealth.FormatBytes(bytes);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
rem Runs Packwright in command-line mode. A batch file waits for the program, so the prompt returns after the output.
|
||||
"%~dp0Packwright.exe" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public readonly record struct AmprFileRecord(string RelativePath, long Size, long UnixModifiedTime);
|
||||
public sealed record AmprIndexInfo(int RecordCount, int HashSlotCount, long PathBlobLength);
|
||||
|
||||
/// <summary>Native AMPRIDX3 index builder and structural validator.</summary>
|
||||
public static class AmprIndex
|
||||
{
|
||||
private const int HeaderSize = 48;
|
||||
private const int RecordSize = 24;
|
||||
private const int SlotSize = 16;
|
||||
private const ulong FnvOffset = 1469598103934665603;
|
||||
private const ulong FnvPrime = 1099511628211;
|
||||
|
||||
public static byte[] Build(IEnumerable<AmprFileRecord> sourceFiles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceFiles);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var rows = sourceFiles
|
||||
.Where(file => file.Size >= 0)
|
||||
.Select(file => new AmprFileRecord(NormalizeRelativePath(file.RelativePath), file.Size, file.UnixModifiedTime))
|
||||
.Where(file => !file.RelativePath.Equals("ampr_emu.index", StringComparison.OrdinalIgnoreCase) &&
|
||||
!file.RelativePath.Equals("ampr_emu.index.tmp", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(file => seen.Add(Key("/app0/" + file.RelativePath)))
|
||||
.OrderBy(file => Key("/app0/" + file.RelativePath), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (rows.Length == 0) return [];
|
||||
|
||||
var paths = new List<byte[]>(rows.Length);
|
||||
long pathBlobLength = 0;
|
||||
foreach (AmprFileRecord row in rows)
|
||||
{
|
||||
byte[] path = Encoding.UTF8.GetBytes("/app0/" + row.RelativePath);
|
||||
if (pathBlobLength > uint.MaxValue)
|
||||
throw new InvalidDataException("The AMPR path table exceeds its 32-bit format limit.");
|
||||
paths.Add(path);
|
||||
pathBlobLength = checked(pathBlobLength + path.Length + 1);
|
||||
}
|
||||
|
||||
int slotCount = 2;
|
||||
while (slotCount < checked(rows.Length * 2)) slotCount = checked(slotCount * 2);
|
||||
long pathEnd = checked(HeaderSize + (long)rows.Length * RecordSize + pathBlobLength);
|
||||
long hashOffset = Align(pathEnd, SlotSize);
|
||||
long totalLength = checked(hashOffset + (long)slotCount * SlotSize);
|
||||
if (totalLength > int.MaxValue) throw new InvalidDataException("The AMPR index is too large to hold in memory.");
|
||||
byte[] result = new byte[checked((int)totalLength)];
|
||||
"AMPRIDX3"u8.CopyTo(result);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(8, 4), 3);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(12, 4), RecordSize);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(16, 8), checked((ulong)rows.Length));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(24, 8), checked((ulong)pathBlobLength));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(32, 8), checked((ulong)hashOffset));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(40, 4), SlotSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(44, 4), checked((uint)slotCount));
|
||||
|
||||
int pathOffset = 0;
|
||||
int pathBase = checked(HeaderSize + rows.Length * RecordSize);
|
||||
for (int index = 0; index < rows.Length; index++)
|
||||
{
|
||||
Span<byte> record = result.AsSpan(HeaderSize + index * RecordSize, RecordSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.Slice(0, 4), checked((uint)pathOffset));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.Slice(4, 4), checked((uint)paths[index].Length));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(record.Slice(8, 8), checked((ulong)rows[index].Size));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.Slice(16, 8), rows[index].UnixModifiedTime);
|
||||
paths[index].CopyTo(result, pathBase + pathOffset);
|
||||
pathOffset += paths[index].Length + 1;
|
||||
}
|
||||
|
||||
int mask = slotCount - 1;
|
||||
for (int index = 0; index < rows.Length; index++)
|
||||
{
|
||||
ulong hash = HashPath("/app0/" + rows[index].RelativePath);
|
||||
int position = checked((int)(hash & (uint)mask));
|
||||
while (true)
|
||||
{
|
||||
Span<byte> slot = result.AsSpan(checked((int)hashOffset + position * SlotSize), SlotSize);
|
||||
uint existingIndex = BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(8, 4));
|
||||
if (existingIndex == 0)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(slot.Slice(0, 8), hash);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(slot.Slice(8, 4), checked((uint)(index + 1)));
|
||||
break;
|
||||
}
|
||||
if (BinaryPrimitives.ReadUInt64LittleEndian(slot.Slice(0, 8)) == hash)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(slot.Slice(12, 4), 1);
|
||||
position = (position + 1) & mask;
|
||||
}
|
||||
}
|
||||
_ = Inspect(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static AmprIndexInfo Inspect(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < HeaderSize || !data.Slice(0, 8).SequenceEqual("AMPRIDX3"u8))
|
||||
throw new InvalidDataException("AMPRIDX3 magic is missing.");
|
||||
uint version = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(8, 4));
|
||||
uint recordSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(12, 4));
|
||||
ulong rowCount = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(16, 8));
|
||||
ulong pathLength = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(24, 8));
|
||||
ulong hashOffset = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(32, 8));
|
||||
uint slotSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(40, 4));
|
||||
uint slotCount = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(44, 4));
|
||||
if (version != 3 || recordSize != RecordSize || slotSize != SlotSize || rowCount == 0 || slotCount == 0 ||
|
||||
(slotCount & (slotCount - 1)) != 0 || rowCount > int.MaxValue || slotCount > int.MaxValue)
|
||||
throw new InvalidDataException("The AMPRIDX3 header is invalid.");
|
||||
ulong recordsEnd = checked((ulong)HeaderSize + rowCount * recordSize);
|
||||
ulong pathEnd = checked(recordsEnd + pathLength);
|
||||
ulong expectedEnd = checked(hashOffset + slotCount * slotSize);
|
||||
if (hashOffset < pathEnd || hashOffset % slotSize != 0 || expectedEnd != (ulong)data.Length)
|
||||
throw new InvalidDataException("The AMPRIDX3 ranges are invalid.");
|
||||
|
||||
for (int index = 0; index < checked((int)rowCount); index++)
|
||||
{
|
||||
ReadOnlySpan<byte> record = data.Slice(HeaderSize + index * RecordSize, RecordSize);
|
||||
uint offset = BinaryPrimitives.ReadUInt32LittleEndian(record.Slice(0, 4));
|
||||
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record.Slice(4, 4));
|
||||
if ((ulong)offset + length >= pathLength || data[checked((int)(recordsEnd + offset + length))] != 0)
|
||||
throw new InvalidDataException("An AMPRIDX3 path record is invalid.");
|
||||
}
|
||||
return new AmprIndexInfo(checked((int)rowCount), checked((int)slotCount), checked((long)pathLength));
|
||||
}
|
||||
|
||||
public static ulong HashPath(string path)
|
||||
{
|
||||
ulong hash = FnvOffset;
|
||||
foreach (Rune value in Key(path).EnumerateRunes())
|
||||
{
|
||||
hash ^= checked((uint)value.Value);
|
||||
hash = unchecked(hash * FnvPrime);
|
||||
}
|
||||
return hash == 0 ? 1 : hash;
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
string normalized = path.Replace('\\', '/').TrimStart('/');
|
||||
if (normalized.Length == 0 || normalized.Split('/').Any(part => part.Length == 0 || part is "." or ".."))
|
||||
throw new InvalidDataException($"The AMPR relative path is invalid: {path}");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string Key(string path) => path.Replace('\\', '/').ToLowerInvariant();
|
||||
private static long Align(long value, int alignment) => checked((value + alignment - 1) / alignment * alignment);
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed record ExfatAmprRefreshResult(
|
||||
string ImagePath,
|
||||
int RecordCount,
|
||||
int IndexBytes,
|
||||
uint FirstCluster,
|
||||
int ClusterCount,
|
||||
string Sha256,
|
||||
bool Changed)
|
||||
{
|
||||
public bool Created { get; init; }
|
||||
public bool ImageGrew { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or refreshes the root AMPRIDX3 file in an exFAT image. Metadata changes are journaled in memory and
|
||||
/// rolled back if cancellation or post-write verification fails.
|
||||
/// </summary>
|
||||
public static class ExfatAmprPatcher
|
||||
{
|
||||
private const string IndexPath = "ampr_emu.index";
|
||||
private const string MarkerPath = "fakelib/libSceAmpr.sprx";
|
||||
private const int MaximumIndexBytes = 256 * 1024 * 1024;
|
||||
private const int MaximumIndexClusters = 4096;
|
||||
private const int DesiredSlackClusters = 16;
|
||||
private const uint FatEnd = 0xFFFFFFFF;
|
||||
|
||||
public static Task<ExfatAmprRefreshResult> RefreshAsync(string imagePath,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() => Refresh(imagePath, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static ExfatAmprRefreshResult Refresh(string imagePath, IProgress<FfpfscProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
string fullPath = Path.GetFullPath(imagePath);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
progress?.Report(new FfpfscProgress("Building AMPR index", 0, 1));
|
||||
|
||||
byte[] replacement;
|
||||
AmprIndexInfo indexInfo;
|
||||
ExistingIndex? existing;
|
||||
VolumeState state;
|
||||
using (var image = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
1024 * 1024, FileOptions.RandomAccess))
|
||||
using (var volume = new ExfatVolume(image, leaveOpen: true))
|
||||
{
|
||||
if (!volume.Entries.Any(entry => !entry.IsDirectory &&
|
||||
entry.Path.Equals(MarkerPath, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException("The image has no fakelib/libSceAmpr.sprx AMPR marker.");
|
||||
replacement = AmprIndex.Build(volume.Entries.Where(entry => !entry.IsDirectory).Select(entry =>
|
||||
new AmprFileRecord(entry.Path, entry.Size, 0)));
|
||||
if (replacement.Length is <= 0 or > MaximumIndexBytes)
|
||||
throw new InvalidDataException("The refreshed AMPR index has an unsupported size.");
|
||||
indexInfo = AmprIndex.Inspect(replacement);
|
||||
state = VolumeState.Load(image);
|
||||
existing = state.Index;
|
||||
|
||||
if (existing is not null && existing.DataLength == replacement.Length)
|
||||
{
|
||||
uint[] dataClusters = existing.DataClusters(state).ToArray();
|
||||
byte[] original = ReadClusterData(image, state, dataClusters, replacement.Length);
|
||||
if (original.AsSpan().SequenceEqual(replacement))
|
||||
return CreateResult(fullPath, replacement, dataClusters[0], dataClusters.Length, indexInfo,
|
||||
changed: false, created: false, imageGrew: false);
|
||||
|
||||
if (!IsSequential(dataClusters))
|
||||
throw new InvalidOperationException(
|
||||
"The existing AMPR index uses fragmented allocation and cannot be updated safely in place.");
|
||||
var directPlan = new PatchPlan(image.Length);
|
||||
directPlan.HadIndexBefore = true;
|
||||
directPlan.Add(state.ClusterOffset(dataClusters[0]), PadToClusters(replacement,
|
||||
dataClusters.Length, state.ClusterSize));
|
||||
volume.Dispose();
|
||||
image.Dispose();
|
||||
ApplyAndVerify(fullPath, directPlan, replacement, progress, cancellationToken);
|
||||
return CreateResult(fullPath, replacement, dataClusters[0], dataClusters.Length, indexInfo,
|
||||
changed: true, created: false, imageGrew: false);
|
||||
}
|
||||
}
|
||||
|
||||
PatchPlan plan = BuildMigrationPlan(state, replacement, existing);
|
||||
ApplyAndVerify(fullPath, plan, replacement, progress, cancellationToken);
|
||||
return CreateResult(fullPath, replacement, plan.IndexFirstCluster, plan.IndexDataClusters, indexInfo,
|
||||
changed: true, created: existing is null, imageGrew: plan.NewLength > plan.OriginalLength);
|
||||
}
|
||||
|
||||
private static PatchPlan BuildMigrationPlan(VolumeState state, byte[] replacement, ExistingIndex? existing)
|
||||
{
|
||||
int neededClusters = DivideRoundUp(replacement.Length, state.ClusterSize);
|
||||
if (neededClusters is <= 0 or > MaximumIndexClusters)
|
||||
throw new InvalidDataException("The AMPR index is too large for safe exFAT tail allocation.");
|
||||
|
||||
byte[] bitmap = state.BitmapData.ToArray();
|
||||
byte[] fat = state.FatData.ToArray();
|
||||
if (existing is not null)
|
||||
{
|
||||
foreach (uint cluster in existing.AllocationClusters(state))
|
||||
{
|
||||
SetBitmap(bitmap, cluster, false);
|
||||
WriteFat(fat, cluster, 0);
|
||||
}
|
||||
}
|
||||
|
||||
int entryBytes = BuildFileEntrySet(IndexPath, 2, replacement.Length, state.UpcaseMap).Length;
|
||||
bool rootNeedsExtension = existing is null && state.RootInsertOffset + entryBytes + 32 > state.RootData.Length;
|
||||
int requiredClusters = checked(neededClusters + (rootNeedsExtension ? 1 : 0));
|
||||
(uint first, int count) = FindTailRun(bitmap, state.ClusterCount, requiredClusters,
|
||||
checked(requiredClusters + DesiredSlackClusters));
|
||||
|
||||
uint newClusterCount = state.ClusterCount;
|
||||
long newLength = state.ImageLength;
|
||||
byte[] boot = state.BootRegions.ToArray();
|
||||
if (first == 0)
|
||||
{
|
||||
uint extra = checked((uint)(requiredClusters + DesiredSlackClusters));
|
||||
newClusterCount = checked(state.ClusterCount + extra);
|
||||
EnsureGrowthCapacity(state, newClusterCount);
|
||||
int newBitmapLength = DivideRoundUp(newClusterCount, 8);
|
||||
Array.Resize(ref bitmap, newBitmapLength);
|
||||
first = checked(state.ClusterCount + 2);
|
||||
count = checked((int)extra);
|
||||
newLength = BuildGrownBoot(state, boot, bitmap, newClusterCount);
|
||||
}
|
||||
|
||||
uint rootExtensionCluster = rootNeedsExtension ? first : 0;
|
||||
uint indexFirstCluster = checked(first + (rootNeedsExtension ? 1u : 0u));
|
||||
int indexAllocationClusters = count - (rootNeedsExtension ? 1 : 0);
|
||||
if (indexAllocationClusters < neededClusters)
|
||||
throw new InvalidDataException("The selected exFAT tail allocation is shorter than the AMPR index.");
|
||||
|
||||
for (int index = 0; index < count; index++) SetBitmap(bitmap, checked(first + (uint)index), true);
|
||||
if (rootNeedsExtension)
|
||||
{
|
||||
WriteFat(fat, state.RootClusters[^1], rootExtensionCluster);
|
||||
WriteFat(fat, rootExtensionCluster, FatEnd);
|
||||
}
|
||||
WriteFatChain(fat, indexFirstCluster, indexAllocationClusters);
|
||||
|
||||
int newBitmapLengthFinal = DivideRoundUp(newClusterCount, 8);
|
||||
if (bitmap.Length != newBitmapLengthFinal) Array.Resize(ref bitmap, newBitmapLengthFinal);
|
||||
byte[] root = new byte[checked(state.RootData.Length + (rootNeedsExtension ? state.ClusterSize : 0))];
|
||||
state.RootData.CopyTo(root, 0);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(root.AsSpan(state.BitmapEntryOffset + 0x18, 8),
|
||||
checked((ulong)newBitmapLengthFinal));
|
||||
|
||||
byte[] entrySet = BuildFileEntrySet(IndexPath, indexFirstCluster, replacement.Length, state.UpcaseMap);
|
||||
int entryOffset = existing?.RootEntryOffset ?? state.RootInsertOffset;
|
||||
entrySet.CopyTo(root, entryOffset);
|
||||
if (existing is null) root.AsSpan(entryOffset + entrySet.Length, 32).Clear();
|
||||
|
||||
UpdatePercentInUse(boot, state.SectorSize, bitmap, newClusterCount);
|
||||
var plan = new PatchPlan(state.ImageLength)
|
||||
{
|
||||
NewLength = newLength,
|
||||
IndexFirstCluster = indexFirstCluster,
|
||||
IndexDataClusters = neededClusters,
|
||||
HadIndexBefore = existing is not null
|
||||
};
|
||||
plan.Add(0, boot);
|
||||
plan.Add(state.FatOffset, fat);
|
||||
AddClusterStreamPatches(plan, state, state.BitmapClusters, bitmap);
|
||||
|
||||
var rootClusters = state.RootClusters.ToList();
|
||||
if (rootNeedsExtension) rootClusters.Add(rootExtensionCluster);
|
||||
AddClusterStreamPatches(plan, state, rootClusters, root);
|
||||
plan.Add(state.ClusterOffset(indexFirstCluster), PadToClusters(replacement, neededClusters, state.ClusterSize));
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static void ApplyAndVerify(string path, PatchPlan plan, byte[] expected,
|
||||
IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
plan.Apply(path, progress, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
VerifyIndex(path, expected);
|
||||
}
|
||||
catch (Exception writeError)
|
||||
{
|
||||
try
|
||||
{
|
||||
plan.Rollback(path);
|
||||
plan.VerifyRestored(path);
|
||||
VerifyRollback(path, plan.HadIndexBefore);
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new IOException("AMPR update failed and the original exFAT image could not be restored.",
|
||||
new AggregateException(writeError, rollbackError));
|
||||
}
|
||||
if (writeError is OperationCanceledException) throw;
|
||||
throw new IOException("AMPR update verification failed; the original exFAT image was restored.", writeError);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyIndex(string imagePath, byte[] expected)
|
||||
{
|
||||
using var image = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
|
||||
1024 * 1024, FileOptions.RandomAccess);
|
||||
using var volume = new ExfatVolume(image, leaveOpen: true);
|
||||
byte[] actual = volume.ReadAllBytes(IndexPath, MaximumIndexBytes);
|
||||
if (!actual.AsSpan().SequenceEqual(expected))
|
||||
throw new InvalidDataException("The AMPR index differs after writing.");
|
||||
_ = AmprIndex.Inspect(actual);
|
||||
}
|
||||
|
||||
private static void VerifyRollback(string imagePath, bool hadIndex)
|
||||
{
|
||||
using var image = File.OpenRead(imagePath);
|
||||
using var volume = new ExfatVolume(image);
|
||||
if ((volume.Find(IndexPath) is not null) != hadIndex)
|
||||
throw new InvalidDataException("The original AMPR directory entry was not restored.");
|
||||
}
|
||||
|
||||
private static ExfatAmprRefreshResult CreateResult(string path, byte[] index, uint firstCluster,
|
||||
int clusterCount, AmprIndexInfo info, bool changed, bool created, bool imageGrew) =>
|
||||
new(path, info.RecordCount, index.Length, firstCluster, clusterCount,
|
||||
Convert.ToHexString(SHA256.HashData(index)).ToLowerInvariant(), changed)
|
||||
{
|
||||
Created = created,
|
||||
ImageGrew = imageGrew
|
||||
};
|
||||
|
||||
private static byte[] ReadClusterData(Stream image, VolumeState state, IReadOnlyList<uint> clusters, int length)
|
||||
{
|
||||
byte[] result = new byte[length];
|
||||
int copied = 0;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
int count = Math.Min(state.ClusterSize, length - copied);
|
||||
if (count <= 0) break;
|
||||
image.Position = state.ClusterOffset(cluster);
|
||||
image.ReadExactly(result.AsSpan(copied, count));
|
||||
copied += count;
|
||||
}
|
||||
if (copied != length) throw new EndOfStreamException("The AMPR allocation is shorter than its data length.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddClusterStreamPatches(PatchPlan plan, VolumeState state,
|
||||
IReadOnlyList<uint> clusters, byte[] data)
|
||||
{
|
||||
int copied = 0;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
if (copied >= data.Length) break;
|
||||
int count = Math.Min(state.ClusterSize, data.Length - copied);
|
||||
plan.Add(state.ClusterOffset(cluster), data.AsSpan(copied, count).ToArray());
|
||||
copied += count;
|
||||
}
|
||||
if (copied != data.Length) throw new InvalidDataException("An exFAT metadata stream exceeds its allocation.");
|
||||
}
|
||||
|
||||
private static byte[] PadToClusters(byte[] data, int clusterCount, int clusterSize)
|
||||
{
|
||||
byte[] result = new byte[checked(clusterCount * clusterSize)];
|
||||
data.CopyTo(result, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (uint First, int Count) FindTailRun(byte[] bitmap, uint clusterCount, int needed, int desired)
|
||||
{
|
||||
uint selected = 0;
|
||||
int selectedCount = 0;
|
||||
uint cluster = 2;
|
||||
uint end = checked(clusterCount + 2);
|
||||
while (cluster < end)
|
||||
{
|
||||
while (cluster < end && GetBitmap(bitmap, cluster)) cluster++;
|
||||
uint first = cluster;
|
||||
while (cluster < end && !GetBitmap(bitmap, cluster)) cluster++;
|
||||
int count = checked((int)(cluster - first));
|
||||
if (count >= needed)
|
||||
{
|
||||
selected = first;
|
||||
selectedCount = Math.Min(count, desired);
|
||||
}
|
||||
}
|
||||
return (selected, selectedCount);
|
||||
}
|
||||
|
||||
private static void EnsureGrowthCapacity(VolumeState state, uint newClusterCount)
|
||||
{
|
||||
long fatCapacity = state.FatData.LongLength / 4 - 2;
|
||||
if (newClusterCount > fatCapacity)
|
||||
throw new IOException("The exFAT FAT has no spare capacity for safe tail growth.");
|
||||
long bitmapCapacity = checked((long)state.BitmapClusters.Count * state.ClusterSize * 8);
|
||||
if (newClusterCount > bitmapCapacity)
|
||||
throw new IOException("The exFAT allocation bitmap has no spare capacity for safe tail growth.");
|
||||
if (newClusterCount > 0xFFFFFF00u)
|
||||
throw new IOException("The exFAT image is too large to grow safely.");
|
||||
}
|
||||
|
||||
private static long BuildGrownBoot(VolumeState state, byte[] boot, byte[] bitmap, uint newClusterCount)
|
||||
{
|
||||
long sectorsPerCluster = state.ClusterSize / state.SectorSize;
|
||||
ulong oldDataEnd = checked(state.HeapOffsetSectors + (ulong)state.ClusterCount * (ulong)sectorsPerCluster);
|
||||
ulong tailPad = state.VolumeLengthSectors > oldDataEnd ? state.VolumeLengthSectors - oldDataEnd : 0;
|
||||
if (tailPad < 24) tailPad = 24;
|
||||
ulong newDataEnd = checked(state.HeapOffsetSectors + (ulong)newClusterCount * (ulong)sectorsPerCluster);
|
||||
ulong newVolumeLength = checked(newDataEnd + tailPad);
|
||||
long newLength = checked((long)newVolumeLength * state.SectorSize);
|
||||
for (int copy = 0; copy < 2; copy++)
|
||||
{
|
||||
Span<byte> region = boot.AsSpan(copy * 12 * state.SectorSize, 12 * state.SectorSize);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(region.Slice(0x48, 8), newVolumeLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(region.Slice(0x5C, 4), newClusterCount);
|
||||
WriteBootChecksum(region, state.SectorSize);
|
||||
}
|
||||
return newLength;
|
||||
}
|
||||
|
||||
private static void UpdatePercentInUse(byte[] boot, int sectorSize, byte[] bitmap, uint clusterCount)
|
||||
{
|
||||
long allocated = 0;
|
||||
for (uint cluster = 2; cluster < clusterCount + 2; cluster++)
|
||||
if (GetBitmap(bitmap, cluster)) allocated++;
|
||||
byte percent = clusterCount == 0 ? (byte)0 :
|
||||
checked((byte)Math.Min(100, (allocated * 100 + clusterCount - 1) / clusterCount));
|
||||
for (int copy = 0; copy < 2; copy++)
|
||||
{
|
||||
Span<byte> region = boot.AsSpan(copy * 12 * sectorSize, 12 * sectorSize);
|
||||
region[0x70] = percent;
|
||||
WriteBootChecksum(region, sectorSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteBootChecksum(Span<byte> region, int sectorSize)
|
||||
{
|
||||
uint checksum = 0;
|
||||
for (int index = 0; index < 11 * sectorSize; index++)
|
||||
{
|
||||
if (index is 106 or 107 or 112) continue;
|
||||
checksum = unchecked((checksum >> 1 | checksum << 31) + region[index]);
|
||||
}
|
||||
for (int index = 11 * sectorSize; index < 12 * sectorSize; index += 4)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(region.Slice(index, 4), checksum);
|
||||
}
|
||||
|
||||
private static byte[] BuildFileEntrySet(string name, uint firstCluster, long size, ushort[] upcaseMap)
|
||||
{
|
||||
int nameEntries = DivideRoundUp(name.Length, 15);
|
||||
int secondaryCount = checked(1 + nameEntries);
|
||||
byte[] result = new byte[checked((secondaryCount + 1) * 32)];
|
||||
Span<byte> file = result.AsSpan(0, 32);
|
||||
file[0] = 0x85;
|
||||
file[1] = checked((byte)secondaryCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(file.Slice(4, 2), 0x20);
|
||||
const uint fixedTimestamp = ((2024 - 1980u) << 25) | (1u << 21) | (1u << 16);
|
||||
for (int offset = 8; offset <= 0x10; offset += 4)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(file.Slice(offset, 4), fixedTimestamp);
|
||||
|
||||
Span<byte> stream = result.AsSpan(32, 32);
|
||||
stream[0] = 0xC0;
|
||||
stream[1] = 0x03;
|
||||
stream[3] = checked((byte)name.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(stream.Slice(4, 2), NameHash(name, upcaseMap));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(stream.Slice(8, 8), checked((ulong)size));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(stream.Slice(0x14, 4), firstCluster);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(stream.Slice(0x18, 8), checked((ulong)size));
|
||||
byte[] nameBytes = Encoding.Unicode.GetBytes(name);
|
||||
for (int index = 0; index < nameEntries; index++)
|
||||
{
|
||||
Span<byte> target = result.AsSpan((index + 2) * 32, 32);
|
||||
target[0] = 0xC1;
|
||||
int source = index * 30;
|
||||
nameBytes.AsSpan(source, Math.Min(30, nameBytes.Length - source)).CopyTo(target.Slice(2));
|
||||
}
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(file.Slice(2, 2), EntrySetChecksum(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ushort[] BuildUpcaseMap(byte[] data)
|
||||
{
|
||||
var result = new ushort[char.MaxValue + 1];
|
||||
int source = 0;
|
||||
int codePoint = 0;
|
||||
while (source < data.Length && codePoint < result.Length)
|
||||
{
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(source, 2));
|
||||
source += 2;
|
||||
if (value != ushort.MaxValue)
|
||||
{
|
||||
result[codePoint++] = value;
|
||||
continue;
|
||||
}
|
||||
if (source == data.Length && codePoint == ushort.MaxValue)
|
||||
{
|
||||
result[codePoint++] = ushort.MaxValue;
|
||||
continue;
|
||||
}
|
||||
if (source + 2 > data.Length) throw new InvalidDataException("The exFAT up-case table is truncated.");
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(source, 2));
|
||||
source += 2;
|
||||
if (count <= 0 || count > result.Length - codePoint)
|
||||
throw new InvalidDataException("The exFAT up-case table has an invalid identity run.");
|
||||
for (int index = 0; index < count; index++) result[codePoint] = checked((ushort)codePoint++);
|
||||
}
|
||||
if (source != data.Length || codePoint != result.Length)
|
||||
throw new InvalidDataException("The exFAT up-case table does not cover the Unicode BMP.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ushort NameHash(string name, ushort[] upcaseMap)
|
||||
{
|
||||
ushort hash = 0;
|
||||
foreach (char value in name)
|
||||
{
|
||||
ushort upper = upcaseMap[value];
|
||||
hash = unchecked((ushort)((hash >> 1 | hash << 15) + (byte)upper));
|
||||
hash = unchecked((ushort)((hash >> 1 | hash << 15) + (byte)(upper >> 8)));
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static ushort EntrySetChecksum(byte[] entries)
|
||||
{
|
||||
ushort checksum = 0;
|
||||
for (int index = 0; index < entries.Length; index++)
|
||||
{
|
||||
if (index is 2 or 3) continue;
|
||||
checksum = unchecked((ushort)((checksum >> 1 | checksum << 15) + entries[index]));
|
||||
}
|
||||
return checksum;
|
||||
}
|
||||
|
||||
private static bool IsSequential(IReadOnlyList<uint> clusters) =>
|
||||
clusters.Count > 0 && !clusters.Skip(1).Where((cluster, index) => cluster != clusters[0] + index + 1).Any();
|
||||
private static int DivideRoundUp(long value, int divisor) =>
|
||||
value == 0 ? 0 : checked((int)((value - 1) / divisor + 1));
|
||||
private static bool GetBitmap(byte[] bitmap, uint cluster)
|
||||
{
|
||||
uint bit = checked(cluster - 2);
|
||||
return (bitmap[bit / 8] & (1 << checked((int)(bit % 8)))) != 0;
|
||||
}
|
||||
private static void SetBitmap(byte[] bitmap, uint cluster, bool allocated)
|
||||
{
|
||||
uint bit = checked(cluster - 2);
|
||||
int index = checked((int)(bit / 8));
|
||||
byte mask = checked((byte)(1 << checked((int)(bit % 8))));
|
||||
if (allocated) bitmap[index] |= mask;
|
||||
else bitmap[index] &= unchecked((byte)~mask);
|
||||
}
|
||||
private static uint ReadFat(byte[] fat, uint cluster) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(fat.AsSpan(checked((int)cluster * 4), 4));
|
||||
private static void WriteFat(byte[] fat, uint cluster, uint value) =>
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(fat.AsSpan(checked((int)cluster * 4), 4), value);
|
||||
private static void WriteFatChain(byte[] fat, uint first, int count)
|
||||
{
|
||||
for (int index = 0; index < count; index++)
|
||||
WriteFat(fat, checked(first + (uint)index), index + 1 < count ? checked(first + (uint)index + 1) : FatEnd);
|
||||
}
|
||||
|
||||
private sealed class ExistingIndex(int rootEntryOffset, long dataLength, uint firstCluster, byte streamFlags)
|
||||
{
|
||||
public int RootEntryOffset { get; } = rootEntryOffset;
|
||||
public long DataLength { get; } = dataLength;
|
||||
public uint FirstCluster { get; } = firstCluster;
|
||||
public byte StreamFlags { get; } = streamFlags;
|
||||
public IReadOnlyList<uint> DataClusters(VolumeState state) =>
|
||||
state.GetClusters(FirstCluster, DivideRoundUp(DataLength, state.ClusterSize), StreamFlags, includeFatSlack: false);
|
||||
public IReadOnlyList<uint> AllocationClusters(VolumeState state) =>
|
||||
state.GetClusters(FirstCluster, DivideRoundUp(DataLength, state.ClusterSize), StreamFlags, includeFatSlack: true);
|
||||
}
|
||||
|
||||
private sealed class VolumeState
|
||||
{
|
||||
public required int SectorSize { get; init; }
|
||||
public required int ClusterSize { get; init; }
|
||||
public required uint ClusterCount { get; init; }
|
||||
public required ulong VolumeLengthSectors { get; init; }
|
||||
public required ulong HeapOffsetSectors { get; init; }
|
||||
public required long FatOffset { get; init; }
|
||||
public required long ClusterHeapOffset { get; init; }
|
||||
public required long ImageLength { get; init; }
|
||||
public required byte[] BootRegions { get; init; }
|
||||
public required byte[] FatData { get; init; }
|
||||
public required byte[] BitmapData { get; init; }
|
||||
public required IReadOnlyList<uint> BitmapClusters { get; init; }
|
||||
public required int BitmapEntryOffset { get; init; }
|
||||
public required byte[] RootData { get; init; }
|
||||
public required IReadOnlyList<uint> RootClusters { get; init; }
|
||||
public required int RootInsertOffset { get; init; }
|
||||
public required ushort[] UpcaseMap { get; init; }
|
||||
public ExistingIndex? Index { get; init; }
|
||||
|
||||
public long ClusterOffset(uint cluster) => checked(ClusterHeapOffset + ((long)cluster - 2) * ClusterSize);
|
||||
|
||||
public IReadOnlyList<uint> GetClusters(uint first, int minimumCount, byte flags, bool includeFatSlack)
|
||||
{
|
||||
if (first < 2 || first >= ClusterCount + 2) throw new InvalidDataException("An exFAT cluster is invalid.");
|
||||
var result = new List<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
uint cluster = first;
|
||||
bool reachedEnd = false;
|
||||
while (cluster < 0xFFFFFFF8)
|
||||
{
|
||||
if (cluster < 2 || cluster >= FatData.Length / 4 || !visited.Add(cluster)) break;
|
||||
result.Add(cluster);
|
||||
uint next = ReadFat(FatData, cluster);
|
||||
if (next >= 0xFFFFFFF8)
|
||||
{
|
||||
reachedEnd = true;
|
||||
break;
|
||||
}
|
||||
if (next < 2 || next >= ClusterCount + 2) break;
|
||||
cluster = next;
|
||||
if (result.Count > MaximumIndexClusters + DesiredSlackClusters && includeFatSlack) break;
|
||||
}
|
||||
if (reachedEnd && result.Count >= minimumCount && (includeFatSlack || result.Count == minimumCount))
|
||||
return includeFatSlack ? result : result.Take(minimumCount).ToArray();
|
||||
if ((flags & 0x02) != 0 && (long)first + minimumCount <= ClusterCount + 2)
|
||||
return Enumerable.Range(checked((int)first), minimumCount).Select(value => checked((uint)value)).ToArray();
|
||||
throw new InvalidDataException("An exFAT allocation chain is shorter than its declared size.");
|
||||
}
|
||||
|
||||
public static VolumeState Load(FileStream image)
|
||||
{
|
||||
byte[] boot = ReadAt(image, 0, 512);
|
||||
if (!boot.AsSpan(3, 8).SequenceEqual("EXFAT "u8))
|
||||
throw new InvalidDataException("The image is not an exFAT filesystem.");
|
||||
int sectorSize = checked(1 << boot[0x6C]);
|
||||
int clusterSize = checked(sectorSize * (1 << boot[0x6D]));
|
||||
uint fatOffsetSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x50, 4));
|
||||
uint fatLengthSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x54, 4));
|
||||
uint heapOffsetSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x58, 4));
|
||||
uint clusterCount = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x5C, 4));
|
||||
uint rootCluster = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x60, 4));
|
||||
ulong volumeLength = BinaryPrimitives.ReadUInt64LittleEndian(boot.AsSpan(0x48, 8));
|
||||
long fatOffset = checked((long)fatOffsetSectors * sectorSize);
|
||||
int fatBytes = checked((int)((long)fatLengthSectors * sectorSize));
|
||||
if (fatBytes > 512 * 1024 * 1024) throw new InvalidDataException("The exFAT FAT is too large to patch safely.");
|
||||
byte[] fat = ReadAt(image, fatOffset, fatBytes);
|
||||
long heapOffset = checked((long)heapOffsetSectors * sectorSize);
|
||||
|
||||
IReadOnlyList<uint> rootClusters = ReadChain(fat, rootCluster, clusterCount);
|
||||
byte[] root = ReadClusters(image, heapOffset, clusterSize, rootClusters);
|
||||
int bitmapEntryOffset = -1;
|
||||
uint bitmapCluster = 0;
|
||||
long bitmapLength = 0;
|
||||
uint upcaseCluster = 0;
|
||||
long upcaseLength = 0;
|
||||
int insertOffset = root.Length;
|
||||
ExistingIndex? existing = null;
|
||||
for (int offset = 0; offset + 32 <= root.Length;)
|
||||
{
|
||||
byte type = root[offset];
|
||||
if (type == 0)
|
||||
{
|
||||
insertOffset = offset;
|
||||
break;
|
||||
}
|
||||
if (type == 0x81)
|
||||
{
|
||||
bitmapEntryOffset = offset;
|
||||
bitmapCluster = BinaryPrimitives.ReadUInt32LittleEndian(root.AsSpan(offset + 0x14, 4));
|
||||
bitmapLength = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(root.AsSpan(offset + 0x18, 8)));
|
||||
offset += 32;
|
||||
continue;
|
||||
}
|
||||
if (type == 0x82)
|
||||
{
|
||||
upcaseCluster = BinaryPrimitives.ReadUInt32LittleEndian(root.AsSpan(offset + 0x14, 4));
|
||||
upcaseLength = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(root.AsSpan(offset + 0x18, 8)));
|
||||
offset += 32;
|
||||
continue;
|
||||
}
|
||||
if (type != 0x85)
|
||||
{
|
||||
offset += 32;
|
||||
continue;
|
||||
}
|
||||
int entryCount = root[offset + 1] + 1;
|
||||
if (entryCount < 3 || offset + entryCount * 32 > root.Length || root[offset + 32] != 0xC0)
|
||||
throw new InvalidDataException("The exFAT root directory contains an invalid entry set.");
|
||||
Span<byte> stream = root.AsSpan(offset + 32, 32);
|
||||
int nameLength = stream[3];
|
||||
byte[] nameBytes = new byte[nameLength * 2];
|
||||
int copied = 0;
|
||||
for (int index = 0; copied < nameBytes.Length; index++)
|
||||
{
|
||||
Span<byte> nameEntry = root.AsSpan(offset + (index + 2) * 32, 32);
|
||||
if (nameEntry[0] != 0xC1) throw new InvalidDataException("An exFAT filename entry is missing.");
|
||||
int count = Math.Min(30, nameBytes.Length - copied);
|
||||
nameEntry.Slice(2, count).CopyTo(nameBytes.AsSpan(copied));
|
||||
copied += count;
|
||||
}
|
||||
string name = Encoding.Unicode.GetString(nameBytes);
|
||||
if (name.Equals(IndexPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ushort attributes = BinaryPrimitives.ReadUInt16LittleEndian(root.AsSpan(offset + 4, 2));
|
||||
if ((attributes & 0x10) != 0) throw new InvalidDataException("The root ampr_emu.index is a directory.");
|
||||
long length = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(stream.Slice(0x18, 8)));
|
||||
uint first = BinaryPrimitives.ReadUInt32LittleEndian(stream.Slice(0x14, 4));
|
||||
existing = new ExistingIndex(offset, length, first, stream[1]);
|
||||
}
|
||||
offset += entryCount * 32;
|
||||
}
|
||||
if (bitmapEntryOffset < 0 || bitmapLength <= 0 || bitmapLength > int.MaxValue)
|
||||
throw new InvalidDataException("The exFAT root allocation bitmap is missing or invalid.");
|
||||
if (upcaseCluster < 2 || upcaseLength <= 0 || upcaseLength > 1024 * 1024)
|
||||
throw new InvalidDataException("The exFAT up-case table is missing or invalid.");
|
||||
IReadOnlyList<uint> bitmapClusters = ReadChain(fat, bitmapCluster, clusterCount);
|
||||
if ((long)bitmapClusters.Count * clusterSize < bitmapLength)
|
||||
throw new InvalidDataException("The exFAT allocation bitmap chain is truncated.");
|
||||
byte[] bitmap = ReadClusterStream(image, heapOffset, clusterSize, bitmapClusters, checked((int)bitmapLength));
|
||||
IReadOnlyList<uint> upcaseClusters = ReadChain(fat, upcaseCluster, clusterCount);
|
||||
byte[] upcase = ReadClusterStream(image, heapOffset, clusterSize, upcaseClusters, checked((int)upcaseLength));
|
||||
return new VolumeState
|
||||
{
|
||||
SectorSize = sectorSize,
|
||||
ClusterSize = clusterSize,
|
||||
ClusterCount = clusterCount,
|
||||
VolumeLengthSectors = volumeLength,
|
||||
HeapOffsetSectors = heapOffsetSectors,
|
||||
FatOffset = fatOffset,
|
||||
ClusterHeapOffset = heapOffset,
|
||||
ImageLength = image.Length,
|
||||
BootRegions = ReadAt(image, 0, checked(24 * sectorSize)),
|
||||
FatData = fat,
|
||||
BitmapData = bitmap,
|
||||
BitmapClusters = bitmapClusters,
|
||||
BitmapEntryOffset = bitmapEntryOffset,
|
||||
RootData = root,
|
||||
RootClusters = rootClusters,
|
||||
RootInsertOffset = insertOffset,
|
||||
UpcaseMap = BuildUpcaseMap(upcase),
|
||||
Index = existing
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> ReadChain(byte[] fat, uint first, uint clusterCount)
|
||||
{
|
||||
var result = new List<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
uint cluster = first;
|
||||
while (cluster < 0xFFFFFFF8)
|
||||
{
|
||||
if (cluster < 2 || cluster >= clusterCount + 2 || !visited.Add(cluster))
|
||||
throw new InvalidDataException("An exFAT metadata chain is invalid or cyclic.");
|
||||
result.Add(cluster);
|
||||
cluster = ReadFat(fat, cluster);
|
||||
if (result.Count > clusterCount) throw new InvalidDataException("An exFAT metadata chain is too long.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] ReadClusters(Stream image, long heapOffset, int clusterSize,
|
||||
IReadOnlyList<uint> clusters) => ReadClusterStream(image, heapOffset, clusterSize, clusters,
|
||||
checked(clusters.Count * clusterSize));
|
||||
|
||||
private static byte[] ReadClusterStream(Stream image, long heapOffset, int clusterSize,
|
||||
IReadOnlyList<uint> clusters, int length)
|
||||
{
|
||||
byte[] result = new byte[length];
|
||||
int copied = 0;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
int count = Math.Min(clusterSize, length - copied);
|
||||
if (count <= 0) break;
|
||||
image.Position = checked(heapOffset + ((long)cluster - 2) * clusterSize);
|
||||
image.ReadExactly(result.AsSpan(copied, count));
|
||||
copied += count;
|
||||
}
|
||||
if (copied != length) throw new EndOfStreamException("An exFAT metadata stream is truncated.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PatchPlan(long originalLength)
|
||||
{
|
||||
private readonly List<Patch> _patches = [];
|
||||
public long OriginalLength { get; } = originalLength;
|
||||
public long NewLength { get; set; } = originalLength;
|
||||
public uint IndexFirstCluster { get; set; }
|
||||
public int IndexDataClusters { get; set; }
|
||||
public bool HadIndexBefore { get; set; }
|
||||
|
||||
public void Add(long offset, byte[] data)
|
||||
{
|
||||
if (offset < 0 || data.Length == 0) throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
long end = checked(offset + data.Length);
|
||||
if (_patches.Any(patch => offset < patch.Offset + patch.Data.Length && patch.Offset < end))
|
||||
throw new InvalidOperationException("The exFAT patch plan contains overlapping byte ranges.");
|
||||
_patches.Add(new Patch(offset, data));
|
||||
}
|
||||
|
||||
public void Apply(string path, IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
long total = _patches.Sum(patch => (long)patch.Data.Length);
|
||||
long written = 0;
|
||||
using var image = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read,
|
||||
1024 * 1024, FileOptions.RandomAccess);
|
||||
if (image.Length != OriginalLength) throw new IOException("The exFAT image changed before it could be updated.");
|
||||
try
|
||||
{
|
||||
if (NewLength > OriginalLength) image.SetLength(NewLength);
|
||||
foreach (Patch patch in _patches)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int originalCount = checked((int)Math.Min(patch.Data.Length,
|
||||
Math.Max(0, OriginalLength - patch.Offset)));
|
||||
patch.Original = originalCount == 0 ? [] : ReadAt(image, patch.Offset, originalCount);
|
||||
image.Position = patch.Offset;
|
||||
image.Write(patch.Data);
|
||||
patch.Applied = true;
|
||||
written += patch.Data.Length;
|
||||
progress?.Report(new FfpfscProgress("Writing AMPR index", written, total));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Restore(image);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Rollback(string path)
|
||||
{
|
||||
using var image = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read,
|
||||
1024 * 1024, FileOptions.RandomAccess);
|
||||
Restore(image);
|
||||
}
|
||||
|
||||
public void VerifyRestored(string path)
|
||||
{
|
||||
using var image = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
|
||||
1024 * 1024, FileOptions.RandomAccess);
|
||||
if (image.Length != OriginalLength)
|
||||
throw new InvalidDataException("The original exFAT image length was not restored.");
|
||||
foreach (Patch patch in _patches)
|
||||
{
|
||||
if (!patch.Applied || patch.Original is null || patch.Original.Length == 0) continue;
|
||||
byte[] actual = ReadAt(image, patch.Offset, patch.Original.Length);
|
||||
if (!actual.AsSpan().SequenceEqual(patch.Original))
|
||||
throw new InvalidDataException("An original exFAT metadata range was not restored.");
|
||||
}
|
||||
}
|
||||
|
||||
private void Restore(FileStream image)
|
||||
{
|
||||
foreach (Patch patch in _patches.AsEnumerable().Reverse())
|
||||
{
|
||||
if (!patch.Applied || patch.Original is null || patch.Original.Length == 0) continue;
|
||||
image.Position = patch.Offset;
|
||||
image.Write(patch.Original);
|
||||
}
|
||||
image.SetLength(OriginalLength);
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Patch(long offset, byte[] data)
|
||||
{
|
||||
public long Offset { get; } = offset;
|
||||
public byte[] Data { get; } = data;
|
||||
public byte[]? Original { get; set; }
|
||||
public bool Applied { get; set; }
|
||||
}
|
||||
|
||||
private static byte[] ReadAt(Stream stream, long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > stream.Length || count > stream.Length - offset)
|
||||
throw new EndOfStreamException("An exFAT patch read is outside the image.");
|
||||
byte[] result = new byte[count];
|
||||
stream.Position = offset;
|
||||
stream.ReadExactly(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed class ExfatBuildOptions
|
||||
{
|
||||
public int? ClusterSize { get; init; }
|
||||
public bool GenerateAmprIndex { get; init; } = true;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (ClusterSize is int size &&
|
||||
(size is < 0x1000 or > 0x2000000 || (size & (size - 1)) != 0 || size % 512 != 0))
|
||||
throw new ArgumentOutOfRangeException(nameof(ClusterSize),
|
||||
"The exFAT cluster size must be a power of two from 4 KiB through 32 MiB.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExfatImageSource : IDisposable
|
||||
{
|
||||
internal ExfatImageSource(Stream stream, long length, int clusterSize, int fileCount, int directoryCount)
|
||||
{
|
||||
Stream = stream;
|
||||
Length = length;
|
||||
ClusterSize = clusterSize;
|
||||
FileCount = fileCount;
|
||||
DirectoryCount = directoryCount;
|
||||
}
|
||||
|
||||
public Stream Stream { get; }
|
||||
public long Length { get; }
|
||||
public int ClusterSize { get; }
|
||||
public int FileCount { get; }
|
||||
public int DirectoryCount { get; }
|
||||
public void Dispose() => Stream.Dispose();
|
||||
}
|
||||
|
||||
public sealed record ExfatVerificationResult(
|
||||
string ImagePath,
|
||||
long ImageSize,
|
||||
int ClusterSize,
|
||||
int FileCount,
|
||||
int DirectoryCount,
|
||||
long LogicalFileBytes,
|
||||
string ManifestSha256);
|
||||
|
||||
/// <summary>Creates a standards-based exFAT image as a forward-only stream over a loose game directory.</summary>
|
||||
public static class ExfatImage
|
||||
{
|
||||
private const int SectorSize = 512;
|
||||
private const int FatOffsetSectors = 128;
|
||||
private const uint FatEnd = 0xFFFFFFFF;
|
||||
private const int FirstCluster = 2;
|
||||
private const int FileChunkSize = 1024 * 1024;
|
||||
private const uint VolumeSerial = 0x4D6B5046; // "MkPF", matching MkPFS deterministic images.
|
||||
private static readonly IComparer<string> MkPfsNameComparer = Comparer<string>.Create((left, right) =>
|
||||
string.Compare(left.ToLowerInvariant(), right.ToLowerInvariant(), StringComparison.Ordinal));
|
||||
private static readonly HashSet<string> IgnoredNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".ds_store", ".spotlight-v100", ".trashes", ".fseventsd", ".temporaryitems",
|
||||
".documentrevisions-v100", ".apdisk", "__macosx", ".volumeicon.icns",
|
||||
"thumbs.db", "ehthumbs.db", "desktop.ini", "$recycle.bin", "system volume information"
|
||||
};
|
||||
|
||||
public static ExfatImageSource OpenDirectory(string sourceDirectory, ExfatBuildOptions? options = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
options ??= new ExfatBuildOptions();
|
||||
options.Validate();
|
||||
string rootPath = Path.GetFullPath(sourceDirectory);
|
||||
if (!Directory.Exists(rootPath)) throw new DirectoryNotFoundException(rootPath);
|
||||
|
||||
ExfatNode root = ScanTree(rootPath);
|
||||
if (options.GenerateAmprIndex) AddVirtualAmprIndex(root);
|
||||
int clusterSize = options.ClusterSize ?? ChooseClusterSize(root);
|
||||
byte[] upcase = BuildUpcaseTable();
|
||||
Layout layout = BuildLayout(root, clusterSize, upcase.Length);
|
||||
IEnumerable<ReadOnlyMemory<byte>> chunks = EmitImage(root, layout, upcase);
|
||||
int fileCount = Walk(root).Count(node => !node.IsDirectory);
|
||||
int directoryCount = Walk(root).Count(node => node.IsDirectory);
|
||||
return new ExfatImageSource(new ChunkSequenceStream(chunks), layout.ImageLength, clusterSize,
|
||||
fileCount, directoryCount);
|
||||
}
|
||||
|
||||
public static async Task WriteDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
ExfatBuildOptions? options = null, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string outputFullPath = Path.GetFullPath(outputPath);
|
||||
if (File.Exists(outputFullPath)) throw new IOException($"The output file already exists: {outputFullPath}");
|
||||
string? directory = Path.GetDirectoryName(outputFullPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
string tempPath = outputFullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
using ExfatImageSource source = OpenDirectory(sourceDirectory, options);
|
||||
await using var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
FileChunkSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
byte[] buffer = new byte[FileChunkSize];
|
||||
long copied = 0;
|
||||
while (copied < source.Length)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int wanted = checked((int)Math.Min(buffer.Length, source.Length - copied));
|
||||
int read = await source.Stream.ReadAsync(buffer.AsMemory(0, wanted), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) throw new EndOfStreamException("The exFAT serializer ended before its planned length.");
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
copied += read;
|
||||
progress?.Report(new FfpfscProgress("Building exFAT", copied, source.Length));
|
||||
}
|
||||
if (source.Stream.ReadByte() != -1) throw new InvalidDataException("The exFAT serializer exceeded its planned length.");
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
output.Close();
|
||||
File.Move(tempPath, outputFullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(tempPath); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<ExfatVerificationResult> VerifyAsync(string imagePath,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() => Verify(imagePath, progress, cancellationToken), cancellationToken);
|
||||
|
||||
public static async Task ExtractDirectoryAsync(string imagePath, string outputDirectory,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
|
||||
string sourcePath = Path.GetFullPath(imagePath);
|
||||
string destination = Path.GetFullPath(outputDirectory);
|
||||
if (Directory.Exists(destination) || File.Exists(destination))
|
||||
throw new IOException($"The extraction destination already exists: {destination}");
|
||||
string? parent = Path.GetDirectoryName(destination);
|
||||
if (string.IsNullOrEmpty(parent)) throw new IOException("The extraction destination has no parent directory.");
|
||||
Directory.CreateDirectory(parent);
|
||||
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(temporary);
|
||||
await using var image = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
FileChunkSize, FileOptions.Asynchronous | FileOptions.RandomAccess);
|
||||
using var volume = new ExfatVolume(image, leaveOpen: true);
|
||||
ExfatEntry[] files = volume.Entries.Where(entry => !entry.IsDirectory)
|
||||
.OrderBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
long totalBytes = files.Sum(entry => entry.Size);
|
||||
long copied = 0;
|
||||
|
||||
foreach (ExfatEntry directoryEntry in volume.Entries.Where(entry => entry.IsDirectory)
|
||||
.OrderBy(entry => entry.Path.Count(character => character == '/')))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Directory.CreateDirectory(ResolveExtractionPath(temporary, directoryEntry.Path));
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[FileChunkSize];
|
||||
foreach (ExfatEntry entry in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string outputPath = ResolveExtractionPath(temporary, entry.Path);
|
||||
string? outputParent = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(outputParent)) Directory.CreateDirectory(outputParent);
|
||||
using Stream input = volume.OpenFile(entry.Path);
|
||||
await using var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, FileChunkSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
copied += read;
|
||||
progress?.Report(new FfpfscProgress("Extracting exFAT", copied, totalBytes));
|
||||
}
|
||||
}
|
||||
Directory.Move(temporary, destination);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExfatVerificationResult Verify(string imagePath, IProgress<FfpfscProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
string fullPath = Path.GetFullPath(imagePath);
|
||||
using var image = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
FileChunkSize, FileOptions.RandomAccess);
|
||||
using var volume = new ExfatVolume(image, leaveOpen: true);
|
||||
ExfatEntry[] files = volume.Entries.Where(entry => !entry.IsDirectory)
|
||||
.OrderBy(entry => entry.Path, StringComparer.Ordinal).ToArray();
|
||||
long totalBytes = files.Sum(entry => entry.Size);
|
||||
long checkedBytes = 0;
|
||||
using IncrementalHash manifest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
byte[] buffer = new byte[FileChunkSize];
|
||||
byte[] sizeBytes = new byte[sizeof(long)];
|
||||
foreach (ExfatEntry entry in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
manifest.AppendData(Encoding.UTF8.GetBytes(entry.Path));
|
||||
manifest.AppendData([0]);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(sizeBytes, entry.Size);
|
||||
manifest.AppendData(sizeBytes);
|
||||
using Stream input = volume.OpenFile(entry.Path);
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int read = input.Read(buffer, 0, buffer.Length);
|
||||
if (read == 0) break;
|
||||
manifest.AppendData(buffer, 0, read);
|
||||
checkedBytes += read;
|
||||
progress?.Report(new FfpfscProgress("Verifying exFAT", checkedBytes, totalBytes));
|
||||
}
|
||||
}
|
||||
return new ExfatVerificationResult(fullPath, image.Length, volume.ClusterSize, files.Length,
|
||||
volume.Entries.Count(entry => entry.IsDirectory) + 1, totalBytes,
|
||||
Convert.ToHexString(manifest.GetHashAndReset()).ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string ResolveExtractionPath(string root, string relativePath)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(Path.Combine(root,
|
||||
relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
string prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
if (!fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("An exFAT entry leaves the extraction directory.");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static ExfatNode ScanTree(string rootPath)
|
||||
{
|
||||
var root = new ExfatNode(string.Empty, string.Empty, true, 0, null);
|
||||
ScanDirectory(rootPath, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void ScanDirectory(string directoryPath, ExfatNode parent)
|
||||
{
|
||||
var entries = new DirectoryInfo(directoryPath).EnumerateFileSystemInfos()
|
||||
.OrderBy(entry => entry.Name, MkPfsNameComparer).ToArray();
|
||||
foreach (FileSystemInfo entry in entries)
|
||||
{
|
||||
if (IsIgnoredName(entry.Name) || (entry.Attributes & FileAttributes.ReparsePoint) != 0) continue;
|
||||
ValidateName(entry.Name);
|
||||
string relative = parent.RelativePath.Length == 0
|
||||
? entry.Name
|
||||
: parent.RelativePath + '/' + entry.Name;
|
||||
if (entry is DirectoryInfo directory)
|
||||
{
|
||||
var child = new ExfatNode(relative, entry.Name, true, 0, null);
|
||||
parent.Children.Add(child);
|
||||
ScanDirectory(directory.FullName, child);
|
||||
}
|
||||
else if (entry is FileInfo file)
|
||||
{
|
||||
parent.Children.Add(new ExfatNode(relative, entry.Name, false, file.Length, file.FullName,
|
||||
new DateTimeOffset(file.LastWriteTimeUtc).ToUnixTimeSeconds()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIgnoredName(string name) => name.StartsWith("._", StringComparison.Ordinal) ||
|
||||
IgnoredNames.Contains(name);
|
||||
|
||||
private static void AddVirtualAmprIndex(ExfatNode root)
|
||||
{
|
||||
bool hasMarker = Walk(root).Any(node => !node.IsDirectory &&
|
||||
node.RelativePath.Equals("fakelib/libSceAmpr.sprx", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasMarker) return;
|
||||
root.Children.RemoveAll(node => !node.IsDirectory &&
|
||||
(node.Name.Equals("ampr_emu.index", StringComparison.OrdinalIgnoreCase) ||
|
||||
node.Name.Equals("ampr_emu.index.tmp", StringComparison.OrdinalIgnoreCase)));
|
||||
byte[] index = AmprIndex.Build(Walk(root).Where(node => !node.IsDirectory).Select(node =>
|
||||
new AmprFileRecord(node.RelativePath, node.Size, node.ModifiedTime)));
|
||||
if (index.Length == 0) return;
|
||||
root.Children.Add(new ExfatNode("ampr_emu.index", "ampr_emu.index", false, index.Length, null,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds(), index));
|
||||
root.Children.Sort((left, right) => MkPfsNameComparer.Compare(left.Name, right.Name));
|
||||
}
|
||||
|
||||
private static void ValidateName(string name)
|
||||
{
|
||||
if (name.Length is < 1 or > 255 || name is "." or ".." ||
|
||||
name.Any(value => value < 0x20 || "\"*/:<>?\\|".Contains(value)))
|
||||
throw new IOException($"The filename cannot be represented safely in exFAT: {name}");
|
||||
}
|
||||
|
||||
private static int ChooseClusterSize(ExfatNode root)
|
||||
{
|
||||
ExfatNode[] files = Walk(root).Where(node => !node.IsDirectory).ToArray();
|
||||
long total = files.Sum(node => node.Size);
|
||||
long average = files.Length == 0 ? 0 : total / files.Length;
|
||||
return average >= 1024 * 1024 ? 0x10000 : 0x8000;
|
||||
}
|
||||
|
||||
private static Layout BuildLayout(ExfatNode root, int clusterSize, int upcaseLength)
|
||||
{
|
||||
int upcaseClusters = DivideRoundUp(upcaseLength, clusterSize);
|
||||
int contentClusters = upcaseClusters;
|
||||
foreach (ExfatNode node in Walk(root))
|
||||
{
|
||||
node.ClusterCount = node.IsDirectory
|
||||
? Math.Max(1, DivideRoundUp(DirectoryEntryCount(node, node == root) * 32L, clusterSize))
|
||||
: DivideRoundUp(node.Size, clusterSize);
|
||||
contentClusters = checked(contentClusters + node.ClusterCount);
|
||||
}
|
||||
|
||||
int sectorsPerCluster = clusterSize / SectorSize;
|
||||
int bitmapClusters;
|
||||
int clusterCount;
|
||||
int fatLengthSectors;
|
||||
int heapOffsetSectors;
|
||||
long volumeSectors;
|
||||
bitmapClusters = 1;
|
||||
while (true)
|
||||
{
|
||||
int total = checked(contentClusters + bitmapClusters);
|
||||
int needed = DivideRoundUp(DivideRoundUp(total, 8), clusterSize);
|
||||
if (needed == bitmapClusters) break;
|
||||
bitmapClusters = needed;
|
||||
}
|
||||
clusterCount = checked(contentClusters + bitmapClusters);
|
||||
int fatEntries = checked(clusterCount + 2);
|
||||
fatLengthSectors = Align(DivideRoundUp(checked((long)fatEntries * 4), SectorSize), sectorsPerCluster);
|
||||
heapOffsetSectors = Align(checked(FatOffsetSectors + fatLengthSectors), sectorsPerCluster);
|
||||
volumeSectors = checked((long)heapOffsetSectors + (long)clusterCount * sectorsPerCluster);
|
||||
|
||||
int nextCluster = checked(FirstCluster + bitmapClusters + upcaseClusters);
|
||||
foreach (ExfatNode node in Walk(root))
|
||||
{
|
||||
if (node.ClusterCount == 0) continue;
|
||||
node.FirstCluster = nextCluster;
|
||||
nextCluster = checked(nextCluster + node.ClusterCount);
|
||||
}
|
||||
if (nextCluster != FirstCluster + clusterCount) throw new InvalidOperationException("exFAT layout accounting failed.");
|
||||
|
||||
if (volumeSectors > uint.MaxValue * (long)sectorsPerCluster)
|
||||
throw new IOException("The dump is too large for this exFAT layout.");
|
||||
|
||||
return new Layout(clusterSize, bitmapClusters, upcaseClusters, clusterCount, root.FirstCluster,
|
||||
fatLengthSectors, heapOffsetSectors, checked(volumeSectors * SectorSize));
|
||||
}
|
||||
|
||||
private static IEnumerable<ReadOnlyMemory<byte>> EmitImage(ExfatNode root, Layout layout, byte[] upcase)
|
||||
{
|
||||
byte[] boot = BuildBootRegion(layout);
|
||||
yield return boot;
|
||||
yield return boot;
|
||||
yield return new byte[(FatOffsetSectors - 24) * SectorSize];
|
||||
foreach (ReadOnlyMemory<byte> chunk in EmitFat(root, layout)) yield return chunk;
|
||||
int paddingSectors = layout.HeapOffsetSectors - (FatOffsetSectors + layout.FatLengthSectors);
|
||||
if (paddingSectors > 0) yield return new byte[paddingSectors * SectorSize];
|
||||
|
||||
foreach (ReadOnlyMemory<byte> chunk in EmitBitmap(layout.ClusterCount, layout.BitmapClusters * layout.ClusterSize))
|
||||
yield return chunk;
|
||||
yield return upcase;
|
||||
int upcasePadding = layout.UpcaseClusters * layout.ClusterSize - upcase.Length;
|
||||
if (upcasePadding > 0) yield return new byte[upcasePadding];
|
||||
|
||||
foreach (ReadOnlyMemory<byte> chunk in EmitDirectory(root, root, layout)) yield return chunk;
|
||||
foreach (ExfatNode child in Walk(root).Skip(1))
|
||||
{
|
||||
if (child.IsDirectory)
|
||||
{
|
||||
foreach (ReadOnlyMemory<byte> chunk in EmitDirectory(root, child, layout)) yield return chunk;
|
||||
continue;
|
||||
}
|
||||
if (child.Size == 0) continue;
|
||||
byte[] buffer = new byte[FileChunkSize];
|
||||
long emitted = 0;
|
||||
if (child.VirtualContent is not null)
|
||||
{
|
||||
yield return child.VirtualContent;
|
||||
long virtualPadding = checked((long)child.ClusterCount * layout.ClusterSize - child.VirtualContent.Length);
|
||||
foreach (ReadOnlyMemory<byte> chunk in ZeroChunks(virtualPadding)) yield return chunk;
|
||||
continue;
|
||||
}
|
||||
using (var input = new FileStream(child.FullPath!, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
FileChunkSize, FileOptions.SequentialScan))
|
||||
{
|
||||
while (emitted < child.Size)
|
||||
{
|
||||
int wanted = checked((int)Math.Min(buffer.Length, child.Size - emitted));
|
||||
int read = input.Read(buffer, 0, wanted);
|
||||
if (read == 0) throw new EndOfStreamException($"The source file changed during conversion: {child.FullPath}");
|
||||
emitted += read;
|
||||
yield return buffer.AsMemory(0, read);
|
||||
}
|
||||
if (input.ReadByte() != -1)
|
||||
throw new IOException($"The source file grew during conversion: {child.FullPath}");
|
||||
}
|
||||
long padding = checked((long)child.ClusterCount * layout.ClusterSize - emitted);
|
||||
foreach (ReadOnlyMemory<byte> chunk in ZeroChunks(padding)) yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ReadOnlyMemory<byte>> EmitFat(ExfatNode root, Layout layout)
|
||||
{
|
||||
var chainEnds = new HashSet<int>
|
||||
{
|
||||
checked(FirstCluster + layout.BitmapClusters - 1),
|
||||
checked(FirstCluster + layout.BitmapClusters + layout.UpcaseClusters - 1)
|
||||
};
|
||||
foreach (ExfatNode node in Walk(root).Where(node => node.ClusterCount > 0))
|
||||
chainEnds.Add(checked(node.FirstCluster + node.ClusterCount - 1));
|
||||
|
||||
long fatBytes = checked((long)layout.FatLengthSectors * SectorSize);
|
||||
byte[] buffer = new byte[FileChunkSize];
|
||||
long offset = 0;
|
||||
while (offset < fatBytes)
|
||||
{
|
||||
int count = checked((int)Math.Min(buffer.Length, fatBytes - offset));
|
||||
Array.Clear(buffer, 0, count);
|
||||
int firstEntry = checked((int)(offset / 4));
|
||||
int entries = count / 4;
|
||||
for (int index = 0; index < entries; index++)
|
||||
{
|
||||
int cluster = firstEntry + index;
|
||||
uint value = cluster switch
|
||||
{
|
||||
0 => 0xFFFFFFF8,
|
||||
1 => FatEnd,
|
||||
_ when cluster > layout.ClusterCount + 1 => 0,
|
||||
_ when chainEnds.Contains(cluster) => FatEnd,
|
||||
_ => checked((uint)(cluster + 1))
|
||||
};
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(index * 4, 4), value);
|
||||
}
|
||||
offset += count;
|
||||
yield return buffer.AsMemory(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ReadOnlyMemory<byte>> EmitBitmap(int clusterCount, int allocationBytes)
|
||||
{
|
||||
byte[] buffer = new byte[Math.Min(FileChunkSize, allocationBytes)];
|
||||
int bytesRemaining = DivideRoundUp(clusterCount, 8);
|
||||
int allocationRemaining = allocationBytes;
|
||||
while (allocationRemaining > 0)
|
||||
{
|
||||
int count = Math.Min(buffer.Length, allocationRemaining);
|
||||
Array.Clear(buffer, 0, count);
|
||||
int used = Math.Min(count, bytesRemaining);
|
||||
Array.Fill(buffer, (byte)0xFF, 0, used);
|
||||
if (used > 0 && bytesRemaining <= count && (clusterCount & 7) != 0)
|
||||
buffer[used - 1] = checked((byte)((1 << (clusterCount & 7)) - 1));
|
||||
bytesRemaining -= used;
|
||||
allocationRemaining -= count;
|
||||
yield return buffer.AsMemory(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ReadOnlyMemory<byte>> EmitDirectory(ExfatNode root, ExfatNode directory, Layout layout)
|
||||
{
|
||||
int emitted = 0;
|
||||
if (directory == root)
|
||||
{
|
||||
byte[] label = new byte[32];
|
||||
label[0] = 0x83;
|
||||
emitted += label.Length;
|
||||
yield return label;
|
||||
|
||||
byte[] bitmap = new byte[32];
|
||||
bitmap[0] = 0x81;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bitmap.AsSpan(0x14, 4), FirstCluster);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bitmap.AsSpan(0x18, 8),
|
||||
checked((ulong)DivideRoundUp(layout.ClusterCount, 8)));
|
||||
emitted += bitmap.Length;
|
||||
yield return bitmap;
|
||||
|
||||
byte[] upcaseEntry = new byte[32];
|
||||
upcaseEntry[0] = 0x82;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(upcaseEntry.AsSpan(0x04, 4), layout.UpcaseChecksum);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(upcaseEntry.AsSpan(0x14, 4),
|
||||
checked((uint)(FirstCluster + layout.BitmapClusters)));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(upcaseEntry.AsSpan(0x18, 8), checked((ulong)layout.UpcaseLength));
|
||||
emitted += upcaseEntry.Length;
|
||||
yield return upcaseEntry;
|
||||
}
|
||||
foreach (ExfatNode child in directory.Children)
|
||||
{
|
||||
byte[] entries = BuildFileEntrySet(child, layout.ClusterSize, layout.UpcaseMap);
|
||||
emitted += entries.Length;
|
||||
yield return entries;
|
||||
}
|
||||
long padding = checked((long)directory.ClusterCount * layout.ClusterSize - emitted);
|
||||
foreach (ReadOnlyMemory<byte> chunk in ZeroChunks(padding)) yield return chunk;
|
||||
}
|
||||
|
||||
private static byte[] BuildFileEntrySet(ExfatNode child, int clusterSize, ushort[] upcaseMap)
|
||||
{
|
||||
int nameEntries = DivideRoundUp(child.Name.Length, 15);
|
||||
int secondaryCount = 1 + nameEntries;
|
||||
byte[] result = new byte[checked((secondaryCount + 1) * 32)];
|
||||
Span<byte> file = result.AsSpan(0, 32);
|
||||
file[0] = 0x85;
|
||||
file[1] = checked((byte)secondaryCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(file.Slice(0x04, 2), child.IsDirectory ? (ushort)0x10 : (ushort)0x20);
|
||||
const uint fixedTimestamp = ((2024 - 1980u) << 25) | (1u << 21) | (1u << 16);
|
||||
for (int offset = 0x08; offset <= 0x10; offset += 4)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(file.Slice(offset, 4), fixedTimestamp);
|
||||
|
||||
Span<byte> stream = result.AsSpan(32, 32);
|
||||
stream[0] = 0xC0;
|
||||
bool allocated = child.FirstCluster >= FirstCluster;
|
||||
stream[1] = allocated ? (byte)0x01 : (byte)0;
|
||||
stream[3] = checked((byte)child.Name.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(stream.Slice(0x04, 2), NameHash(child.Name, upcaseMap));
|
||||
long dataLength = child.IsDirectory ? checked((long)child.ClusterCount * clusterSize) : child.Size;
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(stream.Slice(0x08, 8), checked((ulong)dataLength));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(stream.Slice(0x14, 4), allocated ? checked((uint)child.FirstCluster) : 0);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(stream.Slice(0x18, 8), checked((ulong)dataLength));
|
||||
|
||||
byte[] nameBytes = Encoding.Unicode.GetBytes(child.Name);
|
||||
for (int index = 0; index < nameEntries; index++)
|
||||
{
|
||||
Span<byte> nameEntry = result.AsSpan((2 + index) * 32, 32);
|
||||
nameEntry[0] = 0xC1;
|
||||
int sourceOffset = index * 30;
|
||||
int count = Math.Min(30, nameBytes.Length - sourceOffset);
|
||||
nameBytes.AsSpan(sourceOffset, count).CopyTo(nameEntry.Slice(2, count));
|
||||
}
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(file.Slice(0x02, 2), EntrySetChecksum(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildBootRegion(Layout layout)
|
||||
{
|
||||
byte[] region = new byte[12 * SectorSize];
|
||||
Span<byte> vbr = region.AsSpan(0, SectorSize);
|
||||
vbr[0] = 0xEB; vbr[1] = 0x76; vbr[2] = 0x90;
|
||||
"EXFAT "u8.CopyTo(vbr.Slice(3, 8));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(vbr.Slice(0x48, 8), checked((ulong)(layout.ImageLength / SectorSize)));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x50, 4), FatOffsetSectors);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x54, 4), checked((uint)layout.FatLengthSectors));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x58, 4), checked((uint)layout.HeapOffsetSectors));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x5C, 4), checked((uint)layout.ClusterCount));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x60, 4), checked((uint)layout.RootCluster));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(vbr.Slice(0x64, 4), VolumeSerial);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(vbr.Slice(0x68, 2), 0x0100);
|
||||
vbr[0x6C] = 9;
|
||||
vbr[0x6D] = checked((byte)BitOperationsLog2(layout.ClusterSize / SectorSize));
|
||||
vbr[0x6E] = 1;
|
||||
vbr[0x6F] = 0x80;
|
||||
vbr[0x70] = 0xFF;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(vbr.Slice(0x1FE, 2), 0xAA55);
|
||||
for (int sector = 1; sector <= 8; sector++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(region.AsSpan((sector + 1) * SectorSize - 4, 4), 0xAA550000);
|
||||
|
||||
uint checksum = 0;
|
||||
for (int index = 0; index < 11 * SectorSize; index++)
|
||||
{
|
||||
if (index is 106 or 107 or 112) continue;
|
||||
checksum = unchecked((checksum >> 1 | checksum << 31) + region[index]);
|
||||
}
|
||||
for (int index = 11 * SectorSize; index < 12 * SectorSize; index += 4)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(region.AsSpan(index, 4), checksum);
|
||||
return region;
|
||||
}
|
||||
|
||||
private static byte[] BuildUpcaseTable() => ExfatUpcaseTable.Create();
|
||||
|
||||
private static ushort[] BuildUpcaseMap(byte[] upcase)
|
||||
{
|
||||
var result = new ushort[char.MaxValue + 1];
|
||||
int sourceOffset = 0;
|
||||
int codePoint = 0;
|
||||
while (sourceOffset < upcase.Length && codePoint < result.Length)
|
||||
{
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(upcase.AsSpan(sourceOffset, 2));
|
||||
sourceOffset += 2;
|
||||
if (value != ushort.MaxValue)
|
||||
{
|
||||
result[codePoint++] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceOffset == upcase.Length && codePoint == ushort.MaxValue)
|
||||
{
|
||||
result[codePoint++] = ushort.MaxValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceOffset + 2 > upcase.Length)
|
||||
throw new InvalidDataException("The exFAT up-case table ends inside an identity run.");
|
||||
int count = BinaryPrimitives.ReadUInt16LittleEndian(upcase.AsSpan(sourceOffset, 2));
|
||||
sourceOffset += 2;
|
||||
if (count == 0 || count > result.Length - codePoint)
|
||||
throw new InvalidDataException("The exFAT up-case table contains an invalid identity run.");
|
||||
for (int index = 0; index < count; index++) result[codePoint] = checked((ushort)codePoint++);
|
||||
}
|
||||
if (sourceOffset != upcase.Length || codePoint != result.Length)
|
||||
throw new InvalidDataException("The exFAT up-case table does not cover the Unicode BMP.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint TableChecksum(byte[] data)
|
||||
{
|
||||
uint checksum = 0;
|
||||
foreach (byte value in data) checksum = unchecked((checksum >> 1 | checksum << 31) + value);
|
||||
return checksum;
|
||||
}
|
||||
|
||||
private static ushort NameHash(string name, ushort[] upcaseMap)
|
||||
{
|
||||
ushort hash = 0;
|
||||
foreach (char value in name)
|
||||
{
|
||||
ushort upper = upcaseMap[value];
|
||||
hash = unchecked((ushort)((hash >> 1 | hash << 15) + (byte)upper));
|
||||
hash = unchecked((ushort)((hash >> 1 | hash << 15) + (byte)(upper >> 8)));
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static ushort EntrySetChecksum(byte[] entries)
|
||||
{
|
||||
ushort checksum = 0;
|
||||
for (int index = 0; index < entries.Length; index++)
|
||||
{
|
||||
if (index is 2 or 3) continue;
|
||||
checksum = unchecked((ushort)((checksum >> 1 | checksum << 15) + entries[index]));
|
||||
}
|
||||
return checksum;
|
||||
}
|
||||
|
||||
private static int DirectoryEntryCount(ExfatNode node, bool root)
|
||||
{
|
||||
int count = root ? 3 : 0;
|
||||
foreach (ExfatNode child in node.Children) count = checked(count + 2 + DivideRoundUp(child.Name.Length, 15));
|
||||
return count;
|
||||
}
|
||||
|
||||
private static IEnumerable<ExfatNode> Walk(ExfatNode root)
|
||||
{
|
||||
yield return root;
|
||||
foreach (ExfatNode child in root.Children)
|
||||
{
|
||||
yield return child;
|
||||
if (!child.IsDirectory) continue;
|
||||
foreach (ExfatNode descendant in Walk(child).Skip(1)) yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ReadOnlyMemory<byte>> ZeroChunks(long count)
|
||||
{
|
||||
if (count <= 0) yield break;
|
||||
byte[] zeros = new byte[checked((int)Math.Min(FileChunkSize, count))];
|
||||
while (count > 0)
|
||||
{
|
||||
int size = checked((int)Math.Min(zeros.Length, count));
|
||||
count -= size;
|
||||
yield return zeros.AsMemory(0, size);
|
||||
}
|
||||
}
|
||||
|
||||
private static int DivideRoundUp(long value, int divisor) => value == 0 ? 0 : checked((int)((value - 1) / divisor + 1));
|
||||
private static int Align(int value, int alignment) => checked((value + alignment - 1) / alignment * alignment);
|
||||
private static int BitOperationsLog2(int powerOfTwo) => System.Numerics.BitOperations.Log2(checked((uint)powerOfTwo));
|
||||
|
||||
private sealed class ExfatNode(string relativePath, string name, bool isDirectory, long size, string? fullPath,
|
||||
long modifiedTime = 0, byte[]? virtualContent = null)
|
||||
{
|
||||
public string RelativePath { get; } = relativePath;
|
||||
public string Name { get; } = name;
|
||||
public bool IsDirectory { get; } = isDirectory;
|
||||
public long Size { get; } = size;
|
||||
public string? FullPath { get; } = fullPath;
|
||||
public long ModifiedTime { get; } = modifiedTime;
|
||||
public byte[]? VirtualContent { get; } = virtualContent;
|
||||
public List<ExfatNode> Children { get; } = [];
|
||||
public int FirstCluster { get; set; }
|
||||
public int ClusterCount { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Layout
|
||||
{
|
||||
public Layout(int clusterSize, int bitmapClusters, int upcaseClusters, int clusterCount, int rootCluster,
|
||||
int fatLengthSectors, int heapOffsetSectors, long imageLength)
|
||||
{
|
||||
ClusterSize = clusterSize;
|
||||
BitmapClusters = bitmapClusters;
|
||||
UpcaseClusters = upcaseClusters;
|
||||
ClusterCount = clusterCount;
|
||||
RootCluster = rootCluster;
|
||||
FatLengthSectors = fatLengthSectors;
|
||||
HeapOffsetSectors = heapOffsetSectors;
|
||||
ImageLength = imageLength;
|
||||
byte[] table = BuildUpcaseTable();
|
||||
UpcaseLength = table.Length;
|
||||
UpcaseChecksum = TableChecksum(table);
|
||||
UpcaseMap = BuildUpcaseMap(table);
|
||||
}
|
||||
|
||||
public int ClusterSize { get; }
|
||||
public int BitmapClusters { get; }
|
||||
public int UpcaseClusters { get; }
|
||||
public int ClusterCount { get; }
|
||||
public int RootCluster { get; }
|
||||
public int FatLengthSectors { get; }
|
||||
public int HeapOffsetSectors { get; }
|
||||
public long ImageLength { get; }
|
||||
public int UpcaseLength { get; }
|
||||
public uint UpcaseChecksum { get; }
|
||||
public ushort[] UpcaseMap { get; }
|
||||
}
|
||||
|
||||
private sealed class ChunkSequenceStream(IEnumerable<ReadOnlyMemory<byte>> chunks) : Stream
|
||||
{
|
||||
private readonly IEnumerator<ReadOnlyMemory<byte>> _chunks = chunks.GetEnumerator();
|
||||
private ReadOnlyMemory<byte> _current;
|
||||
private int _currentOffset;
|
||||
private bool _ended;
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
public override int Read(Span<byte> destination)
|
||||
{
|
||||
int written = 0;
|
||||
while (written < destination.Length)
|
||||
{
|
||||
if (_currentOffset >= _current.Length)
|
||||
{
|
||||
if (_ended || !_chunks.MoveNext()) { _ended = true; break; }
|
||||
_current = _chunks.Current;
|
||||
_currentOffset = 0;
|
||||
if (_current.Length == 0) continue;
|
||||
}
|
||||
int count = Math.Min(destination.Length - written, _current.Length - _currentOffset);
|
||||
_current.Span.Slice(_currentOffset, count).CopyTo(destination.Slice(written, count));
|
||||
_currentOffset += count;
|
||||
written += count;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult(Read(buffer.Span));
|
||||
}
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _chunks.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public enum ExfatEditKind
|
||||
{
|
||||
ReplaceFile,
|
||||
AddFile,
|
||||
AddDirectory,
|
||||
AddDirectoryTree,
|
||||
Delete
|
||||
}
|
||||
|
||||
public sealed record ExfatEditOperation(ExfatEditKind Kind, string ImagePath, string? SourcePath = null)
|
||||
{
|
||||
public static ExfatEditOperation Replace(string imagePath, string sourcePath) =>
|
||||
new(ExfatEditKind.ReplaceFile, imagePath, sourcePath);
|
||||
public static ExfatEditOperation AddFile(string imagePath, string sourcePath) =>
|
||||
new(ExfatEditKind.AddFile, imagePath, sourcePath);
|
||||
public static ExfatEditOperation AddDirectory(string imagePath) =>
|
||||
new(ExfatEditKind.AddDirectory, imagePath);
|
||||
public static ExfatEditOperation AddDirectoryTree(string imagePath, string sourcePath) =>
|
||||
new(ExfatEditKind.AddDirectoryTree, imagePath, sourcePath);
|
||||
public static ExfatEditOperation Delete(string imagePath) => new(ExfatEditKind.Delete, imagePath);
|
||||
}
|
||||
|
||||
public sealed record ExfatEditResult(
|
||||
string ImagePath,
|
||||
int OperationCount,
|
||||
bool Rebuilt,
|
||||
ExfatVerificationResult Verification);
|
||||
|
||||
public sealed record ExfatRepairResult(
|
||||
string ImagePath,
|
||||
bool BootRegionRecovered,
|
||||
ExfatVerificationResult Verification);
|
||||
|
||||
/// <summary>
|
||||
/// Transactional editing and repair operations for standalone exFAT images. Exact-size single-file replacement is
|
||||
/// patched in place with an on-disk rollback journal. Structural edits are applied to a staging tree and atomically
|
||||
/// swapped in only after a newly generated image passes full verification.
|
||||
/// </summary>
|
||||
public static class ExfatImageMaintenance
|
||||
{
|
||||
private const int BufferSize = 1024 * 1024;
|
||||
|
||||
public static async Task<ExfatEditResult> ApplyEditsAsync(string imagePath,
|
||||
IReadOnlyList<ExfatEditOperation> operations, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
ArgumentNullException.ThrowIfNull(operations);
|
||||
if (operations.Count == 0) throw new ArgumentException("At least one exFAT edit is required.", nameof(operations));
|
||||
string fullPath = Path.GetFullPath(imagePath);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException("The exFAT image was not found.", fullPath);
|
||||
ExfatEditOperation[] normalized = operations.Select(NormalizeOperation).ToArray();
|
||||
if (normalized.Any(operation => operation.SourcePath is not null &&
|
||||
operation.SourcePath.Equals(fullPath, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new IOException("The exFAT image itself cannot be used as an edit source file.");
|
||||
if (normalized.Any(operation => operation.Kind == ExfatEditKind.AddDirectoryTree &&
|
||||
operation.SourcePath is not null && IsInside(fullPath, operation.SourcePath)))
|
||||
throw new IOException("A directory containing the exFAT image cannot be imported into that image.");
|
||||
|
||||
if (normalized is [{ Kind: ExfatEditKind.ReplaceFile } replacement] &&
|
||||
replacement.SourcePath is not null && CanReplaceExactly(fullPath, replacement))
|
||||
{
|
||||
await ReplaceExactlyAsync(fullPath, replacement.ImagePath, replacement.SourcePath, progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
ExfatVerificationResult verification = await ExfatImage.VerifyAsync(fullPath, progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new ExfatEditResult(fullPath, 1, false, verification);
|
||||
}
|
||||
|
||||
ExfatVerificationResult rebuilt = await RebuildAsync(fullPath,
|
||||
staging => ApplyOperations(staging, normalized, cancellationToken), progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new ExfatEditResult(fullPath, normalized.Length, true, rebuilt);
|
||||
}
|
||||
|
||||
public static async Task<ExfatRepairResult> RepairAsync(string imagePath,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||
string fullPath = Path.GetFullPath(imagePath);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException("The exFAT image was not found.", fullPath);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
progress?.Report(new FfpfscProgress("Checking exFAT boot regions", 0, 1));
|
||||
|
||||
byte[]? bootJournal = null;
|
||||
int sectorSize = 0;
|
||||
bool bootRecovered = false;
|
||||
try
|
||||
{
|
||||
(bootJournal, sectorSize, bootRecovered) = NormalizeBootRegions(fullPath);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ExfatVerificationResult verification = await RebuildAsync(fullPath, _ => { }, progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new ExfatRepairResult(fullPath, bootRecovered, verification);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (bootRecovered && bootJournal is not null)
|
||||
{
|
||||
using var image = new FileStream(fullPath, FileMode.Open, FileAccess.Write, FileShare.Read,
|
||||
BufferSize, FileOptions.RandomAccess);
|
||||
image.Position = 0;
|
||||
image.Write(bootJournal);
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExfatEditOperation NormalizeOperation(ExfatEditOperation operation)
|
||||
{
|
||||
string path = NormalizeImagePath(operation.ImagePath);
|
||||
string? source = operation.SourcePath is null ? null : Path.GetFullPath(operation.SourcePath);
|
||||
switch (operation.Kind)
|
||||
{
|
||||
case ExfatEditKind.ReplaceFile:
|
||||
case ExfatEditKind.AddFile:
|
||||
if (source is null || !File.Exists(source))
|
||||
throw new FileNotFoundException("The edit source file was not found.", source);
|
||||
break;
|
||||
case ExfatEditKind.AddDirectoryTree:
|
||||
if (source is null || !Directory.Exists(source))
|
||||
throw new DirectoryNotFoundException(source);
|
||||
break;
|
||||
}
|
||||
return operation with { ImagePath = path, SourcePath = source };
|
||||
}
|
||||
|
||||
public static string NormalizeImagePath(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
string normalized = path.Replace('\\', '/').Trim('/');
|
||||
string[] parts = normalized.Split('/');
|
||||
if (parts.Any(part => part.Length == 0 || part is "." or ".." ||
|
||||
part.Any(value => value < 0x20 || "\"*:<>?\\|".Contains(value))))
|
||||
throw new IOException($"The internal exFAT path is unsafe: {path}");
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static bool CanReplaceExactly(string imagePath, ExfatEditOperation operation)
|
||||
{
|
||||
using var image = File.OpenRead(imagePath);
|
||||
using var volume = new ExfatVolume(image);
|
||||
ExfatEntry? entry = volume.Find(operation.ImagePath);
|
||||
return entry is { IsDirectory: false } && new FileInfo(operation.SourcePath!).Length == entry.Size;
|
||||
}
|
||||
|
||||
private static async Task ReplaceExactlyAsync(string imagePath, string internalPath, string sourcePath,
|
||||
IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
uint[] clusters;
|
||||
int clusterSize;
|
||||
long heapOffset;
|
||||
long fileLength;
|
||||
await using (var readImage = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.RandomAccess))
|
||||
using (var volume = new ExfatVolume(readImage, leaveOpen: true))
|
||||
{
|
||||
ExfatEntry entry = volume.Find(internalPath) ??
|
||||
throw new FileNotFoundException("The internal exFAT file was not found.", internalPath);
|
||||
if (entry.IsDirectory) throw new IOException("A directory cannot be replaced with a file.");
|
||||
fileLength = entry.Size;
|
||||
if (new FileInfo(sourcePath).Length != fileLength)
|
||||
throw new InvalidOperationException("Fast replacement requires an exactly equal file size.");
|
||||
clusters = volume.GetFileClusters(internalPath).ToArray();
|
||||
clusterSize = volume.ClusterSize;
|
||||
heapOffset = volume.ClusterHeapOffset;
|
||||
}
|
||||
|
||||
string journalPath = imagePath + "." + Guid.NewGuid().ToString("N") + ".replace-journal";
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
bool journalComplete = false;
|
||||
bool imageModified = false;
|
||||
try
|
||||
{
|
||||
await using (var image = new FileStream(imagePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.RandomAccess))
|
||||
await using (var journal = new FileStream(journalPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
long copied = 0;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int count = checked((int)Math.Min(clusterSize, fileLength - copied));
|
||||
image.Position = checked(heapOffset + ((long)cluster - 2) * clusterSize);
|
||||
int remaining = count;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int chunk = Math.Min(buffer.Length, remaining);
|
||||
await image.ReadExactlyAsync(buffer.AsMemory(0, chunk), cancellationToken).ConfigureAwait(false);
|
||||
await journal.WriteAsync(buffer.AsMemory(0, chunk), cancellationToken).ConfigureAwait(false);
|
||||
remaining -= chunk;
|
||||
}
|
||||
copied += count;
|
||||
progress?.Report(new FfpfscProgress("Journaling original exFAT file", copied, fileLength));
|
||||
}
|
||||
await journal.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
journalComplete = true;
|
||||
|
||||
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
copied = 0;
|
||||
imageModified = true;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int count = checked((int)Math.Min(clusterSize, fileLength - copied));
|
||||
image.Position = checked(heapOffset + ((long)cluster - 2) * clusterSize);
|
||||
int remaining = count;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int chunk = Math.Min(buffer.Length, remaining);
|
||||
await source.ReadExactlyAsync(buffer.AsMemory(0, chunk), cancellationToken).ConfigureAwait(false);
|
||||
await image.WriteAsync(buffer.AsMemory(0, chunk), cancellationToken).ConfigureAwait(false);
|
||||
remaining -= chunk;
|
||||
}
|
||||
copied += count;
|
||||
progress?.Report(new FfpfscProgress("Replacing exFAT file", copied, fileLength));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
await image.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
byte[] sourceHash;
|
||||
byte[] imageHash;
|
||||
await using (FileStream source = File.OpenRead(sourcePath))
|
||||
sourceHash = await SHA256.HashDataAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
await using (var image = File.OpenRead(imagePath))
|
||||
using (var volume = new ExfatVolume(image, leaveOpen: true))
|
||||
await using (Stream replaced = volume.OpenFile(internalPath))
|
||||
imageHash = await SHA256.HashDataAsync(replaced, cancellationToken).ConfigureAwait(false);
|
||||
if (!sourceHash.AsSpan().SequenceEqual(imageHash))
|
||||
throw new InvalidDataException("The replaced exFAT file failed SHA-256 verification.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (imageModified && journalComplete && File.Exists(journalPath))
|
||||
await RestoreJournalAsync(imagePath, journalPath, clusters, heapOffset, clusterSize, fileLength)
|
||||
.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(journalPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RestoreJournalAsync(string imagePath, string journalPath, IReadOnlyList<uint> clusters,
|
||||
long heapOffset, int clusterSize, long fileLength)
|
||||
{
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
await using var image = new FileStream(imagePath, FileMode.Open, FileAccess.Write, FileShare.Read,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.RandomAccess);
|
||||
await using var journal = new FileStream(journalPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
long copied = 0;
|
||||
foreach (uint cluster in clusters)
|
||||
{
|
||||
int count = checked((int)Math.Min(clusterSize, fileLength - copied));
|
||||
image.Position = checked(heapOffset + ((long)cluster - 2) * clusterSize);
|
||||
int remaining = count;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int chunk = Math.Min(buffer.Length, remaining);
|
||||
await journal.ReadExactlyAsync(buffer.AsMemory(0, chunk)).ConfigureAwait(false);
|
||||
await image.WriteAsync(buffer.AsMemory(0, chunk)).ConfigureAwait(false);
|
||||
remaining -= chunk;
|
||||
}
|
||||
copied += count;
|
||||
}
|
||||
await image.FlushAsync().ConfigureAwait(false);
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
private static async Task<ExfatVerificationResult> RebuildAsync(string imagePath, Action<string> editStaging,
|
||||
IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
int clusterSize;
|
||||
using (var image = File.OpenRead(imagePath))
|
||||
using (var volume = new ExfatVolume(image))
|
||||
clusterSize = volume.ClusterSize;
|
||||
|
||||
string directory = Path.GetDirectoryName(imagePath) ?? throw new IOException("The image has no parent directory.");
|
||||
string token = Guid.NewGuid().ToString("N");
|
||||
string staging = Path.Combine(directory, ".Packwright-exfat-edit-" + token);
|
||||
string replacement = imagePath + "." + token + ".rebuild";
|
||||
string backup = imagePath + "." + token + ".backup";
|
||||
try
|
||||
{
|
||||
await ExfatImage.ExtractDirectoryAsync(imagePath, staging, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
editStaging(staging);
|
||||
RemoveStaleAmprIndex(staging);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ExfatImage.WriteDirectoryAsync(staging, replacement,
|
||||
new ExfatBuildOptions { ClusterSize = clusterSize, GenerateAmprIndex = true }, progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_ = await ExfatImage.VerifyAsync(replacement, progress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
File.Move(imagePath, backup);
|
||||
try
|
||||
{
|
||||
File.Move(replacement, imagePath);
|
||||
ExfatVerificationResult finalVerification = await ExfatImage.VerifyAsync(imagePath, progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
TryDeleteFile(backup);
|
||||
return finalVerification;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(imagePath)) File.Delete(imagePath);
|
||||
if (File.Exists(backup)) File.Move(backup, imagePath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(staging);
|
||||
TryDeleteFile(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyOperations(string stagingRoot, IReadOnlyList<ExfatEditOperation> operations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (ExfatEditOperation operation in operations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string target = ResolveStagingPath(stagingRoot, operation.ImagePath);
|
||||
switch (operation.Kind)
|
||||
{
|
||||
case ExfatEditKind.ReplaceFile:
|
||||
if (!File.Exists(target)) throw new FileNotFoundException("The replacement target was not found.", operation.ImagePath);
|
||||
File.Copy(operation.SourcePath!, target, overwrite: true);
|
||||
break;
|
||||
case ExfatEditKind.AddFile:
|
||||
if (File.Exists(target) || Directory.Exists(target))
|
||||
throw new IOException($"The added path already exists: {operation.ImagePath}");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(operation.SourcePath!, target);
|
||||
break;
|
||||
case ExfatEditKind.AddDirectory:
|
||||
if (File.Exists(target)) throw new IOException($"A file already uses this path: {operation.ImagePath}");
|
||||
Directory.CreateDirectory(target);
|
||||
break;
|
||||
case ExfatEditKind.AddDirectoryTree:
|
||||
if (File.Exists(target) || Directory.Exists(target))
|
||||
throw new IOException($"The added path already exists: {operation.ImagePath}");
|
||||
CopyDirectory(operation.SourcePath!, target, cancellationToken);
|
||||
break;
|
||||
case ExfatEditKind.Delete:
|
||||
if (File.Exists(target)) File.Delete(target);
|
||||
else if (Directory.Exists(target)) Directory.Delete(target, recursive: true);
|
||||
else throw new FileNotFoundException("The deletion target was not found.", operation.ImagePath);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(operation.Kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveStagingPath(string root, string imagePath)
|
||||
{
|
||||
string full = Path.GetFullPath(Path.Combine(root, imagePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
string prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
throw new IOException("An exFAT edit path leaves the staging directory.");
|
||||
return full;
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string source, string destination, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (string directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = Path.GetRelativePath(source, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destination, relative));
|
||||
}
|
||||
foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = Path.GetRelativePath(source, file);
|
||||
string target = Path.Combine(destination, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveStaleAmprIndex(string stagingRoot)
|
||||
{
|
||||
if (File.Exists(Path.Combine(stagingRoot, "fakelib", "libSceAmpr.sprx"))) return;
|
||||
TryDeleteFile(Path.Combine(stagingRoot, "ampr_emu.index"));
|
||||
TryDeleteFile(Path.Combine(stagingRoot, "ampr_emu.index.tmp"));
|
||||
}
|
||||
|
||||
private static (byte[] Original, int SectorSize, bool Changed) NormalizeBootRegions(string imagePath)
|
||||
{
|
||||
using var image = new FileStream(imagePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read,
|
||||
BufferSize, FileOptions.RandomAccess);
|
||||
int sectorSize = DetectSectorSize(image);
|
||||
int regionSize = checked(12 * sectorSize);
|
||||
byte[] original = new byte[checked(regionSize * 2)];
|
||||
image.Position = 0;
|
||||
image.ReadExactly(original);
|
||||
ReadOnlySpan<byte> main = original.AsSpan(0, regionSize);
|
||||
ReadOnlySpan<byte> backup = original.AsSpan(regionSize, regionSize);
|
||||
bool mainValid = IsValidBootRegion(main, sectorSize);
|
||||
bool backupValid = IsValidBootRegion(backup, sectorSize);
|
||||
if (!mainValid && !backupValid)
|
||||
throw new InvalidDataException("Both exFAT boot regions are invalid; automatic repair has no trusted source.");
|
||||
byte[] repaired = original.ToArray();
|
||||
bool changed = false;
|
||||
if (!mainValid && backupValid)
|
||||
{
|
||||
backup.CopyTo(repaired.AsSpan(0, regionSize));
|
||||
changed = true;
|
||||
}
|
||||
else if (!backupValid || !main.SequenceEqual(backup))
|
||||
{
|
||||
main.CopyTo(repaired.AsSpan(regionSize, regionSize));
|
||||
changed = true;
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
image.Position = 0;
|
||||
image.Write(repaired);
|
||||
image.Flush(flushToDisk: true);
|
||||
}
|
||||
return (original, sectorSize, changed);
|
||||
}
|
||||
|
||||
private static int DetectSectorSize(Stream image)
|
||||
{
|
||||
byte[] first = new byte[512];
|
||||
image.Position = 0;
|
||||
image.ReadExactly(first);
|
||||
if (first.AsSpan(3, 8).SequenceEqual("EXFAT "u8) && first[0x6C] is >= 9 and <= 12)
|
||||
return 1 << first[0x6C];
|
||||
foreach (int size in new[] { 512, 1024, 2048, 4096 })
|
||||
{
|
||||
long offset = checked(12L * size);
|
||||
if (offset + 512 > image.Length) continue;
|
||||
image.Position = offset;
|
||||
image.ReadExactly(first);
|
||||
if (first.AsSpan(3, 8).SequenceEqual("EXFAT "u8) && first[0x6C] is >= 9 and <= 12 &&
|
||||
(1 << first[0x6C]) == size) return size;
|
||||
}
|
||||
throw new InvalidDataException("No recognizable exFAT boot region was found.");
|
||||
}
|
||||
|
||||
private static bool IsValidBootRegion(ReadOnlySpan<byte> region, int sectorSize)
|
||||
{
|
||||
if (region.Length != 12 * sectorSize || !region.Slice(3, 8).SequenceEqual("EXFAT "u8) ||
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(region.Slice(0x1FE, 2)) != 0xAA55) return false;
|
||||
uint checksum = 0;
|
||||
for (int index = 0; index < 11 * sectorSize; index++)
|
||||
{
|
||||
if (index is 106 or 107 or 112) continue;
|
||||
checksum = unchecked((checksum >> 1 | checksum << 31) + region[index]);
|
||||
}
|
||||
for (int offset = 11 * sectorSize; offset < 12 * sectorSize; offset += 4)
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(region.Slice(offset, 4)) != checksum) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
private static bool IsInside(string path, string directory)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string fullDirectory = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar,
|
||||
Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(fullDirectory, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>Microsoft reference exFAT up-case table used by MkPFS deterministic images.</summary>
|
||||
internal static class ExfatUpcaseTable
|
||||
{
|
||||
private const int ExpectedLength = 5_836;
|
||||
private const string CompressedBase64 =
|
||||
"H4sIAAAAAAAC/63YddAWx5YG8J7uGVze4NZvn4YXbzS4uwcLEByChRAghBBCCMHd3d3dnQ/4cHd3QgghBHc/+5Dd2qp72aq7Nr/qP56aOjPd09VjQnhC" +
|
||||
"CiV8EYgYIqaIJWKLOCKuiCfiiwQioQiJT0QikVgkEUlFMpFcpBApRSqRWqQRWoSFESSsSCvSiYhILzKIjCKTyCyyiKzCiWwiu8ghcopcIrf4VOQReUU+" +
|
||||
"kV8UEAVFIVFYFBFFRTFRXJQQJUUpUVqUEWVFOVFeVBAVRSVRWVQRn4mqopqoLmqImuJzUUvUFnXEF6KuqCfqiwaioWgkGosmoun/qv5n0U38IrqLHqKn" +
|
||||
"6CV6iz6ir+gn+osBYqAYJAaLIWKoGCaGixFipBglRosxYqwYJ8aLCWKimCQmiyliqpgmposZYqaYJWaLOWKumCfmiwVioVgkFoslYqlYJpaLFWKlWCVW" +
|
||||
"izVirVgn1osNYqPYJDaLLSJKbBXbxHYRLXaInWKX2C32iL1in9gvDoiD4pA4LI6Io+KYOC5OiJPilDgtzoiz4pw4Ly6Ii+KSuCyuiKvi2v+w/sU/1Xfx" +
|
||||
"MP2eBB9iQCyIA/EgAYQgESSBZJACUkEaCANBWohABsgEWcBBdsjp5fJyQx7IBwW8glAYikJxKAmloSyUh4peJagCVaE61IRaUAfqQn1oCI2hKTSDFtAK" +
|
||||
"WkMbaAvtoQN0hE7QGbp4P8HP8Av08ErLXl5v6Av9vQEwyBvsDYFh3nBvhDfSGwVjvLHec2+8N8GbCEXlFG+qN80jOcObCbNhLsz3FsAib7G3xFsKy70V" +
|
||||
"sMpb7a2BdbDB2wibvS1eFGzzXnjR3g5vp7fL2+3tQdvn7Uc76B1COwLH4AScgjNwDi7AJRjuXYXrcANuwi24DXfgLtyHh94j7zHaU3iO872E1/AW3gMW" +
|
||||
"v/yw+RADYkEciAcJIASJIAkkgxSQCtJAGEhamRYikAEyQRZwkB1yQm7II/PKfDK/LCALypausCwsi8pWrrgsIUtCaVlGlpXloAJUgipQFarLGrIm5qi/" +
|
||||
"V1t+mJm6coRXX470GspGsrFsIsd4X8pmcqzXQraUreRXcgJmqY1s5trKdrK9nOp1kN/Jad73shPmqrP8UXaRP8mu8mfZTbZw3WUPOd/rJXvLRV5f2U/2" +
|
||||
"lwPkcq+M/DBjZeUwOVyOkCPlKLnBGyPHynFyvJwgJ8pJcrKcIqfKaXK6nCFnyllytpwj58p5cr5cIBfKRXKxXCKXymVyuVwhV8pVcrVcI9fKdXK93CA3" +
|
||||
"yk1ys9wio+RWuU1ul9Fyh9wpd8ndco/cK/fJ/fKAPCgPycPyiDwqj8nj8oQ8KU/J0/KMPCvPyfPygrwoL8nL8oq8Kq/J6/JXeUP+Jm/K3+Ut+Ye8Lf+U" +
|
||||
"d+Rf8q68J+/LB/KhfCQfyyfyqXwmn8sX8qV8JV/LN/KtfCffS8at31NSKeWrQMVQMVUsFVvFUXFVPBVfJVAJVUh9ohKpxCqJSqqSqeQqhUqpUqnUKo3S" +
|
||||
"KqyMImVVWpVORVR6lUFlVJlUZpVFZVVOZVPZVQ6VU+VSudWnKo/Kq/Kp/KqAKqgKqcKqiCqqiqniqoQqqUqp0qqMKqvKqfKqgqqoKqnKqor6TFVV1VR1" +
|
||||
"VUPVVJ+rWqq2qqO+UHVVPVVfNVANVSPVWDVRTdWXqplqrlqolqqV+kq1Vl+rNuob1Va1U+3Vt6qD+k51VN+rTuoH1Vn9qLqon1RX9U69V6y6qx6qp+ql" +
|
||||
"eqs+qq/qp/qrAWqgGqQGqyFqqBqmhqsRaqQapUarMWqsGqfGqwlqopqkJqspaqqapqarGWqmmqVmqzlqrpqn5qsFaqFapBarJTjWvx9p5X+jfs5/UT/0" +
|
||||
"77MfU8fVCXVSnVKn1Rl1Vp1T5+EiXIarcB1uwE24BbfhDtyF+/BQPVKv1BP1VD1Tz9ULeKVew9v/uAYCN37pK9/3Az+GH9OP5cf24/hx/Xh+fD+Bn9AP" +
|
||||
"+Z/4ifzEfhI/qZ/MT+6n8FP6qfzUfhpf+2Hf+ORbP62fzo/46f0MfkY/k5/Zz+Jn9Z2fzc/u5/g/1/+r/jWFZtACWkFraANtoT10gI7QCTpDF+gK3aA7" +
|
||||
"9ITefh+/r9/P7+8P8Af6g/zBMBSGw0gYDWNhPEyEyTAVpsNMmA1zYT4shMWwFJbDSlgNa2E9bITNEAXbINrfAbtgD+yDA3AIjkC0fxxOwmk4C+fhIlyG" +
|
||||
"q3AdbsBNuAW34Q7chfvwEB7DU3gOL+E1vIX3gNe+QAIubxADYkEciAcJIASJIEmQNEgWJA9SBCmDVEHqIE2gg3BgAgpskDZIF0SC9EGGIGOQKcgcZAmy" +
|
||||
"Bi7IFmQPcgQ5g1xB7uDTIE+QN8gX5A8KBAWDQkHhoEhQNCgWFA9KBCWDUkHpoExQNigXlA8qBBWDSkHloErwWVA1qBZUD2oENYPPg1pB7aBO8EVQN6gX" +
|
||||
"1A8aBA2DRkHjoEnQ9P/1+MzPkzd33XUP3VP30r11H91X99P99QA9UA/Sg/UQPVQP08P1CD1Sj9Kj9Rg9Vo/T4/UEPVFP0pP1FD1VT9PT9Qw9U8/Ss/Uc" +
|
||||
"PVfP0/P1Ar1QL9KL9RK9VC/Ty/UKvVKv0qv1Gr1Wr9Pr9Qa9UW/Sm/UWHaW36m16u47WO/ROvUvv1nv0Xr1P79cH9EF9SB/WR/RRfUwf1yf0SX1Kn9Zn" +
|
||||
"9Fl9Tp/XF/RFfUlf1lf0VX1NX9e/6hv6N31T/65v6T/0bf2nvqP/0nf1PX1fP9AP9SP9WD/RT/Uz/Vy/0C/1K/1av9Fv9Tv9XvOHl/sPj/cwFmk4BsSC" +
|
||||
"OBAPEkAIEkESSAYpIBWkgQ8bQVqIQAbIBFnAQXbICbkhD+SDAlAIikAxKAGloAyUgwpQCapAVagONaEW1IG6UB8aQmNoCs2gBbSC1tAG2kJ76AAdoRN0" +
|
||||
"hi7QFbpBd+gJvaEv9IeBMBiGwnAYCaNhLIwPTwhPDE8KTw5PCU8NTwtPD88Iz4TZMBfmw0JYDEthOayE1bAW1sNG2AxRsA2iYSfshr2wHw7CYTgKx+Ek" +
|
||||
"nIazcB4uwmW4CtfhBtyEW3Ab7sBduA8P4TE8hefwEl6H34Tfht+F34c5HMvENnFMXBPPxDcJTELzzzmFSWlSmdQmjdEmmUn+DzlsjMlkMpssJqtxJpvJ" +
|
||||
"bnJ8lAuYgqaQKWyKmKKmmCn+Ua5gKppKprKpYj4z5Uz5f8hVTTVT3dQzNU0DU8s0MnVME1MXuT5yQ+TGyK3N16aN+ca0Ne1Me/PtR3mz2WL2mwPmoDlk" +
|
||||
"LppL5qV5Ze6Yv8xr88Z0Nz3MQDPIDDZDzFAzzAw3Iz7KE80kM9lMMVPNNDPdzPgoLzSLzGKzxCw1y8xys+KjvNFsMqtNlFlr1pn1ZsPf+UOfosxWs81s" +
|
||||
"N9Fmh9lpdpndZo/Za/b9Z193mSPmqDlmzpsL5qQ5ZU6bM+asOfd3/jCOy+aKuWqumdvmT3PD/GZumrvmlvnj7/xhfHfNPXPfPDAPzSPz2DwxT80z89y8" +
|
||||
"+Hv8H8b+xLwz7w3jwx6fN6TIp4BiUEyKRbEpDsWleBSfElBCCtEnlIgSUxJKSskoOaWglJSKUlMa0lj6hogspaV0FKH0lIEyUibKTFkoKznKRtkpB+Wk" +
|
||||
"XJSbPqU8lJfyUX4qQAWpEBWmIlSUilFxKkElqRSVpjJUlspReapAFakSVaYq9BlVpWpUnWpQTfqcalFtqkNfUF2qR/WpATWkRtSYmlBT+pKaUXNqQS2p" +
|
||||
"FX1FrelrakPfUFtqR+3pW+pA31FH+p460Q/UmX6kLvQTdaWfqRv9Qt2pB/WkXtSb+lBf6kf9aQANpEE0mIbQUBpGw2kEjaRRNJrG0FgaR+NpAk2kSTSZ" +
|
||||
"ptBUmkbTaQbNpFk0m+bQXJpH82kBLaRFtJiW0FJaRstpBa2kVbSa1tBaWkfraQNtpE20mbZQFG2lbbSdomkH7aRdtJv20F7aR/vpAB2kQ3SYjtBROkbH" +
|
||||
"6QSdpFN0ms7QWTpH5+kCXaRLdJmu0FW6RtfpV7pBv9FN+p1u0R90m/6kO/QX3aV7dJ8e0EN6RI/pCT2lZ/ScXtBLekWv6Q29pXf0nhi/dTwrrbK+DWwM" +
|
||||
"G9PGsrFtHBvXxrPxbQKb0IbsJzaRTWyT2KQ2mU1uU9iUNpVNbdNYbcPWWLLWprXpbMSmtxlsRpvJZrZZbFbrbDab3eawOW0um9t+avPYvDafzW8L2IK2" +
|
||||
"kC1si9iitpgtbkvYkraULW3L2LK2nC1vK9iKtpKtbKvYz1BXzVa3NWxN+7mtZWvbOvYLW9fWs/VtA9vQNrKNbRPb1H5pm9nmtoVtaVvZr2xr+7VtY7+x" +
|
||||
"bW07295++y/397S9bG/bB5grq/WRDZGNkU2RzZEtkajI1si2yPZIdGRHZGdkV2R3ZE9kb2RfZH/kQORg5FDkcORI5GjkWIS5XEyB3yDSKee7wMVwMV0s" +
|
||||
"F9vFcXFdPBffJXAJXch94hK5xC6JS+qSueQuhUvpUrnULo3TeFAaR866tC6di7j0LoPL6DK5zC6Ly+qcy+ayuyauKTRzzV0L19K1cl/B1/ANtHPt3beu" +
|
||||
"g/vOdXTfu07uB/jRdXE/ua7uZ9fN/eK6ux6uJ/SGvtAfBsJgGArDYSSMhrEwHibCZJgK02EmzIa5MB8WwmJYCsthJayGtbAeNsJmiIJtEA07YTfshf1w" +
|
||||
"EA7DUTgOJ+E0nIXzcBEuw1W4Djfgpvvd3XJ/uNvuT3fH/eXuunvuvnvgHrpH7rF74p66Z+65e+FeulfutXvj3rp37r1jNzM0KzQ7NCc0NzQvND+0ILQw" +
|
||||
"tCi0OLQktDS0LLQ8tCK0MrQqtDq0JrQ2tC60PrQhtDG0KbQ5tCUUFdoa2hbaHooO7QjtDO0K7Q7tCTGnPmk5LafjCKfnDJyRM3FmzsJZ2XE2zs45OCfn" +
|
||||
"4tz8KefhvJyP83MBLsiFuAE35EbcmJtwU/6Sm3FzbsEtuRV/xa35a27D33Bbbsft+VvuwN9xR/6eO/EP3Jl/5C78E3fln7kb/8LduQf35F7cm/twX+7H" +
|
||||
"/XkAD+RBPJiH8FAexsN5BI/kUTyax/BYHsfjeQJP5Ek8mafwVJ7G03kGz+RZPJvn8Fyex/N5AS/kRbyYl/BSXsbLeQWv5FW8mtfwWl7H63kDb+RNvJm3" +
|
||||
"cBRv5W28naN5B+/kXbyb9/Be3sf7+QAf5EN8mI/wUT7Gx/kEn+RTfJrP8Fk+x+f5Al/kS3yZr/BVvsbX+Ve+wb/xTf6db/EffJv/5Dv8F9/le3yfH/BD" +
|
||||
"fsSP+Qk/5Wf8nF/wS37Fr/kNv+V3/J6Z/w3TIObzzBYAAA==";
|
||||
|
||||
public static byte[] Create()
|
||||
{
|
||||
byte[] compressed = Convert.FromBase64String(CompressedBase64);
|
||||
using var input = new MemoryStream(compressed, writable: false);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream(ExpectedLength);
|
||||
gzip.CopyTo(output);
|
||||
byte[] result = output.ToArray();
|
||||
if (result.Length != ExpectedLength)
|
||||
throw new InvalidDataException("The embedded exFAT up-case table has an invalid length.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed class ExfatEntry
|
||||
{
|
||||
internal ExfatEntry(string path, string name, bool isDirectory, long size, uint firstCluster,
|
||||
bool contiguous, ushort attributes)
|
||||
{
|
||||
Path = path;
|
||||
Name = name;
|
||||
IsDirectory = isDirectory;
|
||||
Size = size;
|
||||
FirstCluster = firstCluster;
|
||||
IsContiguous = contiguous;
|
||||
Attributes = attributes;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string Name { get; }
|
||||
public bool IsDirectory { get; }
|
||||
public long Size { get; }
|
||||
public uint FirstCluster { get; }
|
||||
public bool IsContiguous { get; }
|
||||
public ushort Attributes { get; }
|
||||
}
|
||||
|
||||
/// <summary>Minimal read-only exFAT filesystem used by PS5 FFPFSC images.</summary>
|
||||
public sealed class ExfatVolume : IDisposable
|
||||
{
|
||||
private const uint FatEndMinimum = 0xFFFFFFF8;
|
||||
private const int MaximumEntries = 2_000_000;
|
||||
private const int MaximumDepth = 256;
|
||||
private readonly Stream _image;
|
||||
private readonly bool _leaveOpen;
|
||||
private readonly object _sync = new();
|
||||
private readonly uint[] _fat;
|
||||
private readonly Dictionary<string, ExfatEntry> _byPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<ExfatEntry> _entries = [];
|
||||
private bool _disposed;
|
||||
|
||||
public ExfatVolume(Stream image, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(image);
|
||||
if (!image.CanRead || !image.CanSeek)
|
||||
throw new ArgumentException("The exFAT image must be readable and seekable.", nameof(image));
|
||||
_image = image;
|
||||
_leaveOpen = leaveOpen;
|
||||
|
||||
byte[] boot = ReadAt(0, 512);
|
||||
if (!boot.AsSpan(3, 8).SequenceEqual("EXFAT "u8) ||
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(boot.AsSpan(0x1FE, 2)) != 0xAA55)
|
||||
throw new InvalidDataException("The image is not a supported exFAT filesystem.");
|
||||
int sectorShift = boot[0x6C];
|
||||
int clusterShift = boot[0x6D];
|
||||
if (sectorShift is < 9 or > 12 || clusterShift > 25)
|
||||
throw new InvalidDataException("The exFAT sector or cluster shift is invalid.");
|
||||
SectorSize = checked(1 << sectorShift);
|
||||
SectorsPerCluster = checked(1 << clusterShift);
|
||||
ClusterSize = checked(SectorSize * SectorsPerCluster);
|
||||
uint fatOffsetSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x50, 4));
|
||||
uint fatLengthSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x54, 4));
|
||||
uint heapOffsetSectors = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x58, 4));
|
||||
ClusterCount = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x5C, 4));
|
||||
RootCluster = BinaryPrimitives.ReadUInt32LittleEndian(boot.AsSpan(0x60, 4));
|
||||
FatOffset = checked((long)fatOffsetSectors * SectorSize);
|
||||
ClusterHeapOffset = checked((long)heapOffsetSectors * SectorSize);
|
||||
long fatBytes = checked((long)fatLengthSectors * SectorSize);
|
||||
if (ClusterCount == 0 || RootCluster < 2 || RootCluster >= ClusterCount + 2 ||
|
||||
fatBytes < checked((long)(ClusterCount + 2) * 4) || ClusterHeapOffset >= image.Length)
|
||||
throw new InvalidDataException("The exFAT volume geometry is invalid.");
|
||||
byte[] fatData = ReadAt(FatOffset, checked((int)((ClusterCount + 2) * 4L)));
|
||||
_fat = new uint[ClusterCount + 2];
|
||||
for (int index = 0; index < _fat.Length; index++)
|
||||
_fat[index] = BinaryPrimitives.ReadUInt32LittleEndian(fatData.AsSpan(index * 4, 4));
|
||||
|
||||
var visitedDirectories = new HashSet<uint>();
|
||||
ReadDirectory(string.Empty, RootCluster, null, false, visitedDirectories, 0);
|
||||
}
|
||||
|
||||
public int SectorSize { get; }
|
||||
public int SectorsPerCluster { get; }
|
||||
public int ClusterSize { get; }
|
||||
public uint ClusterCount { get; }
|
||||
public uint RootCluster { get; }
|
||||
public long FatOffset { get; }
|
||||
public long ClusterHeapOffset { get; }
|
||||
public IReadOnlyList<ExfatEntry> Entries => _entries;
|
||||
|
||||
public ExfatEntry? Find(string path)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_byPath.TryGetValue(NormalizePath(path), out ExfatEntry? entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public Stream OpenFile(string path)
|
||||
{
|
||||
ExfatEntry entry = Find(path) ?? throw new FileNotFoundException("The file was not found in the exFAT image.", path);
|
||||
if (entry.IsDirectory) throw new IOException("The requested exFAT entry is a directory.");
|
||||
return OpenEntry(entry);
|
||||
}
|
||||
|
||||
public byte[] ReadAllBytes(string path, int maximumBytes = 256 * 1024 * 1024)
|
||||
{
|
||||
ExfatEntry entry = Find(path) ?? throw new FileNotFoundException("The file was not found in the exFAT image.", path);
|
||||
if (entry.IsDirectory) throw new IOException("The requested exFAT entry is a directory.");
|
||||
if (entry.Size > maximumBytes || entry.Size > int.MaxValue)
|
||||
throw new IOException($"The exFAT file is too large to load into memory: {entry.Path}");
|
||||
using Stream input = OpenEntry(entry);
|
||||
byte[] data = new byte[checked((int)entry.Size)];
|
||||
input.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
public string ReadAllText(string path, Encoding? encoding = null, int maximumBytes = 16 * 1024 * 1024) =>
|
||||
(encoding ?? new UTF8Encoding(false, true)).GetString(ReadAllBytes(path, maximumBytes));
|
||||
|
||||
public IReadOnlyList<uint> GetFileClusters(string path)
|
||||
{
|
||||
ExfatEntry entry = Find(path) ?? throw new FileNotFoundException("The file was not found in the exFAT image.", path);
|
||||
if (entry.IsDirectory) throw new IOException("The requested exFAT entry is a directory.");
|
||||
if (entry.Size == 0) return [];
|
||||
return GetClusters(entry.FirstCluster, entry.Size, entry.IsContiguous);
|
||||
}
|
||||
|
||||
private Stream OpenEntry(ExfatEntry entry)
|
||||
{
|
||||
if (entry.Size == 0) return new MemoryStream(Array.Empty<byte>(), writable: false);
|
||||
uint[] clusters = GetClusters(entry.FirstCluster, entry.Size, entry.IsContiguous);
|
||||
return new ExfatFileStream(_image, _sync, clusters, ClusterHeapOffset, ClusterSize, entry.Size);
|
||||
}
|
||||
|
||||
private void ReadDirectory(string parentPath, uint firstCluster, long? dataLength, bool contiguous,
|
||||
HashSet<uint> visitedDirectories, int depth)
|
||||
{
|
||||
if (depth > MaximumDepth) throw new InvalidDataException("The exFAT directory depth exceeds the safety limit.");
|
||||
if (!visitedDirectories.Add(firstCluster)) throw new InvalidDataException("The exFAT directory graph contains a cycle.");
|
||||
long length = dataLength ?? checked((long)GetClusters(firstCluster, null, false).Length * ClusterSize);
|
||||
if (length > 512L * 1024 * 1024) throw new InvalidDataException("An exFAT directory is unreasonably large.");
|
||||
var pseudo = new ExfatEntry(parentPath, Path.GetFileName(parentPath), true, length, firstCluster, contiguous, 0x10);
|
||||
using Stream directory = OpenEntry(pseudo);
|
||||
byte[] data = new byte[checked((int)length)];
|
||||
directory.ReadExactly(data);
|
||||
|
||||
for (int offset = 0; offset + 32 <= data.Length;)
|
||||
{
|
||||
byte type = data[offset];
|
||||
if (type == 0) break;
|
||||
if (type != 0x85) { offset += 32; continue; }
|
||||
int secondaryCount = data[offset + 1];
|
||||
int setLength = checked((secondaryCount + 1) * 32);
|
||||
if (secondaryCount < 2 || offset + setLength > data.Length)
|
||||
throw new InvalidDataException("An exFAT file entry set is truncated.");
|
||||
ReadOnlySpan<byte> primary = data.AsSpan(offset, 32);
|
||||
ReadOnlySpan<byte> stream = data.AsSpan(offset + 32, 32);
|
||||
if (stream[0] != 0xC0) throw new InvalidDataException("An exFAT file entry has no stream extension.");
|
||||
int nameLength = stream[3];
|
||||
int requiredNames = (nameLength + 14) / 15;
|
||||
if (requiredNames > secondaryCount - 1)
|
||||
throw new InvalidDataException("An exFAT filename entry set is incomplete.");
|
||||
byte[] nameBytes = new byte[nameLength * 2];
|
||||
int copied = 0;
|
||||
for (int index = 0; index < requiredNames; index++)
|
||||
{
|
||||
ReadOnlySpan<byte> nameEntry = data.AsSpan(offset + (index + 2) * 32, 32);
|
||||
if (nameEntry[0] != 0xC1) throw new InvalidDataException("An exFAT filename extension is missing.");
|
||||
int count = Math.Min(30, nameBytes.Length - copied);
|
||||
nameEntry.Slice(2, count).CopyTo(nameBytes.AsSpan(copied));
|
||||
copied += count;
|
||||
}
|
||||
string name = Encoding.Unicode.GetString(nameBytes);
|
||||
ValidateName(name);
|
||||
ushort attributes = BinaryPrimitives.ReadUInt16LittleEndian(primary.Slice(4, 2));
|
||||
bool isDirectory = (attributes & 0x10) != 0;
|
||||
bool noFatChain = (stream[1] & 0x02) != 0;
|
||||
uint childCluster = BinaryPrimitives.ReadUInt32LittleEndian(stream.Slice(0x14, 4));
|
||||
ulong sizeValue = BinaryPrimitives.ReadUInt64LittleEndian(stream.Slice(0x18, 8));
|
||||
if (sizeValue > long.MaxValue) throw new InvalidDataException("An exFAT entry is too large.");
|
||||
long size = (long)sizeValue;
|
||||
string path = parentPath.Length == 0 ? name : parentPath + '/' + name;
|
||||
var entry = new ExfatEntry(path, name, isDirectory, size, childCluster, noFatChain, attributes);
|
||||
if (!_byPath.TryAdd(path, entry)) throw new InvalidDataException($"Duplicate exFAT path: {path}");
|
||||
_entries.Add(entry);
|
||||
if (_entries.Count > MaximumEntries) throw new InvalidDataException("The exFAT entry count exceeds the safety limit.");
|
||||
if (isDirectory && childCluster >= 2)
|
||||
ReadDirectory(path, childCluster, size, noFatChain, visitedDirectories, depth + 1);
|
||||
offset += setLength;
|
||||
}
|
||||
}
|
||||
|
||||
private uint[] GetClusters(uint firstCluster, long? dataLength, bool contiguous)
|
||||
{
|
||||
if (firstCluster < 2 || firstCluster >= _fat.Length)
|
||||
throw new InvalidDataException("An exFAT entry has an invalid first cluster.");
|
||||
int expected = dataLength.HasValue ? checked((int)((dataLength.Value + ClusterSize - 1) / ClusterSize)) : -1;
|
||||
if (expected == 0) return [];
|
||||
if (contiguous && expected >= 0)
|
||||
{
|
||||
if ((long)firstCluster + expected > _fat.Length)
|
||||
throw new InvalidDataException("A contiguous exFAT extent leaves the cluster heap.");
|
||||
return Enumerable.Range(checked((int)firstCluster), expected).Select(value => checked((uint)value)).ToArray();
|
||||
}
|
||||
var result = new List<uint>(expected > 0 ? expected : 4);
|
||||
var visited = new HashSet<uint>();
|
||||
uint cluster = firstCluster;
|
||||
while (cluster < FatEndMinimum)
|
||||
{
|
||||
if (cluster < 2 || cluster >= _fat.Length || !visited.Add(cluster))
|
||||
throw new InvalidDataException("An exFAT FAT chain is invalid or cyclic.");
|
||||
result.Add(cluster);
|
||||
if (expected >= 0 && result.Count == expected) break;
|
||||
cluster = _fat[cluster];
|
||||
if (result.Count > ClusterCount) throw new InvalidDataException("An exFAT FAT chain is unreasonably long.");
|
||||
}
|
||||
if (expected >= 0 && result.Count != expected)
|
||||
throw new InvalidDataException("An exFAT FAT chain is shorter than its declared data length.");
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private byte[] ReadAt(long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > _image.Length || count > _image.Length - offset)
|
||||
throw new InvalidDataException("An exFAT read is outside the decoded image.");
|
||||
byte[] data = new byte[count];
|
||||
lock (_sync)
|
||||
{
|
||||
_image.Position = offset;
|
||||
_image.ReadExactly(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) => path.Replace('\\', '/').Trim('/');
|
||||
private static void ValidateName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name is "." or ".." || name.Contains('/') || name.Contains('\\'))
|
||||
throw new InvalidDataException("An exFAT entry contains an unsafe filename.");
|
||||
}
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_leaveOpen) _image.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed class ExfatFileStream(Stream image, object sync, uint[] clusters, long heapOffset,
|
||||
int clusterSize, long length) : Stream
|
||||
{
|
||||
private long _position;
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0 || value > length) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
public override int Read(Span<byte> destination)
|
||||
{
|
||||
int total = checked((int)Math.Min(destination.Length, length - _position));
|
||||
int written = 0;
|
||||
lock (sync)
|
||||
{
|
||||
while (written < total)
|
||||
{
|
||||
int clusterIndex = checked((int)(_position / clusterSize));
|
||||
int inCluster = checked((int)(_position % clusterSize));
|
||||
int count = Math.Min(total - written, clusterSize - inCluster);
|
||||
long imageOffset = checked(heapOffset + ((long)clusters[clusterIndex] - 2) * clusterSize + inCluster);
|
||||
image.Position = imageOffset;
|
||||
image.ReadExactly(destination.Slice(written, count));
|
||||
written += count;
|
||||
_position += count;
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long target = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => checked(_position + offset),
|
||||
SeekOrigin.End => checked(length + offset),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(origin))
|
||||
};
|
||||
Position = target;
|
||||
return target;
|
||||
}
|
||||
public override void Flush() { }
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads, writes, and verifies unsigned PS5 PFS images containing one PFSC payload.
|
||||
/// The implementation is streaming and never modifies the source image.
|
||||
/// </summary>
|
||||
public static class FfpfscImage
|
||||
{
|
||||
private const long PfsMagic = 20130315;
|
||||
private const int PfsVersion = 2;
|
||||
private const int PfsModeCaseInsensitive = 0x8;
|
||||
private const int InodeSize = 0xA8;
|
||||
private const ushort PermissionsRx = 0x16D;
|
||||
private const ushort InodeModeDirectory = 0x4000;
|
||||
private const ushort InodeModeFile = 0x8000;
|
||||
private const uint InodeFlagCompressed = 0x1;
|
||||
private const uint InodeFlagReadOnly = 0x10;
|
||||
private const uint InodeFlagInternal = 0x20000;
|
||||
private const int DirentFile = 2;
|
||||
private const int DirentDirectory = 3;
|
||||
private const int DirentDot = 4;
|
||||
private const int DirentDotDot = 5;
|
||||
private const int PayloadInodeNumber = 3;
|
||||
private const int InodeCount = 4;
|
||||
|
||||
public static async Task<FfpfscBuildResult> CreateFromImageAsync(string sourcePath, string outputPath,
|
||||
FfpfscBuildOptions? options = null, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
options ??= new FfpfscBuildOptions();
|
||||
options.Validate();
|
||||
|
||||
string sourceFullPath = Path.GetFullPath(sourcePath);
|
||||
string outputFullPath = Path.GetFullPath(outputPath);
|
||||
if (!File.Exists(sourceFullPath)) throw new FileNotFoundException("The source image does not exist.", sourceFullPath);
|
||||
if (string.Equals(sourceFullPath, outputFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
throw new IOException("The output path must be different from the source image.");
|
||||
if (File.Exists(outputFullPath) && !options.OverwriteExisting)
|
||||
throw new IOException($"The output file already exists: {outputFullPath}");
|
||||
|
||||
string innerName = ResolveInnerName(sourceFullPath, options.InnerFileName);
|
||||
long sourceLength = new FileInfo(sourceFullPath).Length;
|
||||
await using var source = new FileStream(sourceFullPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
0x10000, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
return await CreateCoreAsync(source, sourceLength, outputFullPath, innerName, options, "Compressing",
|
||||
progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<FfpfscBuildResult> CreateFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
FfpfscBuildOptions? options = null, ExfatBuildOptions? exfatOptions = null,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
options ??= new FfpfscBuildOptions();
|
||||
options.Validate();
|
||||
string sourceFullPath = Path.GetFullPath(sourceDirectory);
|
||||
string outputFullPath = Path.GetFullPath(outputPath);
|
||||
if (!Directory.Exists(sourceFullPath)) throw new DirectoryNotFoundException(sourceFullPath);
|
||||
if (File.Exists(outputFullPath) && !options.OverwriteExisting)
|
||||
throw new IOException($"The output file already exists: {outputFullPath}");
|
||||
string relativeOutput = Path.GetRelativePath(sourceFullPath, outputFullPath);
|
||||
if (!Path.IsPathRooted(relativeOutput) && relativeOutput != ".." &&
|
||||
!relativeOutput.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal))
|
||||
throw new IOException("Save the FFPFSC image outside the source dump directory.");
|
||||
|
||||
string gameRootPath = ResolveGameRoot(sourceFullPath);
|
||||
string innerName = options.InnerFileName ?? ResolveDumpImageName(gameRootPath);
|
||||
if (innerName is "." or ".." || innerName != Path.GetFileName(innerName))
|
||||
throw new ArgumentException("The inner payload name must be a plain filename.", nameof(options));
|
||||
EnsureAsciiFileName(innerName);
|
||||
using ExfatImageSource exfat = await Task.Run(() => ExfatImage.OpenDirectory(gameRootPath, exfatOptions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await CreateCoreAsync(exfat.Stream, exfat.Length, outputFullPath, innerName, options, "Packing dump",
|
||||
progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<FfpfscBuildResult> CreateCoreAsync(Stream source, long sourceLength,
|
||||
string outputFullPath, string innerName, FfpfscBuildOptions options, string progressStage,
|
||||
IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
string? outputDirectory = Path.GetDirectoryName(outputFullPath);
|
||||
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
|
||||
string tempPath = outputFullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
int blockSize = options.PfsBlockSize;
|
||||
long timestamp = options.BuildTimestampUnixSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
byte[] fpt = BuildFlatPathTable(innerName);
|
||||
byte[] superRoot = CombineDirents(
|
||||
SerializeDirent(1, DirentFile, "flat_path_table"),
|
||||
SerializeDirent(2, DirentDirectory, "uroot"));
|
||||
byte[] userRoot = CombineDirents(
|
||||
SerializeDirent(2, DirentDot, "."),
|
||||
SerializeDirent(2, DirentDotDot, ".."),
|
||||
SerializeDirent(3, DirentFile, innerName));
|
||||
|
||||
int inodeBlockCount = DivideRoundUp(InodeCount, blockSize / InodeSize);
|
||||
int superRootBlock = 1 + inodeBlockCount;
|
||||
int fptBlock = superRootBlock + 1;
|
||||
int reservedBlock = fptBlock + 1;
|
||||
int userRootBlock = reservedBlock + 1;
|
||||
int payloadBlock = userRootBlock + 1;
|
||||
long payloadOffset = checked((long)payloadBlock * blockSize);
|
||||
|
||||
var inodes = new[]
|
||||
{
|
||||
new PfsInode((ushort)(InodeModeDirectory | PermissionsRx), 1,
|
||||
InodeFlagInternal | InodeFlagReadOnly, blockSize, blockSize, 1, superRootBlock, timestamp),
|
||||
new PfsInode((ushort)(InodeModeFile | PermissionsRx), 1,
|
||||
InodeFlagInternal | InodeFlagReadOnly, fpt.Length, fpt.Length, 1, fptBlock, timestamp),
|
||||
new PfsInode((ushort)(InodeModeDirectory | PermissionsRx), 3,
|
||||
InodeFlagReadOnly, blockSize, blockSize, 1, userRootBlock, timestamp),
|
||||
new PfsInode((ushort)(InodeModeFile | PermissionsRx), 1,
|
||||
InodeFlagReadOnly, 0, 0, 1, payloadBlock, timestamp)
|
||||
};
|
||||
|
||||
PfscWriteResult? pfscResult = null;
|
||||
long finalBlockCount = 0;
|
||||
try
|
||||
{
|
||||
await using (var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None,
|
||||
0x10000, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
await output.WriteAsync(BuildHeader(blockSize, options.CaseInsensitive, 0, inodeBlockCount, timestamp),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
WriteInodeTable(output, blockSize, inodes);
|
||||
WriteAt(output, checked((long)superRootBlock * blockSize), superRoot);
|
||||
WriteAt(output, checked((long)fptBlock * blockSize), fpt);
|
||||
WriteAt(output, checked((long)userRootBlock * blockSize), userRoot);
|
||||
output.Position = payloadOffset;
|
||||
|
||||
var pfscProgress = progress is null
|
||||
? null
|
||||
: new Progress<PfscProgress>(value =>
|
||||
progress.Report(new FfpfscProgress(progressStage, value.BytesProcessed, value.TotalBytes)));
|
||||
pfscResult = await PfscCodec.EncodeAsync(source, sourceLength, output, options.Compression, pfscProgress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int payloadBlocks = Math.Max(1, DivideRoundUp(pfscResult.StoredLength, blockSize));
|
||||
inodes[PayloadInodeNumber] = new PfsInode((ushort)(InodeModeFile | PermissionsRx), 1,
|
||||
InodeFlagReadOnly | InodeFlagCompressed, pfscResult.StoredLength, sourceLength,
|
||||
payloadBlocks, payloadBlock, timestamp);
|
||||
finalBlockCount = checked(payloadBlock + payloadBlocks);
|
||||
output.Position = 0;
|
||||
await output.WriteAsync(BuildHeader(blockSize, options.CaseInsensitive, finalBlockCount,
|
||||
inodeBlockCount, timestamp), cancellationToken).ConfigureAwait(false);
|
||||
WriteInodeTable(output, blockSize, inodes);
|
||||
output.SetLength(checked(finalBlockCount * blockSize));
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
progress?.Report(new FfpfscProgress("Verifying", sourceLength, sourceLength));
|
||||
await using (var verifyStream = File.OpenRead(tempPath))
|
||||
_ = Inspect(verifyStream);
|
||||
File.Move(tempPath, outputFullPath, options.OverwriteExisting);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new FfpfscBuildResult
|
||||
{
|
||||
OutputPath = outputFullPath,
|
||||
InnerFileName = innerName,
|
||||
SourceLength = sourceLength,
|
||||
PfscStoredLength = pfscResult!.StoredLength,
|
||||
ContainerLength = checked(finalBlockCount * blockSize),
|
||||
PfsBlockSize = blockSize,
|
||||
PfscBlockCount = pfscResult.BlockCount,
|
||||
CompressedBlockCount = pfscResult.CompressedBlockCount
|
||||
};
|
||||
}
|
||||
|
||||
public static FfpfscInfo Inspect(Stream source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
if (!source.CanRead || !source.CanSeek)
|
||||
throw new ArgumentException("The FFPFSC stream must be readable and seekable.", nameof(source));
|
||||
if (source.Length < 0x1000) throw new InvalidDataException("The PFS image is too small.");
|
||||
|
||||
byte[] fixedHeader = ReadAt(source, 0, 0x50);
|
||||
long version = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x00, 8));
|
||||
long magic = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x08, 8));
|
||||
int mode = BinaryPrimitives.ReadUInt16LittleEndian(fixedHeader.AsSpan(0x1C, 2));
|
||||
int blockSize = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(fixedHeader.AsSpan(0x20, 4)));
|
||||
long leadingBlocks = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x28, 8));
|
||||
long inodeCount = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x30, 8));
|
||||
long blockCount = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x38, 8));
|
||||
long inodeBlockCount = BinaryPrimitives.ReadInt64LittleEndian(fixedHeader.AsSpan(0x40, 8));
|
||||
|
||||
if (version != PfsVersion || magic != PfsMagic) throw new InvalidDataException("Unsupported PFS header.");
|
||||
if ((mode & ~PfsModeCaseInsensitive) != 0)
|
||||
throw new InvalidDataException("Only unsigned, unencrypted 32-bit PFS images are supported.");
|
||||
if (blockSize is < 0x1000 or > 0x100000 || (blockSize & (blockSize - 1)) != 0)
|
||||
throw new InvalidDataException("The PFS block size is invalid.");
|
||||
if (leadingBlocks != 1 || inodeCount < InodeCount || inodeCount > blockSize / InodeSize)
|
||||
throw new InvalidDataException("The PFS inode layout is unsupported.");
|
||||
if (inodeBlockCount != 1 || blockCount <= 0 || blockCount > source.Length / blockSize)
|
||||
throw new InvalidDataException("The PFS block ranges are invalid.");
|
||||
if (source.Length != checked(blockCount * blockSize))
|
||||
throw new InvalidDataException("The PFS file length does not match its block count.");
|
||||
|
||||
PfsInode[] inodes = new PfsInode[InodeCount];
|
||||
for (int index = 0; index < inodes.Length; index++)
|
||||
inodes[index] = ParseInode(ReadAt(source, checked(blockSize + (long)index * InodeSize), InodeSize));
|
||||
ValidateInodeRange(inodes[0], blockCount, blockSize, "super root");
|
||||
ValidateInodeRange(inodes[1], blockCount, blockSize, "flat path table");
|
||||
ValidateInodeRange(inodes[2], blockCount, blockSize, "user root");
|
||||
ValidateInodeRange(inodes[3], blockCount, blockSize, "payload");
|
||||
|
||||
IReadOnlyList<PfsDirent> superRoot = ReadDirectory(source, inodes[0], blockSize);
|
||||
RequireDirent(superRoot, 1, DirentFile, "flat_path_table");
|
||||
RequireDirent(superRoot, 2, DirentDirectory, "uroot");
|
||||
IReadOnlyList<PfsDirent> userRoot = ReadDirectory(source, inodes[2], blockSize);
|
||||
RequireDirent(userRoot, 2, DirentDot, ".");
|
||||
RequireDirent(userRoot, 2, DirentDotDot, "..");
|
||||
PfsDirent payloadEntry = userRoot.SingleOrDefault(entry => entry.Inode == PayloadInodeNumber && entry.Type == DirentFile)
|
||||
?? throw new InvalidDataException("The PFS user root has no payload file.");
|
||||
|
||||
if ((inodes[3].Flags & InodeFlagCompressed) == 0 || inodes[3].SizeCompressed < 0)
|
||||
throw new InvalidDataException("The PFS payload inode is not PFSC-compressed.");
|
||||
byte[] fpt = ReadAt(source, checked((long)inodes[1].FirstBlock * blockSize), checked((int)inodes[1].Size));
|
||||
if (fpt.Length != 8 || BinaryPrimitives.ReadUInt32LittleEndian(fpt.AsSpan(0, 4)) != HashPath('/' + payloadEntry.Name) ||
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(fpt.AsSpan(4, 4)) != PayloadInodeNumber)
|
||||
throw new InvalidDataException("The PFS flat path table does not match the payload entry.");
|
||||
|
||||
long payloadOffset = checked((long)inodes[3].FirstBlock * blockSize);
|
||||
PfscInfo pfsc = PfscCodec.Inspect(source, payloadOffset, inodes[3].Size);
|
||||
if (pfsc.PaddedLogicalLength < inodes[3].SizeCompressed ||
|
||||
pfsc.PaddedLogicalLength - inodes[3].SizeCompressed >= PfscCodec.LogicalBlockSize)
|
||||
throw new InvalidDataException("The PFSC logical size does not match the payload inode.");
|
||||
|
||||
return new FfpfscInfo
|
||||
{
|
||||
PfsVersion = checked((int)version),
|
||||
PfsMode = mode,
|
||||
PfsBlockSize = blockSize,
|
||||
PfsBlockCount = blockCount,
|
||||
InodeCount = checked((int)inodeCount),
|
||||
InnerFileName = payloadEntry.Name,
|
||||
PayloadOffset = payloadOffset,
|
||||
StoredLength = inodes[3].Size,
|
||||
LogicalLength = inodes[3].SizeCompressed,
|
||||
Pfsc = pfsc
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task ExtractAsync(string ffpfscPath, string outputPath,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string inputFullPath = Path.GetFullPath(ffpfscPath);
|
||||
string outputFullPath = Path.GetFullPath(outputPath);
|
||||
if (string.Equals(inputFullPath, outputFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
throw new IOException("The extraction output must differ from the FFPFSC image.");
|
||||
if (File.Exists(outputFullPath)) throw new IOException($"The output file already exists: {outputFullPath}");
|
||||
string? directory = Path.GetDirectoryName(outputFullPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
string tempPath = outputFullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
await using var input = File.OpenRead(inputFullPath);
|
||||
FfpfscInfo info = Inspect(input);
|
||||
await using var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
0x10000, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var decodeProgress = progress is null
|
||||
? null
|
||||
: new Progress<PfscProgress>(value =>
|
||||
progress.Report(new FfpfscProgress("Extracting", value.BytesProcessed, value.TotalBytes)));
|
||||
await PfscCodec.DecodeAsync(input, output, info.LogicalLength, info.PayloadOffset, info.StoredLength,
|
||||
decodeProgress, cancellationToken).ConfigureAwait(false);
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
output.Close();
|
||||
File.Move(tempPath, outputFullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<FfpfscVerificationResult> VerifyAsync(string ffpfscPath, string? originalSourcePath = null,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var input = File.OpenRead(ffpfscPath);
|
||||
FfpfscInfo info = Inspect(input);
|
||||
using var decodedHasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
await using var hashSink = new IncrementalHashStream(decodedHasher);
|
||||
var decodeProgress = progress is null
|
||||
? null
|
||||
: new Progress<PfscProgress>(value =>
|
||||
progress.Report(new FfpfscProgress("Verifying", value.BytesProcessed, value.TotalBytes)));
|
||||
await PfscCodec.DecodeAsync(input, hashSink, info.LogicalLength, info.PayloadOffset, info.StoredLength,
|
||||
decodeProgress, cancellationToken).ConfigureAwait(false);
|
||||
string decodedHash = Convert.ToHexString(decodedHasher.GetHashAndReset());
|
||||
|
||||
string? sourceHash = null;
|
||||
bool? matches = null;
|
||||
if (originalSourcePath is not null)
|
||||
{
|
||||
await using var original = File.OpenRead(originalSourcePath);
|
||||
byte[] hash = await SHA256.HashDataAsync(original, cancellationToken).ConfigureAwait(false);
|
||||
sourceHash = Convert.ToHexString(hash);
|
||||
matches = original.Length == info.LogicalLength && sourceHash == decodedHash;
|
||||
}
|
||||
|
||||
return new FfpfscVerificationResult
|
||||
{
|
||||
Info = info,
|
||||
StructureValid = true,
|
||||
EveryPfscBlockDecodes = true,
|
||||
SourceMatches = matches,
|
||||
SourceSha256 = sourceHash,
|
||||
DecodedSha256 = decodedHash
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-throwing verification that reports structural validity and PFSC decode success independently.
|
||||
/// </summary>
|
||||
public static async Task<FfpfscVerificationResult> TryVerifyAsync(string ffpfscPath,
|
||||
string? originalSourcePath = null, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
FfpfscInfo info;
|
||||
try
|
||||
{
|
||||
await using var input = File.OpenRead(ffpfscPath);
|
||||
info = Inspect(input);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or IOException or ArgumentException)
|
||||
{
|
||||
return new FfpfscVerificationResult
|
||||
{
|
||||
Info = null,
|
||||
StructureValid = false,
|
||||
EveryPfscBlockDecodes = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await VerifyAsync(ffpfscPath, originalSourcePath, progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or IOException or ArgumentException)
|
||||
{
|
||||
return new FfpfscVerificationResult
|
||||
{
|
||||
Info = info,
|
||||
StructureValid = true,
|
||||
EveryPfscBlockDecodes = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaximumUnwrapDepth = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the full inner tree (exFAT or UFS2), unwrapping nested PFS containers.
|
||||
/// Returns the number of files written.
|
||||
/// </summary>
|
||||
public static async Task<int> ExtractToDirectoryAsync(string ffpfscPath, string outputDirectory,
|
||||
bool overwrite = false, IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ffpfscPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
|
||||
string outputFullPath = Path.GetFullPath(outputDirectory);
|
||||
Directory.CreateDirectory(outputFullPath);
|
||||
using FfpfscVolume volume = FfpfscVolume.Open(ffpfscPath);
|
||||
return await ExtractVolumeToDirectoryAsync(volume, outputFullPath, overwrite, progress, cancellationToken, 0)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ExtractVolumeToDirectoryAsync(FfpfscVolume volume, string root, bool overwrite,
|
||||
IProgress<FfpfscProgress>? progress, CancellationToken cancellationToken, int depth)
|
||||
{
|
||||
if (volume.InnerFilesystemKind == FfpfscInnerFilesystemKind.Pfs)
|
||||
{
|
||||
if (depth >= MaximumUnwrapDepth)
|
||||
throw new InvalidDataException("The FFPFSC image nests more than four container layers.");
|
||||
using FfpfscVolume nested = FfpfscVolume.Open(volume.PayloadStream,
|
||||
volume.Path + "!" + volume.Info.InnerFileName, leaveOpen: true);
|
||||
return await ExtractVolumeToDirectoryAsync(nested, root, overwrite, progress, cancellationToken,
|
||||
depth + 1).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
foreach (FfpfscVolumeEntry entry in volume.Entries)
|
||||
if (!entry.IsDirectory) total += Math.Max(0, entry.Size);
|
||||
|
||||
long written = 0;
|
||||
int count = 0;
|
||||
foreach (FfpfscVolumeEntry entry in volume.Entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string relative = entry.Path.Replace('/', Path.DirectorySeparatorChar);
|
||||
string target = Path.GetFullPath(Path.Combine(root, relative));
|
||||
if (!string.Equals(target, root, StringComparison.OrdinalIgnoreCase) &&
|
||||
!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"The inner path escapes the output folder: {entry.Path}");
|
||||
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
if (File.Exists(target) && !overwrite)
|
||||
throw new IOException($"The output file already exists: {target}");
|
||||
await using (Stream input = volume.OpenFile(entry.Path))
|
||||
await using (var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
0x10000, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
await input.CopyToAsync(output, 0x10000, cancellationToken).ConfigureAwait(false);
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
written += Math.Max(0, entry.Size);
|
||||
count++;
|
||||
progress?.Report(new FfpfscProgress("Extracting", written, total));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static byte[] BuildHeader(int blockSize, bool caseInsensitive, long finalBlockCount,
|
||||
int inodeBlockCount, long timestamp)
|
||||
{
|
||||
byte[] header = new byte[blockSize];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x00, 8), PfsVersion);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x08, 8), PfsMagic);
|
||||
header[0x1A] = 1;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1C, 2),
|
||||
checked((ushort)(caseInsensitive ? PfsModeCaseInsensitive : 0)));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20, 4), checked((uint)blockSize));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x28, 8), 1);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x30, 8), InodeCount);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x38, 8), finalBlockCount);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x40, 8), inodeBlockCount);
|
||||
|
||||
Span<byte> signatureInode = header.AsSpan(0x50, 0x310);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(signatureInode.Slice(0x02, 2), 1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signatureInode.Slice(0x04, 4), InodeFlagReadOnly);
|
||||
long inodeBytes = checked((long)inodeBlockCount * blockSize);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(signatureInode.Slice(0x08, 8), inodeBytes);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(signatureInode.Slice(0x10, 8), inodeBytes);
|
||||
for (int index = 0; index < 4; index++)
|
||||
BinaryPrimitives.WriteInt64LittleEndian(signatureInode.Slice(0x18 + index * 8, 8), timestamp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signatureInode.Slice(0x60, 4), checked((uint)inodeBlockCount));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(signatureInode.Slice(0x88, 8), 1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x368, 4), 1);
|
||||
return header;
|
||||
}
|
||||
|
||||
private static void WriteInodeTable(Stream output, int blockSize, IReadOnlyList<PfsInode> inodes)
|
||||
{
|
||||
output.Position = blockSize;
|
||||
for (int index = 0; index < inodes.Count; index++)
|
||||
output.Write(SerializeInode(inodes[index], index == 0));
|
||||
}
|
||||
|
||||
private static byte[] SerializeInode(PfsInode inode, bool superRoot)
|
||||
{
|
||||
byte[] data = new byte[InodeSize];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x00, 2), inode.Mode);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x02, 2), inode.LinkCount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x04, 4), inode.Flags);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(data.AsSpan(0x08, 8), inode.Size);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(data.AsSpan(0x10, 8), inode.SizeCompressed);
|
||||
for (int index = 0; index < 4; index++)
|
||||
BinaryPrimitives.WriteInt64LittleEndian(data.AsSpan(0x18 + index * 8, 8), inode.Timestamp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x60, 4), checked((uint)inode.Blocks));
|
||||
for (int index = 0; index < 12; index++)
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data.AsSpan(0x64 + index * 4, 4),
|
||||
index == 0 ? inode.FirstBlock : superRoot ? 0 : -1);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static PfsInode ParseInode(byte[] data) => new(
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0x00, 2)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0x02, 2)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x04, 4)),
|
||||
BinaryPrimitives.ReadInt64LittleEndian(data.AsSpan(0x08, 8)),
|
||||
BinaryPrimitives.ReadInt64LittleEndian(data.AsSpan(0x10, 8)),
|
||||
checked((int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x60, 4))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(0x64, 4)),
|
||||
BinaryPrimitives.ReadInt64LittleEndian(data.AsSpan(0x18, 8)));
|
||||
|
||||
private static void ValidateInodeRange(PfsInode inode, long totalBlocks, int blockSize, string label)
|
||||
{
|
||||
if (inode.Size < 0 || inode.SizeCompressed < 0 || inode.Blocks <= 0 || inode.FirstBlock <= 0 ||
|
||||
inode.FirstBlock >= totalBlocks || inode.Blocks > totalBlocks - inode.FirstBlock ||
|
||||
inode.Size > checked((long)inode.Blocks * blockSize))
|
||||
throw new InvalidDataException($"The PFS {label} inode has an invalid range.");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PfsDirent> ReadDirectory(Stream source, PfsInode inode, int blockSize)
|
||||
{
|
||||
int length = checked((int)Math.Min(inode.Size, checked((long)inode.Blocks * blockSize)));
|
||||
byte[] data = ReadAt(source, checked((long)inode.FirstBlock * blockSize), length);
|
||||
var result = new List<PfsDirent>();
|
||||
int offset = 0;
|
||||
while (offset + 16 <= data.Length)
|
||||
{
|
||||
uint ino = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset, 4));
|
||||
int type = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 4, 4));
|
||||
int nameLength = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 8, 4));
|
||||
int entrySize = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 12, 4));
|
||||
if (ino == 0 && type == 0 && nameLength == 0 && entrySize == 0) break;
|
||||
if (nameLength < 0 || entrySize < 16 || (entrySize & 7) != 0 || nameLength > entrySize - 16 ||
|
||||
entrySize > data.Length - offset)
|
||||
throw new InvalidDataException("A PFS directory entry is malformed.");
|
||||
string name;
|
||||
try { name = Encoding.ASCII.GetString(data, offset + 16, nameLength); }
|
||||
catch (DecoderFallbackException ex) { throw new InvalidDataException("A PFS filename is not ASCII.", ex); }
|
||||
result.Add(new PfsDirent(ino, type, name));
|
||||
offset += entrySize;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void RequireDirent(IEnumerable<PfsDirent> entries, uint inode, int type, string name)
|
||||
{
|
||||
if (!entries.Any(entry => entry.Inode == inode && entry.Type == type && entry.Name == name))
|
||||
throw new InvalidDataException($"The PFS directory entry '{name}' is missing.");
|
||||
}
|
||||
|
||||
private static byte[] SerializeDirent(uint inode, int type, string name)
|
||||
{
|
||||
EnsureAsciiFileName(name);
|
||||
byte[] nameBytes = Encoding.ASCII.GetBytes(name);
|
||||
int entrySize = Align(nameBytes.Length + 17, 8);
|
||||
byte[] result = new byte[entrySize];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0, 4), inode);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(4, 4), type);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(8, 4), nameBytes.Length);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(12, 4), entrySize);
|
||||
nameBytes.CopyTo(result, 16);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildFlatPathTable(string innerName)
|
||||
{
|
||||
byte[] result = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(0, 4), HashPath('/' + innerName));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(4, 4), PayloadInodeNumber);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint HashPath(string path)
|
||||
{
|
||||
uint hash = 0;
|
||||
foreach (char value in path)
|
||||
{
|
||||
char folded = char.ToUpperInvariant(value);
|
||||
hash = unchecked(folded + 31u * hash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static string ResolveInnerName(string sourcePath, string? requestedName)
|
||||
{
|
||||
string name = requestedName ?? Path.GetFileName(sourcePath);
|
||||
if (string.IsNullOrWhiteSpace(name) || name is "." or ".." || name != Path.GetFileName(name))
|
||||
throw new ArgumentException("The inner payload name must be a plain filename.", nameof(requestedName));
|
||||
EnsureAsciiFileName(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string ResolveDumpImageName(string sourceDirectory)
|
||||
{
|
||||
string parameterPath = Path.Combine(sourceDirectory, "sce_sys", "param.json");
|
||||
if (File.Exists(parameterPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(parameterPath));
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("titleId", StringComparison.OrdinalIgnoreCase) ||
|
||||
property.Value.ValueKind != JsonValueKind.String) continue;
|
||||
string? value = property.Value.GetString();
|
||||
if (value is not null && Regex.IsMatch(value, "^[A-Za-z]{4}[0-9]{5}$"))
|
||||
return value.ToUpperInvariant() + ".exfat";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
string folderName = new DirectoryInfo(sourceDirectory).Name;
|
||||
string asciiName = new(folderName.Select(value => value is >= ' ' and <= '~' &&
|
||||
value is not '"' and not '*' and not '/' and not ':' and not '<' and not '>' and not '?' and not '\\' and not '|'
|
||||
? value
|
||||
: '_').ToArray());
|
||||
asciiName = asciiName.Trim(' ', '.');
|
||||
return (asciiName.Length == 0 ? "PS5_GAME" : asciiName) + ".exfat";
|
||||
}
|
||||
|
||||
private static string ResolveGameRoot(string sourceDirectory)
|
||||
{
|
||||
if (File.Exists(Path.Combine(sourceDirectory, "sce_sys", "param.json"))) return sourceDirectory;
|
||||
|
||||
var candidates = new List<string>();
|
||||
var pending = new Stack<DirectoryInfo>();
|
||||
pending.Push(new DirectoryInfo(sourceDirectory));
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
DirectoryInfo current = pending.Pop();
|
||||
DirectoryInfo[] children;
|
||||
try
|
||||
{
|
||||
children = current.EnumerateDirectories()
|
||||
.Where(directory => (directory.Attributes & FileAttributes.ReparsePoint) == 0)
|
||||
.OrderByDescending(directory => directory.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
throw new IOException($"Unable to inspect the selected dump folder: {current.FullName}", ex);
|
||||
}
|
||||
|
||||
foreach (DirectoryInfo child in children)
|
||||
{
|
||||
if (child.Name.Equals("sce_sys", StringComparison.OrdinalIgnoreCase) &&
|
||||
File.Exists(Path.Combine(child.FullName, "param.json")))
|
||||
{
|
||||
string candidate = current.FullName;
|
||||
if (!candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) candidates.Add(candidate);
|
||||
if (candidates.Count > 1)
|
||||
{
|
||||
string display = string.Join(Environment.NewLine,
|
||||
candidates.Select(path => " " + Path.GetRelativePath(sourceDirectory, path)));
|
||||
throw new InvalidDataException(
|
||||
$"Multiple PS5 game roots were found in the selected folder. Select one game folder directly:{Environment.NewLine}{display}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending.Push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.Count == 1 ? candidates[0] : sourceDirectory;
|
||||
}
|
||||
|
||||
private static void EnsureAsciiFileName(string name)
|
||||
{
|
||||
if (name.Any(value => value > 0x7F || value == '\0'))
|
||||
throw new ArgumentException($"PFS filenames must contain ASCII characters only: {name}");
|
||||
}
|
||||
|
||||
private static byte[] CombineDirents(params byte[][] entries)
|
||||
{
|
||||
int length = entries.Sum(entry => entry.Length);
|
||||
byte[] result = new byte[length];
|
||||
int offset = 0;
|
||||
foreach (byte[] entry in entries)
|
||||
{
|
||||
entry.CopyTo(result, offset);
|
||||
offset += entry.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void WriteAt(Stream stream, long offset, byte[] data)
|
||||
{
|
||||
stream.Position = offset;
|
||||
stream.Write(data);
|
||||
}
|
||||
|
||||
private static byte[] ReadAt(Stream stream, long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > stream.Length || count > stream.Length - offset)
|
||||
throw new InvalidDataException("A PFS read range is outside the image.");
|
||||
byte[] result = new byte[count];
|
||||
stream.Position = offset;
|
||||
stream.ReadExactly(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int DivideRoundUp(long value, int divisor) => value == 0 ? 0 : checked((int)((value - 1) / divisor + 1));
|
||||
private static int Align(int value, int alignment) => checked((value + alignment - 1) / alignment * alignment);
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
private sealed record PfsInode(ushort Mode, ushort LinkCount, uint Flags, long Size, long SizeCompressed,
|
||||
int Blocks, int FirstBlock, long Timestamp);
|
||||
private sealed record PfsDirent(uint Inode, int Type, string Name);
|
||||
|
||||
private sealed class IncrementalHashStream(IncrementalHash hash) : Stream
|
||||
{
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public override void Write(byte[] buffer, int offset, int count) => hash.AppendData(buffer, offset, count);
|
||||
public override void Write(ReadOnlySpan<byte> buffer) => hash.AppendData(buffer);
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
hash.AppendData(buffer.Span);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed class FfpfscBuildOptions
|
||||
{
|
||||
public const int DefaultPfsBlockSize = 0x10000;
|
||||
|
||||
public int PfsBlockSize { get; init; } = DefaultPfsBlockSize;
|
||||
public bool CaseInsensitive { get; init; } = true;
|
||||
public bool OverwriteExisting { get; init; }
|
||||
public string? InnerFileName { get; init; }
|
||||
/// <summary>Optional deterministic timestamp for interoperability tests; normal builds use the current UTC time.</summary>
|
||||
public long? BuildTimestampUnixSeconds { get; init; }
|
||||
public PfscCompressionOptions Compression { get; init; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (PfsBlockSize is < 0x1000 or > 0x100000 || (PfsBlockSize & (PfsBlockSize - 1)) != 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(PfsBlockSize),
|
||||
"The PFS block size must be a power of two between 4 KiB and 1 MiB.");
|
||||
if (BuildTimestampUnixSeconds is < 0 or > 253_402_300_799)
|
||||
throw new ArgumentOutOfRangeException(nameof(BuildTimestampUnixSeconds),
|
||||
"The optional build timestamp must be a valid Unix timestamp.");
|
||||
Compression.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct FfpfscProgress(string Stage, long BytesProcessed, long TotalBytes);
|
||||
|
||||
public sealed class FfpfscBuildResult
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public required string InnerFileName { get; init; }
|
||||
public required long SourceLength { get; init; }
|
||||
public required long PfscStoredLength { get; init; }
|
||||
public required long ContainerLength { get; init; }
|
||||
public required int PfsBlockSize { get; init; }
|
||||
public required int PfscBlockCount { get; init; }
|
||||
public required int CompressedBlockCount { get; init; }
|
||||
public double PayloadSavingsPercent => SourceLength == 0
|
||||
? 0
|
||||
: (SourceLength - PfscStoredLength) * 100.0 / SourceLength;
|
||||
}
|
||||
|
||||
public sealed class FfpfscInfo
|
||||
{
|
||||
public required int PfsVersion { get; init; }
|
||||
public required int PfsMode { get; init; }
|
||||
public required int PfsBlockSize { get; init; }
|
||||
public required long PfsBlockCount { get; init; }
|
||||
public required int InodeCount { get; init; }
|
||||
public required string InnerFileName { get; init; }
|
||||
public required long PayloadOffset { get; init; }
|
||||
public required long StoredLength { get; init; }
|
||||
public required long LogicalLength { get; init; }
|
||||
public required PfscInfo Pfsc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FfpfscVerificationResult
|
||||
{
|
||||
public FfpfscInfo? Info { get; init; }
|
||||
public required bool StructureValid { get; init; }
|
||||
public required bool EveryPfscBlockDecodes { get; init; }
|
||||
public bool? SourceMatches { get; init; }
|
||||
public string? SourceSha256 { get; init; }
|
||||
public string? DecodedSha256 { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
using UFS2Tool;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public enum FfpfscInnerFilesystemKind
|
||||
{
|
||||
Exfat,
|
||||
Ufs2,
|
||||
/// <summary>The inner payload is itself a PFS container (nested FFPFSC/FFPFS).</summary>
|
||||
Pfs
|
||||
}
|
||||
|
||||
public sealed record FfpfscVolumeEntry(string Path, bool IsDirectory, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the inner payload of an FFPFSC image without extracting it. The inner filesystem is
|
||||
/// detected by signature (exFAT, UFS2) so wrapped payloads do not depend on the inner file name.
|
||||
/// Nested PFS payloads are exposed through <see cref="PayloadStream"/> for recursive unwrapping.
|
||||
/// </summary>
|
||||
public sealed class FfpfscVolume : IDisposable
|
||||
{
|
||||
private readonly Stream _container;
|
||||
private readonly PfscReadStream _decoded;
|
||||
private readonly ExfatVolume? _exfat;
|
||||
private readonly Ufs2Volume? _ufs2;
|
||||
private readonly bool _leaveContainerOpen;
|
||||
private bool _disposed;
|
||||
|
||||
private FfpfscVolume(string path)
|
||||
: this(new FileStream(System.IO.Path.GetFullPath(path), FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
1024 * 1024, FileOptions.RandomAccess), System.IO.Path.GetFullPath(path), leaveContainerOpen: false)
|
||||
{
|
||||
}
|
||||
|
||||
private FfpfscVolume(Stream container, string displayPath, bool leaveContainerOpen)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
Path = displayPath;
|
||||
_container = container;
|
||||
_leaveContainerOpen = leaveContainerOpen;
|
||||
try
|
||||
{
|
||||
Info = FfpfscImage.Inspect(_container);
|
||||
_decoded = new PfscReadStream(_container, Info.Pfsc, Info.PayloadOffset, Info.LogicalLength, leaveOpen: true);
|
||||
|
||||
Ps5ImageFormat format = Ps5ImageFormatProbe.Detect(_decoded);
|
||||
switch (format)
|
||||
{
|
||||
case Ps5ImageFormat.Ufs2:
|
||||
_ufs2 = new Ufs2Volume(_decoded, Path + "!" + Info.InnerFileName, leaveOpen: true);
|
||||
InnerFilesystemKind = FfpfscInnerFilesystemKind.Ufs2;
|
||||
Entries = _ufs2.Entries
|
||||
.Select(entry => new FfpfscVolumeEntry(entry.Path, entry.IsDirectory, entry.Size))
|
||||
.ToArray();
|
||||
break;
|
||||
case Ps5ImageFormat.Pfs:
|
||||
InnerFilesystemKind = FfpfscInnerFilesystemKind.Pfs;
|
||||
Entries = [new FfpfscVolumeEntry(Info.InnerFileName, false, Info.LogicalLength)];
|
||||
break;
|
||||
case Ps5ImageFormat.Exfat:
|
||||
_exfat = new ExfatVolume(_decoded, leaveOpen: true);
|
||||
InnerFilesystemKind = FfpfscInnerFilesystemKind.Exfat;
|
||||
Entries = _exfat.Entries
|
||||
.Select(entry => new FfpfscVolumeEntry(entry.Path, entry.IsDirectory, entry.Size))
|
||||
.ToArray();
|
||||
break;
|
||||
default:
|
||||
throw new InvalidDataException(
|
||||
"The FFPFSC inner payload is not a recognised filesystem (exFAT, UFS2, or PFS).");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_ufs2?.Dispose();
|
||||
_exfat?.Dispose();
|
||||
_decoded?.Dispose();
|
||||
if (!leaveContainerOpen) _container.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public FfpfscInfo Info { get; }
|
||||
public FfpfscInnerFilesystemKind InnerFilesystemKind { get; }
|
||||
public IReadOnlyList<FfpfscVolumeEntry> Entries { get; } = [];
|
||||
public ExfatVolume FileSystem => _exfat ?? throw new InvalidOperationException("The inner filesystem is not exFAT.");
|
||||
|
||||
/// <summary>The decoded inner payload stream (seekable). Used to unwrap nested PFS containers.</summary>
|
||||
public Stream PayloadStream => _decoded;
|
||||
|
||||
public static FfpfscVolume Open(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
return new FfpfscVolume(path);
|
||||
}
|
||||
|
||||
public static FfpfscVolume Open(Stream container, string displayPath, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
return new FfpfscVolume(container, displayPath, leaveOpen);
|
||||
}
|
||||
|
||||
public FfpfscVolumeEntry? Find(string path)
|
||||
{
|
||||
string normalized = path.Replace('\\', '/').Trim('/');
|
||||
return Entries.FirstOrDefault(entry => entry.Path.Equals(normalized, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public Stream OpenFile(string path)
|
||||
{
|
||||
if (_ufs2 is not null) return _ufs2.OpenFile(path);
|
||||
if (_exfat is not null) return _exfat.OpenFile(path);
|
||||
throw new InvalidOperationException(
|
||||
"The FFPFSC inner payload is a nested PFS container, not a file tree.");
|
||||
}
|
||||
|
||||
public byte[] ReadAllBytes(string path, int maximumBytes = 256 * 1024 * 1024)
|
||||
{
|
||||
FfpfscVolumeEntry entry = Find(path) ?? throw new FileNotFoundException("The file was not found in FFPFSC.", path);
|
||||
if (entry.IsDirectory) throw new IOException("The requested FFPFSC entry is a directory.");
|
||||
if (entry.Size < 0 || entry.Size > maximumBytes || entry.Size > int.MaxValue)
|
||||
throw new IOException($"The FFPFSC file is too large to load into memory: {entry.Path}");
|
||||
using Stream input = OpenFile(entry.Path);
|
||||
byte[] result = new byte[checked((int)entry.Size)];
|
||||
input.ReadExactly(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public string ReadAllText(string path, Encoding? encoding = null, int maximumBytes = 16 * 1024 * 1024) =>
|
||||
(encoding ?? new UTF8Encoding(false, true)).GetString(ReadAllBytes(path, maximumBytes));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_exfat?.Dispose();
|
||||
_ufs2?.Dispose();
|
||||
_decoded.Dispose();
|
||||
if (!_leaveContainerOpen) _container.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using UFS2Tool;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public enum Ps5ImageConversionTarget
|
||||
{
|
||||
Exfat,
|
||||
Ffpkg,
|
||||
Ffpfsc,
|
||||
ZArchive
|
||||
}
|
||||
|
||||
public sealed record Ps5ImageConversionProgress(string Stage, long Completed, long Total);
|
||||
|
||||
public sealed class Ps5ImageConversionResult
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public required Ps5ImageFormat SourceFormat { get; init; }
|
||||
public required Ps5ImageConversionTarget Target { get; init; }
|
||||
public required long SourceBytes { get; init; }
|
||||
public required long OutputBytes { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FfpkgBuildOptions
|
||||
{
|
||||
public int BlockSize { get; init; } = 32768;
|
||||
public int FragmentSize { get; init; } = 4096;
|
||||
public int BytesPerInode { get; init; } = 262144;
|
||||
public int MinFreePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts between PS5 dump folders and the exFAT, FFPKG (UFS2), and FFPFSC image formats.
|
||||
/// Filesystem images are extracted to a temporary tree and rebuilt in the target format; wrapping a
|
||||
/// filesystem image into FFPFSC is done directly without extraction.
|
||||
/// </summary>
|
||||
public static class Ps5ImageConversionService
|
||||
{
|
||||
private const int MaximumUnwrapDepth = 4;
|
||||
|
||||
public static Ps5ImageFormat DetectSource(string path) =>
|
||||
Directory.Exists(path) ? Ps5ImageFormat.Unknown : Ps5ImageFormatProbe.Detect(path);
|
||||
|
||||
public static bool IsSupported(Ps5ImageFormat source, Ps5ImageConversionTarget target) => (source, target) switch
|
||||
{
|
||||
(Ps5ImageFormat.Exfat, Ps5ImageConversionTarget.Ffpkg) => true,
|
||||
(Ps5ImageFormat.Exfat, Ps5ImageConversionTarget.Ffpfsc) => true,
|
||||
(Ps5ImageFormat.Exfat, Ps5ImageConversionTarget.ZArchive) => true,
|
||||
(Ps5ImageFormat.Ufs2, Ps5ImageConversionTarget.ZArchive) => true,
|
||||
(Ps5ImageFormat.Pfs, Ps5ImageConversionTarget.ZArchive) => true,
|
||||
(Ps5ImageFormat.ZArchive, Ps5ImageConversionTarget.Exfat) => true,
|
||||
(Ps5ImageFormat.ZArchive, Ps5ImageConversionTarget.Ffpkg) => true,
|
||||
(Ps5ImageFormat.ZArchive, Ps5ImageConversionTarget.Ffpfsc) => true,
|
||||
(Ps5ImageFormat.Ufs2, Ps5ImageConversionTarget.Exfat) => true,
|
||||
(Ps5ImageFormat.Ufs2, Ps5ImageConversionTarget.Ffpfsc) => true,
|
||||
(Ps5ImageFormat.Pfs, Ps5ImageConversionTarget.Exfat) => true,
|
||||
(Ps5ImageFormat.Pfs, Ps5ImageConversionTarget.Ffpkg) => true,
|
||||
(Ps5ImageFormat.Pfs, Ps5ImageConversionTarget.Ffpfsc) => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static async Task<Ps5ImageConversionResult> ConvertAsync(string sourcePath, string outputPath,
|
||||
Ps5ImageConversionTarget target, bool overwrite = false,
|
||||
IProgress<Ps5ImageConversionProgress>? progress = null, CancellationToken cancellationToken = default,
|
||||
ExfatBuildOptions? exfatOptions = null, FfpfscBuildOptions? ffpfscOptions = null,
|
||||
FfpkgBuildOptions? ffpkgOptions = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
string sourceFull = Path.GetFullPath(sourcePath);
|
||||
string outputFull = Path.GetFullPath(outputPath);
|
||||
bool isDirectory = Directory.Exists(sourceFull);
|
||||
if (!isDirectory && !File.Exists(sourceFull))
|
||||
throw new FileNotFoundException("The source image or dump folder does not exist.", sourceFull);
|
||||
|
||||
if (!isDirectory)
|
||||
{
|
||||
if (string.Equals(sourceFull, outputFull, StringComparison.OrdinalIgnoreCase))
|
||||
throw new IOException("The output path must be different from the source image.");
|
||||
if (File.Exists(outputFull) && !overwrite)
|
||||
throw new IOException($"The output file already exists: {outputFull}");
|
||||
}
|
||||
|
||||
Ps5ImageFormat sourceFormat = isDirectory ? Ps5ImageFormat.Unknown : Ps5ImageFormatProbe.Detect(sourceFull);
|
||||
long sourceBytes = isDirectory ? 0 : new FileInfo(sourceFull).Length;
|
||||
|
||||
// Direct wrap: a filesystem image becomes an FFPFSC without an extraction round trip.
|
||||
if (!isDirectory && target == Ps5ImageConversionTarget.Ffpfsc &&
|
||||
sourceFormat is Ps5ImageFormat.Exfat or Ps5ImageFormat.Ufs2)
|
||||
{
|
||||
string innerName = Path.GetFileName(sourceFull);
|
||||
FfpfscBuildOptions wrapOptions = ffpfscOptions is null
|
||||
? new FfpfscBuildOptions { InnerFileName = innerName, OverwriteExisting = overwrite }
|
||||
: new FfpfscBuildOptions
|
||||
{
|
||||
PfsBlockSize = ffpfscOptions.PfsBlockSize,
|
||||
CaseInsensitive = ffpfscOptions.CaseInsensitive,
|
||||
OverwriteExisting = overwrite,
|
||||
InnerFileName = innerName,
|
||||
BuildTimestampUnixSeconds = ffpfscOptions.BuildTimestampUnixSeconds,
|
||||
Compression = ffpfscOptions.Compression
|
||||
};
|
||||
FfpfscBuildResult build = await FfpfscImage.CreateFromImageAsync(sourceFull, outputFull,
|
||||
wrapOptions,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new Ps5ImageConversionResult
|
||||
{
|
||||
OutputPath = outputFull,
|
||||
SourceFormat = sourceFormat,
|
||||
Target = target,
|
||||
SourceBytes = sourceBytes,
|
||||
OutputBytes = build.ContainerLength,
|
||||
FileCount = 1
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDirectory && !IsSupported(sourceFormat, target))
|
||||
throw new InvalidDataException(
|
||||
$"Converting {sourceFormat} to {target} is not supported.");
|
||||
|
||||
string tempRoot = Path.Combine(Path.GetTempPath(), "Packwright", "convert", Guid.NewGuid().ToString("N"));
|
||||
string? tempOutput = null;
|
||||
try
|
||||
{
|
||||
string tree;
|
||||
int fileCount;
|
||||
if (isDirectory)
|
||||
{
|
||||
tree = sourceFull;
|
||||
fileCount = Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories).Count();
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
tree = Path.Combine(tempRoot, "tree");
|
||||
await ExtractAsync(sourceFull, tree, sourceFormat, progress, cancellationToken).ConfigureAwait(false);
|
||||
fileCount = Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories).Count();
|
||||
}
|
||||
|
||||
string outputDirectory = Path.GetDirectoryName(outputFull) ?? tempRoot;
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
tempOutput = outputFull + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
await BuildAsync(tree, tempOutput, target, BuildVolumeName(sourceFull), progress, cancellationToken, exfatOptions, ffpfscOptions, ffpkgOptions)
|
||||
.ConfigureAwait(false);
|
||||
await VerifyOutputAsync(tempOutput, target, progress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long outputBytes = new FileInfo(tempOutput).Length;
|
||||
File.Move(tempOutput, outputFull, overwrite);
|
||||
tempOutput = null;
|
||||
return new Ps5ImageConversionResult
|
||||
{
|
||||
OutputPath = outputFull,
|
||||
SourceFormat = sourceFormat,
|
||||
Target = target,
|
||||
SourceBytes = sourceBytes,
|
||||
OutputBytes = outputBytes,
|
||||
FileCount = fileCount
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempOutput is not null) TryDelete(tempOutput);
|
||||
if (Directory.Exists(tempRoot)) TryDeleteDirectory(tempRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExtractAsync(string source, string tree, Ps5ImageFormat format,
|
||||
IProgress<Ps5ImageConversionProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case Ps5ImageFormat.Exfat:
|
||||
await ExfatImage.ExtractDirectoryAsync(source, tree,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageFormat.Ufs2:
|
||||
await Ufs2Operations.ExtractAsync(source, tree,
|
||||
progress is null ? null : new Progress<Ufs2Progress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.Completed, value.Total))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageFormat.ZArchive:
|
||||
await ZArchiveImage.ExtractToDirectoryAsync(source, tree, overwrite: true,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageFormat.Pfs:
|
||||
await FfpfscImage.ExtractToDirectoryAsync(source, tree, overwrite: true,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidDataException("The source image format is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task BuildAsync(string tree, string output, Ps5ImageConversionTarget target,
|
||||
string volumeName, IProgress<Ps5ImageConversionProgress>? progress, CancellationToken cancellationToken,
|
||||
ExfatBuildOptions? exfatOptions, FfpfscBuildOptions? ffpfscOptions, FfpkgBuildOptions? ffpkgOptions)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case Ps5ImageConversionTarget.Exfat:
|
||||
await ExfatImage.WriteDirectoryAsync(tree, output, exfatOptions ?? new ExfatBuildOptions(),
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.Ffpkg:
|
||||
FfpkgBuildOptions options = ffpkgOptions ?? new FfpkgBuildOptions();
|
||||
await Ufs2Operations.CreateFromDirectoryAsync(tree, output, volumeName,
|
||||
progress is null ? null : new Progress<Ufs2Progress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.Completed, value.Total))),
|
||||
cancellationToken, options.BlockSize, options.FragmentSize, options.BytesPerInode,
|
||||
options.MinFreePercent).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.Ffpfsc:
|
||||
await FfpfscImage.CreateFromDirectoryAsync(tree, output, ffpfscOptions ?? new FfpfscBuildOptions(),
|
||||
exfatOptions,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.ZArchive:
|
||||
await ZArchiveImage.CreateFromDirectoryAsync(tree, output,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidDataException("The target image format is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyOutputAsync(string output, Ps5ImageConversionTarget target,
|
||||
IProgress<Ps5ImageConversionProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case Ps5ImageConversionTarget.Exfat:
|
||||
await ExfatImage.VerifyAsync(output,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.Ffpkg:
|
||||
await Ufs2Operations.VerifyAsync(output,
|
||||
progress is null ? null : new Progress<Ufs2Progress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.Completed, value.Total))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.Ffpfsc:
|
||||
await FfpfscImage.VerifyAsync(output, null,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Ps5ImageConversionTarget.ZArchive:
|
||||
await ZArchiveImage.VerifyAsync(output,
|
||||
progress is null ? null : new Progress<FfpfscProgress>(value =>
|
||||
progress.Report(new Ps5ImageConversionProgress(value.Stage, value.BytesProcessed, value.TotalBytes))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildVolumeName(string sourcePath)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
string sanitized = new string(name.Where(value => value is >= ' ' and <= '~' &&
|
||||
value is not '"' and not '*' and not '/' and not ':' and not '<' and not '>' and not '?' and not '\\' and not '|')
|
||||
.ToArray()).Trim(' ', '.');
|
||||
return sanitized.Length == 0 ? "PS5_GAME" : sanitized;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try { Directory.Delete(path, recursive: true); }
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>Recognised top-level PS5 image container formats.</summary>
|
||||
public enum Ps5ImageFormat
|
||||
{
|
||||
Unknown,
|
||||
Exfat,
|
||||
Pfs,
|
||||
Ufs2,
|
||||
ZArchive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects a PS5 image container by its on-disk signature rather than by file extension, so
|
||||
/// wrapped or renamed payloads (for example a UFS2 payload named without <c>.ffpkg</c>) are still
|
||||
/// identified correctly.
|
||||
/// </summary>
|
||||
public static class Ps5ImageFormatProbe
|
||||
{
|
||||
public const long PfsMagic = 20130315;
|
||||
public const uint Ufs2Magic = 0x19540119;
|
||||
public const long Ufs2SuperblockOffset = 65536;
|
||||
public const int Ufs2MagicOffset = 0x55C;
|
||||
|
||||
public static Ps5ImageFormat Detect(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
if (!stream.CanRead || !stream.CanSeek) return Ps5ImageFormat.Unknown;
|
||||
|
||||
long position = stream.Position;
|
||||
try
|
||||
{
|
||||
// A ZArchive is identified by its footer, which ends with a version and a magic number.
|
||||
if (stream.Length >= ZArchiveWriter.FooterLength)
|
||||
{
|
||||
Span<byte> tail = stackalloc byte[8];
|
||||
stream.Position = stream.Length - tail.Length;
|
||||
stream.ReadExactly(tail);
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(tail) == ZArchiveWriter.FooterVersion1 &&
|
||||
BinaryPrimitives.ReadUInt32BigEndian(tail[4..]) == ZArchiveWriter.FooterMagic)
|
||||
return Ps5ImageFormat.ZArchive;
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[16];
|
||||
if (stream.Length >= header.Length)
|
||||
{
|
||||
stream.Position = 0;
|
||||
stream.ReadExactly(header);
|
||||
if (header[3] == (byte)'E' && header[4] == (byte)'X' && header[5] == (byte)'F' &&
|
||||
header[6] == (byte)'A' && header[7] == (byte)'T')
|
||||
return Ps5ImageFormat.Exfat;
|
||||
if (BinaryPrimitives.ReadInt64LittleEndian(header.Slice(8, 8)) == PfsMagic)
|
||||
return Ps5ImageFormat.Pfs;
|
||||
}
|
||||
|
||||
Span<byte> magic = stackalloc byte[4];
|
||||
long magicOffset = Ufs2SuperblockOffset + Ufs2MagicOffset;
|
||||
if (stream.Length >= magicOffset + magic.Length)
|
||||
{
|
||||
stream.Position = magicOffset;
|
||||
stream.ReadExactly(magic);
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(magic) == Ufs2Magic)
|
||||
return Ps5ImageFormat.Ufs2;
|
||||
}
|
||||
|
||||
return Ps5ImageFormat.Unknown;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stream.Position = position;
|
||||
}
|
||||
}
|
||||
|
||||
public static Ps5ImageFormat Detect(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
using var stream = new FileStream(Path.GetFullPath(path), FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
return Detect(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZstdSharp.Port" Version="0.8.6" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Packwright.Ufs2\Packwright.Ufs2.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Native streaming codec for the PFSC block container used inside FFPFSC images.
|
||||
/// It keeps one 64 KiB logical block in memory and stores only the offset table.
|
||||
/// </summary>
|
||||
public static class PfscCodec
|
||||
{
|
||||
public const uint Magic = 0x43534650;
|
||||
public const int VersionField = 6;
|
||||
public const int LogicalBlockSize = 0x10000;
|
||||
public const int HeaderRecordSize = 0x30;
|
||||
public const int OffsetTableOffset = 0x400;
|
||||
public const int InitialDataOffset = 0x10000;
|
||||
private const int OffsetEntrySize = 8;
|
||||
private const int CopyBufferSize = 0x10000;
|
||||
private const int MaximumBlockCount = 16_777_216;
|
||||
|
||||
public static async Task<PfscWriteResult> EncodeAsync(Stream source, long sourceLength, Stream destination,
|
||||
PfscCompressionOptions? options = null, IProgress<PfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
if (!source.CanRead) throw new ArgumentException("Source stream must be readable.", nameof(source));
|
||||
if (!destination.CanWrite || !destination.CanSeek)
|
||||
throw new ArgumentException("Destination stream must be writable and seekable.", nameof(destination));
|
||||
if (sourceLength < 0) throw new ArgumentOutOfRangeException(nameof(sourceLength));
|
||||
options ??= new PfscCompressionOptions();
|
||||
options.Validate();
|
||||
|
||||
long blockCountValue = DivideRoundUp(sourceLength, LogicalBlockSize);
|
||||
if (blockCountValue > MaximumBlockCount)
|
||||
throw new InvalidDataException($"PFSC block count {blockCountValue:N0} exceeds the safety limit.");
|
||||
int blockCount = checked((int)blockCountValue);
|
||||
long headerSize = CalculateHeaderSize(blockCount);
|
||||
long start = destination.Position;
|
||||
destination.SetLength(checked(start + headerSize));
|
||||
destination.Position = checked(start + headerSize);
|
||||
|
||||
var offsets = new long[blockCount + 1];
|
||||
offsets[0] = headerSize;
|
||||
byte[] rawBlock = new byte[LogicalBlockSize];
|
||||
using var compressedBuffer = new MemoryStream(LogicalBlockSize);
|
||||
int compressedBlockCount = 0;
|
||||
long processed = 0;
|
||||
long lastProgress = 0;
|
||||
|
||||
for (int index = 0; index < blockCount; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int wanted = checked((int)Math.Min(LogicalBlockSize, sourceLength - processed));
|
||||
int read = await ReadExactlyOrEndAsync(source, rawBlock, wanted, cancellationToken).ConfigureAwait(false);
|
||||
if (read != wanted) throw new EndOfStreamException("The PFSC source ended before its declared length.");
|
||||
if (wanted < LogicalBlockSize) Array.Clear(rawBlock, wanted, LogicalBlockSize - wanted);
|
||||
|
||||
compressedBuffer.Position = 0;
|
||||
compressedBuffer.SetLength(0);
|
||||
var zlibOptions = new ZLibCompressionOptions
|
||||
{
|
||||
CompressionLevel = options.CompressionLevel,
|
||||
CompressionStrategy = ZLibCompressionStrategy.Default
|
||||
};
|
||||
using (var zlib = new ZLibStream(compressedBuffer, zlibOptions, leaveOpen: true))
|
||||
zlib.Write(rawBlock);
|
||||
int compressedLength = checked((int)compressedBuffer.Length);
|
||||
double gain = (LogicalBlockSize - compressedLength) * 100.0 / LogicalBlockSize;
|
||||
bool keepCompressed = compressedLength < LogicalBlockSize && gain >= options.MinimumGainPercent;
|
||||
ReadOnlyMemory<byte> selected = keepCompressed
|
||||
? compressedBuffer.GetBuffer().AsMemory(0, compressedLength)
|
||||
: rawBlock;
|
||||
await destination.WriteAsync(selected, cancellationToken).ConfigureAwait(false);
|
||||
if (keepCompressed) compressedBlockCount++;
|
||||
offsets[index + 1] = checked(destination.Position - start);
|
||||
processed += wanted;
|
||||
if (processed - lastProgress >= 8L * 1024 * 1024 || index + 1 == blockCount)
|
||||
{
|
||||
lastProgress = processed;
|
||||
progress?.Report(new PfscProgress(processed, sourceLength, index + 1, blockCount));
|
||||
}
|
||||
}
|
||||
|
||||
long storedLength = checked(destination.Position - start);
|
||||
await WriteHeaderAsync(destination, start, offsets, headerSize, blockCount, cancellationToken).ConfigureAwait(false);
|
||||
destination.Position = checked(start + storedLength);
|
||||
return new PfscWriteResult
|
||||
{
|
||||
SourceLength = sourceLength,
|
||||
PaddedLogicalLength = checked((long)blockCount * LogicalBlockSize),
|
||||
StoredLength = storedLength,
|
||||
BlockCount = blockCount,
|
||||
CompressedBlockCount = compressedBlockCount,
|
||||
HeaderSize = headerSize
|
||||
};
|
||||
}
|
||||
|
||||
public static PfscInfo Inspect(Stream source, long baseOffset = 0, long? storedLength = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
if (!source.CanRead || !source.CanSeek)
|
||||
throw new ArgumentException("PFSC source must be readable and seekable.", nameof(source));
|
||||
if (baseOffset < 0 || baseOffset > source.Length) throw new ArgumentOutOfRangeException(nameof(baseOffset));
|
||||
long available = storedLength ?? source.Length - baseOffset;
|
||||
if (available < HeaderRecordSize || available > source.Length - baseOffset)
|
||||
throw new InvalidDataException("The PFSC stored range is outside the source stream.");
|
||||
|
||||
byte[] header = ReadAt(source, baseOffset, HeaderRecordSize);
|
||||
uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x00, 4));
|
||||
int zero = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0x04, 4));
|
||||
int version = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0x08, 4));
|
||||
int blockSize = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0x0C, 4));
|
||||
long blockSizeMirror = BinaryPrimitives.ReadInt64LittleEndian(header.AsSpan(0x10, 8));
|
||||
long tableOffset = BinaryPrimitives.ReadInt64LittleEndian(header.AsSpan(0x18, 8));
|
||||
ulong dataOffsetValue = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(0x20, 8));
|
||||
long logicalLength = BinaryPrimitives.ReadInt64LittleEndian(header.AsSpan(0x28, 8));
|
||||
|
||||
if (magic != Magic) throw new InvalidDataException("PFSC magic is missing.");
|
||||
if (zero != 0 || version != VersionField) throw new InvalidDataException("Unsupported PFSC header version.");
|
||||
if (blockSize != LogicalBlockSize || blockSizeMirror != LogicalBlockSize)
|
||||
throw new InvalidDataException("Unsupported or mismatched PFSC logical block size.");
|
||||
if (tableOffset < HeaderRecordSize || tableOffset > available)
|
||||
throw new InvalidDataException("PFSC offset table is outside the stored payload.");
|
||||
if (dataOffsetValue > long.MaxValue) throw new InvalidDataException("PFSC data offset is too large.");
|
||||
long dataOffset = (long)dataOffsetValue;
|
||||
if (dataOffset < InitialDataOffset || dataOffset > available || dataOffset % LogicalBlockSize != 0)
|
||||
throw new InvalidDataException("PFSC data offset is invalid.");
|
||||
if (logicalLength < 0 || logicalLength % LogicalBlockSize != 0)
|
||||
throw new InvalidDataException("PFSC logical length is invalid.");
|
||||
|
||||
long blockCountValue = logicalLength / LogicalBlockSize;
|
||||
if (blockCountValue > MaximumBlockCount) throw new InvalidDataException("PFSC block count exceeds the safety limit.");
|
||||
int blockCount = checked((int)blockCountValue);
|
||||
long tableBytes = checked((long)(blockCount + 1) * OffsetEntrySize);
|
||||
if (tableOffset > dataOffset || tableBytes > dataOffset - tableOffset)
|
||||
throw new InvalidDataException("PFSC offset table overlaps the data region.");
|
||||
|
||||
byte[] table = ReadAt(source, checked(baseOffset + tableOffset), checked((int)tableBytes));
|
||||
var offsets = new long[blockCount + 1];
|
||||
for (int index = 0; index < offsets.Length; index++)
|
||||
{
|
||||
ulong value = BinaryPrimitives.ReadUInt64LittleEndian(table.AsSpan(index * OffsetEntrySize, OffsetEntrySize));
|
||||
if (value > long.MaxValue) throw new InvalidDataException("PFSC block offset is too large.");
|
||||
offsets[index] = (long)value;
|
||||
if (offsets[index] < dataOffset || offsets[index] > available)
|
||||
throw new InvalidDataException("PFSC block offset is outside the stored payload.");
|
||||
if (index > 0 && offsets[index] < offsets[index - 1])
|
||||
throw new InvalidDataException("PFSC block offsets are not monotonic.");
|
||||
}
|
||||
if (offsets.Length > 0 && offsets[0] != dataOffset)
|
||||
throw new InvalidDataException("PFSC first block does not begin at the declared data offset.");
|
||||
|
||||
return new PfscInfo
|
||||
{
|
||||
LogicalBlockSize = blockSize,
|
||||
OffsetTableOffset = tableOffset,
|
||||
DataOffset = dataOffset,
|
||||
PaddedLogicalLength = logicalLength,
|
||||
StoredLength = available,
|
||||
BlockOffsets = offsets
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task DecodeAsync(Stream source, Stream destination, long? contentLength = null,
|
||||
long baseOffset = 0, long? storedLength = null, IProgress<PfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
if (!destination.CanWrite) throw new ArgumentException("Destination must be writable.", nameof(destination));
|
||||
PfscInfo info = Inspect(source, baseOffset, storedLength);
|
||||
long outputLength = contentLength ?? info.PaddedLogicalLength;
|
||||
if (outputLength < 0 || outputLength > info.PaddedLogicalLength)
|
||||
throw new ArgumentOutOfRangeException(nameof(contentLength));
|
||||
|
||||
long written = 0;
|
||||
long lastProgress = 0;
|
||||
for (int index = 0; index < info.BlockCount && written < outputLength; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
byte[] block = ReadBlock(source, info, index, baseOffset);
|
||||
int count = checked((int)Math.Min(block.Length, outputLength - written));
|
||||
await destination.WriteAsync(block.AsMemory(0, count), cancellationToken).ConfigureAwait(false);
|
||||
written += count;
|
||||
if (written - lastProgress >= 8L * 1024 * 1024 || written == outputLength)
|
||||
{
|
||||
lastProgress = written;
|
||||
progress?.Report(new PfscProgress(written, outputLength, index + 1, info.BlockCount));
|
||||
}
|
||||
}
|
||||
if (written != outputLength) throw new InvalidDataException("PFSC output length does not match the requested content length.");
|
||||
}
|
||||
|
||||
public static byte[] ReadBlock(Stream source, PfscInfo info, int blockIndex, long baseOffset = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
if (blockIndex < 0 || blockIndex >= info.BlockCount) throw new ArgumentOutOfRangeException(nameof(blockIndex));
|
||||
long first = info.BlockOffsets[blockIndex];
|
||||
long next = info.BlockOffsets[blockIndex + 1];
|
||||
long storedSize = next - first;
|
||||
if (storedSize <= 0 || storedSize > LogicalBlockSize)
|
||||
throw new InvalidDataException($"PFSC block {blockIndex} has an invalid stored size.");
|
||||
byte[] stored = ReadAt(source, checked(baseOffset + first), checked((int)storedSize));
|
||||
if (storedSize == LogicalBlockSize) return stored;
|
||||
|
||||
byte[] output = new byte[LogicalBlockSize];
|
||||
using var input = new MemoryStream(stored, writable: false);
|
||||
using var zlib = new ZLibStream(input, CompressionMode.Decompress, leaveOpen: false);
|
||||
int total = 0;
|
||||
while (total < output.Length)
|
||||
{
|
||||
int read = zlib.Read(output, total, output.Length - total);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
}
|
||||
if (total != output.Length || zlib.ReadByte() != -1)
|
||||
throw new InvalidDataException($"PFSC block {blockIndex} did not decode to exactly {LogicalBlockSize:N0} bytes.");
|
||||
return output;
|
||||
}
|
||||
|
||||
private static async Task WriteHeaderAsync(Stream destination, long start, long[] offsets, long headerSize,
|
||||
int blockCount, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] header = new byte[HeaderRecordSize];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x00, 4), Magic);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(0x04, 4), 0);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(0x08, 4), VersionField);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(0x0C, 4), LogicalBlockSize);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x10, 8), LogicalBlockSize);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x18, 8), OffsetTableOffset);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(0x20, 8), checked((ulong)headerSize));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(0x28, 8), checked((long)blockCount * LogicalBlockSize));
|
||||
destination.Position = start;
|
||||
await destination.WriteAsync(header, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
destination.Position = checked(start + OffsetTableOffset);
|
||||
byte[] tableChunk = new byte[Math.Min(offsets.Length, 8192) * OffsetEntrySize];
|
||||
int cursor = 0;
|
||||
while (cursor < offsets.Length)
|
||||
{
|
||||
int count = Math.Min(tableChunk.Length / OffsetEntrySize, offsets.Length - cursor);
|
||||
for (int index = 0; index < count; index++)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(tableChunk.AsSpan(index * OffsetEntrySize, OffsetEntrySize),
|
||||
checked((ulong)offsets[cursor + index]));
|
||||
await destination.WriteAsync(tableChunk.AsMemory(0, count * OffsetEntrySize), cancellationToken).ConfigureAwait(false);
|
||||
cursor += count;
|
||||
}
|
||||
}
|
||||
|
||||
private static long CalculateHeaderSize(int blockCount)
|
||||
{
|
||||
long tableSize = checked((long)(blockCount + 1) * OffsetEntrySize);
|
||||
long initialCapacity = InitialDataOffset - OffsetTableOffset;
|
||||
long extra = Math.Max(0, tableSize - initialCapacity);
|
||||
return checked(InitialDataOffset + DivideRoundUp(extra, LogicalBlockSize) * LogicalBlockSize);
|
||||
}
|
||||
|
||||
private static long DivideRoundUp(long value, long divisor) => value == 0 ? 0 : checked((value - 1) / divisor + 1);
|
||||
|
||||
private static async Task<int> ReadExactlyOrEndAsync(Stream stream, byte[] buffer, int count,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int total = 0;
|
||||
while (total < count)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer.AsMemory(total, count - total), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static byte[] ReadAt(Stream stream, long offset, int count)
|
||||
{
|
||||
if (offset < 0 || count < 0 || offset > stream.Length || count > stream.Length - offset)
|
||||
throw new InvalidDataException("PFSC read range is outside the source stream.");
|
||||
byte[] buffer = new byte[count];
|
||||
stream.Position = offset;
|
||||
stream.ReadExactly(buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed class PfscCompressionOptions
|
||||
{
|
||||
public int CompressionLevel { get; init; } = 9;
|
||||
public int MinimumGainPercent { get; init; } = 5;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (CompressionLevel is < 0 or > 9)
|
||||
throw new ArgumentOutOfRangeException(nameof(CompressionLevel), "Compression level must be between 0 and 9.");
|
||||
if (MinimumGainPercent is < 0 or > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(MinimumGainPercent), "Minimum gain must be between 0 and 100 percent.");
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PfscProgress(long BytesProcessed, long TotalBytes, int BlocksProcessed, int TotalBlocks);
|
||||
|
||||
public sealed class PfscWriteResult
|
||||
{
|
||||
public required long SourceLength { get; init; }
|
||||
public required long PaddedLogicalLength { get; init; }
|
||||
public required long StoredLength { get; init; }
|
||||
public required int BlockCount { get; init; }
|
||||
public required int CompressedBlockCount { get; init; }
|
||||
public required long HeaderSize { get; init; }
|
||||
public double SavingsPercent => SourceLength == 0 ? 0 : (SourceLength - StoredLength) * 100.0 / SourceLength;
|
||||
}
|
||||
|
||||
public sealed class PfscInfo
|
||||
{
|
||||
public required int LogicalBlockSize { get; init; }
|
||||
public required long OffsetTableOffset { get; init; }
|
||||
public required long DataOffset { get; init; }
|
||||
public required long PaddedLogicalLength { get; init; }
|
||||
public required long StoredLength { get; init; }
|
||||
public required IReadOnlyList<long> BlockOffsets { get; init; }
|
||||
public int BlockCount => BlockOffsets.Count - 1;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>Seekable, read-only view of a PFSC payload which decodes blocks on demand.</summary>
|
||||
public sealed class PfscReadStream : Stream
|
||||
{
|
||||
private const int CacheCapacity = 16;
|
||||
private readonly Stream _source;
|
||||
private readonly PfscInfo _info;
|
||||
private readonly long _baseOffset;
|
||||
private readonly long _length;
|
||||
private readonly bool _leaveOpen;
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<int, (byte[] Data, LinkedListNode<int> Node)> _cache = [];
|
||||
private readonly LinkedList<int> _lru = [];
|
||||
private long _position;
|
||||
private bool _disposed;
|
||||
|
||||
public PfscReadStream(Stream source, PfscInfo info, long baseOffset, long contentLength, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
if (!source.CanRead || !source.CanSeek)
|
||||
throw new ArgumentException("The PFSC source must be readable and seekable.", nameof(source));
|
||||
if (baseOffset < 0) throw new ArgumentOutOfRangeException(nameof(baseOffset));
|
||||
if (contentLength < 0 || contentLength > info.PaddedLogicalLength)
|
||||
throw new ArgumentOutOfRangeException(nameof(contentLength));
|
||||
_source = source;
|
||||
_info = info;
|
||||
_baseOffset = baseOffset;
|
||||
_length = contentLength;
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public override bool CanRead => !_disposed;
|
||||
public override bool CanSeek => !_disposed;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length { get { ThrowIfDisposed(); return _length; } }
|
||||
public override long Position
|
||||
{
|
||||
get { ThrowIfDisposed(); return _position; }
|
||||
set
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (value < 0 || value > _length) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> destination)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
int total = checked((int)Math.Min(destination.Length, _length - _position));
|
||||
int written = 0;
|
||||
while (written < total)
|
||||
{
|
||||
int blockIndex = checked((int)(_position / _info.LogicalBlockSize));
|
||||
int blockOffset = checked((int)(_position % _info.LogicalBlockSize));
|
||||
byte[] block = GetBlock(blockIndex);
|
||||
int count = Math.Min(total - written, block.Length - blockOffset);
|
||||
block.AsSpan(blockOffset, count).CopyTo(destination.Slice(written, count));
|
||||
written += count;
|
||||
_position += count;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult(Read(buffer.Span));
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
long position = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => checked(_position + offset),
|
||||
SeekOrigin.End => checked(_length + offset),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(origin))
|
||||
};
|
||||
if (position < 0 || position > _length) throw new IOException("Attempted to seek outside the PFSC content.");
|
||||
_position = position;
|
||||
return position;
|
||||
}
|
||||
|
||||
private byte[] GetBlock(int blockIndex)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_cache.TryGetValue(blockIndex, out var cached))
|
||||
{
|
||||
_lru.Remove(cached.Node);
|
||||
_lru.AddFirst(cached.Node);
|
||||
return cached.Data;
|
||||
}
|
||||
byte[] data = PfscCodec.ReadBlock(_source, _info, blockIndex, _baseOffset);
|
||||
LinkedListNode<int> node = _lru.AddFirst(blockIndex);
|
||||
_cache.Add(blockIndex, (data, node));
|
||||
if (_cache.Count > CacheCapacity)
|
||||
{
|
||||
LinkedListNode<int> last = _lru.Last!;
|
||||
_lru.RemoveLast();
|
||||
_cache.Remove(last.Value);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing && !_leaveOpen) _source.Dispose();
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Packwright.Containers
|
||||
|
||||
Managed, separately testable codecs used by Packwright:
|
||||
|
||||
- `ExfatImage` plans and streams a standards-based exFAT image from a loose directory without a temporary disk image.
|
||||
- `AmprIndex` builds and validates AMPRIDX3 lookup data. `ExfatImage` adds it virtually when the dump contains `fakelib/libSceAmpr.sprx`; it never writes into the source directory.
|
||||
- `PfscCodec` encodes, inspects, randomly reads, and decodes 64 KiB zlib/raw PFSC blocks with strict offset validation.
|
||||
- `FfpfscImage` creates an unsigned PS5 PFS single-file wrapper, inspects its inode/directory/FPT layout, extracts its payload, and performs full decode/SHA-256 verification.
|
||||
|
||||
Primary entry points:
|
||||
|
||||
```csharp
|
||||
await FfpfscImage.CreateFromDirectoryAsync(dumpFolder, outputFfpfsc);
|
||||
await FfpfscImage.CreateFromImageAsync(sourceExfatOrFfpkg, outputFfpfsc);
|
||||
FfpfscVerificationResult result = await FfpfscImage.VerifyAsync(outputFfpfsc);
|
||||
await FfpfscImage.ExtractAsync(outputFfpfsc, extractedImage);
|
||||
```
|
||||
|
||||
Writers use a unique temporary sibling and publish the destination only after validation. Existing files are not replaced unless `FfpfscBuildOptions.OverwriteExisting` is explicitly enabled. Retail encrypted/signed PFS construction is outside this library; the generated format is the unsigned wrapper used by jailbreak-side FFPFSC mounting workflows.
|
||||
|
||||
The native exFAT serializer mirrors MkPFS deterministic layout details, including its compact Microsoft up-case table, volume serial, fixed exFAT timestamps, directory allocation, ignored metadata names, and AMPRIDX3 generation. Golden interoperability tests require byte-identical raw exFAT output and byte-identical level-9 FFPFSC output when both writers receive the same PFS build timestamp. PFS timestamps normally use the current UTC second, so independently started builds can have different whole-image hashes despite identical payload bytes.
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
public sealed class ZArchiveBuildResult
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
public required int DirectoryCount { get; init; }
|
||||
public required long SourceBytes { get; init; }
|
||||
public required long ArchiveBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Builds and verifies ZArchive (.zar) files from a dump folder.</summary>
|
||||
public static class ZArchiveImage
|
||||
{
|
||||
private const int CopyBufferSize = 1024 * 1024;
|
||||
|
||||
public static async Task<ZArchiveBuildResult> CreateFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
string root = Path.GetFullPath(sourceDirectory);
|
||||
if (!Directory.Exists(root))
|
||||
throw new DirectoryNotFoundException("The source folder does not exist: " + root);
|
||||
|
||||
var directories = new List<string>();
|
||||
var files = new List<(string Path, string Relative, long Size)>();
|
||||
Collect(root, string.Empty, directories, files);
|
||||
long total = files.Sum(file => file.Size);
|
||||
|
||||
await using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
CopyBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
using var writer = new ZArchiveWriter(stream, leaveOpen: true);
|
||||
|
||||
foreach (string directory in directories) writer.MakeDirectory(directory);
|
||||
|
||||
long processed = 0;
|
||||
byte[] buffer = new byte[CopyBufferSize];
|
||||
foreach ((string path, string relative, long size) in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
writer.StartNewFile(relative);
|
||||
await using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
CopyBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
long copied = 0;
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
writer.Append(buffer.AsSpan(0, read));
|
||||
copied += read;
|
||||
processed += read;
|
||||
progress?.Report(new FfpfscProgress("Writing ZArchive", processed, total));
|
||||
}
|
||||
if (copied != size)
|
||||
throw new IOException($"The file changed while it was being read: {relative}");
|
||||
}
|
||||
|
||||
progress?.Report(new FfpfscProgress("Finalizing ZArchive", processed, total));
|
||||
writer.Finish();
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new ZArchiveBuildResult
|
||||
{
|
||||
OutputPath = Path.GetFullPath(outputPath),
|
||||
FileCount = files.Count,
|
||||
DirectoryCount = directories.Count,
|
||||
SourceBytes = total,
|
||||
ArchiveBytes = writer.OutputLength
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Unpacks every file of a ZArchive into <paramref name="destination"/>.</summary>
|
||||
public static async Task ExtractToDirectoryAsync(string archivePath, string destination, bool overwrite = true,
|
||||
IProgress<FfpfscProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var volume = new ZArchiveVolume(archivePath);
|
||||
string root = Path.GetFullPath(destination);
|
||||
Directory.CreateDirectory(root);
|
||||
long total = volume.TotalFileBytes;
|
||||
long processed = 0;
|
||||
byte[] buffer = new byte[CopyBufferSize];
|
||||
foreach (ZArchiveEntry entry in volume.Entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string target = Path.GetFullPath(Path.Combine(root, entry.Path.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!target.StartsWith(root, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("An archive path escapes the extraction folder: " + entry.Path);
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
continue;
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
if (!overwrite && File.Exists(target)) continue;
|
||||
await using Stream input = volume.OpenFile(entry.Path);
|
||||
await using var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
CopyBufferSize, FileOptions.Asynchronous);
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
processed += read;
|
||||
progress?.Report(new FfpfscProgress("Extracting ZArchive", processed, total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a copy of an archive in which some files have new contents (or are added when missing).
|
||||
/// Everything else is streamed across unchanged. Paths in <paramref name="replacements"/> are matched
|
||||
/// without regard to case, as the format itself is case-insensitive.
|
||||
/// </summary>
|
||||
public static async Task RewriteWithReplacementsAsync(string archivePath, string outputPath,
|
||||
IReadOnlyDictionary<string, byte[]> replacements, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
var pending = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach ((string key, byte[] value) in replacements) pending[key.Replace('\\', '/').Trim('/')] = value;
|
||||
|
||||
using var volume = new ZArchiveVolume(archivePath);
|
||||
await using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
CopyBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
using var writer = new ZArchiveWriter(stream, leaveOpen: true);
|
||||
|
||||
foreach (ZArchiveEntry entry in volume.Entries.Where(entry => entry.IsDirectory))
|
||||
writer.MakeDirectory(entry.Path);
|
||||
|
||||
long total = volume.TotalFileBytes + pending.Values.Sum(value => (long)value.Length);
|
||||
long processed = 0;
|
||||
byte[] buffer = new byte[CopyBufferSize];
|
||||
foreach (ZArchiveEntry entry in volume.Entries.Where(entry => !entry.IsDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
writer.StartNewFile(entry.Path);
|
||||
if (pending.Remove(entry.Path, out byte[]? replacement))
|
||||
{
|
||||
writer.Append(replacement);
|
||||
processed += replacement.Length;
|
||||
continue;
|
||||
}
|
||||
await using Stream input = volume.OpenFile(entry.Path);
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
writer.Append(buffer.AsSpan(0, read));
|
||||
processed += read;
|
||||
progress?.Report(new FfpfscProgress("Writing ZArchive", processed, total));
|
||||
}
|
||||
}
|
||||
|
||||
// Files that were not in the archive yet (for example a first icon0.png).
|
||||
foreach ((string path, byte[] data) in pending)
|
||||
{
|
||||
int slash = path.LastIndexOf('/');
|
||||
if (slash > 0) writer.MakeDirectory(path[..slash]);
|
||||
writer.StartNewFile(path);
|
||||
writer.Append(data);
|
||||
}
|
||||
|
||||
progress?.Report(new FfpfscProgress("Finalizing ZArchive", processed, total));
|
||||
writer.Finish();
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void Collect(string directory, string relative, List<string> directories,
|
||||
List<(string Path, string Relative, long Size)> files)
|
||||
{
|
||||
foreach (string file in Directory.EnumerateFiles(directory).Order(StringComparer.Ordinal))
|
||||
{
|
||||
string name = Path.GetFileName(file);
|
||||
files.Add((file, relative.Length == 0 ? name : relative + "/" + name, new FileInfo(file).Length));
|
||||
}
|
||||
foreach (string sub in Directory.EnumerateDirectories(directory).Order(StringComparer.Ordinal))
|
||||
{
|
||||
string name = Path.GetFileName(sub);
|
||||
string child = relative.Length == 0 ? name : relative + "/" + name;
|
||||
directories.Add(child);
|
||||
Collect(sub, child, directories, files);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the footer, the SHA-256 integrity hash, the offset records and file tree, and that every
|
||||
/// compressed block decodes to a full block.
|
||||
/// </summary>
|
||||
public static async Task VerifyAsync(string path, IProgress<FfpfscProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
CopyBufferSize, FileOptions.Asynchronous | FileOptions.RandomAccess);
|
||||
long length = stream.Length;
|
||||
if (length < ZArchiveWriter.FooterLength)
|
||||
throw new InvalidDataException("The file is too small to be a ZArchive.");
|
||||
|
||||
byte[] footer = new byte[ZArchiveWriter.FooterLength];
|
||||
stream.Position = length - footer.Length;
|
||||
await stream.ReadExactlyAsync(footer, cancellationToken).ConfigureAwait(false);
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(footer.AsSpan(140)) != ZArchiveWriter.FooterMagic ||
|
||||
BinaryPrimitives.ReadUInt32BigEndian(footer.AsSpan(136)) != ZArchiveWriter.FooterVersion1)
|
||||
throw new InvalidDataException("The ZArchive footer signature is not valid.");
|
||||
if (BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(128)) != (ulong)length)
|
||||
throw new InvalidDataException("The ZArchive size does not match its footer.");
|
||||
|
||||
(ulong Offset, ulong Size) Section(int index) => (
|
||||
BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(index * 16)),
|
||||
BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(index * 16 + 8)));
|
||||
(ulong dataOffset, ulong dataSize) = Section(0);
|
||||
(ulong recordOffset, ulong recordSize) = Section(1);
|
||||
(ulong nameOffset, ulong nameSize) = Section(2);
|
||||
(ulong treeOffset, ulong treeSize) = Section(3);
|
||||
foreach ((ulong offset, ulong size) in new[] { (dataOffset, dataSize), (recordOffset, recordSize),
|
||||
(nameOffset, nameSize), (treeOffset, treeSize) })
|
||||
if (offset + size > (ulong)length - ZArchiveWriter.FooterLength)
|
||||
throw new InvalidDataException("A ZArchive section lies outside the file.");
|
||||
|
||||
// Integrity hash: SHA-256 of everything with the footer hash field zeroed.
|
||||
using (var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
|
||||
{
|
||||
stream.Position = 0;
|
||||
byte[] buffer = new byte[CopyBufferSize];
|
||||
long remaining = length - footer.Length;
|
||||
long done = 0;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0) throw new EndOfStreamException();
|
||||
hash.AppendData(buffer, 0, read);
|
||||
remaining -= read;
|
||||
done += read;
|
||||
progress?.Report(new FfpfscProgress("Hashing ZArchive", done, length));
|
||||
}
|
||||
byte[] zeroed = (byte[])footer.Clone();
|
||||
Array.Clear(zeroed, 96, 32);
|
||||
hash.AppendData(zeroed);
|
||||
if (!CryptographicOperations.FixedTimeEquals(hash.GetHashAndReset(), footer.AsSpan(96, 32)))
|
||||
throw new InvalidDataException("The ZArchive integrity hash does not match.");
|
||||
}
|
||||
|
||||
// Offset records: each block must lie inside the data section and decode to a full block.
|
||||
if (recordSize % 40 != 0)
|
||||
throw new InvalidDataException("The ZArchive offset records are malformed.");
|
||||
byte[] records = new byte[recordSize];
|
||||
stream.Position = (long)recordOffset;
|
||||
await stream.ReadExactlyAsync(records, cancellationToken).ConfigureAwait(false);
|
||||
using var decompressor = new Decompressor();
|
||||
byte[] compressed = new byte[ZArchiveWriter.BlockSize];
|
||||
byte[] plain = new byte[ZArchiveWriter.BlockSize];
|
||||
int recordCount = (int)(recordSize / 40);
|
||||
long blocksChecked = 0;
|
||||
for (int r = 0; r < recordCount; r++)
|
||||
{
|
||||
int recordBase = r * 40;
|
||||
ulong offset = BinaryPrimitives.ReadUInt64BigEndian(records.AsSpan(recordBase));
|
||||
for (int i = 0; i < ZArchiveWriter.EntriesPerOffsetRecord; i++)
|
||||
{
|
||||
int size = BinaryPrimitives.ReadUInt16BigEndian(records.AsSpan(recordBase + 8 + 2 * i)) + 1;
|
||||
// The unused tail of the last record stores zero, which reads back as size 1 at a
|
||||
// position that cannot hold data; stop once the data section is exhausted.
|
||||
if (offset >= dataSize) break;
|
||||
if (offset + (ulong)size > dataSize)
|
||||
throw new InvalidDataException("A ZArchive block lies outside the data section.");
|
||||
stream.Position = (long)(dataOffset + offset);
|
||||
await stream.ReadExactlyAsync(compressed.AsMemory(0, size), cancellationToken).ConfigureAwait(false);
|
||||
if (size < ZArchiveWriter.BlockSize)
|
||||
{
|
||||
int decoded;
|
||||
try { decoded = decompressor.Unwrap(compressed.AsSpan(0, size), plain); }
|
||||
catch (Exception ex) { throw new InvalidDataException($"ZArchive block {blocksChecked} is corrupt.", ex); }
|
||||
if (decoded != ZArchiveWriter.BlockSize)
|
||||
throw new InvalidDataException($"ZArchive block {blocksChecked} decodes to the wrong size.");
|
||||
}
|
||||
offset += (ulong)size;
|
||||
blocksChecked++;
|
||||
}
|
||||
progress?.Report(new FfpfscProgress("Verifying ZArchive blocks", r + 1, recordCount));
|
||||
}
|
||||
|
||||
// File tree: the root is a directory and every directory range stays inside the tree.
|
||||
if (treeSize < 16 || treeSize % 16 != 0)
|
||||
throw new InvalidDataException("The ZArchive file tree is malformed.");
|
||||
byte[] tree = new byte[treeSize];
|
||||
stream.Position = (long)treeOffset;
|
||||
await stream.ReadExactlyAsync(tree, cancellationToken).ConfigureAwait(false);
|
||||
uint entryCount = (uint)(treeSize / 16);
|
||||
ulong uncompressedLimit = (ulong)blocksChecked * ZArchiveWriter.BlockSize;
|
||||
for (uint i = 0; i < entryCount; i++)
|
||||
{
|
||||
ReadOnlySpan<byte> entry = tree.AsSpan((int)i * 16, 16);
|
||||
uint flags = BinaryPrimitives.ReadUInt32BigEndian(entry);
|
||||
uint a = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
|
||||
uint b = BinaryPrimitives.ReadUInt32BigEndian(entry[8..]);
|
||||
uint c = BinaryPrimitives.ReadUInt32BigEndian(entry[12..]);
|
||||
if ((flags & 0x80000000) != 0)
|
||||
{
|
||||
ulong fileOffset = a | ((ulong)(c & 0xFFFF) << 32);
|
||||
ulong fileSize = b | ((ulong)(c & 0xFFFF0000) << 16);
|
||||
if (fileOffset + fileSize > uncompressedLimit)
|
||||
throw new InvalidDataException("A ZArchive file extends past the stored data.");
|
||||
if ((flags & 0x7FFFFFFF) >= nameSize)
|
||||
throw new InvalidDataException("A ZArchive file name lies outside the name table.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (i == 0 && (flags & 0x7FFFFFFF) != 0x7FFFFFFF)
|
||||
throw new InvalidDataException("The ZArchive root entry is malformed.");
|
||||
if ((ulong)a + b > entryCount)
|
||||
throw new InvalidDataException("A ZArchive directory range lies outside the file tree.");
|
||||
}
|
||||
}
|
||||
if ((tree[0] & 0x80) != 0)
|
||||
throw new InvalidDataException("The ZArchive root entry is not a directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>One file or directory inside a ZArchive.</summary>
|
||||
public sealed record ZArchiveEntry(string Path, string Name, bool IsDirectory, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to a ZArchive (.zar): the file tree is loaded once, and file data is decompressed
|
||||
/// block by block on demand, so a multi-gigabyte archive can be listed and browsed without unpacking it.
|
||||
/// </summary>
|
||||
public sealed class ZArchiveVolume : IDisposable
|
||||
{
|
||||
private sealed record Node(string Path, string Name, bool IsDirectory, ulong Offset, ulong Size);
|
||||
|
||||
private readonly FileStream _file;
|
||||
private readonly long _dataOffset;
|
||||
private readonly ulong _dataSize;
|
||||
private readonly byte[] _records;
|
||||
private readonly Dictionary<string, Node> _nodes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<ZArchiveEntry> _entries = [];
|
||||
private readonly Decompressor _decompressor = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly byte[] _blockBuffer = new byte[ZArchiveWriter.BlockSize];
|
||||
private readonly byte[] _compressedBuffer = new byte[ZArchiveWriter.BlockSize];
|
||||
private long _cachedBlock = -1;
|
||||
|
||||
public ZArchiveVolume(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
_file = new FileStream(System.IO.Path.GetFullPath(path), FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
1024 * 1024, FileOptions.RandomAccess);
|
||||
try
|
||||
{
|
||||
long length = _file.Length;
|
||||
if (length < ZArchiveWriter.FooterLength)
|
||||
throw new InvalidDataException("The file is too small to be a ZArchive.");
|
||||
|
||||
byte[] footer = new byte[ZArchiveWriter.FooterLength];
|
||||
_file.Position = length - footer.Length;
|
||||
_file.ReadExactly(footer);
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(footer.AsSpan(140)) != ZArchiveWriter.FooterMagic ||
|
||||
BinaryPrimitives.ReadUInt32BigEndian(footer.AsSpan(136)) != ZArchiveWriter.FooterVersion1)
|
||||
throw new InvalidDataException("This is not a ZArchive file.");
|
||||
if (BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(128)) != (ulong)length)
|
||||
throw new InvalidDataException("The ZArchive size does not match its footer.");
|
||||
|
||||
(ulong Offset, ulong Size) Section(int index) => (
|
||||
BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(index * 16)),
|
||||
BinaryPrimitives.ReadUInt64BigEndian(footer.AsSpan(index * 16 + 8)));
|
||||
var data = Section(0);
|
||||
var records = Section(1);
|
||||
var names = Section(2);
|
||||
var tree = Section(3);
|
||||
ulong limit = (ulong)length - ZArchiveWriter.FooterLength;
|
||||
foreach ((ulong offset, ulong size) in new[] { data, records, names, tree })
|
||||
if (offset + size > limit) throw new InvalidDataException("A ZArchive section lies outside the file.");
|
||||
if (records.Size % 40 != 0) throw new InvalidDataException("The ZArchive offset records are malformed.");
|
||||
if (tree.Size < 16 || tree.Size % 16 != 0) throw new InvalidDataException("The ZArchive file tree is malformed.");
|
||||
|
||||
_dataOffset = (long)data.Offset;
|
||||
_dataSize = data.Size;
|
||||
_records = ReadSection(records);
|
||||
byte[] nameTable = ReadSection(names);
|
||||
byte[] fileTree = ReadSection(tree);
|
||||
LoadTree(fileTree, nameTable);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_file.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ZArchiveEntry> Entries => _entries;
|
||||
|
||||
/// <summary>Total uncompressed size of all files.</summary>
|
||||
public long TotalFileBytes => _entries.Where(entry => !entry.IsDirectory).Sum(entry => entry.Size);
|
||||
|
||||
private byte[] ReadSection((ulong Offset, ulong Size) section)
|
||||
{
|
||||
byte[] bytes = new byte[section.Size];
|
||||
_file.Position = (long)section.Offset;
|
||||
_file.ReadExactly(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private void LoadTree(byte[] tree, byte[] names)
|
||||
{
|
||||
uint count = (uint)(tree.Length / 16);
|
||||
(uint NameField, uint A, uint B, uint C) Read(uint index)
|
||||
{
|
||||
ReadOnlySpan<byte> entry = tree.AsSpan((int)index * 16, 16);
|
||||
return (BinaryPrimitives.ReadUInt32BigEndian(entry), BinaryPrimitives.ReadUInt32BigEndian(entry[4..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(entry[8..]), BinaryPrimitives.ReadUInt32BigEndian(entry[12..]));
|
||||
}
|
||||
|
||||
string NameAt(uint offset)
|
||||
{
|
||||
if (offset >= names.Length) throw new InvalidDataException("A ZArchive name lies outside the name table.");
|
||||
int length = names[offset];
|
||||
int start = (int)offset + 1;
|
||||
if ((length & 0x80) != 0)
|
||||
{
|
||||
if (start >= names.Length) throw new InvalidDataException("A ZArchive name is truncated.");
|
||||
length = (length & 0x7F) | (names[start] << 7);
|
||||
start++;
|
||||
}
|
||||
if (start + length > names.Length) throw new InvalidDataException("A ZArchive name is truncated.");
|
||||
return Encoding.UTF8.GetString(names, start, length);
|
||||
}
|
||||
|
||||
var stack = new Stack<(uint Index, string Path)>();
|
||||
stack.Push((0, string.Empty));
|
||||
int guard = 0;
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
(uint index, string parent) = stack.Pop();
|
||||
if (++guard > count + 1) throw new InvalidDataException("The ZArchive file tree contains a loop.");
|
||||
(uint nameField, uint a, uint b, uint c) = Read(index);
|
||||
bool isFile = (nameField & 0x80000000) != 0;
|
||||
string name = index == 0 ? string.Empty : NameAt(nameField & 0x7FFFFFFF);
|
||||
string path = index == 0 ? string.Empty : parent.Length == 0 ? name : parent + "/" + name;
|
||||
if (isFile)
|
||||
{
|
||||
ulong offset = a | ((ulong)(c & 0xFFFF) << 32);
|
||||
ulong size = b | ((ulong)(c & 0xFFFF0000) << 16);
|
||||
_nodes[path] = new Node(path, name, false, offset, size);
|
||||
_entries.Add(new ZArchiveEntry(path, name, false, (long)size));
|
||||
}
|
||||
else
|
||||
{
|
||||
if ((ulong)a + b > count) throw new InvalidDataException("A ZArchive directory range lies outside the file tree.");
|
||||
if (index != 0)
|
||||
{
|
||||
_nodes[path] = new Node(path, name, true, 0, 0);
|
||||
_entries.Add(new ZArchiveEntry(path, name, true, 0));
|
||||
}
|
||||
for (uint child = a; child < a + b; child++) stack.Push((child, path));
|
||||
}
|
||||
}
|
||||
_entries.Sort((x, y) => string.Compare(x.Path, y.Path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public bool FileExists(string path) =>
|
||||
_nodes.TryGetValue(Normalize(path), out Node? node) && !node.IsDirectory;
|
||||
|
||||
public long GetSize(string path) => _nodes.TryGetValue(Normalize(path), out Node? node) && !node.IsDirectory
|
||||
? (long)node.Size
|
||||
: throw new FileNotFoundException("The file was not found in the archive.", path);
|
||||
|
||||
public Stream OpenFile(string path)
|
||||
{
|
||||
if (!_nodes.TryGetValue(Normalize(path), out Node? node) || node.IsDirectory)
|
||||
throw new FileNotFoundException("The file was not found in the archive.", path);
|
||||
return new FileStreamView(this, node.Offset, node.Size);
|
||||
}
|
||||
|
||||
public byte[] ReadAllBytes(string path, int maximum = 64 * 1024 * 1024)
|
||||
{
|
||||
long size = GetSize(path);
|
||||
if (size > maximum) throw new InvalidDataException("The file is larger than the allowed read size.");
|
||||
using Stream stream = OpenFile(path);
|
||||
byte[] bytes = new byte[size];
|
||||
stream.ReadExactly(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public string ReadAllText(string path) => Encoding.UTF8.GetString(ReadAllBytes(path, 16 * 1024 * 1024));
|
||||
|
||||
private static string Normalize(string path) => path.Replace('\\', '/').Trim('/');
|
||||
|
||||
/// <summary>Copies bytes from the uncompressed address space of the archive.</summary>
|
||||
private int ReadUncompressed(ulong position, Span<byte> destination)
|
||||
{
|
||||
int total = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
while (total < destination.Length)
|
||||
{
|
||||
long block = (long)((position + (ulong)total) / ZArchiveWriter.BlockSize);
|
||||
int within = (int)((position + (ulong)total) % ZArchiveWriter.BlockSize);
|
||||
LoadBlock(block);
|
||||
int count = Math.Min(destination.Length - total, ZArchiveWriter.BlockSize - within);
|
||||
_blockBuffer.AsSpan(within, count).CopyTo(destination[total..]);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private void LoadBlock(long block)
|
||||
{
|
||||
if (block == _cachedBlock) return;
|
||||
long record = block / ZArchiveWriter.EntriesPerOffsetRecord;
|
||||
int slot = (int)(block % ZArchiveWriter.EntriesPerOffsetRecord);
|
||||
if ((record + 1) * 40 > _records.Length) throw new InvalidDataException("A ZArchive block index is out of range.");
|
||||
ReadOnlySpan<byte> entry = _records.AsSpan((int)record * 40, 40);
|
||||
ulong offset = BinaryPrimitives.ReadUInt64BigEndian(entry);
|
||||
for (int i = 0; i < slot; i++)
|
||||
offset += (ulong)BinaryPrimitives.ReadUInt16BigEndian(entry[(8 + 2 * i)..]) + 1;
|
||||
int size = BinaryPrimitives.ReadUInt16BigEndian(entry[(8 + 2 * slot)..]) + 1;
|
||||
if (offset + (ulong)size > _dataSize) throw new InvalidDataException("A ZArchive block lies outside the data section.");
|
||||
_file.Position = _dataOffset + (long)offset;
|
||||
if (size == ZArchiveWriter.BlockSize)
|
||||
{
|
||||
_file.ReadExactly(_blockBuffer, 0, size);
|
||||
}
|
||||
else
|
||||
{
|
||||
_file.ReadExactly(_compressedBuffer, 0, size);
|
||||
int decoded = _decompressor.Unwrap(_compressedBuffer.AsSpan(0, size), _blockBuffer);
|
||||
if (decoded != ZArchiveWriter.BlockSize)
|
||||
throw new InvalidDataException($"ZArchive block {block} decodes to the wrong size.");
|
||||
}
|
||||
_cachedBlock = block;
|
||||
}
|
||||
|
||||
/// <summary>A seekable view over one file's bytes.</summary>
|
||||
private sealed class FileStreamView(ZArchiveVolume volume, ulong start, ulong length) : Stream
|
||||
{
|
||||
private long _position;
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => (long)length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => _position = Math.Clamp(value, 0, (long)length);
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
long remaining = (long)length - _position;
|
||||
int wanted = (int)Math.Min(buffer.Length, Math.Max(0, remaining));
|
||||
if (wanted == 0) return 0;
|
||||
int read = volume.ReadUncompressed(start + (ulong)_position, buffer[..wanted]);
|
||||
_position += read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
Position = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
_ => (long)length + offset
|
||||
};
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_decompressor.Dispose();
|
||||
_file.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace Packwright.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Managed writer for the ZArchive (.zar) container (https://github.com/Exzap/ZArchive). Data is only
|
||||
/// ever appended: file payloads are cut into 64 KiB blocks that are zstd compressed, followed by the
|
||||
/// offset records, name table, file tree and a footer carrying the SHA-256 of the whole archive.
|
||||
/// </summary>
|
||||
public sealed class ZArchiveWriter : IDisposable
|
||||
{
|
||||
public const int BlockSize = 64 * 1024;
|
||||
public const int EntriesPerOffsetRecord = 16;
|
||||
public const uint FooterMagic = 0x169f52d6;
|
||||
public const uint FooterVersion1 = 0x61bf3a01;
|
||||
public const int FooterLength = 16 * 6 + 32 + 8 + 4 + 4;
|
||||
private const int CompressionLevel = 6;
|
||||
|
||||
private sealed class Node(bool isFile, uint nameIndex)
|
||||
{
|
||||
public readonly bool IsFile = isFile;
|
||||
public readonly uint NameIndex = nameIndex;
|
||||
public readonly List<Node> Children = [];
|
||||
public readonly Dictionary<string, Node> ByName = new(StringComparer.Ordinal);
|
||||
public ulong FileOffset;
|
||||
public ulong FileSize;
|
||||
public uint NodeStartIndex;
|
||||
}
|
||||
|
||||
private readonly Stream _output;
|
||||
private readonly bool _leaveOpen;
|
||||
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
private readonly Compressor _compressor = new(CompressionLevel);
|
||||
private readonly Node _root = new(false, uint.MaxValue);
|
||||
private readonly List<byte[]> _names = [];
|
||||
private readonly Dictionary<string, uint> _nameLookup = new(StringComparer.Ordinal);
|
||||
private readonly byte[] _block = new byte[BlockSize];
|
||||
private int _blockFill;
|
||||
private readonly List<(ulong BaseOffset, ushort[] Sizes)> _offsetRecords = [];
|
||||
private ulong _blockCount;
|
||||
private ulong _written;
|
||||
private ulong _inputOffset;
|
||||
private Node? _currentFile;
|
||||
private bool _finalized;
|
||||
|
||||
public ZArchiveWriter(Stream output, bool leaveOpen = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
_output = output;
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
/// <summary>Total number of bytes written to the output so far.</summary>
|
||||
public long OutputLength => (long)_written;
|
||||
|
||||
/// <summary>Creates a virtual file and makes it the target of <see cref="Append"/>.</summary>
|
||||
public void StartNewFile(string path)
|
||||
{
|
||||
_currentFile = null;
|
||||
string[] parts = SplitPath(path);
|
||||
if (parts.Length == 0) throw new ArgumentException("The file path is empty.", nameof(path));
|
||||
Node directory = _root;
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
Node? next = Find(directory, parts[i]);
|
||||
if (next is null || next.IsFile)
|
||||
throw new DirectoryNotFoundException($"The directory does not exist in the archive: {parts[i]}");
|
||||
directory = next;
|
||||
}
|
||||
string name = parts[^1];
|
||||
if (Find(directory, name) is not null)
|
||||
throw new IOException($"The archive already contains: {path}");
|
||||
var node = new Node(true, CreateName(name)) { FileOffset = _inputOffset };
|
||||
Add(directory, name, node);
|
||||
_currentFile = node;
|
||||
}
|
||||
|
||||
/// <summary>Creates a directory and any missing parents.</summary>
|
||||
public void MakeDirectory(string path)
|
||||
{
|
||||
Node current = _root;
|
||||
foreach (string part in SplitPath(path))
|
||||
{
|
||||
Node? next = Find(current, part);
|
||||
if (next is { IsFile: true })
|
||||
throw new IOException($"A file already exists at: {part}");
|
||||
if (next is null)
|
||||
{
|
||||
next = new Node(false, CreateName(part));
|
||||
Add(current, part, next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
public void Append(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong length = (ulong)data.Length;
|
||||
while (!data.IsEmpty)
|
||||
{
|
||||
int count = Math.Min(BlockSize - _blockFill, data.Length);
|
||||
data[..count].CopyTo(_block.AsSpan(_blockFill));
|
||||
_blockFill += count;
|
||||
data = data[count..];
|
||||
if (_blockFill == BlockSize)
|
||||
{
|
||||
StoreBlock();
|
||||
_blockFill = 0;
|
||||
}
|
||||
}
|
||||
if (_currentFile is not null) _currentFile.FileSize += length;
|
||||
_inputOffset += length;
|
||||
}
|
||||
|
||||
public void Finish()
|
||||
{
|
||||
if (_finalized) throw new InvalidOperationException("The archive has already been finalized.");
|
||||
_finalized = true;
|
||||
_currentFile = null;
|
||||
if (_blockFill > 0)
|
||||
{
|
||||
Array.Clear(_block, _blockFill, BlockSize - _blockFill);
|
||||
StoreBlock();
|
||||
_blockFill = 0;
|
||||
}
|
||||
|
||||
ulong compressedSize = _written;
|
||||
while (_written % 8 != 0) Write([0]);
|
||||
|
||||
ulong recordsOffset = _written;
|
||||
Span<byte> record = stackalloc byte[8 + 2 * EntriesPerOffsetRecord];
|
||||
foreach ((ulong baseOffset, ushort[] sizes) in _offsetRecords)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64BigEndian(record, baseOffset);
|
||||
for (int i = 0; i < EntriesPerOffsetRecord; i++)
|
||||
BinaryPrimitives.WriteUInt16BigEndian(record[(8 + 2 * i)..], sizes[i]);
|
||||
Write(record);
|
||||
}
|
||||
ulong recordsSize = _written - recordsOffset;
|
||||
|
||||
ulong namesOffset = _written;
|
||||
uint[] nameOffsets = new uint[_names.Count];
|
||||
uint nameTableOffset = 0;
|
||||
for (int i = 0; i < _names.Count; i++)
|
||||
{
|
||||
nameOffsets[i] = nameTableOffset;
|
||||
byte[] name = _names[i];
|
||||
int length = Math.Min(name.Length, 0x7FFF);
|
||||
if (length >= 0x80)
|
||||
{
|
||||
Write([(byte)((length & 0x7F) | 0x80), (byte)(length >> 7)]);
|
||||
nameTableOffset += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
Write([(byte)length]);
|
||||
nameTableOffset += 1;
|
||||
}
|
||||
Write(name.AsSpan(0, length));
|
||||
nameTableOffset += (uint)length;
|
||||
}
|
||||
ulong namesSize = _written - namesOffset;
|
||||
|
||||
// Directory entries are numbered breadth first so every directory owns a contiguous range.
|
||||
var queue = new Queue<Node>();
|
||||
queue.Enqueue(_root);
|
||||
uint index = 1;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
Node node = queue.Dequeue();
|
||||
if (node.IsFile) { node.NodeStartIndex = uint.MaxValue; continue; }
|
||||
node.Children.Sort((a, b) => CompareNames(_names[(int)a.NameIndex], _names[(int)b.NameIndex]));
|
||||
node.NodeStartIndex = index;
|
||||
index += (uint)node.Children.Count;
|
||||
foreach (Node child in node.Children) queue.Enqueue(child);
|
||||
}
|
||||
|
||||
ulong treeOffset = _written;
|
||||
Span<byte> entry = stackalloc byte[16];
|
||||
queue.Enqueue(_root);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
Node node = queue.Dequeue();
|
||||
uint nameField = node == _root ? 0x7FFFFFFF : nameOffsets[node.NameIndex] & 0x7FFFFFFF;
|
||||
if (node.IsFile) nameField |= 0x80000000;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry, nameField);
|
||||
if (node.IsFile)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[4..], (uint)node.FileOffset);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[8..], (uint)node.FileSize);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[12..],
|
||||
(uint)(((node.FileSize >> 16) & 0xFFFF0000) | ((node.FileOffset >> 32) & 0xFFFF)));
|
||||
}
|
||||
else
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[4..], node.NodeStartIndex);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[8..], (uint)node.Children.Count);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(entry[12..], 0);
|
||||
}
|
||||
Write(entry);
|
||||
foreach (Node child in node.Children) queue.Enqueue(child);
|
||||
}
|
||||
ulong treeSize = _written - treeOffset;
|
||||
|
||||
// The metadata sections are not used yet; they are empty and sit directly after the tree.
|
||||
ulong metaOffset = _written;
|
||||
ulong totalSize = _written + FooterLength;
|
||||
byte[] footer = new byte[FooterLength];
|
||||
WriteFooter(footer, compressedSize, recordsOffset, recordsSize, namesOffset, namesSize,
|
||||
treeOffset, treeSize, metaOffset, totalSize, null);
|
||||
_hash.AppendData(footer);
|
||||
byte[] digest = _hash.GetHashAndReset();
|
||||
WriteFooter(footer, compressedSize, recordsOffset, recordsSize, namesOffset, namesSize,
|
||||
treeOffset, treeSize, metaOffset, totalSize, digest);
|
||||
_output.Write(footer);
|
||||
_written += FooterLength;
|
||||
_output.Flush();
|
||||
}
|
||||
|
||||
private static void WriteFooter(Span<byte> footer, ulong compressedSize, ulong recordsOffset, ulong recordsSize,
|
||||
ulong namesOffset, ulong namesSize, ulong treeOffset, ulong treeSize, ulong metaOffset, ulong totalSize,
|
||||
byte[]? digest)
|
||||
{
|
||||
footer.Clear();
|
||||
ulong[] sections =
|
||||
[
|
||||
0, compressedSize, recordsOffset, recordsSize, namesOffset, namesSize,
|
||||
treeOffset, treeSize, metaOffset, 0, metaOffset, 0
|
||||
];
|
||||
for (int i = 0; i < sections.Length; i++)
|
||||
BinaryPrimitives.WriteUInt64BigEndian(footer[(i * 8)..], sections[i]);
|
||||
digest?.CopyTo(footer[96..]);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(footer[128..], totalSize);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(footer[136..], FooterVersion1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(footer[140..], FooterMagic);
|
||||
}
|
||||
|
||||
private void StoreBlock()
|
||||
{
|
||||
ulong offset = _written;
|
||||
ReadOnlySpan<byte> compressed = _compressor.Wrap(_block);
|
||||
int stored = compressed.Length;
|
||||
if (stored >= BlockSize)
|
||||
{
|
||||
stored = BlockSize;
|
||||
Write(_block);
|
||||
}
|
||||
else
|
||||
{
|
||||
Write(compressed);
|
||||
}
|
||||
int slot = (int)(_blockCount % EntriesPerOffsetRecord);
|
||||
if (slot == 0) _offsetRecords.Add((offset, new ushort[EntriesPerOffsetRecord]));
|
||||
_offsetRecords[^1].Sizes[slot] = (ushort)(stored - 1);
|
||||
_blockCount++;
|
||||
}
|
||||
|
||||
private void Write(ReadOnlySpan<byte> data)
|
||||
{
|
||||
_output.Write(data);
|
||||
_hash.AppendData(data);
|
||||
_written += (ulong)data.Length;
|
||||
}
|
||||
|
||||
private uint CreateName(string name)
|
||||
{
|
||||
if (_nameLookup.TryGetValue(name, out uint existing)) return existing;
|
||||
uint index = (uint)_names.Count;
|
||||
_names.Add(Encoding.UTF8.GetBytes(name));
|
||||
_nameLookup.Add(name, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
private static void Add(Node parent, string name, Node child)
|
||||
{
|
||||
parent.Children.Add(child);
|
||||
parent.ByName.Add(LookupKey(name), child);
|
||||
}
|
||||
|
||||
private static Node? Find(Node parent, string name) =>
|
||||
parent.ByName.GetValueOrDefault(LookupKey(name));
|
||||
|
||||
// Names are matched case-insensitively over ASCII only, like the reference implementation.
|
||||
private static string LookupKey(string name) =>
|
||||
string.Create(name.Length, name, static (span, source) =>
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i];
|
||||
});
|
||||
|
||||
private static string[] SplitPath(string path) =>
|
||||
path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// The format compares names case-insensitively over ASCII only; shorter names sort first on a tie.
|
||||
private static int CompareNames(byte[] a, byte[] b)
|
||||
{
|
||||
int length = Math.Min(a.Length, b.Length);
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
int x = Lower(a[i]), y = Lower(b[i]);
|
||||
if (x != y) return x - y;
|
||||
}
|
||||
return a.Length - b.Length;
|
||||
}
|
||||
|
||||
private static int Lower(byte value) => value is >= (byte)'A' and <= (byte)'Z' ? value + 32 : value;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_compressor.Dispose();
|
||||
_hash.Dispose();
|
||||
if (!_leaveOpen) _output.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// What a <see cref="IPackageBackend"/> can do. The UI uses this to gate actions and options instead
|
||||
/// of assuming every backend supports everything (for example LibProsperoPkg cannot build from an
|
||||
/// image and cannot build very large titles).
|
||||
/// </summary>
|
||||
public sealed record BackendCapabilities(
|
||||
bool BuildFromDirectory,
|
||||
bool BuildFromImage,
|
||||
bool Validate,
|
||||
bool Extract,
|
||||
bool SupportsLargeImages,
|
||||
bool SupportsSeed,
|
||||
bool SupportsCompressionKnobs,
|
||||
bool SupportsRightSprxToggle,
|
||||
bool SupportsPlayGoChunks);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a backend is asked to do something it cannot do (for example LibProsperoPkg building
|
||||
/// from an image, or its NAPS single-byte layout limit on a very large title). The message is
|
||||
/// user-facing and names the backend; callers may offer to retry with another backend.
|
||||
/// </summary>
|
||||
public sealed class BackendNotSupportedException : NotSupportedException
|
||||
{
|
||||
public BackendNotSupportedException(string backendId, string message)
|
||||
: base(message)
|
||||
{
|
||||
BackendId = backendId;
|
||||
}
|
||||
|
||||
/// <summary>The id of the backend that could not handle the request.</summary>
|
||||
public string BackendId { get; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// The set of package backends the app can select. Defaults to LibProsperoPkg per the product plan;
|
||||
/// ProsperoPkgTool remains available (and is required for image sources and very large titles).
|
||||
/// </summary>
|
||||
public static class BackendRegistry
|
||||
{
|
||||
public const string PptId = PptBackend.BackendId;
|
||||
public const string LppId = LppBackend.BackendId;
|
||||
public const string DefaultId = LppId;
|
||||
|
||||
public static IReadOnlyList<IPackageBackend> All { get; } =
|
||||
[
|
||||
new LppBackend(),
|
||||
new PptBackend(),
|
||||
];
|
||||
|
||||
/// <summary>The default backend (LibProsperoPkg).</summary>
|
||||
public static IPackageBackend Default => GetById(DefaultId);
|
||||
|
||||
/// <summary>Resolves a backend by id, falling back to the default for an unknown or null id.</summary>
|
||||
public static IPackageBackend Get(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return Default;
|
||||
return All.FirstOrDefault(backend =>
|
||||
string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase)) ?? Default;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="id"/> names a known backend.</summary>
|
||||
public static bool IsKnown(string? id) =>
|
||||
!string.IsNullOrWhiteSpace(id) &&
|
||||
All.Any(backend => string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IPackageBackend GetById(string id) =>
|
||||
All.First(backend => string.Equals(backend.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Packwright.Core.Builders;
|
||||
using Packwright.Core.Services;
|
||||
|
||||
namespace Packwright.Core.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// A selectable PS5 package engine that can build, validate and extract packages. Two
|
||||
/// implementations ship: <c>PptBackend</c> (the vendored clean-room ProsperoPkgTool engine) and
|
||||
/// <c>LppBackend</c> (the vendored GPL LibProsperoPkg library). The selected backend is the one that
|
||||
/// both builds a package and later validates/extracts it, so the reader always matches the writer.
|
||||
/// </summary>
|
||||
public interface IPackageBackend
|
||||
{
|
||||
/// <summary>Stable id used in settings and the UI (e.g. <c>ppt</c>, <c>lpp</c>).</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Human-readable name shown in the backend selector.</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>What this backend can do.</summary>
|
||||
BackendCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>Builds a package from an unpacked folder (a dump/app tree).</summary>
|
||||
Task<SonyDebugPackageBuildResult> BuildFromDirectoryAsync(string sourceDirectory, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Builds a package from an image source (exFAT / FFPKG / FFPFSC). Not all backends support this.</summary>
|
||||
Task<SonyDebugPackageBuildResult> BuildFromImageAsync(string imagePath, string outputPath,
|
||||
SonyDebugPackageBuildOptions options, IProgress<SonyDebugPackageProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates a package with this backend's own reader.</summary>
|
||||
SonyDebugPackageValidationResult Validate(string packagePath,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode);
|
||||
|
||||
/// <summary>Extracts a package's filesystem to <paramref name="destination"/>.</summary>
|
||||
Task<SonyPackageExtractResult> ExtractAsync(string packagePath, string destination,
|
||||
string passcode = SonyDebugPackageCredentials.DefaultPasscode,
|
||||
IProgress<SonyPackageExtractProgress>? progress = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user