83 Commits
v0.6.5 ... main

Author SHA1 Message Date
d75c345d36 Pushed updates for release v1.1.0 2026-10-20 23:42:30 +02:00
be7891f066 Pushed new release: v1.0.8 2026-09-19 12:49:03 +02:00
6a508476b9 Added better game crash diagnostics. 2026-09-18 21:36:12 +02:00
9ef0eff2f5 Fixed a issue with resource exhaustion for directx. 2026-09-15 21:48:27 +02:00
06469a0ad3 Updated to version: 1.0.5 2026-09-15 21:29:05 +02:00
f7ceba2b9b Added crash dumps class. 2026-09-15 21:23:10 +02:00
67d89b40d5 Updated telemetry to support game dumps. 2026-09-15 21:15:39 +02:00
c278efa339 Released version 1.0.2 2026-09-12 11:50:04 +02:00
f3059c334e Reworked some game window and gamescope methods for Cinnamon. 2026-09-12 11:50:04 +02:00
2cbb9315bf Added more folders and restructured classes inside them. 2026-09-12 11:50:04 +02:00
be7a4782e8 Reworked the project structure. 2026-09-12 11:50:04 +02:00
15741f5f8a Delete RELEASE_PIPELINE_WORKPLAN.md 2026-09-10 22:15:13 +02:00
0a89323533 Add channel-aware release tooling and updater recovery 2026-09-10 00:39:37 +02:00
cc48bf45d2 Publish signed launcher release history 2026-09-10 00:38:33 +02:00
84f8de543b Upload files to "updates" 2026-09-10 00:29:46 +02:00
1329485ec5 Delete updates/stable.json.sig 2026-09-10 00:29:27 +02:00
8f1655b241 Delete updates/stable.json 2026-09-10 00:29:23 +02:00
be71d31c48 Fix v0.9.11 archive download cache 2026-09-10 00:23:54 +02:00
46e11ae8d5 Upload files to "updates" 2026-09-10 00:15:20 +02:00
eeb993efc9 Delete updates/stable.json.sig 2026-09-10 00:14:32 +02:00
a90bd19858 Delete updates/stable.json 2026-09-10 00:14:27 +02:00
9644de3ff5 Fixed some UI issues with updater. 2026-09-10 00:13:05 +02:00
a362b0bb8e Updated to new release v0.9.10 2026-09-09 23:12:47 +02:00
27ed039f32 Updated to new release v0.9.9 2026-09-09 23:01:49 +02:00
290d1a5383 Added better support for kubuntu and fixed some issues with telemetry. 2026-09-09 22:35:13 +02:00
6e6219107e Updated diagnostic message. 2026-09-09 22:34:34 +02:00
5770969629 Updated the update system to support multi channels (stable / pre-release). 2026-09-09 20:52:00 +02:00
2a53a59cef Released a new version, 0.9.8. 2026-09-08 22:51:19 +02:00
76bef94757 Fixed more issues with Cinnamon window resizing and popups. 2026-09-08 22:46:53 +02:00
1a120bbf86 Fixed issues with telemetry. 2026-09-08 22:46:20 +02:00
9b0abf6223 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	updates/stable.json
#	updates/stable.json.sig
2026-09-07 22:26:26 +02:00
108ea94fb7 Released new stable version 0.9.7. 2026-09-07 22:25:45 +02:00
1cfe8625e9 Added a fix for X11 / Cinnamon window popups and maximize button not visible. 2026-09-07 22:19:05 +02:00
f06fc2e4e3 Added telemetry system to get information about issues. 2026-09-07 21:49:37 +02:00
a1ff4794ae Upload files to "updates" 2026-09-07 00:12:19 +02:00
6eea3b2f2b Delete updates/stable.json.sig 2026-09-07 00:12:00 +02:00
ee9bb064ff Update updates/stable.json 2026-09-07 00:11:47 +02:00
bea9d6fda7 Fixed issues with gamescope not properly launching. 2026-09-06 22:57:54 +02:00
f53f4d0b08 Released new version 0.9.5 2026-09-06 22:09:16 +02:00
3cbe4f72b0 Fixed the on top issue with the game window, it should not pop up when alt tabbing to some other programs now. 2026-09-06 22:03:12 +02:00
6eab4b961f Fixed keyboard / input issues with resizing. 2026-09-06 21:52:07 +02:00
0963ae34b9 Released version 0.9.3 2026-09-06 21:06:48 +02:00
f9a6d6aed0 Updated settings modal layout. 2026-09-06 20:59:48 +02:00
3a01c6ba2a Reworked the gamescope to work better with scaling and maximize window. 2026-09-06 10:57:42 +02:00
f0d631be66 Added asset checks and fixes for missing data in game client. 2026-09-05 21:52:41 +02:00
cccce05a99 Released a new version 0.9.1 2026-09-04 23:43:06 +02:00
09c8f165be Reworked some umu / proton 7 maximize window and keyboard issues. 2026-09-04 23:39:43 +02:00
8fe627b6cf Updated to new version 0.9.0 2026-09-03 23:22:48 +02:00
598d761af4 Added maximize window and free scaling support. 2026-09-03 23:12:48 +02:00
5be4ace6c2 Added bubblewrap / apparmor fixes and diagnostic tools for troubleshooting it on related distros. 2026-09-03 22:16:43 +02:00
ecdea827ac Code cleanup and improvements. 2026-09-03 21:52:06 +02:00
ea323e3ab0 Reworked UI for easier workflow. 2026-09-03 19:07:09 +02:00
c61254e4a9 Added download settings and optimized the downloader system with concurrency. 2026-09-03 18:26:10 +02:00
ba079ef6fa Added resolution settings and updater modal with rollback functionality for launcher versions. 2026-09-02 23:18:16 +02:00
bb90efa9d1 Released version 0.8.5 2026-09-02 21:47:21 +02:00
7ce6ac3dd7 Added umu download and repair for missing components on install, update and manual repair. 2026-09-02 21:42:49 +02:00
6f20b5e359 Fixed issues with windowed mode and fullscreen. 2026-09-02 20:55:00 +02:00
55b2292bfe Released v0.8.3 2026-09-01 22:26:19 +02:00
15dbd90dbb Optimizations and performance boost to pre-loader for launcher and game client. 2026-09-01 22:21:54 +02:00
c2400db19e Changed how borderless setting works. 2026-09-01 22:06:00 +02:00
1a17c1a197 Fixed a gamebox issue where resolution would act up on launch. 2026-09-01 22:05:33 +02:00
25f490c8b8 Hotfixed a issue with resolution in borderless mode. 2026-09-01 20:51:45 +02:00
e8d77f3779 Released v0.8.1. 2026-09-01 20:17:37 +02:00
6900c20734 Prepare release v0.8.1 2026-09-01 20:09:16 +02:00
f96aba9d99 Fixed multiple clients bug in umu. 2026-09-01 20:05:54 +02:00
78dade0d86 Fixed some proton 7 path issues and the override in settings. 2026-09-01 19:07:00 +02:00
d819809197 New release: v0.8.0 2026-08-31 23:30:39 +02:00
dadae8f60a Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over. 2026-08-31 23:23:37 +02:00
99982b12d9 Added debugger class for dashboard and other launcher util classes. 2026-08-31 23:13:04 +02:00
75b21acf87 Added windows identity checker. 2026-08-31 23:12:20 +02:00
b16540099d Added changelog class 2026-08-31 23:11:21 +02:00
b0855ccd9b Added import class. 2026-08-31 23:10:38 +02:00
87dee93b86 Reworked the modal ui's for launcher, settings, updates and game pre-loader. 2026-08-31 22:03:47 +02:00
fe883047e0 Added a debug system and fixed thread issues with update / repair. 2026-08-31 20:23:23 +02:00
20a0bbcd75 Removed player counter. 2026-08-31 17:46:08 +02:00
ed0c278a8b Released v0.7.0. 2026-08-30 22:53:00 +02:00
094c881798 Added tabs for news / patches, integrated new patches api from website. 2026-08-30 22:47:55 +02:00
bc87f10661 Added server status and server selection dropdown. 2026-08-30 22:15:32 +02:00
562c924809 Fixed chmod issues with launcher updater. 2026-08-30 22:06:52 +02:00
94a5e81555 Released new stable version 0.6.7. 2026-08-30 12:38:05 +02:00
94f2260f43 Added support for multiple clients to be ran at the same time. 2026-08-30 12:32:39 +02:00
994fda5cc7 Added proper hotbar/menu icons and added a preloader for the launcher. 2026-08-30 12:06:15 +02:00
2a7da33160 Pushed new v0.6.5 update data. 2026-08-30 11:34:04 +02:00
220 changed files with 46947 additions and 8887 deletions

View File

@@ -0,0 +1,75 @@
name: Publish launcher release status
on:
# Manual-release mode is active. Restore the status-file push trigger when
# automatic channel/history publication is wanted again.
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
history:
name: Sign and publish release history
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Check out trusted release metadata
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Validate release status configuration
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required metadata command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux scripts
python3 scripts/generate_history.py --help >/dev/null
bash -n scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for metadata publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Publish signed release status and history
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-release-history.sh ./dist/status
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

View File

@@ -0,0 +1,164 @@
name: Publish launcher release
# Manual-release mode is active. Restore the v* tag-push trigger when automatic
# Gitea publication is wanted again.
on:
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
release:
name: Build, verify, and publish tagged release
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Check out the exact tag
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Validate and derive release metadata
id: release
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
if [[ "$(git cat-file -t "$TAG")" != "tag" ]]
then
printf 'Release tag must be annotated: %s\n' "$TAG" >&2
exit 1
fi
python3 -m ptlinux.update_resolver inspect-tag "$TAG" > release-metadata.json
VERSION="$(jq -er '.version' release-metadata.json)"
CHANNEL="$(jq -er '.channel' release-metadata.json)"
PRERELEASE="$(jq -er '.prerelease' release-metadata.json)"
COMMIT="$(git rev-parse "${TAG}^{commit}")"
if [[ "$COMMIT" != "$(git rev-parse HEAD)" ]]
then
printf 'Checkout does not match tagged commit %s.\n' "$COMMIT" >&2
exit 1
fi
mapfile -t OTHER_RELEASE_TAGS < <(
git tag --list | while IFS= read -r candidate
do
[[ "$candidate" == "$TAG" ]] || printf '%s\n' "$candidate"
done
)
python3 -m ptlinux.update_resolver validate-next \
"$CHANNEL" "$VERSION" "${OTHER_RELEASE_TAGS[@]}"
{
printf 'tag=%s\n' "$TAG"
printf 'version=%s\n' "$VERSION"
printf 'channel=%s\n' "$CHANNEL"
printf 'prerelease=%s\n' "$PRERELEASE"
printf 'commit=%s\n' "$COMMIT"
} >> "$GITHUB_OUTPUT"
- name: Recover the previewed release notes
shell: bash
run: |
set -euo pipefail
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
git for-each-ref --format='%(contents)' \
"refs/tags/${{ steps.release.outputs.tag }}" > "$NOTES_FILE"
if [[ ! -s "$NOTES_FILE" ]]
then
printf '%s\n' 'Annotated release tag has no release notes.' >&2
exit 1
fi
printf 'RELEASE_NOTES_FILE=%s\n' "$NOTES_FILE" >> "$GITHUB_ENV"
- name: Run release preflight checks
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 tar gzip sha256sum rsync openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required release command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux ptdiag scripts
python3 -m unittest discover -s release_tests -p 'test_*.py'
python3 -m unittest discover -s tests -p 'test_*.py'
bash -n scripts/release.sh scripts/build-release.sh \
scripts/verify-release.sh scripts/publish-gitea-release.sh \
scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for release publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Build deterministic release artifacts
shell: bash
env:
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH="$(git show -s --format=%ct '${{ steps.release.outputs.commit }}')"
./scripts/build-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Verify complete release artifacts
shell: bash
env:
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
./scripts/verify-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Create Gitea release, upload assets, and update channel metadata
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-gitea-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist \
"$RELEASE_NOTES_FILE"
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

33
.gitignore vendored
View File

@@ -3,8 +3,35 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.idea/ .idea/
dist/
# Legacy safety net only. Release tooling requires private keys outside the
# checkout and generated artifact tree.
release-keys/ release-keys/
config/local.toml config/local.toml
results/* diagnostics/config/local.toml
!results/.gitkeep
# Generated builds, candidates, runtime evidence, and machine-local state.
.artifacts/
# Legacy output locations remain ignored while existing local data is migrated
# manually. New commands write only below .artifacts/.
candidates/
dist/
replay_pid*.log
results/
runner-data/
runner-token
infra/gitea-runner/.env
infra/gitea-runner/runner-data/
infra/gitea-runner/runner-token
server/telemetry/.env
/diagnostics/
/devtools/
/docs/
/infra/
/artifacts/
/release_tests/
/tests/
/tools/
/.gitea/
/.run/

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.6.5 installs, updates, repairs, and launches PristonTale EU through a Version 1.0.8 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text ```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \ openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-6-5.tar.gz.sig \ -signature pristontale-eu-linux-installer-v1-0-8.tar.gz.sig \
pristontale-eu-linux-installer-v0-6-5.tar.gz pristontale-eu-linux-installer-v1-0-8.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-6-5.tar.gz.sha256 sha256sum -c pristontale-eu-linux-installer-v1-0-8.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-6-5.tar.gz tar -xzf pristontale-eu-linux-installer-v1-0-8.tar.gz
cd pristontale-eu-linux-installer-v0-6-5 cd pristontale-eu-linux-installer-v1-0-8
./install.sh ./install.sh
``` ```
@@ -34,6 +34,11 @@ Existing installations at the previous default location are preserved during
upgrades. The installer adds **PristonTale EU** to the application menu and upgrades. The installer adds **PristonTale EU** to the application menu and
installs the `pristontale-eu` command in `~/.local/bin`. installs the `pristontale-eu` command in `~/.local/bin`.
Steam Proton 7.0 is discovered in both the default Steam library and secondary
libraries registered with the Steam client. If Proton uses an unregistered
manual layout, open **Settings → Optional Overrides** and select the exact
`Proton 7.0/proton` script.
## Use ## Use
Open **PristonTale EU** from the application menu or run: Open **PristonTale EU** from the application menu or run:
@@ -51,6 +56,9 @@ The main launcher button automatically shows **Install**, **Update**, or
pristontale-eu --help Show all commands and options pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components pristontale-eu doctor Check required host components
pristontale-eu runtime-bootstrap Prepare the verified UMU helper
pristontale-eu container-host Test the Bubblewrap sandbox
pristontale-eu container-host --repair Repair recognized Ubuntu AppArmor states
pristontale-eu login-diagnostics Check the login route without credentials pristontale-eu login-diagnostics Check the login route without credentials
pristontale-eu login-diagnostics --runtime proton7 pristontale-eu login-diagnostics --runtime proton7
Compare the direct-Proton prefix read-only Compare the direct-Proton prefix read-only
@@ -78,4 +86,5 @@ pristontale-eu cache-compatibility PATH Cache a verified compatibility pai
``` ```
Use `./install.sh --dry-run` to preview dependency installation or Use `./install.sh --dry-run` to preview dependency installation or
`./install.sh --skip-deps` to manage distribution packages manually. `./install.sh --skip-deps` to manage distribution packages and the host sandbox
manually.

View File

@@ -3,11 +3,27 @@
set -euo pipefail set -euo pipefail
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")" if [[ -d "${SOURCE_ROOT}/src/ptlinux" ]]
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then then
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2 PACKAGE_SOURCE_ROOT="${SOURCE_ROOT}/src"
else
# Bootstrap archives retain the historical top-level package layout.
PACKAGE_SOURCE_ROOT="$SOURCE_ROOT"
fi
if [[ -d "${SOURCE_ROOT}/native/gamescope" ]]
then
GAMESCOPE_PROVENANCE_ROOT="${SOURCE_ROOT}/native/gamescope"
GAMESCOPE_PATCH_ROOT="${GAMESCOPE_PROVENANCE_ROOT}/patches"
else
GAMESCOPE_PROVENANCE_ROOT="${PACKAGE_SOURCE_ROOT}/ptlinux/native"
GAMESCOPE_PATCH_ROOT="$GAMESCOPE_PROVENANCE_ROOT"
fi
SEMVER_PATTERN='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(beta|rc)\.(0|[1-9][0-9]*))?'
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${PACKAGE_SOURCE_ROOT}/ptlinux/__init__.py")"
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
if [[ ! "$VERSION" =~ ^${SEMVER_PATTERN}$ ]]
then
printf 'Invalid launcher version: %s (expected X.Y.Z, X.Y.Z-beta.N, or X.Y.Z-rc.N)\n' "$VERSION" >&2
exit 2 exit 2
fi fi
if [[ "$VERSION" != "$SOURCE_VERSION" ]] if [[ "$VERSION" != "$SOURCE_VERSION" ]]
@@ -15,6 +31,14 @@ then
printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2 printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2
exit 2 exit 2
fi fi
case "$VERSION" in
*-beta.*|*-rc.*)
RELEASE_CHANNEL="prerelease"
;;
*)
RELEASE_CHANNEL="stable"
;;
esac
DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
BIN_HOME="${HOME}/.local/bin" BIN_HOME="${HOME}/.local/bin"
APPLICATION_HOME="${DATA_HOME}/applications" APPLICATION_HOME="${DATA_HOME}/applications"
@@ -35,14 +59,18 @@ PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
DRY_RUN=0 DRY_RUN=0
RUN_DOCTOR=1 RUN_DOCTOR=1
INSTALL_DEPENDENCIES=1 INSTALL_DEPENDENCIES=1
RUN_RUNTIME_BOOTSTRAP=1
usage() usage()
{ {
printf '%s\n' \ printf '%s\n' \
"Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor]" \ "Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor] [--skip-runtime-bootstrap]" \
"" \ "" \
" --install-dir PATH Store the app, managed game, and runtime data at PATH." \ " --install-dir PATH Store the app, managed game, and runtime data at PATH." \
" Default: ~/Games/PristontaleEU" " Default: ~/Games/PristontaleEU" \
" --skip-deps Do not install packages or repair host AppArmor profiles." \
" --skip-runtime-bootstrap" \
" Do not prepare the verified UMU helper during install."
} }
while (( $# > 0 )) while (( $# > 0 ))
@@ -75,6 +103,10 @@ do
INSTALL_DEPENDENCIES=0 INSTALL_DEPENDENCIES=0
shift shift
;; ;;
--skip-runtime-bootstrap)
RUN_RUNTIME_BOOTSTRAP=0
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -136,15 +168,30 @@ APP_ROOT="${INSTALL_DIRECTORY}/app"
RELEASES_ROOT="${APP_ROOT}/releases" RELEASES_ROOT="${APP_ROOT}/releases"
RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}" RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ if [[ ! -d "${PACKAGE_SOURCE_ROOT}/ptlinux" \
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/assbin.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/__init__.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/logs.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/self_update.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/__init__.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/dashboard.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/ptr-bcrypt-proxy.dll" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \
|| ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.README" \
|| ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.copyright" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-lazy-output-images.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-live-resize.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-letterbox-backing.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-libdecor-input-scope.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-keep-nested-windows-visible.patch" \
|| ! -f "${SOURCE_ROOT}/scripts/__init__.py" \
|| ! -f "${SOURCE_ROOT}/scripts/repository_paths.py" \
|| ! -f "${SOURCE_ROOT}/scripts/release_lib.py" \
|| ! -f "${SOURCE_ROOT}/release/config.json" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
@@ -175,6 +222,11 @@ fi
if (( DRY_RUN == 1 )) if (( DRY_RUN == 1 ))
then then
if (( INSTALL_DEPENDENCIES == 1 ))
then
printf '%s\n' \
"Dry run: installation would probe Bubblewrap and offer only the guarded Ubuntu AppArmor repair."
fi
exit 0 exit 0
fi fi
@@ -189,6 +241,13 @@ then
printf '%s\n' "Python 3.11 or newer is required." >&2 printf '%s\n' "Python 3.11 or newer is required." >&2
exit 1 exit 1
fi fi
if ! "$PYTHON_BIN" -I -B -c \
'import sys; sys.path.insert(0, sys.argv[1]); from ptlinux.update_resolver import validate_channel_version; validate_channel_version(sys.argv[2], sys.argv[3])' \
"$PACKAGE_SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION"
then
printf '%s\n' "Launcher version and release channel validation failed." >&2
exit 1
fi
install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME" install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME"
STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging" STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging"
@@ -197,30 +256,19 @@ then
printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2 printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2
exit 1 exit 1
fi fi
install -d -m 0755 "$STAGING_RELEASE" if ! "$PYTHON_BIN" -I -B -c \
install -d -m 0755 \ 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from scripts.release_lib import load_config, stage_runtime_payload; stage_runtime_payload(Path(sys.argv[2]), load_config(Path(sys.argv[3])), source_root=Path(sys.argv[4]))' \
"${STAGING_RELEASE}/ptlinux" \ "$SOURCE_ROOT" "$STAGING_RELEASE" "${SOURCE_ROOT}/release/config.json" "$SOURCE_ROOT"
"${STAGING_RELEASE}/ptlinux/native" \ then
"${STAGING_RELEASE}/ptdiag" printf '%s\n' "The launcher runtime payload could not be staged." >&2
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py if [[ -d "$STAGING_RELEASE" && ! -L "$STAGING_RELEASE" ]]
do then
case "${SOURCE_MODULE##*/}" in rm -r -- "$STAGING_RELEASE"
desktop_entry.py|launcher_wrapper.py) fi
continue exit 1
;; fi
esac printf '{\n "channel": "%s",\n "config_schema": 1,\n "minimum_config_schema": 1,\n "schema": 1,\n "tag": "v%s",\n "version": "%s"\n}\n' \
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/" "$RELEASE_CHANNEL" "$VERSION" "$VERSION" > "${STAGING_RELEASE}/release.json"
done
install -m 0644 \
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
install -m 0644 \
"${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
"${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe"
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py"
printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
chmod 0644 "${STAGING_RELEASE}/release.json" chmod 0644 "${STAGING_RELEASE}/release.json"
if ! "$PYTHON_BIN" -I -B -c \ if ! "$PYTHON_BIN" -I -B -c \
@@ -261,7 +309,7 @@ CURRENT_TARGET=""
if [[ -L "${APP_ROOT}/current" ]] if [[ -L "${APP_ROOT}/current" ]]
then then
CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")" CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")"
if [[ ! "$CURRENT_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ ]] if [[ ! "$CURRENT_TARGET" =~ ^releases/${SEMVER_PATTERN}$ ]]
then then
printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2 printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2
exit 1 exit 1
@@ -279,7 +327,7 @@ fi
if [[ -L "${APP_ROOT}/previous" ]] if [[ -L "${APP_ROOT}/previous" ]]
then then
PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")" PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")"
if [[ ! "$PREVIOUS_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ \ if [[ ! "$PREVIOUS_TARGET" =~ ^releases/${SEMVER_PATTERN}$ \
|| ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]] || ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]]
then then
printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2 printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2
@@ -300,7 +348,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
ln -s -- "releases/${VERSION}" "$CURRENT_LINK" ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ "$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
"${SOURCE_ROOT}/packaging/pristontale-eu" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \
"$APP_ROOT" \ "$APP_ROOT" \
"$INSTALL_DIRECTORY" \ "$INSTALL_DIRECTORY" \
@@ -308,7 +356,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
install -m 0644 \ install -m 0644 \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"$ICON_PATH" "$ICON_PATH"
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ "$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${BIN_HOME}/pristontale-eu" \ "${BIN_HOME}/pristontale-eu" \
"$ICON_PATH" \ "$ICON_PATH" \
@@ -325,6 +373,29 @@ then
fi fi
printf '%s\n' "Launcher files installed." printf '%s\n' "Launcher files installed."
CONTAINER_HOST_READY=1
if (( INSTALL_DEPENDENCIES == 1 ))
then
printf '%s\n' "Checking Steam Runtime sandbox permissions."
if ! "${BIN_HOME}/pristontale-eu" container-host --repair
then
CONTAINER_HOST_READY=0
fi
else
printf '%s\n' "Steam Runtime sandbox repair skipped with host dependencies."
fi
RUNTIME_BOOTSTRAP_READY=1
if (( RUN_RUNTIME_BOOTSTRAP == 1 ))
then
if ! "${BIN_HOME}/pristontale-eu" runtime-bootstrap
then
RUNTIME_BOOTSTRAP_READY=0
fi
else
printf '%s\n' "Steam Runtime helper preparation skipped."
fi
DOCTOR_READY=1 DOCTOR_READY=1
if (( RUN_DOCTOR == 1 )) if (( RUN_DOCTOR == 1 ))
then then
@@ -356,11 +427,28 @@ case ":${PATH:-}:" in
;; ;;
esac esac
if (( CONTAINER_HOST_READY == 0 ))
then
printf '%s\n' \
"Launcher files were installed, but Bubblewrap cannot create the Steam Runtime sandbox." \
"Retry with: ${BIN_HOME}/pristontale-eu container-host --repair" >&2
exit 1
fi
if (( RUNTIME_BOOTSTRAP_READY == 0 ))
then
printf '%s\n' \
"Launcher files were installed, but the Steam Runtime helper could not be prepared." \
"Retry with: ${BIN_HOME}/pristontale-eu runtime-bootstrap" >&2
exit 1
fi
if (( DOCTOR_READY == 0 )) if (( DOCTOR_READY == 0 ))
then then
printf '%s\n' \ printf '%s\n' \
"Launcher files were installed, but this host is not ready to run the game." \ "Launcher files were installed, but this host is not ready to run the game." \
"Resolve the diagnostics above, then run the launcher command again." >&2 "The exact cause is listed under 'Blocking diagnostics' above; optional tools do not block launch." \
"Re-run the check with: ${BIN_HOME}/pristontale-eu doctor" >&2
exit 1 exit 1
fi fi

View File

@@ -8,3 +8,4 @@ Terminal=false
Categories=Game;RolePlaying; Categories=Game;RolePlaying;
Keywords=PristonTale;MMORPG;Wine;Proton; Keywords=PristonTale;MMORPG;Wine;Proton;
StartupNotify=true StartupNotify=true
StartupWMClass=PristonTaleEU

31
packaging/install-dependencies.sh Executable file → Normal file
View File

@@ -79,6 +79,12 @@ ptlinux_resolve_dependency_packages()
debian:openssl) debian:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
debian:bubblewrap)
candidates=(bubblewrap)
;;
debian:apparmor)
candidates=(apparmor)
;;
fedora:python) fedora:python)
candidates=(python3) candidates=(python3)
;; ;;
@@ -100,6 +106,9 @@ ptlinux_resolve_dependency_packages()
fedora:openssl) fedora:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
fedora:bubblewrap)
candidates=(bubblewrap)
;;
arch:python) arch:python)
candidates=(python) candidates=(python)
;; ;;
@@ -121,6 +130,9 @@ ptlinux_resolve_dependency_packages()
arch:openssl) arch:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
arch:bubblewrap)
candidates=(bubblewrap)
;;
arch:umu) arch:umu)
candidates=(umu-launcher) candidates=(umu-launcher)
;; ;;
@@ -145,6 +157,9 @@ ptlinux_resolve_dependency_packages()
suse:openssl) suse:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
suse:bubblewrap)
candidates=(bubblewrap)
;;
*) *)
printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2 printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2
return 1 return 1
@@ -239,6 +254,18 @@ ptlinux_detect_missing_dependencies()
then then
PTLINUX_MISSING_DEPENDENCIES+=(openssl) PTLINUX_MISSING_DEPENDENCIES+=(openssl)
fi fi
if [[ ! -x /usr/bin/bwrap ]]
then
PTLINUX_MISSING_DEPENDENCIES+=(bubblewrap)
fi
if [[ "$family" == "debian" \
&& ( "${PTLINUX_DISTRIBUTION_ID,,}" == "ubuntu" \
|| " ${PTLINUX_DISTRIBUTION_LIKE,,} " == *" ubuntu "* ) \
&& -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns \
&& ! -x /usr/sbin/apparmor_parser ]]
then
PTLINUX_MISSING_DEPENDENCIES+=(apparmor)
fi
if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1 if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
then then
PTLINUX_MISSING_DEPENDENCIES+=(umu) PTLINUX_MISSING_DEPENDENCIES+=(umu)
@@ -353,7 +380,9 @@ ptlinux_install_host_dependencies()
ptlinux_detect_missing_dependencies "$family" ptlinux_detect_missing_dependencies "$family"
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 )) if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
then then
printf '%s\n' "All required host dependencies are already available." printf '%s\n' \
"All automatically managed host packages are already available." \
"Final graphics and runtime diagnostics run after launcher installation."
return 0 return 0
fi fi
if [[ "$family" == "unknown" ]] if [[ "$family" == "unknown" ]]

View File

@@ -12,17 +12,20 @@ export PTLINUX_APP_HOME="${app_home}"
release_root="" release_root=""
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}" for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
do do
if [ ! -d "${candidate}/ptlinux" ] # Installed releases already pass an isolated import self-check before their
# activation link is changed. Keep only a cheap corruption/fallback check on
# every boot instead of starting Python twice and importing the full CLI twice.
if [ ! -r "${candidate}/ptlinux/__init__.py" ] || \
[ ! -r "${candidate}/ptlinux/__main__.py" ] || \
[ ! -r "${candidate}/ptlinux/cli.py" ] || \
[ ! -r "${candidate}/ptdiag/__init__.py" ] || \
[ ! -r "${candidate}/ptdiag/assbin.py" ] || \
[ ! -r "${candidate}/ptdiag/logs.py" ]
then then
continue continue
fi fi
if "$python_bin" -I -B -c \ release_root="${candidate}"
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \ break
"${candidate}" >/dev/null 2>&1
then
release_root="${candidate}"
break
fi
done done
if [ -z "${release_root}" ] if [ -z "${release_root}" ]
@@ -33,8 +36,9 @@ fi
# Never let an unpacked older installer in the caller's current directory # Never let an unpacked older installer in the caller's current directory
# shadow the selected installed release. Children started by the dashboard # shadow the selected installed release. Children started by the dashboard
# inherit the same safe-path policy and exact package root. # inherit the same safe-path policy, package root, and no-bytecode policy.
export PYTHONSAFEPATH=1 export PYTHONSAFEPATH=1
export PYTHONDONTWRITEBYTECODE=1
export PYTHONPATH="${release_root}" export PYTHONPATH="${release_root}"
exec "$python_bin" -B -P -m ptlinux "$@" exec "$python_bin" -B -P -m ptlinux "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
"""Run the retained Proton 7 script safely on Python 3.14.
Proton 7 predates Python's tar extraction-filter API. The known-good v41
runtime used this compatibility shim; keeping it in the harness makes the
exact launch recipe inspectable and reproducible.
"""
from __future__ import annotations
from pathlib import Path
import runpy
import sys
import tarfile
from typing import Sequence
def main(argv: Sequence[str] | None = None) -> None:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments:
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
proton = Path(arguments[0]).expanduser().resolve(strict=True)
proton_directory = str(proton.parent)
if proton_directory not in sys.path:
sys.path.insert(0, proton_directory)
if hasattr(tarfile, "fully_trusted_filter"):
tarfile.TarFile.extraction_filter = staticmethod(tarfile.fully_trusted_filter)
sys.argv = [str(proton), *arguments[1:]]
runpy.run_path(str(proton), run_name="__main__")
if __name__ == "__main__":
main()

View File

@@ -1,4 +0,0 @@
from .cli import main
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,243 +0,0 @@
"""Cross-distribution dependency and host diagnostics."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from importlib.util import find_spec
import os
from pathlib import Path
import platform
import shutil
from typing import Mapping
from .launcher import find_wine_tools
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
)
@dataclass(frozen=True)
class ToolStatus:
name: str
path: str | None
required: bool
purpose: str
@dataclass(frozen=True)
class DoctorReport:
ok: bool
distribution: str
version: str
architecture: str
display_backend: str
tools: list[ToolStatus]
package_hint: str | None
warnings: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
env = os.environ if environment is None else environment
os_release = _read_os_release()
distribution = os_release.get("ID", "unknown")
version = os_release.get("VERSION_ID", "unknown")
wine_tools = find_wine_tools()
tools = [
ToolStatus(
"python3",
shutil.which("python3"),
True,
"native installer and command-line interface",
),
ToolStatus(
"python-tk",
_python_tk_path(),
True,
"native dashboard",
),
ToolStatus(
"wine",
str(wine_tools.wine) if wine_tools else None,
True,
"legacy updater support and game compatibility utilities",
),
ToolStatus(
"winetricks",
str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"),
True,
"native D3D helper DLLs for the Proton game prefix",
),
ToolStatus(
"gamescope",
shutil.which("gamescope"),
False,
"optional manual game-container diagnostics",
),
ToolStatus(
"cabextract",
shutil.which("cabextract"),
True,
"Microsoft runtime extraction through Winetricks",
),
ToolStatus(
"openssl",
shutil.which("openssl"),
True,
"signed native-launcher update verification",
),
ToolStatus(
"umu-run",
shutil.which("umu-run"),
distribution in ARCH_FAMILY_DISTRIBUTIONS,
"current portable Proton runtime; automatic on Arch-family hosts",
),
ToolStatus(
"vulkaninfo",
shutil.which("vulkaninfo"),
False,
"Vulkan diagnostics",
),
]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
warnings.append("No graphical X11 or Wayland session is visible.")
if shutil.which("umu-run") is None and not _has_steam_proton7(env):
warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
)
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
distribution=distribution,
version=version,
architecture=platform.machine(),
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
tools=tools,
package_hint=_package_hint(distribution),
warnings=warnings,
)
def _read_os_release() -> dict[str, str]:
result: dict[str, str] = {}
try:
lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines()
except OSError:
return result
for line in lines:
if "=" not in line or line.startswith("#"):
continue
key, value = line.split("=", 1)
result[key] = value.strip().strip('"')
return result
def _python_tk_path() -> str | None:
try:
specification = find_spec("_tkinter")
except (ImportError, ValueError):
return None
if specification is None:
return None
return specification.origin or "available"
def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return (
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, "
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
)
if distribution in {"fedora", "nobara"}:
return (
"Install python3-tkinter, wine, winetricks, cabextract, openssl, "
"and the matching 32-bit Vulkan driver with DNF."
)
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return (
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return (
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper."
)
return None
def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
home_value = environment.get("HOME")
if not home_value:
return False
home = Path(home_value)
for steam_root in (
home / ".local/share/Steam",
home / ".steam/steam",
home / "snap/steam/common/.local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
):
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
return True
return False
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
if architecture.casefold() not in {"x86_64", "amd64"}:
return []
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return [
_optional_tool_status(
"lib32-vulkan",
"/usr/lib32/libvulkan.so.1",
"32-bit Vulkan runtime compatibility layer",
),
_optional_tool_status(
"lib32-gstreamer",
"/usr/lib32/gstreamer-1.0",
"32-bit gstreamer runtime compatibility modules",
),
]
return []
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
if distribution not in ARCH_FAMILY_DISTRIBUTIONS:
return []
if architecture.casefold() not in {"x86_64", "amd64"}:
return [
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
]
warnings: list[str] = []
if not _path_exists("/usr/lib32/libvulkan.so.1"):
warnings.append(
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
)
if not _path_exists("/usr/lib32/gstreamer-1.0"):
warnings.append(
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
"other media does not play. Missing media plugins are not evidence of an "
"authentication failure."
)
return warnings
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
if _path_exists(path):
return ToolStatus(name, path, required=False, purpose=purpose)
return ToolStatus(name, None, required=False, purpose=purpose)
def _path_exists(path: str) -> bool:
entry = Path(path)
return entry.is_file() or entry.is_dir()

View File

@@ -1,29 +0,0 @@
"""Native X11 command construction for the clean borderless game window."""
from __future__ import annotations
import sys
from typing import Sequence
from .settings import Settings
def build_gamebox_command(
game_command: Sequence[str],
settings: Settings,
) -> list[str]:
if not game_command:
raise ValueError("game command cannot be empty")
command = [
sys.executable,
"-m",
"ptlinux.gamebox_runtime",
"--window-title",
"PristonTale EU",
]
if settings.borderless:
command.append("--borderless")
if settings.suppress_unresponsive_warnings:
command.append("--suppress-wm-ping")
command.extend(("--", *[str(value) for value in game_command]))
return command

View File

@@ -1,438 +0,0 @@
"""Map and style the real Wine game window on the host X11 display."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import subprocess
import sys
import time
from collections.abc import Collection, Iterable, Sequence
WINDOW_TITLE = "PristonTale EU"
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
def _ignore_x11_error(_display: object, _event: object) -> int:
"""Keep a stale X11 window race from terminating the mapper process."""
return 0
def _matches_game_window_title(title: str, expected_title: str) -> bool:
"""Match the game title without accepting dialogs that merely mention it."""
candidate = title.strip().casefold()
expected = expected_title.strip().casefold()
if not candidate or not expected:
return False
return candidate == expected or candidate.startswith(f"{expected} ")
def _select_game_window(
windows: Iterable[tuple[int, str]],
expected_title: str,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
for window, title in windows:
if window in excluded_window_ids:
continue
if _matches_game_window_title(title, expected_title):
return window, title
return None
def _protocols_without(
protocols: Sequence[int],
blocked_protocol: int,
) -> tuple[int, ...]:
"""Remove only the selected atom while preserving every other protocol."""
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
class GameboxRuntimeError(RuntimeError):
"""Raised when the game command or X11 mapper cannot be started."""
class _MotifWmHints(ctypes.Structure):
_fields_ = [
("flags", ctypes.c_ulong),
("functions", ctypes.c_ulong),
("decorations", ctypes.c_ulong),
("input_mode", ctypes.c_long),
("status", ctypes.c_ulong),
]
class _X11:
def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
try:
self.library = ctypes.CDLL(library_path)
except OSError as exc:
raise GameboxRuntimeError("the X11 client library is unavailable") from exc
self._configure_signatures()
self._error_handler = _X_ERROR_HANDLER(_ignore_x11_error) # type: ignore[arg-type]
self._previous_error_handler = self.library.XSetErrorHandler(
ctypes.cast(self._error_handler, ctypes.c_void_p),
)
self.display = self.library.XOpenDisplay(None)
if not self.display:
self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display")
def close(self) -> None:
try:
if self.display:
self.library.XCloseDisplay(self.display)
self.display = None
finally:
self._restore_error_handler()
def root_window_ids(self) -> set[int]:
return set(self._root_windows())
def find_first_window(
self,
title_fragment: str,
*,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
windows: list[tuple[int, str]] = []
for window in self._root_windows():
title = self._window_title(window)
if title is not None:
windows.append((window, title))
return _select_game_window(windows, title_fragment, excluded_window_ids)
def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display)
root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)()
child_count = ctypes.c_uint()
status = self.library.XQueryTree(
self.display,
root,
ctypes.byref(root_return),
ctypes.byref(parent_return),
ctypes.byref(children),
ctypes.byref(child_count),
)
if status == 0:
return []
try:
return [int(children[index]) for index in range(child_count.value)]
finally:
if children:
self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
def map_window(self, window: int, *, borderless: bool) -> None:
if borderless:
self._remove_decorations(window)
self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display)
def suppress_wm_ping(self, window: int) -> bool:
"""Stop the WM hang prompt for one window, preserving its other protocols."""
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
if ping_atom == 0:
return False
protocols = self._wm_protocols(window)
if protocols is None:
return False
filtered_protocols = _protocols_without(protocols, ping_atom)
if filtered_protocols == protocols:
return True
if filtered_protocols:
protocol_values = (ctypes.c_ulong * len(filtered_protocols))(
*filtered_protocols
)
status = self.library.XSetWMProtocols(
self.display,
window,
protocol_values,
len(filtered_protocols),
)
else:
status = self.library.XSetWMProtocols(
self.display,
window,
ctypes.POINTER(ctypes.c_ulong)(),
0,
)
if status == 0:
return False
self.library.XSync(self.display, 0)
return self._wm_protocols(window) == filtered_protocols
def _wm_protocols(self, window: int) -> tuple[int, ...] | None:
protocols = ctypes.POINTER(ctypes.c_ulong)()
protocol_count = ctypes.c_int()
status = self.library.XGetWMProtocols(
self.display,
window,
ctypes.byref(protocols),
ctypes.byref(protocol_count),
)
if status == 0:
return None
try:
return tuple(
int(protocols[index]) for index in range(protocol_count.value)
)
finally:
if protocols:
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
if atom == 0:
return
hints = _MotifWmHints(
flags=1 << 1,
functions=0,
decorations=0,
input_mode=0,
status=0,
)
self.library.XChangeProperty(
self.display,
window,
atom,
atom,
32,
0,
ctypes.cast(ctypes.byref(hints), ctypes.POINTER(ctypes.c_ubyte)),
5,
)
def _window_title(self, window: int) -> str | None:
value = ctypes.c_char_p()
if self.library.XFetchName(self.display, window, ctypes.byref(value)) == 0:
return None
try:
if value.value is None:
return None
return value.value.decode("utf-8", errors="replace")
finally:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def _restore_error_handler(self) -> None:
if self._error_handler is None:
return
self.library.XSetErrorHandler(self._previous_error_handler)
self._error_handler = None
def _configure_signatures(self) -> None:
self.library.XSetErrorHandler.argtypes = [ctypes.c_void_p]
self.library.XSetErrorHandler.restype = ctypes.c_void_p
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.library.XOpenDisplay.restype = ctypes.c_void_p
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
ctypes.POINTER(ctypes.c_uint),
]
self.library.XQueryTree.restype = ctypes.c_int
self.library.XFetchName.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_char_p),
]
self.library.XFetchName.restype = ctypes.c_int
self.library.XInternAtom.argtypes = [
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_int,
]
self.library.XInternAtom.restype = ctypes.c_ulong
self.library.XChangeProperty.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_int,
ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int,
]
self.library.XChangeProperty.restype = ctypes.c_int
self.library.XGetWMProtocols.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
ctypes.POINTER(ctypes.c_int),
]
self.library.XGetWMProtocols.restype = ctypes.c_int
self.library.XSetWMProtocols.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.c_int,
]
self.library.XSetWMProtocols.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.library.XSync.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p]
self.library.XFlush.restype = ctypes.c_int
self.library.XFree.argtypes = [ctypes.c_void_p]
self.library.XFree.restype = ctypes.c_int
self.library.XCloseDisplay.argtypes = [ctypes.c_void_p]
self.library.XCloseDisplay.restype = ctypes.c_int
def _prepare_game_window(
mapper: _X11,
window: int,
*,
borderless: bool,
suppress_wm_ping: bool,
) -> None:
if suppress_wm_ping:
try:
ping_suppressed = mapper.suppress_wm_ping(window)
except Exception as exc:
print(
f"Warning: could not suppress game-window hang warnings ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
else:
if ping_suppressed:
print(
"Game-window hang warnings suppressed for this launch.",
file=sys.stderr,
flush=True,
)
else:
print(
"Warning: could not verify game-window hang-warning "
"suppression; continuing normally",
file=sys.stderr,
flush=True,
)
mapper.map_window(window, borderless=borderless)
def run_game_window(
command: Sequence[str],
window_title: str = WINDOW_TITLE,
*,
borderless: bool = False,
suppress_wm_ping: bool = False,
) -> int:
if not command:
raise GameboxRuntimeError("game command cannot be empty")
mapper: _X11 | None = None
existing_window_ids: set[int] = set()
try:
created_mapper = _X11()
existing_window_ids = created_mapper.root_window_ids()
mapper = created_mapper
except GameboxRuntimeError as error:
print(
f"Warning: {error}; continuing without X11 window styling",
file=sys.stderr,
flush=True,
)
process = subprocess.Popen([str(value) for value in command])
if mapper is None:
print(
f"Game window ready: unmanaged {window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
game_window: int | None = None
game_title: str | None = None
game_window_created_at: float | None = None
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
try:
while process.poll() is None and time.monotonic() < deadline:
now = time.monotonic()
if game_window is None:
found = mapper.find_first_window(
window_title,
excluded_window_ids=existing_window_ids,
)
if found is not None:
window, title = found
game_window = window
game_title = title
game_window_created_at = now
print(
f"Game window created; preparing it: {title}",
file=sys.stderr,
flush=True,
)
if (
game_window is not None
and game_window_created_at is not None
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
):
_prepare_game_window(
mapper,
game_window,
borderless=borderless,
suppress_wm_ping=suppress_wm_ping,
)
print(
f"Game window ready: {game_title or window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
time.sleep(0.1)
if process.poll() is None:
print(
f"Warning: no new game window matching {window_title!r} appeared",
file=sys.stderr,
flush=True,
)
if game_window is not None:
_prepare_game_window(
mapper,
game_window,
borderless=borderless,
suppress_wm_ping=suppress_wm_ping,
)
return int(process.wait())
finally:
mapper.close()
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--window-title", default=WINDOW_TITLE)
parser.add_argument("--borderless", action="store_true")
parser.add_argument("--suppress-wm-ping", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv)
command = arguments.command
if command and command[0] == "--":
command = command[1:]
try:
return run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
suppress_wm_ping=arguments.suppress_wm_ping,
)
except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,42 +0,0 @@
"""Cross-process locks for launcher-prefix mutations."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator
class OperationBusyError(RuntimeError):
"""Raised when another launcher-prefix operation is still active."""
@contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
handle.seek(0)
owner = handle.read().strip() or "unknown operation"
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
try:
yield
finally:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)

View File

@@ -0,0 +1,54 @@
Gamescope compatibility binary
================================
gamescope-3.16.20-ubuntu26.04-amd64 is an isolated compatibility build for
Ubuntu 26.04 amd64. The launcher uses it only when the installed system
Gamescope reports version 3.16.20 and the binary passes its embedded SHA-256
check. It never replaces /usr/games/gamescope.
Source package:
Ubuntu gamescope 3.16.20+ds-1
gamescope_3.16.20+ds.orig.tar.xz
SHA-256 a27984fdab2bbfe353efc772de214ae88cccab07c4507747ed4b7f063b2faa0e
gamescope_3.16.20+ds-1.debian.tar.xz
SHA-256 ae6e9bac39b576e7d9c15912a713796049a29756f6858364ef49bd02197cb153
Build:
ubuntu:26.04 container
apt-get build-dep gamescope
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage --build=binary --no-sign -jauto
The source changes are:
gamescope-wayland-letterbox-backing.patch
Backports upstream commit 66450376038864700f9b4721343b4d15b29c4198
("Use a backing surface for letter-boxing"). It fixes direct Wayland
output when a unity-scaled game surface has a nonzero letterbox offset.
gamescope-wayland-libdecor-input-scope.patch
Keeps libdecor titlebar and resize-border button events out of the nested
application while preserving complete button sequences that begin on the
game surface. This prevents a decoration operation from leaving the game
with a permanently held mouse button. It addresses the behavior tracked
at https://github.com/ValveSoftware/gamescope/issues/2299.
gamescope-lazy-output-images.patch
Backports upstream commit e383171ff1c0f7b5b37f05f2bb67f7c5282b087f
("rendervulkan: make the shared output images on first use"). Direct
Wayland frames no longer allocate six output-sized Vulkan images at every
intermediate host-window size.
gamescope-wayland-live-resize.patch
Skips the remaining device-idle wait and transparent fallback-image rebuild
when direct Wayland presentation has no live output images. Full-composition
frames still allocate correctly sized images on demand.
gamescope-keep-nested-windows-visible.patch
Honors PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE by rejecting the game's
login-transition iconify request. The outer desktop window retains its
normal size and visibility while host minimize and focus remain untouched.
Binary SHA-256:
3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639
Licensing and copyright notices are in gamescope-compat.copyright.

View File

@@ -0,0 +1,343 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://github.com/ValveSoftware/gamescope
Upstream-Name: gamescope
Upstream-Contact: Pierre-Loup A. Griffais <https://www.valvesoftware.com/en/contact?contact-person=Pierre-Loup%20Griffais>
Comment: Build depends on libopenvr-dev which is in contrib
Files-Excluded:
# These files were excluded in the patch 0005_add_reshade_696b14c.patch
src/reshade/.gitignore
src/reshade/.gitmodules
src/reshade/res/main_icon.ico
src/reshade/res/main_icon_small.png
src/reshade/res/shaders/imgui_ps.spv
src/reshade/res/shaders/imgui_ps_hdr.spv
src/reshade/res/shaders/imgui_vs.spv
src/reshade/tools/7za.exe
src/reshade/examples
src/reshade/setup/Pages/Resources
# reshade gitmodules
src/reshade/deps/imgui
src/reshade/deps/minhook
src/reshade/deps/stb
src/reshade/deps/gl3w
src/reshade/deps/utfcpp
src/reshade/deps/spirv
src/reshade/deps/vma
src/reshade/deps/vulkan
src/reshade/deps/fpng
src/reshade/deps/d3d12
src/reshade/deps/openxr
Files:
*
Copyright:
2013-2023, Valve Corporation
2022, NVIDIA CORPORATION
License: BSD-2-Clause
Files: thirdparty/sol/*
Copyright: 2013-2020, Rapptz, ThePhD and contributors
License: Expat
Files: scripts/00-gamescope/common/inspect.lua
Copyright: 2022, Enrique García Cota
License: Expat
Files: src/color_helpers.h
Copyright: 2021-2022 Collabora, Ltd
License: Expat
Files: src/messagey.h
Copyright: 1997-2022, Sam Lantinga <slouken@libsdl.org>
License: Zlib
Files:
protocol/color-management-v1.xml
Copyright:
2019 Sebastian Wick
2019 Erwin Burema
2020 AMD
2020-2024 Collabora, Ltd.
2024 Xaver Hugl
2022-2025 Red Hat, Inc.
License: Expat
Files:
protocol/frog-color-management-v1.xml
Copyright:
2023, Joshua Ashton for Valve Software
2023, Xaver Hugl
License: Expat
Files:
protocol/gamescope-action-binding.xml
Copyright:
2024 Valve Corporation
License: Expat
Files:
protocol/gamescope-control.xml
protocol/gamescope-private.xml
Copyright:
2023 Valve Corporation
License: Expat
Files:
protocol/gamescope-input-method.xml
Copyright:
2008-2011 Kristian Høgsberg
2010-2011 Intel Corporation
2012-2013 Collabora, Ltd.
2012, 2013 Intel Corporation
2015, 2016 Jan Arne Petersen
2017, 2018 Red Hat, Inc.
2018 Purism SPC
2021 Valve Corporation
License: Expat
Files:
protocol/gamescope-pipewire.xml
protocol/gamescope-xwayland.xml
Copyright:
2021 Valve Corporation
License: Expat
Files:
protocol/gamescope-reshade.xml
Copyright:
2024, Wayne Heaney
License: Expat
Files:
protocol/gamescope-swapchain.xml
Copyright:
2023, Joshua Ashton for Valve Software
License: Expat
Files:
protocol/wlr-layer-shell-unstable-v1.xml
Copyright:
2017, Drew DeVault
License: HPND-sell-variant or NTP~disclaimer
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
Files:
protocol/xdg-toplevel-icon-v1.xml
Copyright:
2023-2024, Matthias Klumpp
2024, David Edmundson
License: Expat
Files:
src/shaders/NVIDIAImageScaling/*
Copyright:
2022 NVIDIA CORPORATION & AFFILIATES
License: Expat
Files:
src/shaders/ffx_*.h
Copyright:
2021 Advanced Micro Devices, Inc.
License: Expat
Files:
src/gpuvis_trace_utils.h
Copyright:
Michael Sartain
License: public-domain-dual
This software is dual-licensed to the public domain and under the following
license: you are granted a perpetual, irrevocable license to copy, modify,
publish, and distribute this file as you see fit.
Files:
src/modegen.cpp
Copyright:
2005-2006 Luc Verhaegen
License: Expat
Files:
src/shaders/heatmap.h
Copyright:
Microsoft Corporation
License: Expat
Files:
src/steamcompmgr.cpp
Copyright:
2003 Keith Packard
Matthew Hawn
License: HPND-sell-variant
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
the above copyright notice appear in all copies and that both that
copyright notice and this permission notice appear in supporting
documentation, and that the name of Keith Packard not be used in
advertising or publicity pertaining to distribution of the software without
specific, written prior permission. Keith Packard makes no
representations about the suitability of this software for any purpose. It
is provided "as is" without express or implied warranty.
.
KEITH PACKARD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO
EVENT SHALL KEITH PACKARD BE LIABLE FOR ANY SPECIAL, INDIRECT OR
CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
Files:
debian/*
Copyright:
2022-2024 Stephan Lachnit <stephanlachnit@debian.org>
2023-2024 Safir Secerovic <sapphire@linux.org.ba>
2025 Ilya Orlov <ilyaorlov124@gmail.com>,
License: BSD-2-Clause
Files:
src/reshade/*
Copyright:
2014-2024 Patrick Mours. All rights reserved.
License: BSD-3-Clause
Files:
src/reshade/deps/d3d911on12/*
Copyright:
Microsoft Corporation.
License: Expat
Files:
src/reshade/deps/vulkan_layer/vk_layer_dispatch_table.h
Copyright:
2015-2023, Google Inc.
2015-2023, LunarG, Inc.
2015-2023, The Khronos Group Inc.
2015-2023, Valve Corporation
License: Apache-2.0
Files: src/reshade/source/imgui_code_editor.cpp
Copyright:
2017, BalazsJako
2018, Patrick Mours
License: Expat
License: BSD-2-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
License: Expat
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.
License: BSD-3-Clause
Copyright (c) The Regents of the University of California.
All rights reserved.
.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the University nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
License: Zlib
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
.
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
.
http://www.apache.org/licenses/LICENSE-2.0
.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
.
On Debian systems, the full text of the Apache Software License version 2 can
be found in the file `/usr/share/common-licenses/Apache-2.0'.

View File

@@ -0,0 +1,34 @@
From: PT Linux Launcher maintainers
Subject: Keep the nested game window visible during its login transition
PristonTale replaces its startup presentation while entering the login screen
and asks the nested X11 window manager to mark the game window iconic. Gamescope
3.16.20 acknowledges that request by publishing IconicState even though the
surface stays mapped. Wine then hides or resizes the client, which also changes
the outer Gamescope presentation.
When the launcher opts in through PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE, keep
the nested client in NormalState. This is scoped to the launcher environment;
Gamescope retains its upstream behavior for every other invocation. Desktop
minimize and focus operate on the outer Wayland window and remain unaffected.
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -5361,8 +5361,17 @@ static void
handle_wm_change_state(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, XClientMessageEvent *ev)
{
long state = ev->data.l[0];
+ static const bool bKeepNestedWindowsVisible = env_to_bool(
+ getenv( "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" ) );
if (state == ICCCM_ICONIC_STATE) {
+ if ( bKeepNestedWindowsVisible )
+ {
+ xwm_log.debugf("Ignoring WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id);
+ set_wm_state( ctx, w->xwayland().id, ICCCM_NORMAL_STATE );
+ return;
+ }
+
xwm_log.debugf("Faking WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id);
set_wm_state( ctx, w->xwayland().id, ICCCM_ICONIC_STATE );
} else {

View File

@@ -0,0 +1,80 @@
From: Matthew Schwartz <matthew.schwartz@linux.dev>
Subject: Lazily allocate shared Vulkan output images
Origin: upstream, commit e383171ff1c0f7b5b37f05f2bb67f7c5282b087f
Backport the upstream lazy-allocation change to Gamescope 3.16.20. Native
Wayland presentation normally sends the game's DMA-BUF directly to the host
compositor, so allocating six output-sized Vulkan images at startup and after
every host-window configure is unnecessary. Allocate those images only if a
frame actually needs full Vulkan composition.
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -3345,6 +3345,31 @@ static bool vulkan_make_output_images( VulkanOutput_t *pOutput )
return true;
}
+// Without a swapchain the shared images are ours to make, so they wait for their first use.
+static bool vulkan_defer_output_images()
+{
+ return !GetBackend()->UsesVulkanSwapchain();
+}
+
+static bool vulkan_ensure_output_images()
+{
+ if ( !vulkan_defer_output_images() )
+ return true;
+
+ VulkanOutput_t *pOutput = &g_output;
+ if ( !pOutput->outputImages.empty() && pOutput->outputImages[0] )
+ return true;
+
+ vk_log.debugf( "Making the shared output images on first use" );
+ if ( vulkan_make_output_images( pOutput ) )
+ return true;
+
+ // A half-made set would pass the check above, so the next use tries again from nothing.
+ pOutput->outputImages.clear();
+ pOutput->outputImagesPartialOverlay.clear();
+ return false;
+}
+
bool vulkan_remake_output_images()
{
VulkanOutput_t *pOutput = &g_output;
@@ -3355,6 +3380,14 @@ bool vulkan_remake_output_images()
for (auto& pScreenshotImage : pOutput->pScreenshotImages)
pScreenshotImage = nullptr;
+ if ( vulkan_defer_output_images() )
+ {
+ pOutput->outputImages.clear();
+ pOutput->outputImagesPartialOverlay.clear();
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ return true;
+ }
+
bool bRet = vulkan_make_output_images( pOutput );
assert( bRet );
return bRet;
@@ -3433,7 +3466,9 @@ bool vulkan_make_output()
return false;
}
- if ( !vulkan_make_output_images( pOutput ) )
+ if ( vulkan_defer_output_images() )
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ else if ( !vulkan_make_output_images( pOutput ) )
return false;
}
@@ -3980,6 +4015,9 @@ std::optional<uint64_t> vulkan_composite( struct FrameInfo_t *frameInfo, gamesco
g_reshadeManager.clear();
}
+ if ( !pOutputOverride && !vulkan_ensure_output_images() )
+ return std::nullopt;
+
gamescope::Rc<CVulkanTexture> compositeImage;
if ( pOutputOverride )
compositeImage = pOutputOverride;

View File

@@ -0,0 +1,21 @@
From: PT Linux Launcher maintainers
Subject: Use a backing surface for letter-boxing
Backport upstream commit 66450376038864700f9b4721343b4d15b29c4198 so
native Wayland presentation covers the complete configured output when the
game layer is unity-scaled with a non-zero letterbox offset.
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -1081,7 +1081,10 @@ namespace gamescope
bool bNeedsBacking = true;
if ( pFrameInfo->layerCount >= 1 )
{
- if ( pFrameInfo->layers[0].isScreenSize() && !pFrameInfo->layers[0].hasAlpha() )
+ if ( pFrameInfo->layers[0].isScreenSize() &&
+ close_enough( pFrameInfo->layers[0].offset.x, 0.0f ) &&
+ close_enough( pFrameInfo->layers[0].offset.y, 0.0f ) &&
+ !pFrameInfo->layers[0].hasAlpha() )
bNeedsBacking = false;
}

View File

@@ -0,0 +1,64 @@
From: PT Linux Launcher maintainers
Subject: Keep client-side decoration clicks out of nested applications
Bug: https://github.com/ValveSoftware/gamescope/issues/2299
Forwarded: https://github.com/ValveSoftware/gamescope/issues/2299
On compositors without xdg-decoration support, libdecor creates client-side
surfaces owned by the Gamescope Wayland connection. Pointer button callbacks
for those titlebar and resize surfaces consequently also reach Gamescope's
input thread. Forwarding a decoration press to the nested application can
leave its button permanently held because libdecor consumes the matching
release while performing a move, resize, or maximize operation.
Only begin nested button sequences while the pointer is over Gamescope's
actual toplevel surface. Track those presses so their releases are delivered
even if the pointer or keyboard focus changes during a game-originated drag.
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -562,6 +562,7 @@
std::atomic<std::shared_ptr<zwp_relative_pointer_v1>> m_pRelativePointer = nullptr;
std::unordered_set<uint32_t> m_uScancodesHeld;
+ std::unordered_set<uint32_t> m_uMouseButtonsHeld;
void Wayland_Registry_Global( wl_registry *pRegistry, uint32_t uName, const char *pInterface, uint32_t uVersion );
static const wl_registry_listener s_RegistryListener;
@@ -3091,13 +3092,33 @@
}
void CWaylandInputThread::Wayland_Pointer_Button( wl_pointer *pPointer, uint32_t uSerial, uint32_t uTime, uint32_t uButton, uint32_t uState )
{
- // Don't do any motion/movement stuff if we don't have kb focus
- if ( !cv_wayland_mouse_warp_without_keyboard_focus && !m_bKeyboardEntered )
- return;
+ const bool bPressed = uState == WL_POINTER_BUTTON_STATE_PRESSED;
+ const bool bWasPressed = m_uMouseButtonsHeld.contains( uButton );
+
+ if ( bPressed )
+ {
+ // libdecor's client-side decorations share our Wayland seat, but
+ // their button events must not be sent to the nested application.
+ if ( !m_bMouseEntered || bWasPressed )
+ return;
+
+ // Don't start an application click without keyboard focus.
+ if ( !cv_wayland_mouse_warp_without_keyboard_focus && !m_bKeyboardEntered )
+ return;
+ }
+ else if ( !bWasPressed )
+ {
+ return;
+ }
wlserver_lock();
- wlserver_mousebutton( uButton, uState == WL_POINTER_BUTTON_STATE_PRESSED, ++m_uFakeTimestamp );
+ wlserver_mousebutton( uButton, bPressed, ++m_uFakeTimestamp );
wlserver_unlock();
+
+ if ( bPressed )
+ m_uMouseButtonsHeld.emplace( uButton );
+ else
+ m_uMouseButtonsHeld.erase( uButton );
}
void CWaylandInputThread::Wayland_Pointer_Axis( wl_pointer *pPointer, uint32_t uTime, uint32_t uAxis, wl_fixed_t fValue )
{

View File

@@ -0,0 +1,50 @@
From: PT Linux Launcher maintainers
Subject: Avoid idle waits for direct Wayland resize configures
The lazy-output-images backport leaves no shared output images allocated while
Gamescope is presenting the game directly as Wayland planes. A host-window
configure nevertheless calls vulkan_remake_output_images(), whose unconditional
device-idle wait and output-sized blank-texture recreation stall the compositor
thread for every intermediate drag size.
Wait only when an output or screenshot image actually exists. The transparent
fallback texture is unused by the non-session Wayland backend, so retain it
across Wayland size changes; session backends preserve the existing recreation
behavior. If a frame later requires full composition, the lazy allocation path
creates correctly sized output images before rendering it.
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -3374,7 +3374,21 @@ static bool vulkan_ensure_output_images()
bool vulkan_remake_output_images()
{
VulkanOutput_t *pOutput = &g_output;
- g_device.waitIdle();
+ bool bHasLiveImages =
+ !pOutput->outputImages.empty() && pOutput->outputImages[0];
+ if ( !bHasLiveImages )
+ {
+ for ( const auto &pScreenshotImage : pOutput->pScreenshotImages )
+ {
+ if ( pScreenshotImage )
+ {
+ bHasLiveImages = true;
+ break;
+ }
+ }
+ }
+ if ( bHasLiveImages )
+ g_device.waitIdle();
pOutput->nOutImage = 0;
@@ -3386,7 +3400,8 @@ bool vulkan_remake_output_images()
{
pOutput->outputImages.clear();
pOutput->outputImagesPartialOverlay.clear();
- pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ if ( GetBackend()->IsSessionBased() || !pOutput->temporaryHackyBlankImage )
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
return true;
}

View File

@@ -1,750 +0,0 @@
"""Portable game runtime selection and Proton/UMU preparation."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import sys
from typing import Mapping, Sequence
from .command import CommandOutcome, CommandRunner, OutputLineFilter
from .gamebox import build_gamebox_command
from .paths import AppPaths
from .settings import Settings
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
DXVK_D3D9_OVERRIDE = "native,builtin"
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
WINETRICKS_CODEPAGE_PROBE = (
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
)
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
RUNTIME_DEFAULT_SYNC = "runtime-default"
CACHYOS_MARKER = "cachyos"
CURRENT_UMU_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
)
GAME_RUNTIME_CONFIGURATION_VERSION = 1
class RuntimeErrorPT(RuntimeError):
"""Raised when a supported game runtime cannot be prepared."""
@dataclass(frozen=True)
class RuntimeSelection:
kind: str
executable: str
proton_path: str | None
steam_root: str | None
def select_runtime(
settings: Settings,
environment: Mapping[str, str] | None = None,
*,
distribution_id: str | None = None,
kernel_release: str | None = None,
) -> RuntimeSelection:
env = os.environ if environment is None else environment
umu = shutil.which("umu-run", path=env.get("PATH"))
current_umu_host = prefers_current_umu_host(
distribution_id=distribution_id,
kernel_release=kernel_release,
)
auto_container = (
settings.runtime == "auto"
and bool(umu)
and not current_umu_host
)
if settings.runtime == "umu":
if umu:
return RuntimeSelection("umu", umu, None, None)
raise RuntimeErrorPT("UMU was requested but umu-run was not found")
if settings.proton_path and settings.runtime in {
"auto",
"proton7",
"proton7-container",
}:
proton = Path(settings.proton_path).expanduser().resolve()
if not proton.is_file():
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
steam_root = _steam_root_for_proton(proton)
if settings.runtime == "proton7-container":
if not umu:
raise RuntimeErrorPT(
"containerized Proton 7 was requested but umu-run was not found"
)
return RuntimeSelection(
"proton7-container",
umu,
str(proton),
str(steam_root),
)
if auto_container:
assert umu is not None
return RuntimeSelection(
"proton7-container",
umu,
str(proton),
str(steam_root),
)
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
# Current UMU avoids several rolling-distribution container/media failures and
# remains the broad Arch-family compatibility default. Direct Proton 7 is also
# known to authenticate on CachyOS, so this is a compatibility policy rather
# than an assertion that Proton 7 itself causes server-side Code 1/Code 3.
if settings.runtime == "auto" and current_umu_host and umu:
return RuntimeSelection("umu", umu, None, None)
if settings.runtime in {"auto", "proton7", "proton7-container"}:
proton_result = find_steam_proton7(env)
if proton_result is not None:
proton, steam_root = proton_result
if settings.runtime == "proton7-container" or auto_container:
if not umu:
raise RuntimeErrorPT(
"containerized Proton 7 was requested but umu-run was not found"
)
return RuntimeSelection(
"proton7-container",
umu,
str(proton),
str(steam_root),
)
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
if settings.runtime in {"proton7", "proton7-container"}:
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
if settings.runtime in {"auto", "umu"}:
if umu:
return RuntimeSelection("umu", umu, None, None)
raise RuntimeErrorPT(
"no supported game runtime found; install umu-launcher or Steam Proton 7.0"
)
raise RuntimeErrorPT(f"unsupported runtime selection: {settings.runtime}")
def find_steam_proton7(
environment: Mapping[str, str] | None = None,
) -> tuple[Path, Path] | None:
env = os.environ if environment is None else environment
home_value = env.get("HOME")
if not home_value:
return None
home = Path(home_value).expanduser()
roots: list[Path] = []
explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH")
if explicit:
roots.append(Path(explicit).expanduser())
roots.extend(
(
home / ".local/share/Steam",
home / ".steam/steam",
home / "snap/steam/common/.local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
)
)
seen: set[Path] = set()
for root in roots:
resolved = root.resolve()
if resolved in seen:
continue
seen.add(resolved)
proton = resolved / "steamapps/common/Proton 7.0/proton"
if proton.is_file():
return proton, resolved
return None
def prepare_game_runtime(
paths: AppPaths,
selection: RuntimeSelection,
runner: CommandRunner,
) -> list[CommandOutcome]:
if not runner.dry_run:
paths.create_base_directories()
results: list[CommandOutcome] = []
if selection.kind == "umu":
environment = _umu_environment(paths)
if not native_d3d_ready(paths.umu_prefix):
results.append(
runner.run(
[selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"],
env_overrides=environment,
timeout_seconds=1800.0,
)
)
elif selection.kind in {"proton7", "proton7-container"}:
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
if not runner.dry_run and _game_runtime_ready(paths, selection):
return results
if not runner.dry_run:
paths.compat_data.mkdir(parents=True, exist_ok=True)
proton = Path(selection.proton_path)
environment = _proton_environment(paths, selection)
prefix_verb = "runinprefix" if _proton_prefix_ready(paths.proton_prefix) else "run"
results.append(
runner.run(
[
sys.executable,
"-m",
"ptlinux.proton_compat",
str(proton),
prefix_verb,
"cmd",
"/c",
"exit",
],
env_overrides=environment,
timeout_seconds=600.0,
)
)
if not runner.dry_run:
_ensure_proton_game_link(paths)
results.append(
runner.run(
[
sys.executable,
"-m",
"ptlinux.proton_compat",
str(proton),
"runinprefix",
"reg.exe",
"add",
DLL_OVERRIDES_KEY,
"/v",
"d3d9",
"/t",
"REG_SZ",
"/d",
DXVK_D3D9_OVERRIDE,
"/f",
],
env_overrides=environment,
timeout_seconds=60.0,
)
)
if not native_d3d_ready(paths.proton_prefix):
wine_tools = _find_proton_wine_tools(proton)
winetricks = shutil.which("winetricks")
if wine_tools is None or not winetricks:
raise RuntimeErrorPT("Proton Winetricks helpers are unavailable")
wine, wineserver = wine_tools
compatible_winetricks = prepare_proton7_winetricks(
Path(winetricks),
paths.cache / "compat/winetricks-proton7",
dry_run=runner.dry_run,
)
results.append(
runner.run(
[
str(compatible_winetricks),
"-q",
"d3dx9_43",
"d3dcompiler_43",
],
env_overrides={
"WINEPREFIX": str(paths.proton_prefix),
"WINE": str(wine),
"WINESERVER": str(wineserver),
"WINEDEBUG": "-all",
},
timeout_seconds=1800.0,
)
)
else:
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
if not runner.dry_run:
_write_json(
paths.game_runtime_marker,
{
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
"configured_at": datetime.now().astimezone().isoformat(),
"native_d3d43": True,
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
"proton_identity": _proton_identity(selection),
"runtime": asdict(selection),
},
)
return results
def run_game(
paths: AppPaths,
settings: Settings,
selection: RuntimeSelection,
runner: CommandRunner,
*,
use_gamebox: bool = True,
timeout_seconds: float = 12 * 60 * 60,
check: bool = True,
extra_environment: Mapping[str, str] | None = None,
output_line_filter: OutputLineFilter | None = None,
) -> CommandOutcome:
if not paths.runtime_view.is_dir() and not runner.dry_run:
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
if selection.kind == "umu":
game_command = [
selection.executable,
str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
]
environment = _umu_environment(paths)
elif selection.kind == "proton7-container":
if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing")
game_command = [
selection.executable,
str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
]
environment = _umu_proton7_environment(
paths,
selection,
synchronization=resolve_proton_sync_mode(settings.proton_sync),
)
else:
if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing")
game_command = [
sys.executable,
"-m",
"ptlinux.proton_compat",
selection.proton_path,
"runinprefix",
WINDOWS_GAME_EXE,
"PTEU_REBORN",
]
environment = _proton_environment(
paths,
selection,
synchronization=resolve_proton_sync_mode(settings.proton_sync),
)
if use_gamebox:
game_command = build_gamebox_command(game_command, settings)
if extra_environment:
environment.update(extra_environment)
environment["PYTHONPATH"] = _runtime_python_path(
os.environ.get("PYTHONPATH"),
)
return runner.run(
game_command,
cwd=paths.runtime_view,
env_overrides=environment,
timeout_seconds=timeout_seconds,
check=check,
stream_output=True,
output_line_filter=output_line_filter,
)
def run_proton_prefix_command(
paths: AppPaths,
selection: RuntimeSelection,
runner: CommandRunner,
arguments: Sequence[str],
*,
check: bool = True,
timeout_seconds: float = 60.0,
) -> CommandOutcome:
"""Run a maintenance command in the pinned Proton 7 prefix."""
if selection.kind not in {"proton7", "proton7-container"}:
raise RuntimeErrorPT("the login trace requires the pinned Proton 7 runtime")
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
return runner.run(
[
sys.executable,
"-m",
"ptlinux.proton_compat",
selection.proton_path,
"runinprefix",
*arguments,
],
env_overrides=_proton_environment(paths, selection),
timeout_seconds=timeout_seconds,
check=check,
)
def run_runtime_prefix_command(
paths: AppPaths,
selection: RuntimeSelection,
runner: CommandRunner,
arguments: Sequence[str],
*,
check: bool = True,
timeout_seconds: float = 60.0,
) -> CommandOutcome:
"""Run a credential-free helper inside the selected game's Wine prefix."""
if not arguments:
raise ValueError("runtime-prefix command arguments cannot be empty")
if selection.kind == "umu":
command = [selection.executable, *arguments]
environment = _umu_environment(paths)
elif selection.kind == "proton7-container":
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
command = [selection.executable, *arguments]
environment = _umu_proton7_environment(paths, selection)
elif selection.kind == "proton7":
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
command = [
sys.executable,
"-m",
"ptlinux.proton_compat",
selection.proton_path,
"runinprefix",
*arguments,
]
environment = _proton_environment(paths, selection)
else:
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
return runner.run(
command,
env_overrides=environment,
timeout_seconds=timeout_seconds,
check=check,
)
def _runtime_python_path(existing: str | None) -> str:
"""Make the installed package importable after changing into runtime_view."""
package_root = str(Path(__file__).resolve().parent.parent)
if not existing:
return package_root
return os.pathsep.join((package_root, existing))
def _umu_environment(paths: AppPaths) -> dict[str, str]:
return {
"WINEPREFIX": str(paths.umu_prefix),
"GAMEID": "umu-pristontale-eu",
"STORE": "none",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
"WINEDEBUG": "-all",
}
def _umu_proton7_environment(
paths: AppPaths,
selection: RuntimeSelection,
*,
synchronization: str = RUNTIME_DEFAULT_SYNC,
) -> dict[str, str]:
"""Run the retained Proton 7 build inside its Steam Runtime container."""
if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing")
environment = {
# UMU expects the Proton compatibility-data root and manages its pfx child.
# Pointing it at proton_prefix would create a nested pfx -> . compatibility
# symlink; compat_data preserves the exact prefix prepared by Proton 7.
"WINEPREFIX": str(paths.compat_data),
"GAMEID": "umu-pristontale-eu",
"STORE": "none",
"PROTONPATH": str(Path(selection.proton_path).parent),
"PROTON_VERB": "waitforexitandrun",
"PROTONFIXES_DISABLE": "1",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
"WINEDEBUG": "-all",
}
environment.update(_proton_sync_environment(synchronization))
return environment
def _proton_environment(
paths: AppPaths,
selection: RuntimeSelection,
*,
synchronization: str = RUNTIME_DEFAULT_SYNC,
) -> dict[str, str]:
if not selection.steam_root:
raise RuntimeErrorPT("Steam root is missing for Proton")
environment = {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
"SteamAppId": "0",
"SteamGameId": "0",
"WINEPREFIX": str(paths.proton_prefix),
"WINEDEBUG": "-all",
"PROTON_LOG": "0",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
}
environment.update(_proton_sync_environment(synchronization))
return environment
def detect_distribution_id() -> str:
"""Return the freedesktop distribution ID without invoking distro tools."""
try:
value = platform.freedesktop_os_release().get("ID", "")
except OSError:
return ""
return value.strip().casefold()
def is_cachyos_host(
*,
distribution_id: str | None = None,
kernel_release: str | None = None,
) -> bool:
"""Identify CachyOS narrowly for runtime compatibility policy."""
distribution = detect_distribution_id() if distribution_id is None else distribution_id
kernel = platform.release() if kernel_release is None else kernel_release
return (
CACHYOS_MARKER in distribution.casefold()
or CACHYOS_MARKER in kernel.casefold()
)
def prefers_current_umu_host(
*,
distribution_id: str | None = None,
kernel_release: str | None = None,
) -> bool:
"""Use current UMU automatically on rolling Arch-family hosts."""
distribution = detect_distribution_id() if distribution_id is None else distribution_id
return (
distribution.strip().casefold() in CURRENT_UMU_DISTRIBUTIONS
or is_cachyos_host(
distribution_id=distribution,
kernel_release=kernel_release,
)
)
def resolve_proton_sync_mode(
configured: str,
*,
distribution_id: str | None = None,
kernel_release: str | None = None,
) -> str:
"""Resolve the configured mode, applying the narrow CachyOS compatibility fallback."""
if configured not in {"auto", "fsync", "esync"}:
raise ValueError(f"unsupported Proton synchronization mode: {configured}")
if configured != "auto":
return configured
if is_cachyos_host(
distribution_id=distribution_id,
kernel_release=kernel_release,
):
return "esync"
return RUNTIME_DEFAULT_SYNC
def _proton_sync_environment(synchronization: str) -> dict[str, str]:
if synchronization == RUNTIME_DEFAULT_SYNC:
return {}
if synchronization == "esync":
return {
"PROTON_NO_FSYNC": "1",
"WINEFSYNC": "0",
"PROTON_NO_ESYNC": "0",
"WINEESYNC": "1",
}
if synchronization == "fsync":
return {
"PROTON_NO_FSYNC": "0",
"WINEFSYNC": "1",
"PROTON_NO_ESYNC": "0",
"WINEESYNC": "1",
}
raise ValueError(f"unsupported resolved Proton synchronization mode: {synchronization}")
def _ensure_proton_game_link(paths: AppPaths) -> None:
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
link.parent.mkdir(parents=True, exist_ok=True)
if link.is_symlink():
if link.resolve() == paths.runtime_view.resolve():
return
raise RuntimeErrorPT(f"Proton game link points somewhere unexpected: {link}")
if link.exists():
if link.is_dir() and not any(link.iterdir()):
link.rmdir()
else:
raise RuntimeErrorPT(f"refusing to replace existing Proton game path: {link}")
link.symlink_to(paths.runtime_view, target_is_directory=True)
def _steam_root_for_proton(proton: Path) -> Path:
parts = proton.parts
try:
index = parts.index("steamapps")
except ValueError as exc:
raise RuntimeErrorPT(
"configured Proton path must be inside a Steam steamapps directory"
) from exc
return Path(*parts[:index])
def native_d3d_ready(prefix: Path) -> bool:
directory = prefix / "drive_c/windows/syswow64"
if not all((directory / f"{name}.dll").is_file() for name in NATIVE_D3D43_DLLS):
return False
try:
registry = (prefix / "user.reg").read_text(
encoding="utf-8",
errors="replace",
).casefold()
except OSError:
return False
return all(_native_override_ready(registry, name) for name in NATIVE_D3D43_DLLS)
def _native_override_ready(registry: str, name: str) -> bool:
return any(
entry in registry
for entry in (
f'"{name}"="native,builtin"',
f'"{name}"="native"',
f'"*{name}"="native"',
)
)
def prepare_proton7_winetricks(
source: Path,
destination: Path,
*,
dry_run: bool = False,
) -> Path:
try:
content = source.read_bytes()
except OSError as exc:
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
probe = WINETRICKS_CODEPAGE_PROBE.encode("utf-8")
replacement = WINETRICKS_PROTON7_PROBE.encode("utf-8")
if probe not in content:
return source
if dry_run:
return destination
destination.parent.mkdir(parents=True, exist_ok=True)
patched = content.replace(
probe,
replacement,
1,
)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
temporary.write_bytes(patched)
temporary.chmod(source.stat().st_mode & 0o777)
temporary.replace(destination)
return destination
def _proton_prefix_ready(prefix: Path) -> bool:
return (prefix / "user.reg").is_file() and (
prefix / "drive_c/windows/system32/cmd.exe"
).exists()
def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
marker = _read_json(paths.game_runtime_marker)
identity = _proton_identity(selection)
if marker is None or identity is None:
return False
if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION:
return False
if marker.get("runtime") != asdict(selection):
return False
if marker.get("proton_identity") != identity:
return False
if marker.get("native_d3d43") is not True:
return False
if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE:
return False
if not _proton_prefix_ready(paths.proton_prefix):
return False
if not native_d3d_ready(paths.proton_prefix):
return False
if not _d3d9_override_ready(paths.proton_prefix):
return False
return _proton_game_link_ready(paths)
def _d3d9_override_ready(prefix: Path) -> bool:
try:
registry = (prefix / "user.reg").read_text(
encoding="utf-8",
errors="replace",
).casefold()
except OSError:
return False
return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry
def _proton_game_link_ready(paths: AppPaths) -> bool:
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
if not link.is_symlink():
return False
try:
return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True)
except (OSError, RuntimeError):
return False
def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None:
if not selection.proton_path:
return None
proton = Path(selection.proton_path)
identity_paths = {
"proton": proton,
"version": proton.parent / "version",
"toolmanifest": proton.parent / "toolmanifest.vdf",
}
identity: dict[str, str] = {}
for name, path in identity_paths.items():
try:
identity[name] = _sha256(path)
except OSError:
return None
return identity
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
wine = directory / "wine"
wineserver = directory / "wineserver"
if wine.is_file() and wineserver.is_file():
return wine, wineserver
return None
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def _read_json(path: Path) -> dict[str, object] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "PTEU-Linux-Launcher" name = "PTEU-Linux-Launcher"
version = "0.6.5" version = "1.0.8"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []
@@ -8,3 +12,27 @@ dependencies = []
[project.scripts] [project.scripts]
ptdiag = "ptdiag.cli:main" ptdiag = "ptdiag.cli:main"
pristontale-eu = "ptlinux.cli:main" pristontale-eu = "ptlinux.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
include-package-data = true
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
ptlinux = ["launcher-update-public.pem", "native/*"]
[tool.ruff]
target-version = "py311"
line-length = 100
extend-exclude = [
".artifacts",
".venv",
"candidates",
"dist",
"results",
]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]

216
release/README.md Normal file
View File

@@ -0,0 +1,216 @@
# Manual Gitea launcher releases
Manual publication mode is currently active. Build a complete local package with:
```text
./scripts/release.sh stable 1.8.0
./scripts/release.sh prerelease 1.9.0-beta.1
```
The command creates `dist/v<VERSION>/`, validates the channel/version pair,
builds and previews release notes, runs the configured preflight, creates a
deterministic archive, signs the manifest, and verifies the complete result. It
changes the requested version only inside the staged package, so the source
checkout is unchanged. It does not commit, tag, push, create a Gitea release,
or upload anything.
The default archive layout is flat: extracting the tar directly produces
`ptlinux/`, `ptdiag/`, and `release.json`. Versions `0.9.10` and `0.9.11` form a
short compatibility window and use the legacy enclosing directory. That lets
deployed `0.9.8` launchers install either release automatically, even if they
missed `0.9.10`. The updater inside both releases accepts both layouts, so
releases after `0.9.11` return to the flat layout.
Use `--description` for short Markdown or `--description-file` for a prepared
Markdown document. Generated commit notes are appended by default. Run
`./scripts/release.sh --help` for dry-run, editor, changelog-only, signing-key,
and test options.
After a successful build, create a new release in Gitea with tag `v<VERSION>`.
Leave **Pre-release** clear for `stable`; select it for `prerelease`. Use
`dist/v<VERSION>/release-notes.md` as the description and upload exactly these
five files from that version directory:
- `PristonTale-EU-<VERSION>.tar.gz`
- `PristonTale-EU-<VERSION>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
Uploading a release does not by itself move the launcher's channel pointer.
Upload and save all five assets before publishing the channel pointer. The
signed archive URL includes its SHA-256 as a query parameter so Cloudflare and
other CDNs cannot reuse an empty response cached before the attachment existed.
After the Gitea release exists and each attachment has a nonzero size, publish
the matching files generated under
`dist/v<VERSION>/channel/` to the repository:
```text
dist/v<VERSION>/channel/stable.json -> updates/stable.json
dist/v<VERSION>/channel/stable.json.sig -> updates/stable.json.sig
dist/v<VERSION>/channel/prerelease.json -> updates/prerelease.json
dist/v<VERSION>/channel/prerelease.json.sig -> updates/prerelease.json.sig
```
Commit and push only the selected channel pair. Publishing a prerelease must not
change `updates/stable.json`.
Manual mode does not rebuild `updates/history.json`. The uploaded version can be
discovered through its channel pointer, but it will not appear as a remote
rollback candidate until signed history publication is run manually or the
automatic publisher is re-enabled.
The published `0.9.9` release is flat and therefore correctly asks deployed
`0.9.8` launchers for a manual update. Releases `0.9.10` and `0.9.11` use the
wrapped bridge layout and minimum updater `0.5.0`, so those launchers can install
either one automatically. Flat manifests after this compatibility window declare
`0.9.9` as their minimum updater version.
For direct artifact tooling without release notes or manual-upload guidance:
```text
PTLINUX_PYTHON=/path/to/python \
./scripts/build-release.sh 0.9.11 stable ./dist/v0.9.11
./scripts/verify-release.sh 0.9.11 stable ./dist/v0.9.11
```
The builder selects the runtime payload declared in `config.json`; it does not
archive the repository. It emits a deterministic versioned archive, checksum,
and external per-file manifest. When `PTLINUX_SIGNING_KEY` is set, it also
emits and immediately verifies `manifest.json.sig`.
`repository_url` is the public HTTPS base used in signed download links.
`allowed_git_remotes` is retained for the deferred automatic release validation;
it is not consulted by the current local packaging command.
## Deferred automatic publication
The automatic publisher and status publisher remain in the repository for a
later migration, but their push triggers are disabled. Both workflows are
manual-dispatch only, so creating a tag or Gitea release cannot race the manual
upload process. The remainder of this section applies only when automatic
publication is re-enabled.
## Repository and runner setup
Enable Gitea Actions for the instance and repository, then register an isolated
Docker-backed `act_runner` for trusted repository workflows. The runner image
must provide Git, Bash, curl, jq, Python 3.11 or newer, tar, gzip, coreutils
(`base64`, `realpath`, `sha256sum`, and `stat`), and OpenSSL. The release job
uses `actions/checkout@v4` and checks that the checked-out commit is the exact
commit referenced by the annotated tag.
Configure these protected Actions secrets:
- `RELEASE_TOKEN`: a dedicated token with only the repository release and
repository-content write permissions needed to create releases, upload
attachments, and update signed channel metadata.
- `MANIFEST_SIGNING_KEY`: the private key corresponding to
`ptlinux/launcher-update-public.pem`.
Secrets are available only to trusted tag and release-status workflows. Do not
add the release secrets to pull-request workflows. The signing key is written under
`RUNNER_TEMP` with mode `0600`, used for signing, and removed by an `always()`
cleanup step. Shell tracing is intentionally disabled around all secret-bearing
commands.
The current installed population verifies the deployed RSA/SHA-256 public key.
Release tooling can use OpenSSL Ed25519 signing, but changing the production
key or algorithm requires a staged key-rotation release first. Until that
migration is deployed, configure the matching existing RSA private key.
## Automatic publication behavior
The workflow validates the tag in CI, runs tests, builds and verifies every
artifact, creates a non-draft Gitea release with the derived prerelease flag,
and uploads these immutable assets separately:
- `PristonTale-EU-<version>.tar.gz`
- `PristonTale-EU-<version>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
It re-queries the release and checks every attachment name and size before
updating metadata. A stable tag updates only `updates/stable.json` and its
signature. A beta or RC tag updates only `updates/prerelease.json` and its
signature. Both rebuild `updates/history.json` and its signature from real
Gitea releases, following API pagination so older rollback entries are retained.
The four metadata files are committed atomically through the Gitea contents API.
Existing release detection is fail-closed. Never delete, move, or reuse a tag
after publication. Fix a failed release with a new patch, beta, or RC number.
## Withdrawing or blocking a release
Edit `release/statuses.json` on `main` and set the immutable tag to
`withdrawn` or `blocked`, for example:
```json
{
"releases": {
"v1.9.0-beta.2": "withdrawn"
},
"schema": 1
}
```
The trusted `release-status.yml` workflow rebuilds and signs `updates/history.json`
from real Gitea releases. It also rebuilds both channel manifests: each channel
points to its greatest active release, or carries an authenticated inactive
status when that channel has no active release. The workflow verifies every
selected release manifest before signing and atomically publishing the channel
and history files. This prevents a withdrawn channel head from continuing to be
offered by launchers that read `updates/stable.json` or
`updates/prerelease.json` directly.
The release assets remain available for support, while new automatic update and
rollback selection excludes the inactive release. Deleting the tag's override
returns it to `active`. A status entry can also declare compatibility when a
future release changes the persistent settings schema:
```json
{
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 2,
"minimum_config_schema": 1
}
},
"schema": 1
}
```
The workflow can be run manually after a runner or API outage.
## Launcher history and rollback
The launcher fetches and verifies `updates/history.json` and its detached
signature for the version-history window. An explicit historical rollback uses
that release's immutable `manifest.json`, `manifest.json.sig`, and versioned
archive:
```text
pristontale-eu launcher-update --rollback 1.8.0
```
Running `--rollback` without a version keeps the original fast path to the
locally retained previous release. Every remote target is signature-, size-,
archive-hash-, and per-file-hash checked before it enters the immutable release
store. The launcher also persists that authenticated file inventory and verifies
it again before reactivating a locally retained release. Releases installed by an
older launcher remain usable through the compatibility path when no persisted
inventory exists. Complete release directories make reconciliation exact: files
managed by the current release but absent from the target disappear when
`current` changes, while settings, logs, saves, and downloads remain outside the
release tree.
Activation records the original `current` and `previous` links in a durable
transaction journal. Link or post-activation validation failure restores those
links, and the next launcher run recovers an interrupted transaction before any
new mutation. The local operation history retains the latest 30 update and
rollback results.
See `SIGNING_MIGRATION.md` before rotating the deployed manifest key.

View File

@@ -0,0 +1,35 @@
# Manifest signing migration
The installed launcher population currently pins the 3072-bit RSA public key in
`ptlinux/launcher-update-public.pem`. Release scripts also support OpenSSL
Ed25519 keys, but changing the Gitea secret before clients receive the new
public key would make all metadata unverifiable.
Use this staged migration when Ed25519 is adopted:
1. Generate the Ed25519 key pair offline. Keep the private key out of the
repository and record the public-key fingerprint through the normal release
review process.
2. Publish a normal RSA-signed transition release. That immutable archive adds
the Ed25519 public key and updater support, while its channel manifest still
verifies with the RSA key already installed on clients.
3. Verify the transition release from a clean pre-transition installation on
both stable and prerelease channel paths. Confirm rollback still verifies old
RSA-signed historical manifests.
4. Keep `manifest.json.sig` RSA-signed while enough installations move to the
transition release. If dual signatures are introduced, upload the Ed25519
signature under a separate filename; never change the meaning of the legacy
`.sig` asset for old clients.
5. Publish a second RSA-signed release that makes the updater choose signatures
by an authenticated algorithm and key identifier, and bundles a keyring with
both public keys. Its minimum updater compatibility must prevent an older
verifier from trusting metadata it cannot authenticate.
6. Replace the protected Actions signing secret only after the keyring release
is deployed and measured. Exercise stable discovery, prerelease discovery,
remote rollback, withdrawal publication, and tamper rejection with Ed25519.
7. Retire RSA verification in a later release. Retain the old public key for
immutable historical rollback metadata until those releases are outside the
supported rollback window.
At every stage, a failed or incorrectly signed tag remains immutable. Correct
the pipeline and publish the next semantic version rather than replacing assets.

140
release/config.json Normal file
View File

@@ -0,0 +1,140 @@
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"wrapped_bridge_versions": [
"0.9.10",
"0.9.11"
],
"channels": [
"stable",
"prerelease"
],
"manifest_version": 1,
"config_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.9.9",
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux",
"allowed_git_remotes": [
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git"
],
"version_mirrors": [
"pyproject.toml"
],
"version_source": "ptlinux/__init__.py",
"payload": {
"python_trees": [
{
"exclude": [
"desktop_entry.py",
"launcher_wrapper.py"
],
"source": "ptlinux"
}
],
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem"
},
{
"mode": "0644",
"path": "ptdiag/__init__.py"
},
{
"mode": "0644",
"path": "ptdiag/assbin.py"
},
{
"mode": "0644",
"path": "ptdiag/logs.py"
},
{
"mode": "0644",
"path": "ptlinux/native/pt-identity-probe.exe"
},
{
"mode": "0755",
"path": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.README"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.copyright"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-lazy-output-images.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-live-resize.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-letterbox-backing.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-keep-nested-windows-visible.patch"
}
]
},
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/__main__.py",
"ptlinux/cli.py",
"ptlinux/self_update.py",
"ptlinux/update_resolver.py",
"ptlinux/launcher-update-public.pem"
],
"exclusions": [
".git",
".gitea",
".idea",
".run",
".venv",
".vscode",
"dist",
"logs",
"results",
"test-output",
"tests",
"testcases",
"tools",
"__pycache__",
"*.pyc",
"*.pyo",
".env",
".env.*",
"*.pem",
"*.key",
"id_rsa",
"id_ed25519",
"*.part",
"*.tmp",
"downloads"
],
"allowed_pem_files": [
"ptlinux/launcher-update-public.pem"
],
"preflight": [
"\"$PTLINUX_PYTHON\" -m compileall -q ptlinux ptdiag scripts release_tests",
"\"$PTLINUX_PYTHON\" -m unittest discover -s release_tests -p 'test_*.py'",
"\"$PTLINUX_PYTHON\" -m unittest discover -s tests -p 'test_*.py'"
]
}

4
release/statuses.json Normal file
View File

@@ -0,0 +1,4 @@
{
"releases": {},
"schema": 1
}

View File

@@ -0,0 +1 @@
"""Tracked preflight tests for the automated release system."""

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from unittest.mock import patch
import unittest
from ptlinux.self_update import (
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL,
DEFAULT_STABLE_UPDATE_MANIFEST_URL,
UpdateManifest,
check_for_launcher_update,
configured_manifest_urls,
)
def manifest(version: str, channel: str, *, status: str = "active") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T00:00:00Z",
notes_url=f"https://example.test/releases/v{version}",
tag=f"v{version}",
status=status,
)
class ChannelDiscoveryTests(unittest.TestCase):
def test_stable_fetches_only_stable_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="stable", environment={}),
(("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),),
)
def test_prerelease_fetches_stable_and_optional_prerelease_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="prerelease", environment={}),
(
("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),
("prerelease", DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, True),
),
)
def test_stable_client_ignores_a_newer_prerelease(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
) as fetch:
check = check_for_launcher_update(
current_version="1.7.4",
channel="stable",
)
self.assertEqual(fetch.call_count, 1)
self.assertEqual(check.latest_version, "1.8.0")
self.assertEqual(check.manifest.channel, "stable")
self.assertTrue(check.available)
def test_prerelease_client_chooses_the_highest_eligible_release(self) -> None:
def fetch(url: str, *_args: object, **_kwargs: object) -> UpdateManifest:
if url == DEFAULT_STABLE_UPDATE_MANIFEST_URL:
return manifest("1.8.0", "stable")
return manifest("1.9.0-beta.2", "prerelease")
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
side_effect=fetch,
):
check = check_for_launcher_update(
current_version="1.9.0-beta.1",
channel="prerelease",
)
self.assertEqual(check.latest_version, "1.9.0-beta.2")
self.assertEqual(check.manifest.channel, "prerelease")
self.assertTrue(check.available)
def test_returning_to_stable_never_offers_an_older_stable_release(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
):
check = check_for_launcher_update(
current_version="1.9.0-beta.2",
channel="stable",
)
self.assertFalse(check.available)
self.assertIn("will not downgrade", check.reason)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import unittest
from scripts.prepare_channel_metadata import prepare_channel_metadata
from scripts.release_lib import ReleaseToolError
def manifest(version: str, channel: str) -> bytes:
value = {
"application": "PristonTale-EU",
"archive": {
"name": f"PristonTale-EU-{version}.tar.gz",
"sha256": "a" * 64,
"size": 123,
"url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
"channel": channel,
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": f"https://gitea.example.test/releases/tag/v{version}",
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
}
return (json.dumps(value, sort_keys=True) + "\n").encode()
def record(version: str, channel: str, status: str) -> dict[str, object]:
return {
"channel": channel,
"compatibility": {
"config_schema": 1,
"minimum_config_schema": 1,
},
"manifest_url": f"https://gitea.example.test/releases/download/v{version}/manifest.json",
"status": status,
"tag": f"v{version}",
"version": version,
}
class ChannelMetadataTests(unittest.TestCase):
def test_selects_latest_active_release_instead_of_withdrawn_head(self) -> None:
history = {
"schema": 1,
"releases": [
record("1.9.0", "stable", "withdrawn"),
record("1.8.0", "stable", "active"),
],
}
selected, prepared = prepare_channel_metadata(
history,
"stable",
manifest("1.8.0", "stable"),
)
self.assertEqual(selected["version"], "1.8.0")
self.assertEqual(prepared["status"], "active")
def test_marks_channel_inactive_when_no_active_release_exists(self) -> None:
history = {
"schema": 1,
"releases": [record("1.9.0-beta.1", "prerelease", "blocked")],
}
selected, prepared = prepare_channel_metadata(
history,
"prerelease",
manifest("1.9.0-beta.1", "prerelease"),
)
self.assertEqual(selected["version"], "1.9.0-beta.1")
self.assertEqual(prepared["status"], "blocked")
def test_rejects_a_manifest_that_does_not_match_selected_history(self) -> None:
history = {
"schema": 1,
"releases": [record("1.8.0", "stable", "active")],
}
with self.assertRaisesRegex(ReleaseToolError, "does not match"):
prepare_channel_metadata(
history,
"stable",
manifest("1.7.4", "stable"),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from ptlinux.paths import AppPaths
from ptlinux.settings import Settings, load_settings, save_settings
class ChannelSettingsTests(unittest.TestCase):
def test_existing_users_default_to_stable(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"realm": "Babel"}\n', encoding="utf-8")
self.assertEqual(load_settings(paths).update_channel, "stable")
def test_prerelease_preference_round_trips(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
save_settings(paths, Settings(update_channel="prerelease"))
self.assertEqual(load_settings(paths).update_channel, "prerelease")
def test_unknown_channel_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"update_channel": "nightly"}\n', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "update channel"):
load_settings(paths)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,643 @@
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from scripts.release_lib import verify_signature
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MOCK_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
FORM=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--form)
FORM="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
release_json()
{
local assets
assets="$(jq -s '.' "${MOCK_STATE_DIR}"/asset-*.json)"
jq -n \
--arg html_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" \
--arg published_at "2026-09-08T12:00:00Z" \
--arg tag "$RELEASE_TAG" \
--arg name "PristonTale EU Launcher ${MOCK_VERSION}" \
--arg target_commitish "$RELEASE_COMMIT" \
--argjson assets "$assets" \
--argjson prerelease "$RELEASE_PRERELEASE" \
'{id:71,tag_name:$tag,name:$name,draft:false,prerelease:$prerelease,target_commitish:$target_commitish,published_at:$published_at,created_at:$published_at,html_url:$html_url,assets:$assets}'
}
case "${METHOD} ${URL}" in
"GET "*"/releases/tags/"*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "existing" ]]
then
printf 'curl: (28) simulated release lookup timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_EXISTING_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release lookup failure"}' > "$OUTPUT"
printf '%s' "$MOCK_EXISTING_STATUS"
exit 0
fi
if [[ -f "${MOCK_STATE_DIR}/created" ]]
then
release_json > "$OUTPUT"
printf '200'
else
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
fi
;;
"POST "*"/releases")
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "create" ]]
then
printf 'curl: (28) simulated release creation timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_CREATE_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release creation failure"}' > "$OUTPUT"
printf '%s' "$MOCK_CREATE_STATUS"
exit 0
fi
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/release-payload.json"
: > "${MOCK_STATE_DIR}/created"
printf '%s\n' '{"id":71}' > "$OUTPUT"
printf '201'
;;
"POST "*"/assets?name="*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "asset" ]]
then
printf 'curl: (28) simulated attachment timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_ASSET_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated attachment failure"}' > "$OUTPUT"
printf '%s' "$MOCK_ASSET_STATUS"
exit 0
fi
name="${URL##*?name=}"
file="${FORM#attachment=@}"
file="${file%%;*}"
size="$(stat -c '%s' -- "$file")"
jq -n \
--arg name "$name" \
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${name}" \
--argjson size "$size" \
'{name:$name,size:$size,browser_download_url:$url}' > "$OUTPUT"
cp -- "$OUTPUT" "${MOCK_STATE_DIR}/asset-${name}.json"
printf '201'
;;
"GET "*"/releases?limit=100&page=1")
if [[ "${MOCK_RELEASE_PAGINATION:-}" == "1" ]]
then
jq -n '[range(0;100) as $index | {draft:true,tag_name:("ignored-" + ($index|tostring))}]' > "$OUTPUT"
else
release_json | jq -s '.' > "$OUTPUT"
fi
printf '200'
;;
"GET "*"/releases?limit=100&page=2")
release_json | jq -s '.' > "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/channel-payload.json"
printf '%s\n' '{"commit":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected mock curl request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
MOCK_HISTORY_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
case "${METHOD} ${URL}" in
"GET "*"/releases?limit=100&page=1")
cp -- "$MOCK_RELEASES" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json.sig")
cp -- "$MOCK_MANIFEST_SIGNATURE" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json")
cp -- "$MOCK_MANIFEST" "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "$MOCK_HISTORY_PAYLOAD"
printf '%s\n' '{"commit":{"sha":"cccccccccccccccccccccccccccccccccccccccc"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected history mock request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
def _publisher_manifest(version: str = "1.8.0") -> bytes:
archive_name = f"PristonTale-EU-{version}.tar.gz"
archive_url = (
f"https://gitea.example.test/owner/project/releases/download/"
f"v{version}/{archive_name}"
)
return (
json.dumps(
{
"application": "PristonTale-EU",
"archive": {
"name": archive_name,
"sha256": "a" * 64,
"size": 123,
"url": archive_url,
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": archive_url,
"channel": "stable",
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": (
f"https://gitea.example.test/owner/project/releases/tag/v{version}"
),
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
},
indent=2,
sort_keys=True,
)
+ "\n"
).encode()
class GiteaPublisherTests(unittest.TestCase):
def test_publishes_assets_and_updates_only_the_selected_channel(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
(output / "manifest.json").write_text('{"schema":1}\n', encoding="utf-8")
(output / "manifest.json.sig").write_bytes(b"manifest signature")
(output / "release-notes.md").write_text("Release notes.\n", encoding="utf-8")
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_STATE_DIR": str(state),
"MOCK_RELEASE_PAGINATION": "1",
"MOCK_VERSION": "0.9.8",
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
release_payload = json.loads(
(state / "release-payload.json").read_text(encoding="utf-8")
)
self.assertFalse(release_payload["draft"])
self.assertFalse(release_payload["prerelease"])
self.assertEqual(release_payload["tag_name"], "v0.9.8")
self.assertEqual(release_payload["body"], "Release notes.\n")
self.assertEqual(len(list(state.glob("asset-*.json"))), 5)
channel_payload = json.loads(
(state / "channel-payload.json").read_text(encoding="utf-8")
)
paths = {operation["path"] for operation in channel_payload["files"]}
self.assertIn("updates/stable.json", paths)
self.assertIn("updates/stable.json.sig", paths)
self.assertNotIn("updates/prerelease.json", paths)
self.assertIn("updates/history.json", paths)
self.assertIn("Status | Published", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
second = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(second.returncode, 0)
self.assertIn("already exists", second.stderr)
def test_status_publication_rebuilds_signed_channel_metadata(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake_bin = root / "bin"
fake_bin.mkdir()
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
manifest = root / "manifest.json"
manifest.write_bytes(_publisher_manifest())
manifest_signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-sign",
str(private_key),
"-out",
str(manifest_signature),
str(manifest),
],
check=True,
capture_output=True,
)
releases = root / "releases.json"
releases.write_text(
json.dumps(
[
{
"assets": [
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json"
),
"name": "manifest.json",
},
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json.sig"
),
"name": "manifest.json.sig",
},
],
"draft": False,
"html_url": (
"https://gitea.example.test/owner/project/releases/tag/v1.8.0"
),
"id": 71,
"name": "PristonTale EU Launcher 1.8.0",
"prerelease": False,
"published_at": "2026-09-08T12:00:00Z",
"tag_name": "v1.8.0",
}
]
),
encoding="utf-8",
)
statuses = root / "statuses.json"
statuses.write_text(
(
'{"releases":{"v1.8.0":{"config_schema":2,'
'"minimum_config_schema":1,"status":"withdrawn"}},"schema":1}\n'
),
encoding="utf-8",
)
mock_curl = fake_bin / "curl"
mock_curl.write_text(MOCK_HISTORY_CURL, encoding="utf-8")
mock_curl.chmod(0o755)
payload = root / "history-payload.json"
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_HISTORY_PAYLOAD": str(payload),
"MOCK_MANIFEST": str(manifest),
"MOCK_MANIFEST_SIGNATURE": str(manifest_signature),
"MOCK_RELEASES": str(releases),
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_STATUS_FILE": str(statuses),
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[str(PROJECT_ROOT / "scripts/publish-release-history.sh"), str(root / "dist")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
published = json.loads(payload.read_text(encoding="utf-8"))
files = {item["path"]: item for item in published["files"]}
self.assertEqual(
set(files),
{
"updates/history.json",
"updates/history.json.sig",
"updates/stable.json",
"updates/stable.json.sig",
},
)
stable = json.loads(base64.b64decode(files["updates/stable.json"]["content"]))
history = json.loads(base64.b64decode(files["updates/history.json"]["content"]))
self.assertEqual(stable["status"], "withdrawn")
self.assertEqual(history["releases"][0]["status"], "withdrawn")
self.assertEqual(
history["releases"][0]["compatibility"],
{"config_schema": 2, "minimum_config_schema": 1},
)
self.assertEqual(
stable["compatibility"]["config_schema"],
2,
)
published_stable = root / "published-stable.json"
published_stable_signature = root / "published-stable.json.sig"
published_stable.write_bytes(
base64.b64decode(files["updates/stable.json"]["content"])
)
published_stable_signature.write_bytes(
base64.b64decode(files["updates/stable.json.sig"]["content"])
)
verify_signature(published_stable, published_stable_signature, public_key)
self.assertIn("stable channel: `1.8.0`", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
def test_api_failures_never_report_publication_success(self) -> None:
if shutil.which("jq") is None:
self.skipTest("jq is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
for name in ("manifest.json", "manifest.json.sig", "release-notes.md"):
(output / name).write_text("fixture\n", encoding="utf-8")
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
base_environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"MOCK_STATE_DIR": str(state),
"MOCK_VERSION": "0.9.8",
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
command = [
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
]
cases = [
("MOCK_EXISTING_STATUS", "401"),
("MOCK_EXISTING_STATUS", "403"),
("MOCK_EXISTING_STATUS", "500"),
("MOCK_CREATE_STATUS", "404"),
("MOCK_CREATE_STATUS", "409"),
("MOCK_CREATE_STATUS", "422"),
("MOCK_CREATE_STATUS", "500"),
("MOCK_ASSET_STATUS", "500"),
("MOCK_TIMEOUT_STAGE", "existing"),
("MOCK_TIMEOUT_STAGE", "create"),
("MOCK_TIMEOUT_STAGE", "asset"),
]
for variable, value in cases:
with self.subTest(variable=variable, value=value):
(state / "created").unlink(missing_ok=True)
environment = {**base_environment, variable: value}
result = subprocess.run(
command,
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Published stable release", result.stdout)
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from contextlib import redirect_stderr
import io
import unittest
from ptlinux.cli import build_parser
class LauncherUpdateCliTests(unittest.TestCase):
def test_bare_rollback_uses_retained_previous_release(self) -> None:
arguments = build_parser().parse_args(["launcher-update", "--rollback"])
self.assertEqual(arguments.rollback, "")
def test_explicit_rollback_accepts_version_and_history_override(self) -> None:
arguments = build_parser().parse_args(
[
"launcher-update",
"--rollback",
"1.8.0",
"--history-url",
"https://updates.example.test/history.json",
]
)
self.assertEqual(arguments.rollback, "1.8.0")
self.assertEqual(
arguments.history_url,
"https://updates.example.test/history.json",
)
def test_rollback_remains_exclusive_with_check_and_verify(self) -> None:
parser = build_parser()
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
parser.parse_args(["launcher-update", "--rollback", "--check"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import json
from pathlib import Path
from pathlib import PurePosixPath
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
_evaluate_launcher_update,
_stage_and_install_release,
parse_update_manifest,
)
from scripts.release_lib import (
ReleaseToolError,
_archive_layout_for_version,
_forbid_release_path,
_safe_output_directory,
build_release,
load_config,
sha256_file,
sign_file,
verify_release,
verify_signature,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseArtifactTests(unittest.TestCase):
def test_build_is_reproducible_and_matches_the_runtime_parser(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
notes = root / "notes.md"
notes.write_text("## Highlights\n\nChannel-aware updates.\n", encoding="utf-8")
commit = "a" * 40
first = root / "first"
second = root / "second"
for output in (first, second):
build_release(
__version__,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit=commit,
source_date_epoch=1_700_000_000,
release_notes_file=notes,
)
verify_release(__version__, "stable", output)
archive_name = f"PristonTale-EU-{__version__}.tar.gz"
self.assertEqual(
sha256_file(first / archive_name),
sha256_file(second / archive_name),
)
self.assertEqual(
(first / "manifest.json").read_bytes(),
(second / "manifest.json").read_bytes(),
)
manifest = parse_update_manifest((first / "manifest.json").read_bytes())
self.assertEqual(manifest.version, __version__)
self.assertEqual(manifest.channel, "stable")
self.assertEqual(manifest.tag, f"v{__version__}")
self.assertGreater(len(manifest.files), 10)
self.assertEqual(manifest.archive_name, archive_name)
raw_manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(
raw_manifest["archive_url"],
f"https://gitea.example.test/owner/project/releases/download/"
f"v{__version__}/{archive_name}?sha256={manifest.archive_sha256}",
)
expected_layout = _archive_layout_for_version(load_config(), __version__)
self.assertEqual(raw_manifest["archive"]["layout"], expected_layout)
self.assertIn("Channel-aware updates.", manifest.notes_markdown or "")
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(
paths,
first / archive_name,
manifest,
)
self.assertEqual(installed, paths.launcher_releases / __version__)
self.assertTrue((installed / "ptlinux/update_resolver.py").is_file())
with tarfile.open(first / archive_name, "r:gz") as bundle:
names = bundle.getnames()
archive_root = (
f"pristontale-eu-linux-update-v{__version__}/"
if expected_layout == "wrapped"
else ""
)
self.assertIn(f"{archive_root}release.json", names)
self.assertIn(f"{archive_root}ptlinux/__init__.py", names)
self.assertEqual(
any(name.startswith("pristontale-eu-linux-update-v") for name in names),
expected_layout == "wrapped",
)
self.assertFalse(any("__pycache__" in name for name in names))
self.assertFalse(any(name.endswith(".pyc") for name in names))
self.assertFalse(any("/.git/" in name for name in names))
def test_manifest_file_list_rejects_reordering(self) -> None:
manifest = {
"schema": 1,
"channel": "stable",
"version": "1.8.0",
"minimum_updater_version": "0.5.0",
"archive_url": "https://example.test/PristonTale-EU-1.8.0.tar.gz",
"archive_sha256": "a" * 64,
"archive_size": 100,
"published_at": "2026-09-08T00:00:00Z",
"notes_url": "https://example.test/releases/v1.8.0",
"files": [
{"path": "ptlinux/z.py", "size": 1, "sha256": "b" * 64},
{"path": "ptlinux/a.py", "size": 1, "sha256": "c" * 64},
],
}
with self.assertRaisesRegex(RuntimeError, "uniquely sorted"):
parse_update_manifest(
(json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8")
)
def test_manual_build_can_set_only_the_packaged_launcher_version(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target_version = "9.8.7-beta.2"
source_path = PROJECT_ROOT / "ptlinux/__init__.py"
source_before = source_path.read_bytes()
build_release(
target_version,
"prerelease",
root / "dist",
repository_url="https://gitea.example.test/owner/project",
commit="b" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(target_version, "prerelease", root / "dist")
archive = root / "dist" / f"PristonTale-EU-{target_version}.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
member = bundle.extractfile("ptlinux/__init__.py")
assert member is not None
self.assertIn(
f'__version__ = "{target_version}"'.encode(),
member.read(),
)
self.assertEqual(source_path.read_bytes(), source_before)
def test_configured_bridge_release_uses_legacy_wrapper(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
version = "0.9.10"
output = root / "dist"
build_release(
version,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit="c" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(version, "stable", output)
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["archive"]["layout"], "wrapped")
self.assertEqual(manifest["minimum_updater_version"], "0.5.0")
archive = output / f"PristonTale-EU-{version}.tar.gz"
archive_root = f"pristontale-eu-linux-update-v{version}"
with tarfile.open(archive, "r:gz") as bundle:
names = bundle.getnames()
self.assertIn(f"{archive_root}/release.json", names)
self.assertIn(f"{archive_root}/ptlinux/self_update.py", names)
self.assertFalse(any(name == "release.json" for name in names))
parsed = parse_update_manifest((output / "manifest.json").read_bytes())
check = _evaluate_launcher_update(
parsed,
"0.9.8",
"https://gitea.example.test/owner/project/raw/branch/main/updates/stable.json",
)
self.assertTrue(check.available)
self.assertTrue(check.supported)
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(paths, archive, parsed)
self.assertEqual(installed.name, version)
def test_output_cleaning_cannot_target_source_directories(self) -> None:
self.assertEqual(
_safe_output_directory(PROJECT_ROOT / "dist/v9.8.7"),
(PROJECT_ROOT / "dist/v9.8.7").resolve(),
)
with self.assertRaisesRegex(ReleaseToolError, "repository dist directory"):
_safe_output_directory(PROJECT_ROOT / "docs")
with self.assertRaisesRegex(ReleaseToolError, "unsafe output"):
_safe_output_directory(Path(tempfile.gettempdir()))
def test_forbidden_release_paths_are_rejected(self) -> None:
config = load_config()
for path in (
PurePosixPath(".env"),
PurePosixPath("private.key"),
PurePosixPath("ptlinux/__pycache__/module.pyc"),
PurePosixPath("logs/launcher.log"),
):
with self.subTest(path=path):
with self.assertRaisesRegex(ReleaseToolError, "forbidden"):
_forbid_release_path(path, config)
_forbid_release_path(
PurePosixPath("ptlinux/launcher-update-public.pem"),
config,
)
def test_signature_verification_rejects_modified_metadata(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "private.pem"
public_key = root / "public.pem"
metadata = root / "manifest.json"
signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
metadata.write_text('{"schema": 1}\n', encoding="utf-8")
sign_file(metadata, signature, private_key)
verify_signature(metadata, signature, public_key)
metadata.write_text('{"schema": 2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(metadata, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseCliTests(unittest.TestCase):
def setUp(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
self.temporary = tempfile.TemporaryDirectory()
base = Path(self.temporary.name)
self.repository = base / "working"
self.remote = base / "remote.git"
for relative in ("scripts", "ptlinux", "release"):
(self.repository / relative).mkdir(parents=True, exist_ok=True)
for relative in (
"scripts/__init__.py",
"scripts/build-release.sh",
"scripts/build_release.py",
"scripts/release.sh",
"scripts/release_lib.py",
"scripts/verify-release.sh",
"scripts/verify_release.py",
"ptlinux/update_resolver.py",
):
shutil.copy2(PROJECT_ROOT / relative, self.repository / relative)
(self.repository / "ptlinux/__init__.py").write_text(
'__version__ = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / "pyproject.toml").write_text(
'[project]\nname = "release-fixture"\nversion = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / ".gitignore").write_text(
"dist/\n__pycache__/\n*.pyc\n",
encoding="utf-8",
)
self.private_key = base / "private.pem"
public_key = self.repository / "ptlinux/launcher-update-public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(self.private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(self.private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
(self.repository / "release/config.json").write_text(
json.dumps(
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"channels": ["stable", "prerelease"],
"config_schema": 1,
"exclusions": [],
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"manifest_version": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
"payload": {
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem",
}
],
"python_trees": [{"exclude": [], "source": "ptlinux"}],
},
"preflight": [],
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://gitea.example.test/owner/project",
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/launcher-update-public.pem",
],
"allowed_pem_files": ["ptlinux/launcher-update-public.pem"],
"version_mirrors": ["pyproject.toml"],
"version_source": "ptlinux/__init__.py",
"wrapped_bridge_versions": [],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
self._run(["git", "init", "--bare", str(self.remote)], cwd=base)
self._git("init", "-b", "main")
self._git("config", "user.name", "Release Test")
self._git("config", "user.email", "release-test@example.test")
self._git("add", ".")
self._git("commit", "-m", "Initial launcher release")
self._git("tag", "-a", "v1.7.4", "-m", "Launcher 1.7.4")
self._git("remote", "add", "origin", str(self.remote))
self._git("push", "-u", "origin", "main", "v1.7.4")
(self.repository / "change.txt").write_text("update\n", encoding="utf-8")
self._git("add", "change.txt")
self._git("commit", "-m", "Improve updater reliability")
self._git("push", "origin", "main")
def tearDown(self) -> None:
self.temporary.cleanup()
def _run(
self,
command: list[str],
*,
cwd: Path | None = None,
environment: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd or self.repository,
env=environment,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
check=False,
)
def _git(self, *arguments: str) -> str:
result = self._run(["git", *arguments])
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.strip()
def _release(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess[str]:
return self._run(
[str(self.repository / "scripts/release.sh"), *arguments],
environment={
**os.environ,
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(self.private_key),
**environment,
},
)
def test_changelog_modes_preserve_markdown_and_do_not_mutate_git(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
inline = self._release(
"stable",
"1.8.0",
"--description",
"## Highlights\n\n- Signed channels",
"--changelog-only",
)
self.assertEqual(inline.returncode, 0, inline.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertLess(notes.index("## Highlights"), notes.index("## Changes"))
self.assertIn("Improve updater reliability", notes)
description = Path(self.temporary.name) / "description.md"
description.write_text("## Fixes\n\n- Preserved **Markdown**", encoding="utf-8")
from_file = self._release(
"stable",
"1.8.0",
"--description-file",
str(description),
"--no-auto-changelog",
"--changelog-only",
)
self.assertEqual(from_file.returncode, 0, from_file.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertIn("- Preserved **Markdown**", notes)
self.assertNotIn("## Changes", notes)
editor = Path(self.temporary.name) / "editor"
editor.write_text(
'#!/usr/bin/env bash\nprintf "\\nEdited before publication.\\n" >> "$1"\n',
encoding="utf-8",
)
editor.chmod(0o755)
edited = self._release(
"stable",
"1.8.0",
"--editor",
"--changelog-only",
EDITOR=str(editor),
)
self.assertEqual(edited.returncode, 0, edited.stderr)
self.assertIn(
"Edited before publication.",
(self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
),
)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
def test_dry_run_then_builds_manual_stable_and_prerelease_packages(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
remote_before = self._git("ls-remote", "origin")
dry_run = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
"--dry-run",
)
self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
self.assertIn("Dry run complete", dry_run.stdout)
self.assertFalse((self.repository / "dist/v1.8.0/manifest.json").exists())
stable = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
)
self.assertEqual(stable.returncode, 0, stable.stderr)
stable_output = self.repository / "dist/v1.8.0"
manifest_path = stable_output / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.8.0")
self.assertEqual(manifest["channel"], "stable")
self.assertTrue((stable_output / "manifest.json.sig").is_file())
self.assertEqual(
manifest_path.read_bytes(),
(stable_output / "channel/stable.json").read_bytes(),
)
archive = stable_output / "PristonTale-EU-1.8.0.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
version_source = bundle.extractfile("ptlinux/__init__.py")
assert version_source is not None
self.assertIn(b'__version__ = "1.8.0"', version_source.read())
self.assertIn("leave the Gitea", stable.stdout)
prerelease = self._release(
"prerelease",
"1.9.0-beta.1",
"--description",
"First beta",
"--skip-tests",
)
self.assertEqual(prerelease.returncode, 0, prerelease.stderr)
prerelease_output = self.repository / "dist/v1.9.0-beta.1"
manifest = json.loads(
(prerelease_output / "manifest.json").read_text(encoding="utf-8")
)
self.assertEqual(manifest["version"], "1.9.0-beta.1")
self.assertEqual(manifest["channel"], "prerelease")
self.assertTrue(manifest_path.is_file())
self.assertIn('select the Gitea "Pre-release" checkbox', prerelease.stdout)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
self.assertEqual(self._git("ls-remote", "origin"), remote_before)
self.assertIn('1.7.4', (self.repository / "ptlinux/__init__.py").read_text())
self.assertIn('1.7.4', (self.repository / "pyproject.toml").read_text())
def test_rejects_a_channel_version_mismatch_before_mutation(self) -> None:
result = self._release(
"stable",
"1.9.0-beta.1",
"--changelog-only",
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self._git("tag", "-l", "v1.9.0-beta.1"), "")
def test_build_does_not_depend_on_release_branch_or_clean_worktree(self) -> None:
self._git("switch", "-c", "release-candidate")
(self.repository / "uncommitted.txt").write_text("local source\n", encoding="utf-8")
result = self._release(
"stable",
"1.8.0",
"--skip-tests",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._git("tag", "-l", "v1.8.0"), "")
self.assertTrue((self.repository / "dist/v1.8.0/manifest.json").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import unittest
from scripts.generate_history import load_release_statuses, release_history
from scripts.release_lib import ReleaseToolError
import json
from pathlib import Path
import tempfile
def gitea_release(tag: str, *, prerelease: bool) -> dict[str, object]:
return {
"id": 42,
"tag_name": tag,
"name": f"Release {tag}",
"draft": False,
"prerelease": prerelease,
"published_at": "2026-09-08T12:00:00Z",
"html_url": f"https://gitea.example.test/owner/project/releases/tag/{tag}",
"assets": [
{
"name": "manifest.json",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json"
),
},
{
"name": "manifest.json.sig",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json.sig"
),
},
],
}
class ReleaseHistoryTests(unittest.TestCase):
def test_history_uses_only_installable_immutable_releases(self) -> None:
stable = gitea_release("v1.8.0", prerelease=False)
beta = gitea_release("v1.9.0-beta.2", prerelease=True)
wrong_flag = gitea_release("v2.0.0-beta.1", prerelease=False)
draft = gitea_release("v2.0.0", prerelease=False)
draft["draft"] = True
history = release_history(
[stable, wrong_flag, draft, beta],
"2026-09-08T12:01:00Z",
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
[release["version"] for release in releases],
["1.9.0-beta.2", "1.8.0"],
)
self.assertEqual(releases[0]["channel"], "prerelease")
self.assertEqual(releases[1]["channel"], "stable")
self.assertTrue(releases[0]["manifest_url"].endswith("/manifest.json"))
def test_history_rejects_an_invalid_generation_timestamp(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "timestamp"):
release_history([], "today")
def test_history_applies_signed_release_status_overrides(self) -> None:
history = release_history(
[gitea_release("v1.9.0-beta.2", prerelease=True)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.2": "withdrawn"},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(releases[0]["status"], "withdrawn")
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 1, "minimum_config_schema": 1},
)
def test_history_supports_explicit_config_compatibility(self) -> None:
history = release_history(
[gitea_release("v2.0.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{
"v2.0.0": {
"status": "active",
"config_schema": 3,
"minimum_config_schema": 2,
}
},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 3, "minimum_config_schema": 2},
)
def test_status_file_rejects_unknown_fields_and_unsafe_schema_ranges(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "statuses.json"
path.write_text(
json.dumps(
{
"schema": 1,
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 1,
"minimum_config_schema": 2,
}
},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "exceeds"):
load_release_statuses(path)
path.write_text(
'{"schema":1,"releases":{"v2.0.0":{"status":"active","note":"x"}}}',
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "unknown"):
load_release_statuses(path)
def test_history_rejects_an_unknown_status(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "invalid status"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.8.0": "broken"},
)
def test_history_rejects_status_override_for_a_missing_release(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "do not match"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.1": "withdrawn"},
)
def test_history_rejects_duplicate_release_tags(self) -> None:
release = gitea_release("v1.8.0", prerelease=False)
with self.assertRaisesRegex(ReleaseToolError, "duplicate"):
release_history(
[release, dict(release)],
"2026-09-08T12:01:00Z",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from scripts.validate_release_remote import remote_matches, repository_identity
class ReleaseRemoteTests(unittest.TestCase):
def test_https_and_ssh_forms_identify_the_same_repository(self) -> None:
expected = repository_identity(
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
self.assertEqual(
repository_identity("git@git.m4ko.net:m4ko/pristontale-eu-linux.git"),
expected,
)
self.assertEqual(
repository_identity(
"ssh://git@git.m4ko.net/m4ko/pristontale-eu-linux.git"
),
expected,
)
def test_file_url_and_local_path_identify_the_same_repository(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "remote.git"
self.assertEqual(
repository_identity(path.as_uri()),
repository_identity(str(path)),
)
def test_remote_must_match_one_explicitly_allowed_identity(self) -> None:
self.assertTrue(
remote_matches(
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
[
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
],
)
)
self.assertFalse(
remote_matches(
"ssh://git@untrusted.test/m4ko/pristontale-eu-linux.git",
["https://git.m4ko.net/m4ko/pristontale-eu-linux"],
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherRemoteHistory,
LauncherRemoteRelease,
LauncherUpdateError,
ManifestFile,
UpdateManifest,
_activate_release,
_fetch_historical_manifest,
_recover_interrupted_activation,
_validate_release_directory,
launcher_rollback_candidates,
parse_launcher_release_history,
rollback_launcher_update,
)
def history_release(
version: str,
channel: str,
*,
status: str = "active",
) -> dict[str, object]:
tag = f"v{version}"
base = f"https://updates.example.test/releases/download/{tag}"
return {
"channel": channel,
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
"manifest_signature_url": f"{base}/manifest.json.sig",
"manifest_url": f"{base}/manifest.json",
"release_date": "2026-09-08T12:00:00Z",
"release_id": 42,
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
"status": status,
"tag": tag,
"title": f"Launcher {version}",
"version": version,
}
def history_bytes(releases: list[dict[str, object]]) -> bytes:
return (
json.dumps(
{
"application": "PristonTale-EU",
"generated_at": "2026-09-08T12:01:00Z",
"releases": releases,
"schema": 1,
},
sort_keys=True,
)
+ "\n"
).encode("utf-8")
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
files = {
"release.json": (
json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
},
sort_keys=True,
)
+ "\n"
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
if extra_file:
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
release = root / "payload"
make_release(release, version)
archive = root / f"PristonTale-EU-{version}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
for path in sorted(release.rglob("*")):
relative = path.relative_to(release).as_posix()
info = tarfile.TarInfo(relative)
info.mode = 0o755 if path.is_dir() else 0o644
if path.is_dir():
info.type = tarfile.DIRTYPE
bundle.addfile(info)
else:
data = path.read_bytes()
info.size = len(data)
bundle.addfile(info, io.BytesIO(data))
files = tuple(
ManifestFile(
path=path.relative_to(release).as_posix(),
size=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
)
for path in sorted(release.rglob("*"))
if path.is_file()
)
return archive, files
class RemoteHistoryTests(unittest.TestCase):
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir(mode=0o775)
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
cache.chmod(0o775)
self.assertEqual(_validate_release_directory(release), "1.8.0")
self.assertFalse(cache.exists())
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir()
unexpected = cache / "payload.txt"
unexpected.write_text("not bytecode\n", encoding="utf-8")
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
_validate_release_directory(release)
self.assertTrue(unexpected.is_file())
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
data = history_bytes(
[
history_release("1.9.0-beta.2", "prerelease"),
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
history_release("1.8.0", "stable"),
history_release("1.7.4", "stable"),
]
)
history = parse_launcher_release_history(
data,
history_url="https://updates.example.test/updates/history.json",
)
stable = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="stable",
)
prerelease = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="prerelease",
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.8.0", "1.7.4"],
)
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
parse_launcher_release_history(
history_bytes(
[
history_release("1.8.0", "stable"),
history_release("1.9.0", "stable"),
]
),
history_url="https://updates.example.test/updates/history.json",
)
detached = history_release("1.8.0", "stable")
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
parse_launcher_release_history(
history_bytes([detached]),
history_url="https://updates.example.test/updates/history.json",
)
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
config_schema=3,
minimum_config_schema=2,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=1,
minimum_config_schema=1,
)
with (
patch(
"ptlinux.self_update._fetch_small_file",
side_effect=[b"manifest", b"signature"],
),
patch("ptlinux.self_update._verify_manifest_signature"),
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
):
result = _fetch_historical_manifest(
record,
Path("public.pem"),
timeout_seconds=1,
)
self.assertEqual(result.config_schema, 3)
self.assertEqual(result.minimum_config_schema, 2)
class RemoteRollbackTests(unittest.TestCase):
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0-beta.3"
make_release(current, current.name, extra_file=True)
paths.launcher_current.symlink_to(Path("releases") / current.name)
archive, files = archived_release(root, "1.8.0")
archive_bytes = archive.read_bytes()
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
archive_size=len(archive_bytes),
published_at="2026-09-08T12:00:00Z",
notes_url="https://updates.example.test/releases/tag/v1.8.0",
tag="v1.8.0",
files=files,
manifest_sha256="a" * 64,
)
record_value = history_release("1.8.0", "stable")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url=str(record_value["manifest_url"]),
manifest_signature_url=str(record_value["manifest_signature_url"]),
release_date=str(record_value["release_date"]),
release_notes_url=str(record_value["release_notes_url"]),
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/updates/history.json",
releases=(record,),
history_sha256="b" * 64,
)
paths.config.mkdir(parents=True)
paths.settings_file.write_text(
json.dumps({"schema": 1, "user_choice": "preserve me"}),
encoding="utf-8",
)
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive_bytes)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
patch("ptlinux.self_update._self_check_release"),
):
result = rollback_launcher_update(
paths,
target_version="1.8.0",
selected_channel="prerelease",
)
self.assertEqual(result.from_version, "1.9.0-beta.3")
self.assertEqual(result.to_version, "1.8.0")
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
self.assertEqual(
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
"preserve me",
)
operations = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)["operations"]
self.assertEqual(operations[-1]["reason"], "manual")
self.assertTrue(operations[-1]["rollback"])
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
self.assertTrue(integrity_record.is_file())
(paths.launcher_current / "ptlinux/__init__.py").write_text(
"locally modified after verification\n",
encoding="utf-8",
)
_activate_release(paths, current)
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=3,
minimum_config_schema=2,
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with (
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
self.assertRaisesRegex(RuntimeError, "active managed game client"),
):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_precheck_rejects_insufficient_disk_space(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch(
"ptlinux.self_update.shutil.disk_usage",
return_value=SimpleNamespace(free=0),
),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
from ptlinux import self_update
real_replace = self_update._replace_release_link
calls = 0
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated current-link failure")
real_replace(link, release, releases)
with (
patch(
"ptlinux.self_update._replace_release_link",
side_effect=fail_current_once,
),
self.assertRaisesRegex(OSError, "simulated current-link failure"),
):
_activate_release(paths, target)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "applying",
"current": current.name,
"previous": previous.name,
"target": "1.9.0",
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
self.assertEqual(
[call.args[0] for call in send.call_args_list],
["rollback_restore_started", "rollback_restore_success"],
)
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / target.name)
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "activated",
"current": current.name,
"previous": previous.name,
"target": target.name,
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
send.assert_not_called()
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with patch(
"ptlinux.self_update._write_json",
side_effect=OSError("read-only diagnostics directory"),
):
result = rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
self.assertFalse(result.state_recorded)
self.assertIn("could not be saved", result.warning or "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from scripts.release_lib import ReleaseToolError, sign_file, verify_signature
class SigningTests(unittest.TestCase):
def test_ed25519_metadata_signatures_and_tamper_rejection(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "manifest-ed25519-private.pem"
public_key = root / "manifest-ed25519-public.pem"
generated = subprocess.run(
[
openssl,
"genpkey",
"-algorithm",
"ED25519",
"-out",
str(private_key),
],
capture_output=True,
text=True,
check=False,
)
if generated.returncode != 0:
self.skipTest("installed OpenSSL does not support Ed25519")
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
capture_output=True,
text=True,
check=True,
)
private_key.chmod(0o600)
source = root / "manifest.json"
signature = root / "manifest.json.sig"
source.write_text('{"schema":1}\n', encoding="utf-8")
sign_file(source, signature, private_key)
verify_signature(source, signature, public_key)
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
source.write_text('{"schema":2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(source, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import unittest
from ptlinux.ui.components.update_dialog import (
release_history_state,
release_notes_preview,
update_version_heading,
)
class UpdateDialogTests(unittest.TestCase):
def test_release_notes_preview_shows_changes_without_build_footer(self) -> None:
preview = release_notes_preview(
"# Launcher 1.8.0\n\n"
"## Highlights\n\n"
"- Added channel-aware updates\n"
"- Improved rollback\n\n"
"---\n\nVersion: `1.8.0`\n"
)
self.assertIn("## Highlights", preview)
self.assertIn("- Improved rollback", preview)
self.assertNotIn("Version:", preview)
self.assertNotIn("Launcher 1.8.0", preview)
def test_release_notes_preview_is_bounded(self) -> None:
preview = release_notes_preview(
"\n".join(f"- Change {index}" for index in range(30))
)
self.assertIn("- Change 0", preview)
self.assertNotIn("- Change 20", preview)
self.assertTrue(preview.endswith("…"))
def test_current_version_heading_uses_running_launcher(self) -> None:
self.assertEqual(
update_version_heading(
"0.9.11",
"0.9.10",
"stable",
update_available=False,
),
"Current version: 0.9.11\nLatest Stable release: 0.9.10",
)
def test_history_distinguishes_running_and_installed_versions(self) -> None:
self.assertEqual(
release_history_state(
"active",
is_running=True,
local_active=False,
local_retained=False,
is_older=False,
),
"Current",
)
self.assertEqual(
release_history_state(
"active",
is_running=False,
local_active=True,
local_retained=True,
is_older=False,
),
"Installed",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from ptlinux.update_resolver import (
BLOCKED_STATUS,
PRERELEASE_CHANNEL,
STABLE_CHANNEL,
WITHDRAWN_STATUS,
ReleaseModelError,
ReleaseRecord,
SemanticVersion,
UpdateResolver,
previous_release_tag,
validate_channel_version,
validate_release_progression,
)
class SemanticVersionTests(unittest.TestCase):
def test_orders_beta_rc_final_and_multidigit_versions(self) -> None:
versions = [
SemanticVersion.parse(value)
for value in (
"1.9.0",
"1.9.0-rc.1",
"1.10.0",
"1.9.0-beta.2",
"1.9.0-beta.1",
"1.9.9",
)
]
self.assertEqual(
[str(value) for value in sorted(versions)],
[
"1.9.0-beta.1",
"1.9.0-beta.2",
"1.9.0-rc.1",
"1.9.0",
"1.9.9",
"1.10.0",
],
)
def test_rejects_unsupported_or_noncanonical_versions(self) -> None:
for value in (
"v1.2.3",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-alpha.1",
"1.2.3-beta",
"1.2.3+build",
):
with self.subTest(value=value):
with self.assertRaises(ReleaseModelError):
SemanticVersion.parse(value)
def test_rejects_channel_version_mismatches(self) -> None:
with self.assertRaisesRegex(ReleaseModelError, "belongs to prerelease"):
validate_channel_version(STABLE_CHANNEL, "1.9.0-beta.1")
with self.assertRaisesRegex(ReleaseModelError, "belongs to stable"):
validate_channel_version(PRERELEASE_CHANNEL, "1.9.0")
def test_selects_channel_specific_changelog_bases(self) -> None:
tags = ["not-a-release", "v1.7.4", "v1.9.0-beta.1", "v1.9.0-beta.2"]
self.assertEqual(
previous_release_tag(STABLE_CHANNEL, "1.8.0", tags),
"v1.7.4",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "1.9.0-rc.1", tags),
"v1.9.0-beta.2",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "2.0.0-beta.1", tags),
"v1.7.4",
)
def test_release_progression_cannot_move_a_channel_backwards(self) -> None:
validate_release_progression(
STABLE_CHANNEL,
"1.9.0",
["v1.8.0", "v2.0.0-beta.1"],
)
validate_release_progression(
PRERELEASE_CHANNEL,
"2.0.0-beta.2",
["v1.9.0", "v2.0.0-beta.1"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
STABLE_CHANNEL,
"1.8.0",
["v1.9.0"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
PRERELEASE_CHANNEL,
"1.9.0-rc.1",
["v2.0.0-beta.1"],
)
class UpdateResolverTests(unittest.TestCase):
def setUp(self) -> None:
self.resolver = UpdateResolver(
[
ReleaseRecord("1.7.4", STABLE_CHANNEL, tag="v1.7.4"),
ReleaseRecord("1.8.0", STABLE_CHANNEL, tag="v1.8.0"),
ReleaseRecord("1.9.0-beta.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-beta.2", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("2.0.0-beta.1", PRERELEASE_CHANNEL, WITHDRAWN_STATUS),
ReleaseRecord("2.0.0-beta.2", PRERELEASE_CHANNEL, BLOCKED_STATUS),
]
)
def test_stable_clients_never_receive_prereleases(self) -> None:
candidate = self.resolver.update_candidate("1.7.4", STABLE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.8.0")
def test_prerelease_clients_receive_highest_active_semantic_version(self) -> None:
candidate = self.resolver.update_candidate(
"1.9.0-beta.1",
PRERELEASE_CHANNEL,
)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0-rc.1")
def test_prerelease_client_transitions_to_the_final_stable_release(self) -> None:
resolver = UpdateResolver(
[
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0", STABLE_CHANNEL),
]
)
candidate = resolver.update_candidate("1.9.0-rc.1", PRERELEASE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0")
def test_switching_to_stable_does_not_downgrade(self) -> None:
self.assertIsNone(
self.resolver.update_candidate("1.9.0-beta.2", STABLE_CHANNEL)
)
def test_channel_aware_rollback_candidates_are_real_releases(self) -> None:
stable = self.resolver.rollback_candidates("1.9.0", STABLE_CHANNEL)
prerelease = self.resolver.rollback_candidates(
"1.9.0-rc.1",
PRERELEASE_CHANNEL,
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.9.0-beta.1", "1.8.0", "1.7.4"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import replace
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherUpdateCheck,
UpdateManifest,
_download_archive,
_record_launcher_operation,
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
)
def release_manifest(version: str, channel: str = "stable") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://updates.example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=f"https://updates.example.test/releases/tag/v{version}",
tag=f"v{version}",
manifest_sha256="b" * 64,
)
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str) -> None:
files = {
"release.json": json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
}
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
class UpdateTelemetryTests(unittest.TestCase):
def test_local_operation_history_is_bounded_to_thirty_records(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
for index in range(35):
self.assertTrue(
_record_launcher_operation(
paths,
from_version=f"1.0.{index}",
to_version=f"1.0.{index + 1}",
from_channel="stable",
to_channel="stable",
selected_channel="stable",
result="success",
rollback=False,
reason="automatic_update",
)
)
value = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)
self.assertEqual(len(value["operations"]), 30)
self.assertEqual(value["operations"][0]["from"], "1.0.5")
self.assertEqual(value["operations"][-1]["to"], "1.0.35")
def test_rollback_download_emits_success_and_failure_families(self) -> None:
content = b"authenticated rollback archive"
manifest = replace(
release_manifest("1.7.4"),
archive_size=len(content),
archive_sha256=hashlib.sha256(content).hexdigest(),
)
class Response(io.BytesIO):
def __init__(self, data: bytes) -> None:
super().__init__(data)
self.headers = {"Content-Length": str(len(data))}
def geturl(self) -> str:
return manifest.archive_url
def __enter__(self) -> Response:
return self
def __exit__(self, *_args: object) -> None:
self.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
metadata = {
"from_version": "1.8.0",
"to_version": "1.7.4",
"rollback": True,
}
telemetry = MagicMock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(content)),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
_download_archive(
manifest,
root / "success.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_success"],
)
telemetry.reset_mock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(b"x" * len(content))),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
self.assertRaisesRegex(RuntimeError, "SHA-256"),
):
_download_archive(
manifest,
root / "failure.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_failed"],
)
def test_discovery_emits_release_identity_without_release_notes(self) -> None:
telemetry = MagicMock()
manifest = release_manifest("1.8.0")
with (
patch("ptlinux.self_update._fetch_authenticated_manifest", return_value=manifest),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
check_for_launcher_update(
current_version="1.7.4",
manifest_url="https://updates.example.test/stable.json",
channel="stable",
)
call = telemetry.send_event.call_args
self.assertEqual(call.args[0], "update_release_discovered")
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertEqual(metadata["current_channel"], "stable")
self.assertEqual(metadata["target_channel"], "stable")
self.assertEqual(metadata["selected_update_channel"], "stable")
self.assertEqual(metadata["release_tag"], "v1.8.0")
self.assertEqual(metadata["manifest_version"], 1)
self.assertEqual(metadata["manifest_hash"], "b" * 64)
self.assertFalse(metadata["rollback"])
self.assertNotIn("notes", metadata)
def test_update_apply_emits_started_and_success_with_shared_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
manifest = release_manifest("1.8.0")
check = LauncherUpdateCheck(
current_version="1.7.4",
latest_version="1.8.0",
available=True,
supported=True,
reason="Launcher 1.8.0 is available.",
manifest_url="https://updates.example.test/stable.json",
manifest=manifest,
selected_channel="stable",
)
release = paths.launcher_releases / "1.8.0"
telemetry = MagicMock()
with (
patch("ptlinux.self_update.check_for_launcher_update", return_value=check),
patch("ptlinux.self_update._precheck_release_install"),
patch("ptlinux.self_update._download_archive"),
patch("ptlinux.self_update._stage_and_install_release", return_value=release),
patch("ptlinux.self_update._activate_release", return_value=None),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
result = install_launcher_update(paths, current_version="1.7.4")
self.assertTrue(result.changed)
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(events, ["update_apply_started", "update_apply_success"])
for call in telemetry.send_event.call_args_list:
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertFalse(metadata["rollback"])
def test_local_rollback_emits_requested_and_apply_lifecycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
telemetry = MagicMock()
with patch("ptlinux.self_update.current_telemetry", return_value=telemetry):
rollback_launcher_update(paths, reason="support_recommendation")
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(
events,
[
"rollback_requested",
"rollback_apply_started",
"rollback_apply_success",
],
)
metadata = telemetry.send_event.call_args_list[0].kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.8.0")
self.assertEqual(metadata["target_version"], "1.7.4")
self.assertEqual(metadata["from_version"], "1.8.0")
self.assertEqual(metadata["to_version"], "1.7.4")
self.assertEqual(metadata["from_channel"], "stable")
self.assertEqual(metadata["to_channel"], "stable")
self.assertEqual(metadata["reason"], "support_recommendation")
self.assertTrue(metadata["rollback"])
if __name__ == "__main__":
unittest.main()

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Release automation modules."""

60
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$3"
cd -- "$PROJECT_ROOT"
ARGUMENTS=(
"${SCRIPT_ROOT}/build_release.py"
"$VERSION"
"$CHANNEL"
"$OUTPUT_DIRECTORY"
)
if [[ -n "${REPOSITORY_URL:-}" ]]
then
ARGUMENTS+=(--repository-url "$REPOSITORY_URL")
fi
if [[ -n "${RELEASE_TAG:-}" ]]
then
ARGUMENTS+=(--tag "$RELEASE_TAG")
fi
if [[ -n "${RELEASE_COMMIT:-}" ]]
then
ARGUMENTS+=(--commit "$RELEASE_COMMIT")
fi
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]
then
ARGUMENTS+=(--source-date-epoch "$SOURCE_DATE_EPOCH")
fi
if [[ -n "${RELEASE_NOTES_FILE:-}" ]]
then
ARGUMENTS+=(--release-notes "$RELEASE_NOTES_FILE")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
if [[ "${ALLOW_SOURCE_VERSION_OVERRIDE:-0}" == "1" ]]
then
ARGUMENTS+=(--allow-source-version-override)
fi
if [[ -n "${RELEASE_ARCHIVE_LAYOUT:-}" ]]
then
ARGUMENTS+=(--archive-layout "$RELEASE_ARCHIVE_LAYOUT")
fi
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

66
scripts/build_release.py Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Build one deterministic launcher release and its external metadata."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, build_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--repository-url")
parser.add_argument("--tag")
parser.add_argument("--commit")
parser.add_argument("--source-date-epoch", type=int)
parser.add_argument("--release-notes", type=Path)
parser.add_argument("--signing-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
parser.add_argument("--allow-source-version-override", action="store_true")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
return parser
def main() -> int:
arguments = build_parser().parse_args()
signing_key = arguments.signing_key
if signing_key is None and os.environ.get("PTLINUX_SIGNING_KEY"):
signing_key = Path(os.environ["PTLINUX_SIGNING_KEY"])
try:
artifacts = build_release(
arguments.version,
arguments.channel,
arguments.output_directory,
repository_url=arguments.repository_url,
tag=arguments.tag,
commit=arguments.commit,
source_date_epoch=arguments.source_date_epoch,
release_notes_file=arguments.release_notes,
signing_key=signing_key,
require_signature=arguments.require_signature,
allow_source_version_override=arguments.allow_source_version_override,
archive_layout=arguments.archive_layout,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release build failed: {exc}", file=sys.stderr)
return 1
for artifact in artifacts:
print(artifact)
return 0
if __name__ == "__main__":
raise SystemExit(main())

77
scripts/generate-manifest.py Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Generate the external schema-1 release manifest for an existing archive."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
generate_manifest,
load_config,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--file-root", required=True, type=Path)
parser.add_argument("--archive", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--repository-url", required=True)
parser.add_argument("--published-at", required=True)
parser.add_argument("--release-title")
parser.add_argument("--release-summary-file", type=Path)
parser.add_argument("--release-notes-url")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
parser.add_argument("--minimum-updater-version")
return parser
def main() -> int:
arguments = build_parser().parse_args()
release_notes = ""
if arguments.release_summary_file is not None:
try:
release_notes = arguments.release_summary_file.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
try:
generate_manifest(
arguments.output,
config=load_config(),
file_root=arguments.file_root,
archive=arguments.archive,
version=arguments.version,
channel=arguments.channel,
tag=arguments.tag,
commit=arguments.commit,
repository_url=arguments.repository_url,
release_timestamp=arguments.published_at,
release_notes=release_notes,
release_title=arguments.release_title,
release_notes_url=arguments.release_notes_url,
archive_layout=arguments.archive_layout,
minimum_updater_version=arguments.minimum_updater_version,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
print(arguments.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())

263
scripts/generate_history.py Executable file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Generate signed-history source data from Gitea release API results."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from typing import Mapping
from urllib.parse import urlsplit
if __package__:
from .repository_paths import PROJECT_ROOT, ensure_project_imports
else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_parse_timestamp,
load_config,
)
from ptlinux.update_resolver import (
SUPPORTED_RELEASE_STATUSES,
ReleaseModelError,
SemanticVersion,
)
def release_history(
releases: object,
generated_at: str,
statuses: Mapping[str, object] | None = None,
) -> dict[str, object]:
_parse_timestamp(generated_at)
if not isinstance(releases, list):
raise ReleaseToolError("Gitea releases response must be an array")
records: list[tuple[SemanticVersion, dict[str, object]]] = []
seen_versions: set[SemanticVersion] = set()
included_tags: set[str] = set()
for item in releases:
if not isinstance(item, dict) or item.get("draft") is True:
continue
tag = item.get("tag_name")
if not isinstance(tag, str):
continue
try:
version = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if version in seen_versions:
raise ReleaseToolError(f"Gitea returned duplicate release tag {tag}")
seen_versions.add(version)
if bool(item.get("prerelease")) != version.is_prerelease:
continue
assets = item.get("assets")
if not isinstance(assets, list):
continue
asset_urls: dict[str, str] = {}
for asset in assets:
if not isinstance(asset, dict):
continue
name = asset.get("name")
url = asset.get("browser_download_url")
if isinstance(name, str) and isinstance(url, str):
asset_urls[name] = url
manifest_url = asset_urls.get("manifest.json")
signature_url = asset_urls.get("manifest.json.sig")
release_date = item.get("published_at") or item.get("created_at")
release_notes_url = item.get("html_url")
if (
manifest_url is None
or signature_url is None
or not isinstance(release_date, str)
or not isinstance(release_notes_url, str)
or not all(
_https_url(url)
for url in (manifest_url, signature_url, release_notes_url)
)
):
continue
try:
_parse_timestamp(release_date)
except ReleaseToolError:
continue
release_id = item.get("id")
override = (statuses or {}).get(tag, "active")
if isinstance(override, dict):
status = override.get("status", "active")
config_schema = _positive_integer(
override.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
override.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
else:
status = override
config_schema = 1
minimum_config_schema = 1
if status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
title = item.get("name")
if not isinstance(title, str) or not title:
title = f"PristonTale EU Launcher {version}"
records.append(
(
version,
{
"channel": version.channel,
"compatibility": {
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
},
"manifest_signature_url": signature_url,
"manifest_url": manifest_url,
"release_date": release_date,
"release_id": (
release_id
if isinstance(release_id, int)
and not isinstance(release_id, bool)
and release_id > 0
else None
),
"release_notes_url": release_notes_url,
"status": status,
"tag": tag,
"title": title,
"version": str(version),
},
)
)
included_tags.add(tag)
unused_statuses = set(statuses or {}).difference(included_tags)
if unused_statuses:
raise ReleaseToolError(
"release status overrides do not match published, manifest-backed tags: "
+ ", ".join(sorted(unused_statuses))
)
records.sort(key=lambda entry: entry[0], reverse=True)
config = load_config()
return {
"application": config["application"],
"generated_at": generated_at,
"releases": [record for _version, record in records],
"schema": 1,
}
def load_release_statuses(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"invalid release status file {path}: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise ReleaseToolError("release status file must use schema 1")
releases = value.get("releases")
if not isinstance(releases, dict):
raise ReleaseToolError("release status file releases must be an object")
statuses: dict[str, object] = {}
for tag, status_value in releases.items():
if not isinstance(tag, str):
raise ReleaseToolError("release status tags must be strings")
try:
SemanticVersion.from_tag(tag)
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release status tag {tag!r}") from exc
status = status_value.get("status", "active") if isinstance(status_value, dict) else status_value
if not isinstance(status, str) or status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if isinstance(status_value, dict):
unknown = set(status_value).difference(
{"status", "config_schema", "minimum_config_schema"}
)
if unknown:
raise ReleaseToolError(
f"unknown release override fields for {tag}: {', '.join(sorted(unknown))}"
)
config_schema = _positive_integer(
status_value.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
status_value.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
statuses[tag] = {
"status": status,
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
}
else:
statuses[tag] = status
return statuses
def _positive_integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ReleaseToolError(f"{label} must be a positive integer")
return value
def _https_url(value: str) -> bool:
parts = urlsplit(value)
return (
parts.scheme.casefold() == "https"
and bool(parts.netloc)
and parts.username is None
and parts.password is None
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--generated-at", required=True)
parser.add_argument("--status-file", type=Path)
arguments = parser.parse_args()
try:
releases = json.loads(arguments.input.read_text(encoding="utf-8"))
config = load_config()
status_file = arguments.status_file or (
PROJECT_ROOT / str(config["release_status_file"])
)
history = release_history(
releases,
arguments.generated_at,
load_release_statuses(status_file),
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
temporary = arguments.output.with_name(
f".{arguments.output.name}.{os.getpid()}.tmp"
)
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(history, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, arguments.output)
finally:
temporary.unlink(missing_ok=True)
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Release history generation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Build one signed channel view from authenticated release history metadata."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402
from ptlinux.update_resolver import ( # noqa: E402
ReleaseModelError,
ReleaseRecord,
UpdateResolver,
validate_channel,
)
from scripts.release_lib import ReleaseToolError # noqa: E402
def prepare_channel_metadata(
history: object,
channel: str,
manifest_bytes: bytes,
) -> tuple[dict[str, object], dict[str, object]]:
"""Return the selected history record and a status-aware manifest copy."""
try:
validate_channel(channel)
except ReleaseModelError as exc:
raise ReleaseToolError(str(exc)) from exc
if not isinstance(history, dict) or history.get("schema") != 1:
raise ReleaseToolError("release history must use schema 1")
releases = history.get("releases")
if not isinstance(releases, list):
raise ReleaseToolError("release history releases must be an array")
records: list[tuple[dict[str, object], ReleaseRecord]] = []
for value in releases:
if not isinstance(value, dict) or value.get("channel") != channel:
continue
try:
record = ReleaseRecord(
version=_string(value, "version"),
channel=_string(value, "channel"),
status=_string(value, "status"),
tag=_string(value, "tag"),
manifest_url=_string(value, "manifest_url"),
).validate()
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history record: {exc}") from exc
records.append((value, record))
if not records:
raise ReleaseToolError(f"release history has no {channel} releases")
try:
resolver = UpdateResolver(record for _value, record in records)
selected_record = resolver.latest_eligible(channel)
if selected_record is None:
selected_record = resolver.eligible_releases(channel, include_inactive=True)[0]
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history: {exc}") from exc
selected = next(
value for value, record in records if record.version == selected_record.version
)
try:
manifest = parse_update_manifest(manifest_bytes)
except LauncherUpdateError as exc:
raise ReleaseToolError(f"selected channel manifest is invalid: {exc}") from exc
for field, actual, expected in (
("version", manifest.version, selected_record.version),
("channel", manifest.channel, selected_record.channel),
("tag", manifest.tag, selected_record.tag),
):
if actual != expected:
raise ReleaseToolError(
f"selected manifest {field} {actual!r} does not match history {expected!r}"
)
try:
manifest_value = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"selected channel manifest is invalid JSON: {exc}") from exc
if not isinstance(manifest_value, dict):
raise ReleaseToolError("selected channel manifest must be an object")
manifest_value["status"] = selected_record.status
history_compatibility = selected.get("compatibility")
if not isinstance(history_compatibility, dict):
raise ReleaseToolError("selected release compatibility must be an object")
config_schema = _positive_integer(history_compatibility, "config_schema")
minimum_config_schema = _positive_integer(
history_compatibility,
"minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
"selected release minimum_config_schema exceeds config_schema"
)
manifest_compatibility = manifest_value.get("compatibility")
if not isinstance(manifest_compatibility, dict):
raise ReleaseToolError("selected channel manifest compatibility is invalid")
manifest_compatibility["config_schema"] = config_schema
manifest_compatibility["minimum_config_schema"] = minimum_config_schema
return selected, manifest_value
def _string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise ReleaseToolError(f"release history field {key!r} must be a string")
return item
def _positive_integer(value: dict[str, object], key: str) -> int:
item = value.get(key)
if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
raise ReleaseToolError(
f"selected release compatibility {key!r} must be positive"
)
return item
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("history", type=Path)
parser.add_argument("channel", choices=("stable", "prerelease"))
parser.add_argument("manifest", type=Path)
parser.add_argument("output", type=Path)
arguments = parser.parse_args()
try:
history = json.loads(arguments.history.read_text(encoding="utf-8"))
selected, manifest = prepare_channel_metadata(
history,
arguments.channel,
arguments.manifest.read_bytes(),
)
_write_json(arguments.output, manifest)
print(_string(selected, "version"))
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Channel metadata preparation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

351
scripts/publish-gitea-release.sh Executable file
View File

@@ -0,0 +1,351 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 4 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR RELEASE_NOTES\n' "$0" >&2
exit 2
fi
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$(realpath -- "$3")"
RELEASE_NOTES="$(realpath -- "$4")"
TAG="${RELEASE_TAG:-v${VERSION}}"
COMMIT="${RELEASE_COMMIT:?RELEASE_COMMIT is required}"
PRERELEASE="${RELEASE_PRERELEASE:?RELEASE_PRERELEASE is required}"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ARCHIVE_PREFIX="$(jq -er '.archive_prefix' "${SCRIPT_ROOT}/../release/config.json")"
ARCHIVE="${OUTPUT_DIRECTORY}/${ARCHIVE_PREFIX}-${VERSION}.tar.gz"
CHECKSUM="${ARCHIVE}.sha256"
MANIFEST="${OUTPUT_DIRECTORY}/manifest.json"
MANIFEST_SIGNATURE="${OUTPUT_DIRECTORY}/manifest.json.sig"
RELEASE_NOTES_ASSET="${OUTPUT_DIRECTORY}/release-notes.md"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
RELEASE_MODEL="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/../src/ptlinux/update_resolver.py" inspect-tag "$TAG"
)"
if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \
|| "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \
|| "$(jq -r '.prerelease' <<< "$RELEASE_MODEL")" != "$PRERELEASE" ]]
then
printf 'Release tag, version, channel, and prerelease flag are inconsistent.\n' >&2
exit 1
fi
if [[ ! "$COMMIT" =~ ^[0-9a-f]{40,64}$ ]]
then
printf 'Release commit is not a lowercase Git object ID: %s\n' "$COMMIT" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
if [[ ! -s "$REQUIRED_ARTIFACT" ]]
then
printf 'Required release artifact is missing or empty: %s\n' "$REQUIRED_ARTIFACT" >&2
exit 1
fi
done
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
EXISTING_RESPONSE="${RESPONSE_DIRECTORY}/existing.json"
EXISTING_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$EXISTING_RESPONSE")"
case "$EXISTING_STATUS" in
404)
;;
200)
printf 'Release %s already exists. Published releases are immutable.\n' "$TAG" >&2
exit 1
;;
*)
printf 'Could not check existing release %s: HTTP %s\n' "$TAG" "$EXISTING_STATUS" >&2
jq -r '.message // empty' "$EXISTING_RESPONSE" >&2 || true
exit 1
;;
esac
RELEASE_PAYLOAD="${RESPONSE_DIRECTORY}/release-payload.json"
jq -n \
--rawfile body "$RELEASE_NOTES" \
--arg name "PristonTale EU Launcher ${VERSION}" \
--arg tag_name "$TAG" \
--arg target_commitish "$COMMIT" \
--argjson prerelease "$PRERELEASE" \
'{body:$body,draft:false,name:$name,prerelease:$prerelease,tag_name:$tag_name,target_commitish:$target_commitish}' \
> "$RELEASE_PAYLOAD"
CREATE_RESPONSE="${RESPONSE_DIRECTORY}/created.json"
CREATE_STATUS="$(
api_request POST "${API_ROOT}/releases" "$CREATE_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${RELEASE_PAYLOAD}"
)"
if [[ "$CREATE_STATUS" != "201" ]]
then
printf 'Gitea release creation failed: HTTP %s\n' "$CREATE_STATUS" >&2
jq -r '.message // empty' "$CREATE_RESPONSE" >&2 || true
exit 1
fi
RELEASE_ID="$(jq -er '.id | select(type == "number" and . > 0)' "$CREATE_RESPONSE")"
upload_asset()
{
local FILE="$1"
local NAME
local RESPONSE
local STATUS
NAME="$(basename -- "$FILE")"
RESPONSE="${RESPONSE_DIRECTORY}/asset-${NAME}.json"
STATUS="$(
api_request POST "${API_ROOT}/releases/${RELEASE_ID}/assets?name=${NAME}" "$RESPONSE" \
--form "attachment=@${FILE};type=application/octet-stream"
)"
if [[ "$STATUS" != "201" ]]
then
printf 'Gitea asset upload failed for %s: HTTP %s\n' "$NAME" "$STATUS" >&2
jq -r '.message // empty' "$RESPONSE" >&2 || true
exit 1
fi
if [[ "$(jq -r '.name // empty' "$RESPONSE")" != "$NAME" ]]
then
printf 'Gitea returned the wrong attachment name for %s.\n' "$NAME" >&2
exit 1
fi
}
upload_asset "$ARCHIVE"
upload_asset "$CHECKSUM"
upload_asset "$MANIFEST"
upload_asset "$MANIFEST_SIGNATURE"
upload_asset "$RELEASE_NOTES_ASSET"
VERIFY_RESPONSE="${RESPONSE_DIRECTORY}/verified.json"
VERIFY_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$VERIFY_RESPONSE")"
if [[ "$VERIFY_STATUS" != "200" ]]
then
printf 'Could not re-query published release %s: HTTP %s\n' "$TAG" "$VERIFY_STATUS" >&2
exit 1
fi
if [[ "$(jq -r '.prerelease' "$VERIFY_RESPONSE")" != "$PRERELEASE" ]]
then
printf 'Published release prerelease flag does not match channel %s.\n' "$CHANNEL" >&2
exit 1
fi
if [[ "$(jq -r '.tag_name // empty' "$VERIFY_RESPONSE")" != "$TAG" \
|| "$(jq -r '.draft' "$VERIFY_RESPONSE")" != "false" \
|| "$(jq -r '.target_commitish // empty' "$VERIFY_RESPONSE")" != "$COMMIT" ]]
then
printf 'Published release identity or target commit does not match %s.\n' "$TAG" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
ASSET_NAME="$(basename -- "$REQUIRED_ARTIFACT")"
EXPECTED_SIZE="$(stat -c '%s' -- "$REQUIRED_ARTIFACT")"
ACTUAL_SIZE="$(
jq -er --arg name "$ASSET_NAME" \
'.assets[] | select(.name == $name) | .size' "$VERIFY_RESPONSE"
)"
if [[ "$ACTUAL_SIZE" != "$EXPECTED_SIZE" ]]
then
printf 'Published asset size mismatch for %s: expected %s, got %s.\n' \
"$ASSET_NAME" "$EXPECTED_SIZE" "$ACTUAL_SIZE" >&2
exit 1
fi
done
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
PUBLISHED_AT="$(jq -er '.published_at' "$VERIFY_RESPONSE")"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$PUBLISHED_AT"
if ! jq -e \
--arg tag "$TAG" \
--arg channel "$CHANNEL" \
'.releases[] | select(.tag == $tag and .channel == $channel and .status == "active")' \
"$HISTORY" >/dev/null
then
printf 'Published release %s is absent or inactive in generated history.\n' "$TAG" >&2
exit 1
fi
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_CONTENT="$(base64 -w 0 -- "$MANIFEST")"
CHANNEL_SIGNATURE_CONTENT="$(base64 -w 0 -- "$MANIFEST_SIGNATURE")"
HISTORY_CONTENT="$(base64 -w 0 -- "$HISTORY")"
HISTORY_SIGNATURE_CONTENT="$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
CHANNEL_PATH="updates/${CHANNEL}.json"
CHANNEL_SIGNATURE_PATH="updates/${CHANNEL}.json.sig"
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
CHANNEL_OPERATION="$(file_operation "$CHANNEL_PATH" "$CHANNEL_CONTENT")"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "$CHANNEL_SIGNATURE_PATH" "$CHANNEL_SIGNATURE_CONTENT"
)"
HISTORY_OPERATION="$(file_operation updates/history.json "$HISTORY_CONTENT")"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$HISTORY_SIGNATURE_CONTENT"
)"
CHANNEL_PAYLOAD="${RESPONSE_DIRECTORY}/channel-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message "Update ${CHANNEL} channel to ${VERSION}" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson channel_signature "$CHANNEL_SIGNATURE_OPERATION" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'{branch:$branch,message:$message,files:[$channel,$channel_signature,$history,$history_signature]}' \
> "$CHANNEL_PAYLOAD"
CHANNEL_RESPONSE="${RESPONSE_DIRECTORY}/channel-response.json"
CHANNEL_STATUS="$(
api_request POST "${API_ROOT}/contents" "$CHANNEL_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${CHANNEL_PAYLOAD}"
)"
if [[ "$CHANNEL_STATUS" != "201" ]]
then
printf 'Atomic channel/history metadata update failed: HTTP %s\n' "$CHANNEL_STATUS" >&2
jq -r '.message // empty' "$CHANNEL_RESPONSE" >&2 || true
exit 1
fi
ARCHIVE_SHA256="$(cut -d ' ' -f 1 -- "$CHECKSUM")"
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published release\n\n'
printf '| Field | Value |\n|---|---|\n'
printf '| Release | PristonTale EU Launcher %s |\n' "$VERSION"
printf '| Channel | `%s` |\n' "$CHANNEL"
printf '| Tag | `%s` |\n' "$TAG"
printf '| Commit | `%s` |\n' "$COMMIT"
printf '| Archive | `%s` |\n' "$(basename -- "$ARCHIVE")"
printf '| SHA-256 | `%s` |\n' "$ARCHIVE_SHA256"
printf '| Manifest | `manifest.json` |\n'
printf '| Status | Published |\n'
} >> "$SUMMARY_FILE"
printf 'Published %s release %s (%s) with verified assets and channel metadata.\n' \
"$CHANNEL" "$TAG" "${SERVER_URL%/}/${REPOSITORY}/releases/tag/${TAG}"

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]
then
printf 'Usage: %s OUTPUT_DIR\n' "$0" >&2
exit 2
fi
OUTPUT_DIRECTORY="$(realpath -m -- "$1")"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STATUS_FILE="${RELEASE_STATUS_FILE:-${SCRIPT_ROOT}/../release/statuses.json}"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
mkdir -p -- "$OUTPUT_DIRECTORY"
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
GENERATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$GENERATED_AT" \
--status-file "$STATUS_FILE"
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_DIRECTORY="${RESPONSE_DIRECTORY}/channels"
mkdir -p -- "$CHANNEL_DIRECTORY"
CHANNELS_TO_PUBLISH=()
for CHANNEL in stable prerelease
do
RECORD="$(
jq -cer --arg channel "$CHANNEL" \
'([.releases[] | select(.channel == $channel)]
| (map(select(.status == "active"))[0] // .[0]))' \
"$HISTORY" 2>/dev/null || true
)"
if [[ -z "$RECORD" || "$RECORD" == "null" ]]
then
continue
fi
MANIFEST_URL="$(jq -er '.manifest_url' <<< "$RECORD")"
MANIFEST_SIGNATURE_URL="$(jq -er '.manifest_signature_url' <<< "$RECORD")"
for ASSET_URL in "$MANIFEST_URL" "$MANIFEST_SIGNATURE_URL"
do
case "$ASSET_URL" in
"${SERVER_URL%/}/"*)
;;
*)
printf 'Refusing to send release credentials to untrusted URL: %s\n' \
"$ASSET_URL" >&2
exit 1
;;
esac
done
SOURCE_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json"
SOURCE_SIGNATURE="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json.sig"
MANIFEST_STATUS="$(api_request GET "$MANIFEST_URL" "$SOURCE_MANIFEST")"
if [[ "$MANIFEST_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest: HTTP %s\n' \
"$CHANNEL" "$MANIFEST_STATUS" >&2
exit 1
fi
SIGNATURE_STATUS="$(
api_request GET "$MANIFEST_SIGNATURE_URL" "$SOURCE_SIGNATURE"
)"
if [[ "$SIGNATURE_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest signature: HTTP %s\n' \
"$CHANNEL" "$SIGNATURE_STATUS" >&2
exit 1
fi
VERIFICATION_ARGUMENTS=("$SOURCE_MANIFEST" "$SOURCE_SIGNATURE")
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
VERIFICATION_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/verify_metadata.py" \
"${VERIFICATION_ARGUMENTS[@]}"
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_VERSION="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/prepare_channel_metadata.py" \
"$HISTORY" "$CHANNEL" "$SOURCE_MANIFEST" "$CHANNEL_MANIFEST"
)"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_SIGNING_ARGUMENTS=(
"$CHANNEL_MANIFEST"
"$CHANNEL_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
CHANNEL_SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" \
"${CHANNEL_SIGNING_ARGUMENTS[@]}"
printf '%s\n' "$CHANNEL_VERSION" > "${CHANNEL_DIRECTORY}/${CHANNEL}.version"
CHANNELS_TO_PUBLISH+=("$CHANNEL")
done
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
OPERATIONS='[]'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_OPERATION="$(
file_operation "updates/${CHANNEL}.json" "$(base64 -w 0 -- "$CHANNEL_MANIFEST")"
)"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "updates/${CHANNEL}.json.sig" \
"$(base64 -w 0 -- "$CHANNEL_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson signature "$CHANNEL_SIGNATURE_OPERATION" \
'$operations + [$channel, $signature]'
)"
done
HISTORY_OPERATION="$(
file_operation updates/history.json "$(base64 -w 0 -- "$HISTORY")"
)"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'$operations + [$history, $history_signature]'
)"
PAYLOAD="${RESPONSE_DIRECTORY}/history-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message 'Publish signed launcher release status' \
--argjson files "$OPERATIONS" \
'{branch:$branch,message:$message,files:$files}' \
> "$PAYLOAD"
PUBLISH_RESPONSE="${RESPONSE_DIRECTORY}/history-publish.json"
PUBLISH_STATUS="$(
api_request POST "${API_ROOT}/contents" "$PUBLISH_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${PAYLOAD}"
)"
if [[ "$PUBLISH_STATUS" != "201" ]]
then
printf 'Signed release-history publication failed: HTTP %s\n' "$PUBLISH_STATUS" >&2
jq -r '.message // empty' "$PUBLISH_RESPONSE" >&2 || true
exit 1
fi
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published launcher release status\n\n'
printf -- '- Generated: `%s`\n' "$GENERATED_AT"
printf -- '- History: `updates/history.json`\n'
printf -- '- Signature: `updates/history.json.sig`\n'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
printf -- '- %s channel: `%s`\n' \
"$CHANNEL" "$(cat -- "${CHANNEL_DIRECTORY}/${CHANNEL}.version")"
done
} >> "$SUMMARY_FILE"
printf 'Published signed launcher history and status-aware channel metadata.\n'

381
scripts/release.sh Executable file
View File

@@ -0,0 +1,381 @@
#!/usr/bin/env bash
set -euo pipefail
usage()
{
cat <<'EOF'
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
Build a verified local release package in .artifacts/releases/vVERSION/. This
command does not commit, tag, push, create a Gitea release, or upload files.
Channels:
stable
prerelease (aliases: pre, beta)
Options:
--description TEXT Developer-written Markdown release description
--description-file PATH Read developer-written Markdown from PATH
--no-auto-changelog Omit commit-derived change notes
--changelog-only Write and display release notes without building
--editor Edit the final notes with ${EDITOR:-nano}
--dry-run Build and verify in a temporary directory only
--skip-tests Skip configured local preflight checks
--signing-key PATH Use an external private signing key at PATH
--output-directory PATH Write the version package to PATH
-h, --help Show this help
EOF
}
if [[ $# -lt 1 ]]
then
usage >&2
exit 2
fi
if [[ "$1" == "-h" || "$1" == "--help" ]]
then
usage
exit 0
fi
if [[ $# -lt 2 ]]
then
usage >&2
exit 2
fi
CHANNEL_INPUT="$1"
VERSION="$2"
shift 2
case "$CHANNEL_INPUT" in
stable)
CHANNEL="stable"
;;
prerelease|pre|beta)
CHANNEL="prerelease"
;;
*)
printf 'Unsupported release channel: %s\n' "$CHANNEL_INPUT" >&2
exit 2
;;
esac
DESCRIPTION=""
DESCRIPTION_FILE=""
AUTO_CHANGELOG=1
CHANGELOG_ONLY=0
OPEN_EDITOR=0
DRY_RUN=0
SKIP_TESTS=0
SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}"
REQUESTED_OUTPUT_DIRECTORY=""
while [[ $# -gt 0 ]]
do
case "$1" in
--description)
[[ $# -ge 2 ]] || { printf '%s\n' '--description requires text.' >&2; exit 2; }
DESCRIPTION="$2"
shift 2
;;
--description-file)
[[ $# -ge 2 ]] || { printf '%s\n' '--description-file requires a path.' >&2; exit 2; }
DESCRIPTION_FILE="$2"
shift 2
;;
--no-auto-changelog)
AUTO_CHANGELOG=0
shift
;;
--changelog-only)
CHANGELOG_ONLY=1
shift
;;
--editor)
OPEN_EDITOR=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-tests)
SKIP_TESTS=1
shift
;;
--signing-key)
[[ $# -ge 2 ]] || { printf '%s\n' '--signing-key requires a path.' >&2; exit 2; }
SIGNING_KEY="$2"
shift 2
;;
--output-directory)
[[ $# -ge 2 ]] || { printf '%s\n' '--output-directory requires a path.' >&2; exit 2; }
REQUESTED_OUTPUT_DIRECTORY="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown release option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -n "$DESCRIPTION" && -n "$DESCRIPTION_FILE" ]]
then
printf '%s\n' 'Use either --description or --description-file, not both.' >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
CONFIG_PATH="${PROJECT_ROOT}/release/config.json"
if [[ -n "${PTLINUX_PYTHON:-}" ]]
then
PYTHON_BIN="$PTLINUX_PYTHON"
elif [[ -x "${PROJECT_ROOT}/.venv/bin/python" ]]
then
PYTHON_BIN="${PROJECT_ROOT}/.venv/bin/python"
else
PYTHON_BIN="python3"
fi
if [[ "$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" != "$PROJECT_ROOT" ]]
then
printf 'Release command is not inside the expected Git repository: %s\n' "$PROJECT_ROOT" >&2
exit 1
fi
cd -- "$PROJECT_ROOT"
export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
"$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION"
TAG="v${VERSION}"
COMMIT="$(git rev-parse HEAD)"
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]
then
printf '%s\n' \
"Warning: packaging the current working tree on top of source commit ${COMMIT}." \
'The signed per-file manifest records the exact packaged content.' >&2
fi
if [[ -n "$DESCRIPTION_FILE" && ! -f "$DESCRIPTION_FILE" ]]
then
printf 'Release description file does not exist: %s\n' "$DESCRIPTION_FILE" >&2
exit 1
fi
TEMPORARY_ROOT="$(mktemp -d)"
cleanup()
{
rm -rf -- "$TEMPORARY_ROOT"
}
trap cleanup EXIT
NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md"
if [[ "$DRY_RUN" -eq 1 && "$CHANGELOG_ONLY" -eq 0 ]]
then
OUTPUT_CANDIDATE="${TEMPORARY_ROOT}/releases/${TAG}"
else
OUTPUT_CANDIDATE="${REQUESTED_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/${TAG}}"
fi
OUTPUT_DIRECTORY="$(
"$PYTHON_BIN" -c \
'import sys; from pathlib import Path; from scripts.release_lib import _safe_output_directory; print(_safe_output_directory(Path(sys.argv[1])))' \
"$OUTPUT_CANDIDATE"
)"
if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]]
then
printf '%s\n' 'Release description' '-------------------' '' \
'Enter release notes. Finish with Ctrl+D.' ''
DESCRIPTION_FILE="${TEMPORARY_ROOT}/description.md"
cat < /dev/tty > "$DESCRIPTION_FILE"
fi
{
printf '# PristonTale EU Launcher %s\n\n' "$VERSION"
if [[ -n "$DESCRIPTION" ]]
then
printf '%s\n\n' "$DESCRIPTION"
elif [[ -n "$DESCRIPTION_FILE" ]]
then
cat -- "$DESCRIPTION_FILE"
if [[ -s "$DESCRIPTION_FILE" && "$(tail -c 1 -- "$DESCRIPTION_FILE" | wc -l)" -eq 0 ]]
then
printf '\n'
fi
printf '\n'
fi
if [[ "$AUTO_CHANGELOG" -eq 1 ]]
then
mapfile -t RELEASE_TAGS < <(git tag --merged HEAD)
PREVIOUS_TAG="$(
"$PYTHON_BIN" -m ptlinux.update_resolver previous-tag \
"$CHANNEL" "$VERSION" "${RELEASE_TAGS[@]}"
)"
printf '## Changes\n\n'
if [[ -n "$PREVIOUS_TAG" ]]
then
CHANGELOG_RANGE="${PREVIOUS_TAG}..HEAD"
else
CHANGELOG_RANGE="HEAD"
fi
CHANGELOG="$(git log "$CHANGELOG_RANGE" --no-merges --pretty=format:'- %s (`%h`)')"
if [[ -n "$CHANGELOG" ]]
then
printf '%s\n' "$CHANGELOG"
else
printf '%s\n' '- No commits since the previous release.'
fi
printf '\n'
fi
printf '%s\n' '---' '' \
"Version: \`${VERSION}\` " \
"Channel: \`${CHANNEL}\` " \
"Tag: \`${TAG}\` " \
"Source commit: \`${COMMIT}\`"
} > "$NOTES_PATH"
if [[ "$OPEN_EDITOR" -eq 1 ]]
then
EDITOR_COMMAND="${EDITOR:-nano}"
"$EDITOR_COMMAND" "$NOTES_PATH"
fi
printf '%s\n' '--------------------------------------------------' 'Release Notes' \
'--------------------------------------------------'
cat -- "$NOTES_PATH"
printf '%s\n' '--------------------------------------------------'
if [[ "$CHANGELOG_ONLY" -eq 1 ]]
then
mkdir -p -- "$OUTPUT_DIRECTORY"
cp -- "$NOTES_PATH" "${OUTPUT_DIRECTORY}/release-notes.md"
printf 'Release notes written to %s\n' \
"${OUTPUT_DIRECTORY}/release-notes.md"
exit 0
fi
if [[ -z "$SIGNING_KEY" ]]
then
printf '%s\n' 'A release signing key is required.' >&2
printf '%s\n' \
'Use --signing-key PATH or set PTLINUX_SIGNING_KEY to a path outside the repository.' >&2
exit 1
fi
SIGNING_KEY="$(
"$PYTHON_BIN" -c \
'import sys
from pathlib import Path
from scripts.release_lib import ReleaseToolError, _safe_signing_key_path
try:
print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2])))
except ReleaseToolError as exc:
print(f"Invalid release signing key: {exc}", file=sys.stderr)
raise SystemExit(2)' \
"$SIGNING_KEY" "$OUTPUT_DIRECTORY"
)"
if [[ "$SKIP_TESTS" -ne 1 ]]
then
mapfile -t PREFLIGHT_COMMANDS < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1],encoding="utf-8")); print("\n".join(value["preflight"]))' \
"$CONFIG_PATH"
)
for PREFLIGHT_COMMAND in "${PREFLIGHT_COMMANDS[@]}"
do
printf 'Preflight: %s\n' "$PREFLIGHT_COMMAND"
PTLINUX_PYTHON="$PYTHON_BIN" bash -o pipefail -c "$PREFLIGHT_COMMAND" \
< /dev/null
done
else
printf '%s\n' 'Skipping local preflight checks by request.'
fi
if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]]
then
printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2
printf '%s\n' 'Use --signing-key PATH or set PTLINUX_SIGNING_KEY.' >&2
exit 1
fi
SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
RELEASE_COMMIT="$COMMIT" \
RELEASE_TAG="$TAG" \
RELEASE_NOTES_FILE="$NOTES_PATH" \
PTLINUX_PYTHON="$PYTHON_BIN" \
PTLINUX_SIGNING_KEY="$SIGNING_KEY" \
REQUIRE_RELEASE_SIGNATURE=1 \
ALLOW_SOURCE_VERSION_OVERRIDE=1 \
"${SCRIPT_ROOT}/build-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
PTLINUX_PYTHON="$PYTHON_BIN" \
REQUIRE_RELEASE_SIGNATURE=1 \
"${SCRIPT_ROOT}/verify-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
if [[ "$DRY_RUN" -eq 1 ]]
then
printf '%s\n' \
'Dry run complete. The release built and verified in a temporary directory; .artifacts/ was not changed.'
exit 0
fi
mkdir -p -- "${OUTPUT_DIRECTORY}/channel"
cp -- "${OUTPUT_DIRECTORY}/manifest.json" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json"
cp -- "${OUTPUT_DIRECTORY}/manifest.json.sig" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json.sig"
mapfile -t PACKAGE_METADATA < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1], encoding="utf-8")); archive=value["archive"]; print(archive["layout"]); print(value["minimum_updater_version"]); print(archive["size"]); print(archive["sha256"]); print(archive["url"])' \
"${OUTPUT_DIRECTORY}/manifest.json"
)
ARCHIVE_LAYOUT="${PACKAGE_METADATA[0]}"
MINIMUM_UPDATER_VERSION="${PACKAGE_METADATA[1]}"
ARCHIVE_SIZE="${PACKAGE_METADATA[2]}"
ARCHIVE_SHA256="${PACKAGE_METADATA[3]}"
ARCHIVE_URL="${PACKAGE_METADATA[4]}"
if [[ "$CHANNEL" == "prerelease" ]]
then
PRERELEASE_INSTRUCTION='select the Gitea "Pre-release" checkbox'
else
PRERELEASE_INSTRUCTION='leave the Gitea "Pre-release" checkbox clear'
fi
printf '\nManual Gitea release package is ready in %s\n' "$OUTPUT_DIRECTORY"
printf 'Create tag/release: %s\n' "$TAG"
printf 'Release type: %s; %s.\n' "$CHANNEL" "$PRERELEASE_INSTRUCTION"
printf 'Archive layout: %s.\n' "$ARCHIVE_LAYOUT"
printf 'Minimum updater: %s.\n' "$MINIMUM_UPDATER_VERSION"
printf 'Archive size: %s bytes.\n' "$ARCHIVE_SIZE"
printf 'Archive SHA256: %s\n' "$ARCHIVE_SHA256"
printf 'Signed archive URL: %s\n' "$ARCHIVE_URL"
if [[ "$ARCHIVE_LAYOUT" == "wrapped" ]]
then
printf '%s\n' \
'Compatibility: legacy bridge archive; older launchers can install it automatically.'
else
printf '%s\n' \
'Compatibility: flat archive; launchers older than the minimum require a bridge release or installer update.'
fi
printf '%s\n' 'Upload these five files:' \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz" \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz.sha256" \
" ${OUTPUT_DIRECTORY}/manifest.json" \
" ${OUTPUT_DIRECTORY}/manifest.json.sig" \
" ${OUTPUT_DIRECTORY}/release-notes.md"
printf '%s\n' \
'Wait for every upload to finish and confirm the archive has the exact nonzero size above.' \
'Publish the channel files only after all five Gitea attachments are present.'
printf 'Channel files ready for a later repository update: %s/channel/%s.json{,.sig}\n' \
"$OUTPUT_DIRECTORY" "$CHANNEL"
printf '%s\n' 'No commit, tag, push, Gitea release, or upload was performed.'

1574
scripts/release_lib.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
"""Repository-only paths shared by maintainer scripts and tests."""
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
ARTIFACTS_ROOT = PROJECT_ROOT / ".artifacts"
RELEASE_ARTIFACTS_ROOT = ARTIFACTS_ROOT / "releases"
def ensure_project_imports() -> None:
"""Put checkout-only import roots ahead of ambient Python installations."""
ordered = [SOURCE_ROOT, PROJECT_ROOT]
for path in ordered:
text = str(path)
while text in sys.path:
sys.path.remove(text)
for path in reversed(ordered):
if path.is_dir():
sys.path.insert(0, str(path))

85
scripts/set_version.py Executable file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Update the authoritative launcher version and its project metadata mirror."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import sys
if __package__:
from .repository_paths import PROJECT_ROOT, ensure_project_imports
else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, load_config # noqa: E402
from ptlinux.update_resolver import SemanticVersion
SOURCE_PATTERN = re.compile(
r'(?m)^__version__ = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
PROJECT_PATTERN = re.compile(
r'(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ReleaseToolError(f"cannot read version file {path}: {exc}") from exc
updated, count = pattern.subn(replacement, original)
if count != 1:
raise ReleaseToolError(
f"expected exactly one version declaration in {path}, found {count}"
)
if updated == original:
return
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(path.stat().st_mode & 0o777)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
arguments = parser.parse_args()
try:
SemanticVersion.parse(arguments.version)
config = load_config()
version_source = config.get("version_source")
if not isinstance(version_source, str):
raise ReleaseToolError("version_source must be configured")
replace_once(
PROJECT_ROOT / version_source,
SOURCE_PATTERN,
f'__version__ = "{arguments.version}"',
)
mirrors = config.get("version_mirrors")
if not isinstance(mirrors, list) or not all(isinstance(item, str) for item in mirrors):
raise ReleaseToolError("version_mirrors must be configured")
for mirror in mirrors:
replace_once(
PROJECT_ROOT / mirror,
PROJECT_PATTERN,
f'version = "{arguments.version}"',
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Version update failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

48
scripts/sign_metadata.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Sign release metadata and verify it against the launcher's public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
if __package__:
from .repository_paths import SOURCE_ROOT, ensure_project_imports
else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
sign_file,
verify_signature,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument("private_key", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
sign_file(arguments.source, arguments.signature, arguments.private_key)
verify_signature(
arguments.source,
arguments.signature,
arguments.public_key,
)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata signing failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Confirm that a Git remote points at the configured release repository."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
from urllib.parse import unquote, urlsplit
class RemoteValidationError(ValueError):
pass
def repository_identity(value: str) -> tuple[str, str]:
if not value or any(character in value for character in "\r\n\0"):
raise RemoteValidationError("repository URL is empty or malformed")
scp = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value)
if scp is not None and "://" not in value:
return _remote_identity(scp.group(1), scp.group(2))
parts = urlsplit(value)
if parts.scheme in {"https", "http", "ssh", "git"}:
hostname = parts.hostname
if hostname is None:
raise RemoteValidationError("repository URL has no host")
host = hostname.casefold()
if parts.port is not None:
host += f":{parts.port}"
return _remote_identity(host, unquote(parts.path))
if parts.scheme == "file":
return "file", str(Path(unquote(parts.path)).expanduser().resolve())
if parts.scheme:
raise RemoteValidationError(f"unsupported repository URL scheme: {parts.scheme}")
return "file", str(Path(value).expanduser().resolve())
def remote_matches(actual: str, expected: list[str]) -> bool:
actual_identity = repository_identity(actual)
return any(repository_identity(candidate) == actual_identity for candidate in expected)
def _remote_identity(host: str, path: str) -> tuple[str, str]:
normalized = path.strip("/")
if normalized.endswith(".git"):
normalized = normalized[:-4]
if not host or not normalized or normalized.startswith("../"):
raise RemoteValidationError("repository URL has an invalid host or path")
return host.casefold(), normalized
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("actual")
parser.add_argument("expected", nargs="+")
arguments = parser.parse_args()
try:
if not remote_matches(arguments.actual, arguments.expected):
raise RemoteValidationError(
"configured Git remote does not point at the expected release repository"
)
except RemoteValidationError as exc:
print(f"Release remote validation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

31
scripts/verify-release.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
ARGUMENTS=(
"${SCRIPT_ROOT}/verify_release.py"
"$1"
"$2"
"$3"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
cd -- "$PROJECT_ROOT"
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Verify a detached release-metadata signature with the launcher public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
if __package__:
from .repository_paths import SOURCE_ROOT, ensure_project_imports
else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
verify_signature(arguments.source, arguments.signature, arguments.public_key)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata verification failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

50
scripts/verify_release.py Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Verify one complete release artifact set before publication."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--public-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
return parser
def main() -> int:
arguments = build_parser().parse_args()
try:
verify_release(
arguments.version,
arguments.channel,
arguments.output_directory,
public_key=arguments.public_key,
require_signature=arguments.require_signature,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release verification failed: {exc}", file=sys.stderr)
return 1
print(
f"Verified {arguments.channel} release {arguments.version} in "
f"{arguments.output_directory}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -13,9 +13,9 @@ __version__ = "0.1.0"
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
"""Load diagnostic configuration helpers only when the full harness needs them. """Load diagnostic configuration helpers only when the full harness needs them.
The production launcher ships only :mod:`ptdiag.assbin`, which it uses for The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as
verified scene handling. Deferring the larger diagnostic-only imports keeps compatibility façades. Deferring the larger diagnostic-only imports keeps the
the compact package importable without shipping the investigation harness. compact package importable without shipping the investigation harness.
""" """
if name in {"AppConfig", "ConfigError", "load_config"}: if name in {"AppConfig", "ConfigError", "load_config"}:
from .config import AppConfig, ConfigError, load_config from .config import AppConfig, ConfigError, load_config

22
src/ptdiag/artifacts.py Normal file
View File

@@ -0,0 +1,22 @@
"""Generated diagnostic output paths for checkout-only experiments."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
from .config import repository_root
RESULTS_ROOT_ENVIRONMENT_VARIABLE = "PTDIAG_RESULTS_ROOT"
def default_results_root(environment: Mapping[str, str] | None = None) -> Path:
"""Return the configurable root for generated diagnostic evidence."""
values = os.environ if environment is None else environment
explicit = values.get(RESULTS_ROOT_ENVIRONMENT_VARIABLE, "").strip()
if explicit:
return Path(explicit).expanduser().absolute()
return repository_root() / ".artifacts/diagnostics/results"

72
src/ptdiag/assbin.py Normal file
View File

@@ -0,0 +1,72 @@
"""Compatibility façade for launcher-owned wrapped Assbin handling."""
from ptlinux.game.compatibility.assbin import (
ASSBIN_CHUNK_AIANIMATION,
ASSBIN_CHUNK_AIMATERIAL,
ASSBIN_CHUNK_AIMATERIALPROPERTY,
ASSBIN_CHUNK_AIMESH,
ASSBIN_CHUNK_AINODE,
ASSBIN_CHUNK_AISCENE,
ASSBIN_CHUNK_AITEXTURE,
ASSBIN_HEADER_LENGTH,
MAX_CAPTURE_LENGTH,
MAX_STRING_LENGTH,
PROPERTY_TYPE_NAMES,
READ_CHUNK_SIZE,
AssbinError,
AssbinHeader,
BlobSummary,
BytesReader,
Chunk,
EmbeddedTexture,
Material,
MaterialProperty,
PngMetadata,
Reader,
SceneCounts,
SceneInspection,
XorFileReader,
_inspect_png,
_png_scanlines_sha256,
extract_embedded_texture,
inspect_wrapped_assbin,
normalize_png_16_rgb_to_8,
normalize_wrapped_assbin_textures,
xor_bytes,
)
__all__ = [
"ASSBIN_CHUNK_AIANIMATION",
"ASSBIN_CHUNK_AIMATERIAL",
"ASSBIN_CHUNK_AIMATERIALPROPERTY",
"ASSBIN_CHUNK_AIMESH",
"ASSBIN_CHUNK_AINODE",
"ASSBIN_CHUNK_AISCENE",
"ASSBIN_CHUNK_AITEXTURE",
"ASSBIN_HEADER_LENGTH",
"MAX_CAPTURE_LENGTH",
"MAX_STRING_LENGTH",
"PROPERTY_TYPE_NAMES",
"READ_CHUNK_SIZE",
"AssbinError",
"AssbinHeader",
"BlobSummary",
"BytesReader",
"Chunk",
"EmbeddedTexture",
"Material",
"MaterialProperty",
"PngMetadata",
"Reader",
"SceneCounts",
"SceneInspection",
"XorFileReader",
"_inspect_png",
"_png_scanlines_sha256",
"extract_embedded_texture",
"inspect_wrapped_assbin",
"normalize_png_16_rgb_to_8",
"normalize_wrapped_assbin_textures",
"xor_bytes",
]

View File

@@ -9,7 +9,8 @@ import re
import tomllib import tomllib
DEFAULT_CONFIG_PATH = Path("config/local.toml") DEFAULT_CONFIG_PATH = Path("diagnostics/config/local.toml")
LEGACY_CONFIG_PATH = Path("config/local.toml")
class ConfigError(ValueError): class ConfigError(ValueError):
@@ -40,19 +41,11 @@ class GameConfig:
virtual_desktop: str virtual_desktop: str
@dataclass(frozen=True)
class SafetyConfig:
allow_live_game_modification: bool
allow_prefix_modification: bool
allow_binary_patch: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class AppConfig: class AppConfig:
paths: PathsConfig paths: PathsConfig
proton7: Proton7Config proton7: Proton7Config
game: GameConfig game: GameConfig
safety: SafetyConfig
source: Path source: Path
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@@ -61,19 +54,48 @@ class AppConfig:
def project_root() -> Path: def project_root() -> Path:
"""Return the Python source root used by checkout-only helper commands."""
return Path(__file__).resolve().parent.parent return Path(__file__).resolve().parent.parent
def repository_root() -> Path:
"""Return the repository root while retaining flat-install compatibility."""
source_root = project_root()
checkout_root = source_root.parent
if source_root.name == "src" and (checkout_root / "pyproject.toml").is_file():
return checkout_root
return source_root
def diagnostics_root() -> Path:
return repository_root() / "diagnostics"
def resolve_config_path(path: str | Path | None = None) -> Path:
if path is not None:
return Path(path).expanduser().absolute()
root = repository_root()
preferred = root / DEFAULT_CONFIG_PATH
legacy = root / LEGACY_CONFIG_PATH
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_config(path: str | Path | None = None) -> AppConfig: def load_config(path: str | Path | None = None) -> AppConfig:
config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH config_path = resolve_config_path(path)
config_path = config_path.expanduser().absolute()
try: try:
with config_path.open("rb") as handle: with config_path.open("rb") as handle:
data = tomllib.load(handle) data = tomllib.load(handle)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise ConfigError( raise ConfigError(
f"Configuration not found: {config_path}. " f"Configuration not found: {config_path}. "
"Copy config/local.example.toml to config/local.toml." "Copy diagnostics/config/local.example.toml to "
"diagnostics/config/local.toml. Legacy config/local.toml is also recognized."
) from exc ) from exc
except tomllib.TOMLDecodeError as exc: except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
@@ -81,7 +103,6 @@ def load_config(path: str | Path | None = None) -> AppConfig:
paths = _section(data, "paths") paths = _section(data, "paths")
proton7 = _section(data, "proton7") proton7 = _section(data, "proton7")
game = _section(data, "game") game = _section(data, "game")
safety = _section(data, "safety")
virtual_desktop = _required_string(game, "virtual_desktop") virtual_desktop = _required_string(game, "virtual_desktop")
if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None: if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None:
raise ConfigError("game.virtual_desktop must look like 1600x900") raise ConfigError("game.virtual_desktop must look like 1600x900")
@@ -105,15 +126,6 @@ def load_config(path: str | Path | None = None) -> AppConfig:
args=_required_string_tuple(game, "args"), args=_required_string_tuple(game, "args"),
virtual_desktop=virtual_desktop, virtual_desktop=virtual_desktop,
), ),
safety=SafetyConfig(
allow_live_game_modification=_required_bool(
safety, "allow_live_game_modification"
),
allow_prefix_modification=_required_bool(
safety, "allow_prefix_modification"
),
allow_binary_patch=_required_bool(safety, "allow_binary_patch"),
),
source=config_path, source=config_path,
) )
@@ -145,13 +157,6 @@ def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, .
return tuple(value) return tuple(value)
def _required_bool(section: Mapping[str, Any], key: str) -> bool:
value = section.get(key)
if not isinstance(value, bool):
raise ConfigError(f"{key} must be true or false")
return value
def _paths_to_strings(value: Any) -> Any: def _paths_to_strings(value: Any) -> Any:
if isinstance(value, Path): if isinstance(value, Path):
return str(value) return str(value)

775
src/ptdiag/experiment.py Normal file
View File

@@ -0,0 +1,775 @@
"""Controlled Proton 7 runtime experiments and result generation."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any, Mapping, Sequence
from .artifacts import default_results_root
from .config import AppConfig, project_root
from .experiments.cleanup import (
DISPOSABLE_DIRECTORY_NAME as _DISPOSABLE_DIRECTORY_NAME,
cleanup_checked as _cleanup_checked_impl,
command_ok as _command_ok,
registry_restore_command as _registry_restore_command_impl,
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
remove_disposable_game as _remove_disposable_game_impl,
replace_game_link as _replace_game_link,
require_command as _require_command,
restore_game_link as _restore_game_link,
run_checked_commands as _run_checked_commands,
validate_disposable_path as _validate_disposable_path_impl,
)
from .experiments.evidence import (
collect_dll_overrides as _collect_dll_overrides_impl,
collect_hashes as _collect_hashes_impl,
desktop_name as _desktop_name,
finalize_result as _finalize_impl,
render_report as _report,
serialize_log_state as _serialize_log_state,
serialize_log_states as _serialize_log_states,
setup_failure_summary as _setup_failure_summary,
write_text as _write_text,
)
from .experiments.execution import (
capture_game_process as _capture_game_process_impl,
game_processes as _game_processes_impl,
run_and_watch as _run_and_watch_impl,
stop_process_group as _stop_process_group_impl,
)
from .experiments.log_selection import (
capture_current_native_logs,
native_log_priority as _native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .experiments.models import ExperimentError, ExperimentResult
from .experiments.preparation import (
_allocated_tree_bytes,
_apply_disposable_ini_overrides,
_dry_run_commands,
_ini_override_command,
_inspect_ini_overrides,
_launch_argv,
_log_modification_plan,
_preflight as _preflight_impl,
_prepare_disposable_game,
_prepare_state_cache,
_proton_command,
_proton_environment,
_resource_copy_paths,
_runtime_environment,
_safe_game_file,
_safe_resource_relative_path,
_state_cache_file_snapshot,
_state_cache_plan,
)
from .filesystem import sha256_file, verify_known_game_hashes
from .logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
from .ini import IniError
from .performance import PerformanceSampler
from .prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
)
from .process import collect_environment
from .registry import (
DesktopRegistrySnapshot,
desktop_registry_restored,
read_registry_string_values,
snapshot_desktop_registry,
)
from .result import ResultDirectory, create_result_directory, write_json
from .runner import CommandLogger
from .testcase import Testcase
from .trace import process_trace_shards
def run_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None = None,
) -> ExperimentResult:
if testcase.modification is not None:
return _run_modified_experiment(
config,
testcase,
dry_run=dry_run,
results_root=results_root,
)
return _run_standard_experiment(
config,
testcase,
dry_run=dry_run,
results_root=results_root,
)
def _run_standard_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None = None,
prepared_result: ResultDirectory | None = None,
started_at: datetime | None = None,
) -> ExperimentResult:
started_at = started_at or datetime.now().astimezone()
result = prepared_result or create_result_directory(
results_root or default_results_root(), testcase.name, started_at
)
logger = CommandLogger(result.commands_path)
(result.path / "runner.log").touch(exist_ok=True)
(result.path / "native.log").touch(exist_ok=True)
environment = collect_environment(config)
hashes = _collect_hashes(config, testcase)
write_json(result.path / "hashes.json", {"files": hashes})
desktop_name = _desktop_name(result.path)
metadata: dict[str, Any] = {
"test": testcase.name,
"description": testcase.description,
"started_at": started_at.isoformat(),
"result_directory": str(result.path),
"dry_run": dry_run,
"testcase": testcase.to_dict(),
"config_source": str(config.source),
"desktop_name": desktop_name,
"native_log_directory": str(config.paths.native_logs),
}
write_json(result.metadata_path, metadata)
preflight_errors = _preflight(config, testcase, environment, hashes)
baseline = snapshot_native_logs(config.paths.native_logs)
metadata["old_log_baseline"] = _serialize_log_states(baseline)
write_json(result.metadata_path, metadata)
registry_before: DesktopRegistrySnapshot | None = None
try:
registry_before = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
except OSError as exc:
preflight_errors.append(f"Could not snapshot dedicated-prefix registry: {exc}")
launch_argv = _launch_argv(config, testcase, result.path)
env_overrides = _runtime_environment(config, testcase, result.path)
state_cache_summary = _state_cache_plan(config, env_overrides)
dll_overrides = (
_collect_dll_overrides(config.proton7.prefix)
if testcase.capture.dll_overrides
else {}
)
_write_text(
result.path / "environment.txt",
json.dumps(
{
"preflight": environment,
"runtime_overrides": dict(sorted(env_overrides.items())),
"dll_overrides": dll_overrides,
},
indent=2,
sort_keys=True,
),
)
metadata["dll_overrides"] = dll_overrides
write_json(result.metadata_path, metadata)
summary: dict[str, Any] = {
"ok": False,
"status": "preflight-failed" if preflight_errors else "prepared",
"dry_run": dry_run,
"test": testcase.name,
"result_directory": str(result.path),
"preflight": {
"ok": not preflight_errors,
"errors": preflight_errors,
},
"launch": {
"argv": launch_argv,
"cwd": str(config.paths.live_game),
"env_overrides": dict(sorted(env_overrides.items())),
"timeout_seconds": testcase.timeout_seconds,
},
"native_log": {
"directory": str(config.paths.native_logs),
"baseline": _serialize_log_states(baseline),
"source": None,
"current_run_bytes": 0,
},
"registry": {
"temporary_change": testcase.display.virtual_desktop,
"before": registry_before.to_dict() if registry_before else None,
"after": None,
"restored": None,
},
"dxvk_state_cache": state_cache_summary,
}
if preflight_errors:
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
assert registry_before is not None
if dry_run:
_dry_run_commands(
config,
testcase,
logger,
registry_before,
launch_argv,
env_overrides,
)
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
summary["registry"]["after"] = registry_after.to_dict()
summary["registry"]["restored"] = desktop_registry_restored(
registry_before, registry_after
)
summary["status"] = "dry-run"
summary["ok"] = True
summary["review_gate"] = (
"Phase C dry-run complete; review artifacts before a live Game.exe launch."
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 0)
runtime_exit = 1
setup_complete = False
try:
if state_cache_summary is not None:
state_cache_summary = _prepare_state_cache(
config,
env_overrides,
logger,
)
summary["dxvk_state_cache"] = state_cache_summary
_cleanup_checked(config, logger)
if testcase.display.virtual_desktop:
_run_checked_commands(
logger,
_registry_setup_commands(config, desktop_name, testcase.display.size),
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
)
setup_complete = True
baseline = snapshot_native_logs(config.paths.native_logs)
metadata["old_log_baseline"] = _serialize_log_states(baseline)
write_json(result.metadata_path, metadata)
runtime = _run_and_watch(
config,
testcase,
result,
logger,
launch_argv,
env_overrides,
baseline,
)
summary["runtime"] = runtime
summary["native_log"].update(runtime.pop("native_log"))
runtime_exit = (
0
if runtime.get("failure_detected") or runtime.get("scene_import_survived")
else 1
)
trigger_epoch = runtime.get("trigger_epoch")
if testcase.capture.strace_files and isinstance(trigger_epoch, (int, float)):
trace = process_trace_shards(
result.path / "raw-strace",
result.path,
trigger_epoch=float(trigger_epoch),
focus_before_seconds=testcase.capture.focus_before_seconds,
focus_after_seconds=testcase.capture.focus_after_seconds,
live_game=config.paths.live_game,
)
summary["trace"] = trace.to_dict()
else:
summary["trace"] = None
if runtime.get("failure_detected"):
summary["status"] = "captured-scene-failure"
elif runtime.get("scene_import_survived"):
summary["status"] = "scene-import-survived"
else:
summary["status"] = "no-trigger"
summary["ok"] = bool(
runtime.get("failure_detected") or runtime.get("scene_import_survived")
)
except (
ExperimentError,
OSError,
ValueError,
PrefixSafetyError,
subprocess.SubprocessError,
) as exc:
summary["status"] = "runner-error"
summary["error"] = str(exc)
summary["ok"] = False
runtime_exit = 2
finally:
cleanup_error: str | None = None
try:
_cleanup_checked(config, logger)
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
cleanup_error = str(exc)
if testcase.display.virtual_desktop and setup_complete:
try:
_run_checked_commands(
logger,
_registry_restore_commands(config, registry_before),
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
)
except ExperimentError as exc:
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
try:
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
summary["registry"]["after"] = registry_after.to_dict()
summary["registry"]["restored"] = desktop_registry_restored(
registry_before, registry_after
)
except OSError as exc:
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
if cleanup_error:
summary["cleanup_error"] = cleanup_error
summary["ok"] = False
runtime_exit = 2
if state_cache_summary is not None:
state_cache_summary["after"] = _state_cache_file_snapshot(
Path(str(state_cache_summary["cache_file"]))
)
summary["dxvk_state_cache"] = state_cache_summary
_finalize(result, summary)
return ExperimentResult(result.path, summary, runtime_exit)
def _run_modified_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None,
) -> ExperimentResult:
modification = testcase.modification
assert modification is not None
started_at = datetime.now().astimezone()
result = create_result_directory(
results_root or default_results_root(), testcase.name, started_at
)
(result.path / "runner.log").touch(exist_ok=True)
(result.path / "native.log").touch(exist_ok=True)
logger = CommandLogger(result.commands_path)
source_environment = collect_environment(config)
source_hashes = _collect_hashes(config, testcase)
source_errors = _preflight(config, testcase, source_environment, source_hashes)
write_json(
result.path / "source-preflight.json",
{
"environment": source_environment,
"hashes": source_hashes,
"ok": not source_errors,
"errors": source_errors,
},
)
disposable_game = (
config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME / result.path.name
).absolute()
modification_summary: dict[str, Any] = {
"kind": modification.kind,
"source": modification.source,
"destination": modification.destination,
"texture_indices": list(modification.texture_indices),
"ini_overrides": [
{
"path": item.path,
"section": item.section,
"key": item.key,
"expected": item.expected,
"value": item.value,
}
for item in modification.ini_overrides
],
"disposable_game": str(disposable_game),
"source_preflight_ok": not source_errors,
"prepared": False,
"prefix_link_restored": None,
"disposable_removed": None,
}
write_json(result.path / "modification.json", modification_summary)
if source_errors:
summary = _setup_failure_summary(
testcase, result, dry_run, source_errors, modification_summary
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
try:
source, live_destination = _resource_copy_paths(
config.paths.live_game, modification
)
ini_preflight = _inspect_ini_overrides(
config.paths.live_game,
modification.ini_overrides,
)
for item in ini_preflight:
if not item["matches_expected"]:
raise ExperimentError(
f"INI preflight mismatch for [{item['section']}] {item['key']}: "
f"expected {item['expected']!r}, found {item['current']!r}"
)
except (OSError, ValueError, ExperimentError) as exc:
summary = _setup_failure_summary(
testcase, result, dry_run, [str(exc)], modification_summary
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
modification_summary.update(
{
"source_path": str(source),
"live_destination_path": str(live_destination),
"live_destination_existed_before": live_destination.exists(),
"source_sha256": sha256_file(source),
"source_size": source.stat().st_size,
"ini_preflight": ini_preflight,
}
)
if dry_run:
_log_modification_plan(
logger,
config,
disposable_game,
source,
modification,
)
outcome = _run_standard_experiment(
config,
testcase,
dry_run=True,
prepared_result=result,
started_at=started_at,
)
modification_summary["status"] = "planned-only"
outcome.summary["modification"] = modification_summary
write_json(result.path / "modification.json", modification_summary)
_finalize(result, outcome.summary)
return outcome
prefix_link = (
config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU"
)
original_link_target: str | None = None
link_changed = False
disposable_created = False
runtime_config = config
outcome: ExperimentResult | None = None
setup_error: str | None = None
cleanup_errors: list[str] = []
try:
_cleanup_checked(config, logger)
preparation = _prepare_disposable_game(
config,
result,
logger,
modification,
disposable_game,
source,
)
disposable_created = True
modification_summary.update(preparation)
original_link_target = _replace_game_link(
prefix_link,
disposable_game,
config.paths.live_game,
logger,
)
link_changed = True
modification_summary.update(
{
"prefix_link": str(prefix_link),
"prefix_link_original_target": original_link_target,
"prefix_link_runtime_target": str(disposable_game),
}
)
runtime_config = replace(
config,
paths=replace(
config.paths,
live_game=disposable_game,
native_logs=disposable_game / "save/logs",
),
)
modification_summary["prepared"] = True
write_json(result.path / "modification.json", modification_summary)
outcome = _run_standard_experiment(
runtime_config,
testcase,
dry_run=False,
prepared_result=result,
started_at=started_at,
)
except (
ExperimentError,
OSError,
ValueError,
PrefixSafetyError,
subprocess.SubprocessError,
) as exc:
setup_error = str(exc)
finally:
try:
_cleanup_checked(runtime_config, logger)
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
cleanup_errors.append(f"wineserver cleanup: {exc}")
if disposable_created and disposable_game.exists() and modification.ini_overrides:
try:
modification_summary["ini_runtime_final"] = _inspect_ini_overrides(
disposable_game,
modification.ini_overrides,
)
except (ExperimentError, IniError, OSError) as exc:
cleanup_errors.append(f"final INI capture: {exc}")
if link_changed and original_link_target is not None:
try:
_restore_game_link(
prefix_link,
original_link_target,
config.paths.live_game,
logger,
)
modification_summary["prefix_link_restored"] = True
except (ExperimentError, OSError) as exc:
modification_summary["prefix_link_restored"] = False
cleanup_errors.append(f"prefix link restore: {exc}")
if disposable_created or disposable_game.exists():
try:
_remove_disposable_game(config, result, disposable_game, logger)
modification_summary["disposable_removed"] = True
except (ExperimentError, OSError) as exc:
modification_summary["disposable_removed"] = False
cleanup_errors.append(f"disposable cleanup: {exc}")
if outcome is None:
errors = [setup_error or "Modified experiment did not start", *cleanup_errors]
summary = _setup_failure_summary(
testcase, result, False, errors, modification_summary
)
exit_code = 2
else:
summary = outcome.summary
exit_code = outcome.exit_code
if setup_error:
summary["status"] = "setup-error"
summary["error"] = setup_error
summary["ok"] = False
exit_code = 2
if cleanup_errors:
summary["cleanup_error"] = "; ".join(cleanup_errors)
summary["ok"] = False
exit_code = 2
summary["modification"] = modification_summary
write_json(result.path / "modification.json", modification_summary)
_finalize(result, summary)
return ExperimentResult(result.path, summary, exit_code)
def _remove_disposable_game(
config: AppConfig,
result: ResultDirectory,
disposable_game: Path,
logger: CommandLogger,
) -> None:
_remove_disposable_game_impl(
config,
result,
disposable_game,
logger,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _validate_disposable_path(
staging_root: Path,
path: Path,
expected_name: str,
) -> None:
_validate_disposable_path_impl(
staging_root,
path,
expected_name,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
) -> list[str]:
return _preflight_impl(
config,
testcase,
environment,
hashes,
game_processes=_game_processes,
)
def _run_and_watch(
config: AppConfig,
testcase: Testcase,
result: ResultDirectory,
logger: CommandLogger,
argv: Sequence[str],
env_overrides: Mapping[str, str],
baseline: Mapping[str, LogFileState],
) -> dict[str, Any]:
return _run_and_watch_impl(
config,
testcase,
result,
logger,
argv,
env_overrides,
baseline,
popen=subprocess.Popen,
clock_ns=time.time_ns,
sleep=time.sleep,
now=lambda: datetime.now().astimezone(),
environment_factory=lambda: os.environ.copy(),
json_writer=write_json,
process_capture=_capture_game_process,
process_stopper=_stop_process_group,
performance_sampler_factory=PerformanceSampler,
log_observer=observe_current_native_logs,
log_capture=capture_current_native_logs,
scene_survived=scene_import_survived,
serialize_state=_serialize_log_state,
text_writer=_write_text,
prioritize_log=_native_log_priority,
discover_logs=discover_current_native_logs,
parse_log=parse_current_run_log,
read_log=read_current_run_log_text,
snapshot_logs=snapshot_native_logs,
)
def _stop_process_group(process: subprocess.Popen[str]) -> None:
_stop_process_group_impl(
process,
kill_group=os.killpg,
signals=(
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
)
def _capture_game_process(
testcase: Testcase,
result_directory: Path,
) -> dict[str, Any] | None:
return _capture_game_process_impl(
testcase,
result_directory,
game_process_scan=_game_processes,
proc_root=Path("/proc"),
copy_file=shutil.copyfile,
)
def _game_processes() -> list[int]:
return _game_processes_impl(proc_root=Path("/proc"))
def _registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
) -> list[list[str]]:
return _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)
def _registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
) -> list[str]:
return _registry_restore_command_impl(
config,
key,
name,
existed,
value,
proton_command=_proton_command,
)
def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None:
_cleanup_checked_impl(
config,
logger,
cleaner=cleanup_dedicated_wineserver,
)
def _collect_hashes(
config: AppConfig,
testcase: Testcase,
) -> list[dict[str, Any]]:
return _collect_hashes_impl(
config,
testcase,
repository_root=project_root(),
verifier=verify_known_game_hashes,
hash_file=sha256_file,
)
def _collect_dll_overrides(prefix: Path) -> dict[str, str]:
return _collect_dll_overrides_impl(
prefix,
reader=read_registry_string_values,
)
def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None:
_finalize_impl(
result,
summary,
json_writer=write_json,
text_writer=_write_text,
report_renderer=_report,
)

View File

@@ -0,0 +1 @@
"""Controlled diagnostic experiment implementation modules."""

View File

@@ -0,0 +1,287 @@
"""Experiment cleanup, prefix-link rollback, and registry restoration."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from ..config import AppConfig
from ..prefix import cleanup_dedicated_wineserver
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger, CommandResult
from .models import ExperimentError
EXPLORER_KEY = r"HKCU\Software\Wine\Explorer"
DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops"
DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable"
ProtonCommand = Callable[..., list[str]]
WineserverCleaner = Callable[..., CommandResult]
def replace_game_link(
prefix_link: Path,
disposable_game: Path,
expected_live_game: Path,
logger: CommandLogger,
) -> str:
"""Point the dedicated prefix at a verified disposable game tree."""
if not prefix_link.is_symlink():
raise ExperimentError(
f"Dedicated-prefix game path is not a symlink: {prefix_link}"
)
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
raise ExperimentError("Dedicated-prefix game link changed after source preflight")
original_target = os.readlink(prefix_link)
require_command(
logger.run(
["ln", "-sfn", "--", str(disposable_game), str(prefix_link)],
cwd=prefix_link.parent,
timeout_seconds=15.0,
),
"point dedicated prefix at disposable game",
)
if (
not prefix_link.is_symlink()
or prefix_link.resolve(strict=True) != disposable_game.resolve(strict=True)
):
raise ExperimentError(
"Dedicated-prefix game link did not resolve to disposable copy"
)
return original_target
def restore_game_link(
prefix_link: Path,
original_target: str,
expected_live_game: Path,
logger: CommandLogger,
) -> None:
"""Restore the dedicated-prefix game link and verify its exact target."""
require_command(
logger.run(
["ln", "-sfn", "--", original_target, str(prefix_link)],
cwd=prefix_link.parent,
timeout_seconds=15.0,
),
"restore dedicated-prefix game link",
)
if not prefix_link.is_symlink():
raise ExperimentError("Restored dedicated-prefix game path is not a symlink")
if os.readlink(prefix_link) != original_target:
raise ExperimentError("Dedicated-prefix game link text was not restored exactly")
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
raise ExperimentError("Dedicated-prefix game link target was not restored")
def remove_disposable_game(
config: AppConfig,
result: ResultDirectory,
disposable_game: Path,
logger: CommandLogger,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Remove only the result-scoped disposable tree beneath its managed root."""
staging_root = (config.paths.app_root / directory_name).absolute()
validate_disposable_path(
staging_root,
disposable_game,
result.path.name,
directory_name=directory_name,
)
require_command(
logger.run(
["rm", "-rf", "--one-file-system", "--", str(disposable_game)],
timeout_seconds=900.0,
),
"remove disposable game tree",
)
if disposable_game.exists() or disposable_game.is_symlink():
raise ExperimentError(
f"Disposable game cleanup left its target: {disposable_game}"
)
try:
staging_root.rmdir()
except OSError:
pass
def validate_disposable_path(
staging_root: Path,
path: Path,
expected_name: str,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Reject cleanup targets outside the managed, result-scoped staging root."""
if path.parent != staging_root or path.name != expected_name:
raise ExperimentError(f"Unsafe disposable game path: {path}")
if staging_root.name != directory_name:
raise ExperimentError(f"Unsafe disposable staging root: {staging_root}")
def require_command(outcome: CommandResult, operation: str) -> None:
if not command_ok(outcome):
raise ExperimentError(
f"Could not {operation} ({outcome.returncode}): "
f"{outcome.error or outcome.stderr.strip() or 'command failed'}"
)
def registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
*,
proton_command: ProtonCommand,
) -> list[list[str]]:
return [
proton_command(
config,
"runinprefix",
"reg.exe",
"add",
EXPLORER_KEY,
"/v",
"Desktop",
"/t",
"REG_SZ",
"/d",
desktop_name,
"/f",
),
proton_command(
config,
"runinprefix",
"reg.exe",
"add",
DESKTOPS_KEY,
"/v",
desktop_name,
"/t",
"REG_SZ",
"/d",
desktop_size,
"/f",
),
]
def registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
*,
proton_command: ProtonCommand,
) -> list[list[str]]:
return [
registry_restore_command(
config,
EXPLORER_KEY,
"Desktop",
snapshot.selected_desktop.exists,
snapshot.selected_desktop.value,
proton_command=proton_command,
),
registry_restore_command(
config,
DESKTOPS_KEY,
snapshot.desktop_name,
snapshot.desktop_size.exists,
snapshot.desktop_size.value,
proton_command=proton_command,
),
]
def registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
*,
proton_command: ProtonCommand,
) -> list[str]:
if existed:
assert value is not None
return proton_command(
config,
"runinprefix",
"reg.exe",
"add",
key,
"/v",
name,
"/t",
"REG_SZ",
"/d",
value,
"/f",
)
return proton_command(
config,
"runinprefix",
"reg.exe",
"delete",
key,
"/v",
name,
"/f",
)
def run_checked_commands(
logger: CommandLogger,
commands: Sequence[Sequence[str]],
*,
cwd: Path,
env_overrides: Mapping[str, str],
) -> None:
for command in commands:
outcome = logger.run(
command,
cwd=cwd,
env_overrides=env_overrides,
timeout_seconds=15.0,
)
if not command_ok(outcome):
raise ExperimentError(
f"Command failed ({outcome.returncode}): {list(command)!r}: "
f"{outcome.error or outcome.stderr.strip()}"
)
def cleanup_checked(
config: AppConfig,
logger: CommandLogger,
*,
cleaner: WineserverCleaner = cleanup_dedicated_wineserver,
) -> None:
"""Stop the dedicated wineserver while accepting its idle return contract."""
outcome = cleaner(config, logger, timeout_seconds=15.0)
if (
outcome.returncode == 1
and not outcome.timed_out
and outcome.error is None
and not outcome.stdout
and not outcome.stderr
):
return
if not command_ok(outcome):
raise ExperimentError(
"Dedicated-prefix wineserver cleanup failed: "
f"{outcome.error or outcome.stderr.strip() or outcome.returncode}"
)
def command_ok(result: CommandResult) -> bool:
return not result.timed_out and result.error is None and result.returncode == 0

View File

@@ -0,0 +1,257 @@
"""Evidence collection, serialization, and experiment report generation."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from typing import Any, Protocol
from ..config import AppConfig, project_root
from ..filesystem import sha256_file, verify_known_game_hashes
from ..logs import LogFileState
from ..registry import read_registry_string_values
from ..result import ResultDirectory, write_json
from ..testcase import Testcase
class HashVerification(Protocol):
def to_dict(self) -> dict[str, Any]: ...
HashVerifier = Callable[[Path], Sequence[HashVerification]]
HashFile = Callable[[Path], str]
RegistryReader = Callable[[Path], Mapping[str, Mapping[str, str]]]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
ReportRenderer = Callable[[Mapping[str, Any]], str]
def collect_hashes(
config: AppConfig,
testcase: Testcase,
*,
repository_root: Path | None = None,
verifier: HashVerifier = verify_known_game_hashes,
hash_file: HashFile = sha256_file,
) -> list[dict[str, Any]]:
"""Collect the immutable inputs needed to interpret one experiment."""
root = project_root() if repository_root is None else repository_root
items: list[dict[str, Any]] = []
for verification in verifier(config.paths.live_game):
item = verification.to_dict()
item["required"] = True
item["role"] = "known-game-baseline"
items.append(item)
extra = {
config.proton7.proton: "proton-launcher",
config.proton7.prefix
/ "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43",
config.proton7.prefix
/ "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43",
config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9",
root / "ptlinux/proton_compat.py": "proton-python314-shim",
config.source: "local-config",
testcase.source: "testcase",
}
dxvk_config = testcase.env.get("DXVK_CONFIG_FILE")
if dxvk_config:
dxvk_config_path = Path(dxvk_config).expanduser()
if not dxvk_config_path.is_absolute():
dxvk_config_path = (root / dxvk_config_path).absolute()
extra[dxvk_config_path] = "dxvk-config"
for path, role in extra.items():
item: dict[str, Any] = {
"path": str(path),
"role": role,
"required": role not in {"local-config", "testcase"},
"expected_sha256": None,
"actual_sha256": None,
"ok": False,
"error": None,
}
try:
item["actual_sha256"] = hash_file(path)
item["ok"] = True
except OSError as error:
item["error"] = str(error)
items.append(item)
return items
def collect_dll_overrides(
prefix: Path,
*,
reader: RegistryReader = read_registry_string_values,
) -> dict[str, str]:
sections = reader(prefix / "user.reg")
return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items()))
def serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]:
return [
serialize_log_state(state)
for state in sorted(states.values(), key=lambda item: str(item.path))
]
def serialize_log_state(state: LogFileState) -> dict[str, Any]:
return {
"path": str(state.path),
"mtime_ns": state.mtime_ns,
"size": state.size,
"inode": state.inode,
}
def desktop_name(result_directory: Path) -> str:
digits = "".join(character for character in result_directory.name if character.isdigit())
return f"PTDiag{digits[-12:]}"
def setup_failure_summary(
testcase: Testcase,
result: ResultDirectory,
dry_run: bool,
errors: Sequence[str],
modification: Mapping[str, Any],
) -> dict[str, Any]:
return {
"ok": False,
"status": "preflight-failed" if errors else "setup-error",
"dry_run": dry_run,
"test": testcase.name,
"result_directory": str(result.path),
"preflight": {"ok": False, "errors": list(errors)},
"modification": dict(modification),
"native_log": {"source": None, "current_run_bytes": 0},
"registry": {"restored": None},
}
def finalize_result(
result: ResultDirectory,
summary: dict[str, Any],
*,
json_writer: JsonWriter = write_json,
text_writer: TextWriter,
report_renderer: ReportRenderer,
) -> None:
json_writer(result.path / "summary.json", summary)
text_writer(result.path / "report.md", report_renderer(summary))
def render_report(summary: Mapping[str, Any]) -> str:
preflight = summary.get("preflight", {})
launch = summary.get("launch", {})
registry = summary.get("registry", {})
native_log = summary.get("native_log", {})
modification = summary.get("modification")
state_cache = summary.get("dxvk_state_cache")
runtime = summary.get("runtime")
lines = [
f"# {summary.get('test', 'experiment')}",
"",
f"Status: `{summary.get('status')}`",
f"Result directory: `{summary.get('result_directory')}`",
f"Dry run: `{summary.get('dry_run')}`",
"",
"## Preflight",
"",
(
f"Passed: `{preflight.get('ok')}`"
if isinstance(preflight, Mapping)
else "Passed: `False`"
),
]
if isinstance(preflight, Mapping):
for error in preflight.get("errors", []):
lines.append(f"- {error}")
lines.extend(
[
"",
"## Launch",
"",
f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "",
(
f"Timeout: `{launch.get('timeout_seconds')}` seconds"
if isinstance(launch, Mapping)
else ""
),
"",
"## Current-run native log",
"",
(
f"Source: `{native_log.get('source')}`"
if isinstance(native_log, Mapping)
else ""
),
(
f"Captured bytes: `{native_log.get('current_run_bytes')}`"
if isinstance(native_log, Mapping)
else ""
),
"",
"## Registry rollback",
"",
(
f"Restored: `{registry.get('restored')}`"
if isinstance(registry, Mapping)
else ""
),
]
)
if summary.get("review_gate"):
lines.extend(["", "## Review gate", "", str(summary["review_gate"])])
if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping):
performance = runtime["performance"]
lines.extend(
[
"",
"## Performance capture",
"",
f"Samples: `{performance.get('sample_count')}`",
f"Interval: `{performance.get('interval_seconds')}` seconds",
f"CSV: `{performance.get('output')}`",
]
)
if isinstance(state_cache, Mapping):
lines.extend(
[
"",
"## DXVK state cache",
"",
f"Directory: `{state_cache.get('directory')}`",
f"Seeded: `{state_cache.get('seeded')}`",
f"Before: `{state_cache.get('before')}`",
f"After: `{state_cache.get('after')}`",
]
)
if isinstance(modification, Mapping):
lines.extend(
[
"",
"## Disposable modification",
"",
f"Kind: `{modification.get('kind')}`",
f"Source: `{modification.get('source')}`",
f"Destination: `{modification.get('destination')}`",
f"Prepared: `{modification.get('prepared')}`",
f"Prefix link restored: `{modification.get('prefix_link_restored')}`",
f"Disposable copy removed: `{modification.get('disposable_removed')}`",
]
)
if summary.get("error"):
lines.extend(["", "## Error", "", str(summary["error"])])
return "\n".join(line for line in lines if line is not None).rstrip() + "\n"
def write_text(path: Path, value: str, *, pid: int | None = None) -> None:
"""Atomically write normalized UTF-8 text evidence."""
path.parent.mkdir(parents=True, exist_ok=True)
process_id = os.getpid() if pid is None else pid
temporary = path.with_name(f".{path.name}.{process_id}.tmp")
temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8")
temporary.replace(path)

View File

@@ -0,0 +1,332 @@
"""Process execution and runtime observation for diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any
from ..config import AppConfig
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
from ..performance import PerformanceSampler
from ..result import ResultDirectory, write_json
from ..runner import CommandLogger
from ..testcase import Testcase
from .evidence import serialize_log_state, write_text
from .log_selection import (
NativeLogObservation,
NativeLogSelection,
capture_current_native_logs,
native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .models import ExperimentError
ProcessFactory = Callable[..., subprocess.Popen[str]]
ProcessCapture = Callable[[Testcase, Path], dict[str, Any] | None]
ProcessStopper = Callable[[subprocess.Popen[str]], None]
PerformanceSamplerFactory = Callable[[int, Path], PerformanceSampler]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
EnvironmentFactory = Callable[[], dict[str, str]]
LogObserver = Callable[..., Sequence[NativeLogObservation]]
LogCapture = Callable[..., NativeLogSelection]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
LogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
def game_processes(*, proc_root: Path = Path("/proc")) -> list[int]:
"""Return the matching game process IDs visible under one proc tree."""
result: list[int] = []
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
comm = (entry / "comm").read_text(encoding="utf-8").strip().casefold()
except OSError:
continue
if comm in {"game.exe", "gamese.exe"}:
result.append(int(entry.name))
return sorted(result)
def capture_game_process(
testcase: Testcase,
result_directory: Path,
*,
game_process_scan: Callable[[], list[int]] = game_processes,
proc_root: Path = Path("/proc"),
copy_file: Callable[[Path, Path], str] = shutil.copyfile,
) -> dict[str, Any] | None:
"""Capture the first matching process identity and optional memory map."""
processes = game_process_scan()
if not processes:
return None
pid = processes[0]
root = proc_root / str(pid)
identity: dict[str, Any] = {"pid": pid}
try:
identity["comm"] = (root / "comm").read_text(encoding="utf-8").strip()
identity["cmdline"] = (
(root / "cmdline").read_bytes().decode("utf-8", errors="replace").split("\0")
)
identity["cwd"] = str((root / "cwd").readlink())
identity["exe"] = str((root / "exe").readlink())
if testcase.capture.process_maps:
copy_file(root / "maps", result_directory / "process-maps.txt")
except OSError as exc:
identity["capture_error"] = str(exc)
return identity
def stop_process_group(
process: subprocess.Popen[str],
*,
kill_group: Callable[[int, int], None] = os.killpg,
signals: Sequence[tuple[int, float]] = (
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
) -> None:
"""Stop a launcher process group with bounded escalation."""
if process.poll() is not None:
return
for signum, timeout in signals:
try:
kill_group(process.pid, signum)
except ProcessLookupError:
return
try:
process.wait(timeout=timeout)
return
except subprocess.TimeoutExpired:
continue
def _current_time() -> datetime:
return datetime.now().astimezone()
def _copy_environment() -> dict[str, str]:
return os.environ.copy()
def run_and_watch(
config: AppConfig,
testcase: Testcase,
result: ResultDirectory,
logger: CommandLogger,
argv: Sequence[str],
env_overrides: Mapping[str, str],
baseline: Mapping[str, LogFileState],
*,
popen: ProcessFactory = subprocess.Popen,
clock_ns: Callable[[], int] = time.time_ns,
sleep: Callable[[float], None] = time.sleep,
now: Callable[[], datetime] = _current_time,
environment_factory: EnvironmentFactory = _copy_environment,
json_writer: JsonWriter = write_json,
process_capture: ProcessCapture = capture_game_process,
process_stopper: ProcessStopper = stop_process_group,
performance_sampler_factory: PerformanceSamplerFactory = PerformanceSampler,
log_observer: LogObserver = observe_current_native_logs,
log_capture: LogCapture = capture_current_native_logs,
scene_survived: Callable[..., bool] = scene_import_survived,
serialize_state: LogStateSerializer = serialize_log_state,
text_writer: TextWriter = write_text,
prioritize_log: LogPrioritizer = native_log_priority,
discover_logs: Callable[..., Sequence[LogFileState]] = discover_current_native_logs,
parse_log: Callable[..., Any] = parse_current_run_log,
read_log: Callable[..., str] = read_current_run_log_text,
snapshot_logs: Callable[[Path], Mapping[str, LogFileState]] = snapshot_native_logs,
) -> dict[str, Any]:
"""Run one diagnostic process and collect its current-run evidence."""
runner_log_path = result.path / "runner.log"
environment = environment_factory()
environment.update(env_overrides)
test_start_ns = clock_ns()
started_at = now()
json_writer(
result.path / "test-start.json",
{
"test_start_ns": test_start_ns,
"test_start_epoch": test_start_ns / 1_000_000_000,
"recorded_at": started_at.isoformat(),
},
)
process: subprocess.Popen[str] | None = None
process_identity: dict[str, Any] | None = None
failure_state: LogFileState | None = None
failure_trigger_mtime_ns: int | None = None
current_observation: NativeLogObservation | None = None
performance_sampler: PerformanceSampler | None = None
performance_summary: dict[str, Any] | None = None
detected_at_ns: int | None = None
timed_out = False
stop_reason = "process-exited"
failure_reason: str | None = None
with runner_log_path.open("a", encoding="utf-8") as runner_log:
try:
process = popen(
list(argv),
cwd=config.paths.live_game,
env=environment,
stdout=runner_log,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
except OSError as exc:
logger.record_lifecycle(
argv,
state="launch-error",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
error=str(exc),
)
raise ExperimentError(f"Could not launch experiment: {exc}") from exc
assert process is not None
logger.record_lifecycle(
argv,
state="started",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
pid=process.pid,
)
deadline_ns = test_start_ns + int(testcase.timeout_seconds * 1_000_000_000)
post_failure_deadline_ns: int | None = None
while process.poll() is None:
now_ns = clock_ns()
if process_identity is None:
process_identity = process_capture(testcase, result.path)
if process_identity is not None and testcase.capture.performance_stats:
try:
performance_sampler = performance_sampler_factory(
int(process_identity["pid"]),
result.path / "performance.csv",
)
except (OSError, ValueError, KeyError) as exc:
performance_summary = {"error": str(exc)}
if performance_sampler is not None:
performance_sampler.maybe_sample(now_ns)
if failure_state is None:
for observation in log_observer(
config.paths.native_logs,
baseline,
test_start_ns,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
):
current_observation = observation
if observation.failure_reason is not None:
failure_state = observation.state
failure_trigger_mtime_ns = observation.state.mtime_ns
detected_at_ns = now_ns
post_failure_deadline_ns = now_ns + int(
testcase.stop_after_failure_seconds * 1_000_000_000
)
failure_reason = observation.failure_reason
break
if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns:
stop_reason = failure_reason or "load-texture-to-material"
process_stopper(process)
break
if now_ns >= deadline_ns:
timed_out = True
stop_reason = "timeout"
process_stopper(process)
break
sleep(0.1)
if process.poll() is None:
process_stopper(process)
returncode = process.wait(timeout=5.0)
if performance_sampler is not None:
performance_summary = performance_sampler.close()
logger.record_lifecycle(
argv,
state="finished",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
pid=process.pid,
returncode=returncode,
timed_out=timed_out,
)
selection = log_capture(
config.paths.native_logs,
baseline,
test_start_ns,
result.path,
fallback=current_observation,
failure_state=failure_state,
serialize_state=serialize_state,
write_text=text_writer,
prioritize=prioritize_log,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
snapshotter=snapshot_logs,
)
current_observation = selection.observation
current_state = current_observation.state if current_observation is not None else None
current_text = current_observation.text if current_observation is not None else ""
current_analysis = (
current_observation.analysis if current_observation is not None else None
)
survived = scene_survived(current_analysis, timed_out=timed_out)
trigger_ns = (
max(test_start_ns, failure_trigger_mtime_ns)
if failure_trigger_mtime_ns is not None
else None
)
return {
"test_start_ns": test_start_ns,
"test_start_epoch": test_start_ns / 1_000_000_000,
"finished_at": now().isoformat(),
"launcher_pid": process.pid if process is not None else None,
"game_process": process_identity,
"returncode": process.returncode if process is not None else None,
"timed_out": timed_out,
"stop_reason": stop_reason,
"failure_detected": failure_state is not None,
"scene_import_survived": survived,
"detected_at_ns": detected_at_ns,
"trigger_epoch": trigger_ns / 1_000_000_000 if trigger_ns is not None else None,
"analysis": current_analysis,
"native_logs": list(selection.captures),
"performance": performance_summary,
"native_log": {
"source": str(current_state.path) if current_state else None,
"state": serialize_state(current_state) if current_state else None,
"current_run_bytes": len(current_text.encode("utf-8")),
},
}

View File

@@ -0,0 +1,212 @@
"""Current-run native-log observation, capture, and selection."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
class NativeLogAnalysis(Protocol):
load_texture_to_material_failed: bool
asset_pack_health_failed: bool
def to_summary(self) -> dict[str, Any]: ...
NativeLogDiscoverer = Callable[
[Path, Mapping[str, LogFileState], int],
Sequence[LogFileState],
]
NativeLogParser = Callable[
[LogFileState, Mapping[str, LogFileState]],
NativeLogAnalysis,
]
NativeLogReader = Callable[
[LogFileState, Mapping[str, LogFileState]],
str,
]
NativeLogSnapshotter = Callable[[Path], Mapping[str, LogFileState]]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
NativeLogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
TextWriter = Callable[[Path, str], None]
@dataclass(frozen=True)
class NativeLogObservation:
state: LogFileState
text: str
analysis: dict[str, Any]
failure_reason: str | None
@dataclass(frozen=True)
class NativeLogSelection:
observation: NativeLogObservation | None
captures: tuple[dict[str, Any], ...]
def observe_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
*,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
) -> tuple[NativeLogObservation, ...]:
"""Parse each current-run candidate and identify an actionable failure."""
observations: list[NativeLogObservation] = []
for state in discoverer(log_directory, baseline, test_start_ns):
parsed = parser(state, baseline)
failure_reason = (
"asset-pack-health"
if parsed.asset_pack_health_failed
else "load-texture-to-material"
if parsed.load_texture_to_material_failed
else None
)
observations.append(
NativeLogObservation(
state=state,
text=reader(state, baseline),
analysis=parsed.to_summary(),
failure_reason=failure_reason,
)
)
return tuple(observations)
def capture_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
result_directory: Path,
*,
fallback: NativeLogObservation | None,
failure_state: LogFileState | None,
serialize_state: LogStateSerializer,
write_text: TextWriter,
prioritize: NativeLogPrioritizer | None = None,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
snapshotter: NativeLogSnapshotter = snapshot_native_logs,
) -> NativeLogSelection:
"""Capture all current-run logs and choose the strongest primary evidence."""
captures: list[dict[str, Any]] = []
candidates: list[NativeLogObservation] = []
for index, state in enumerate(discoverer(log_directory, baseline, test_start_ns)):
text = reader(state, baseline)
analysis = parser(state, baseline).to_summary()
scenes = analysis.get("scenes")
scene_names = (
sorted(str(name) for name in scenes)
if isinstance(scenes, Mapping)
else []
)
captured_path = (
result_directory
/ "native-logs"
/ f"{index:02d}-{state.path.name}.current.log"
)
write_text(captured_path, text)
captures.append(
{
"source": str(state.path),
"state": serialize_state(state),
"captured_path": str(captured_path),
"current_run_bytes": len(text.encode("utf-8")),
"scene_names": scene_names,
"load_texture_to_material_failed": analysis.get(
"load_texture_to_material_failed",
False,
),
"asset_pack_health_failed": analysis.get(
"asset_pack_health_failed",
False,
),
"import_scene_threw": analysis.get("import_scene_threw", False),
}
)
candidates.append(
NativeLogObservation(
state=state,
text=text,
analysis=analysis,
failure_reason=None,
)
)
prioritizer = native_log_priority if prioritize is None else prioritize
selected = (
max(candidates, key=lambda item: prioritizer(item.state, item.analysis))
if candidates
else fallback
)
if failure_state is not None:
final_states = snapshotter(log_directory)
final_state = final_states.get(str(failure_state.path.absolute()))
if final_state is not None:
selected = NativeLogObservation(
state=final_state,
text=reader(final_state, baseline),
analysis=parser(final_state, baseline).to_summary(),
failure_reason=None,
)
if selected is not None:
write_text(result_directory / "native.log", selected.text)
return NativeLogSelection(observation=selected, captures=tuple(captures))
def native_log_priority(
state: LogFileState,
analysis: Mapping[str, Any],
) -> tuple[int, int, int, int, str]:
"""Rank scene-rich engine logs ahead of newer low-signal logs."""
scenes = analysis.get("scenes")
scene_count = 0
if isinstance(scenes, Mapping):
for imports in scenes.values():
if isinstance(imports, list):
scene_count += len(imports)
return (
int(scene_count > 0),
scene_count,
int(state.path.name.casefold().startswith("log_")),
state.mtime_ns,
str(state.path),
)
def scene_import_survived(
analysis: Mapping[str, Any] | None,
*,
timed_out: bool,
) -> bool:
"""Return whether the target scene completed without a captured failure."""
if analysis is None:
return False
return bool(
analysis.get("whispering_vale")
and not timed_out
and not analysis.get("load_texture_to_material_failed", False)
and not analysis.get("asset_pack_health_failed", False)
and not analysis.get("import_scene_threw", False)
and not analysis.get("failed_import_scenes", [])
)

View File

@@ -0,0 +1,18 @@
"""Stable result and error models for controlled diagnostic experiments."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class ExperimentResult:
result_directory: Path
summary: dict[str, Any]
exit_code: int
class ExperimentError(RuntimeError):
"""Raised for a controlled experiment setup or lifecycle failure."""

View File

@@ -0,0 +1,624 @@
"""Safe testcase preparation for controlled diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
from typing import Any
from ..config import AppConfig, project_root
from ..filesystem import sha256_file
from ..ini import IniError, inspect_ini_value
from ..prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
validate_dedicated_prefix,
)
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger
from ..testcase import TestIniOverride, TestModification, Testcase
from .cleanup import (
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
require_command as _require_command,
validate_disposable_path as _validate_disposable_path,
)
from .models import ExperimentError
_WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
_DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable"
_PERSISTENT_STATE_CACHE_SENTINEL = "@ptdiag-persistent"
_PERSISTENT_STATE_CACHE_DIRECTORY = "ptdiag-cache/dxvk-state"
def _resource_copy_paths(
live_game: Path,
modification: TestModification,
) -> tuple[Path, Path]:
if modification.kind not in {"copy-file", "normalize-assbin-textures"}:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
root = live_game.resolve(strict=True)
source_relative = _safe_resource_relative_path(modification.source)
destination_relative = _safe_resource_relative_path(modification.destination)
source = root.joinpath(*source_relative.parts).resolve(strict=True)
try:
source.relative_to(root)
except ValueError as exc:
raise ExperimentError("Modification source escapes the live game tree") from exc
if not source.is_file():
raise ExperimentError(f"Modification source is not a file: {source}")
destination = root.joinpath(*destination_relative.parts)
destination_parent = destination.parent.resolve(strict=True)
try:
destination_parent.relative_to(root)
except ValueError as exc:
raise ExperimentError("Modification destination escapes the live game tree") from exc
if modification.kind == "copy-file" and (destination.exists() or destination.is_symlink()):
raise ExperimentError(
f"Live modification destination already exists; refusing experiment: {destination}"
)
if modification.kind == "normalize-assbin-textures":
if source_relative != destination_relative:
raise ExperimentError(
"Assbin normalization source and destination must be the same disposable file"
)
if not modification.texture_indices:
raise ExperimentError("Assbin normalization requires texture_indices")
if not destination.is_file():
raise ExperimentError(f"Assbin normalization target is not a file: {destination}")
return source, destination
def _safe_resource_relative_path(value: str) -> PurePosixPath:
if "\\" in value:
raise ExperimentError("Modification paths must use forward slashes")
path = PurePosixPath(value)
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
raise ExperimentError(f"Unsafe relative resource path: {value!r}")
return path
def _prepare_disposable_game(
config: AppConfig,
result: ResultDirectory,
logger: CommandLogger,
modification: TestModification,
disposable_game: Path,
source: Path,
) -> dict[str, Any]:
staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute()
_validate_disposable_path(staging_root, disposable_game, result.path.name)
if disposable_game.exists() or disposable_game.is_symlink():
raise ExperimentError(f"Disposable game path already exists: {disposable_game}")
usage = shutil.disk_usage(staging_root.parent)
copy_bytes = _allocated_tree_bytes(config.paths.live_game)
if usage.free < copy_bytes + 2 * 1024**3:
raise ExperimentError(
f"Insufficient free space for disposable game copy: "
f"need at least {copy_bytes + 2 * 1024**3}, have {usage.free}"
)
_require_command(
logger.run(
["mkdir", "-p", "--", str(staging_root)],
timeout_seconds=15.0,
),
"create disposable staging directory",
)
_require_command(
logger.run(
[
"cp",
"-a",
"--reflink=auto",
"--sparse=always",
f"{config.paths.live_game}/.",
str(disposable_game),
],
timeout_seconds=900.0,
),
"copy disposable game tree",
)
destination_relative = _safe_resource_relative_path(modification.destination)
destination = disposable_game.joinpath(*destination_relative.parts)
if modification.kind == "copy-file":
if destination.exists() or destination.is_symlink():
raise ExperimentError(
f"Disposable destination unexpectedly existed before copy: {destination}"
)
_require_command(
logger.run(
["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)],
timeout_seconds=30.0,
),
"copy selected resource",
)
normalization: dict[str, Any] | None = None
destination_existed_before = False
elif modification.kind == "normalize-assbin-textures":
if not destination.is_file() or destination.is_symlink():
raise ExperimentError(
f"Disposable Assbin target is missing or unsafe: {destination}"
)
if sha256_file(destination) != sha256_file(source):
raise ExperimentError("Disposable Assbin differs from live source before transform")
temporary_output = destination.with_name(
f".{destination.name}.{result.path.name}.normalized"
)
if temporary_output.exists() or temporary_output.is_symlink():
raise ExperimentError(f"Normalization temporary output exists: {temporary_output}")
normalize_result = logger.run(
[
sys.executable,
"-m",
"ptdiag",
"scene",
"normalize-textures",
str(destination),
str(temporary_output),
*(str(index) for index in modification.texture_indices),
],
cwd=project_root(),
timeout_seconds=240.0,
)
_require_command(normalize_result, "normalize embedded Assbin textures")
try:
normalization = json.loads(normalize_result.stdout)
except json.JSONDecodeError as exc:
raise ExperimentError("Normalizer returned invalid JSON") from exc
if not isinstance(normalization, dict) or not normalization.get("ok"):
raise ExperimentError("Normalizer did not report a verified result")
_require_command(
logger.run(
["mv", "--", str(temporary_output), str(destination)],
timeout_seconds=30.0,
),
"install normalized Assbin in disposable tree",
)
destination_existed_before = True
else:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
destination_hash = sha256_file(destination)
source_hash = sha256_file(source)
if modification.kind == "copy-file" and destination_hash != source_hash:
raise ExperimentError("Copied resource hash differs from its source")
if modification.kind == "normalize-assbin-textures" and destination_hash == source_hash:
raise ExperimentError("Normalized Assbin hash did not change")
ini_changes = _apply_disposable_ini_overrides(
disposable_game,
modification.ini_overrides,
logger,
)
return {
"staging_root": str(staging_root),
"free_bytes_before": usage.free,
"source_allocated_bytes": copy_bytes,
"destination_path": str(destination),
"destination_existed_before": destination_existed_before,
"destination_sha256_after": destination_hash,
"destination_size_after": destination.stat().st_size,
"normalization": normalization,
"ini_changes": ini_changes,
}
def _inspect_ini_overrides(
game_root: Path,
overrides: Sequence[TestIniOverride],
) -> list[dict[str, Any]]:
root = game_root.resolve(strict=True)
result: list[dict[str, Any]] = []
for override in overrides:
path = _safe_game_file(root, override.path)
state = inspect_ini_value(path, override.section, override.key)
result.append(
{
"path": str(path),
"section": override.section,
"key": override.key,
"expected": override.expected,
"value": override.value,
"current": state["value"],
"matches_expected": state["value"] == override.expected,
"matches_value": state["value"] == override.value,
"sha256": state["sha256"],
"size": state["size"],
"mtime_ns": state["mtime_ns"],
}
)
return result
def _apply_disposable_ini_overrides(
disposable_game: Path,
overrides: Sequence[TestIniOverride],
logger: CommandLogger,
) -> list[dict[str, Any]]:
root = disposable_game.resolve(strict=True)
result: list[dict[str, Any]] = []
for override in overrides:
path = _safe_game_file(root, override.path)
outcome = logger.run(
_ini_override_command(path, override),
cwd=project_root(),
timeout_seconds=30.0,
)
_require_command(outcome, f"override [{override.section}] {override.key}")
try:
payload = json.loads(outcome.stdout)
except json.JSONDecodeError as exc:
raise ExperimentError("INI override returned invalid JSON") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
raise ExperimentError("INI override did not report a verified result")
result.append(payload)
return result
def _safe_game_file(root: Path, relative: str) -> Path:
parts = _safe_resource_relative_path(relative).parts
path = root.joinpath(*parts)
resolved = path.resolve(strict=True)
try:
resolved.relative_to(root)
except ValueError as exc:
raise ExperimentError(f"INI path escapes game tree: {relative}") from exc
if path.is_symlink() or not resolved.is_file():
raise ExperimentError(f"INI path is missing or unsafe: {path}")
return resolved
def _ini_override_command(path: Path, override: TestIniOverride) -> list[str]:
return [
sys.executable,
"-m",
"ptdiag",
"ini",
"override",
str(path),
override.section,
override.key,
override.expected,
override.value,
]
def _allocated_tree_bytes(root: Path) -> int:
total = 0
for directory, _, filenames in os.walk(root, followlinks=False):
for filename in filenames:
path = Path(directory) / filename
try:
total += path.lstat().st_blocks * 512
except FileNotFoundError:
continue
return total
def _log_modification_plan(
logger: CommandLogger,
config: AppConfig,
disposable_game: Path,
source: Path,
modification: TestModification,
) -> None:
staging_root = disposable_game.parent
destination = disposable_game.joinpath(
*_safe_resource_relative_path(modification.destination).parts
)
prefix_link = config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU"
commands: list[tuple[list[str], float]] = [
(["mkdir", "-p", "--", str(staging_root)], 15.0),
(
[
"cp",
"-a",
"--reflink=auto",
"--sparse=always",
f"{config.paths.live_game}/.",
str(disposable_game),
],
900.0,
),
]
if modification.kind == "copy-file":
commands.append(
(["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], 30.0)
)
elif modification.kind == "normalize-assbin-textures":
temporary_output = destination.with_name(f".{destination.name}.planned.normalized")
commands.extend(
(
(
[
sys.executable,
"-m",
"ptdiag",
"scene",
"normalize-textures",
str(destination),
str(temporary_output),
*(str(index) for index in modification.texture_indices),
],
240.0,
),
(["mv", "--", str(temporary_output), str(destination)], 30.0),
)
)
else:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
for override in modification.ini_overrides:
ini_path = disposable_game.joinpath(
*_safe_resource_relative_path(override.path).parts
)
commands.append((_ini_override_command(ini_path, override), 30.0))
commands.extend(
(
(["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], 15.0),
(["ln", "-sfn", "--", os.readlink(prefix_link), str(prefix_link)], 15.0),
(["rm", "-rf", "--one-file-system", "--", str(disposable_game)], 900.0),
)
)
for command, timeout in commands:
logger.run(command, timeout_seconds=timeout, dry_run=True)
def _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
*,
game_processes: Callable[[], list[int]],
) -> list[str]:
errors: list[str] = []
if not environment.get("ok"):
errors.append("Required environment checks failed")
if any(not item.get("ok", False) for item in hashes if item.get("required", False)):
errors.append("A required baseline hash is missing or mismatched")
if testcase.runner != "proton7":
errors.append(f"Unsupported runner: {testcase.runner}")
if testcase.game.args != config.game.args:
errors.append("Testcase game arguments differ from the known PTEU_REBORN baseline")
if testcase.game.cwd != "live_game":
errors.append("Only the verified live_game working directory is supported")
if testcase.display.size != config.game.virtual_desktop:
errors.append("Testcase desktop size differs from the 1600x900 baseline")
if testcase.capture.strace_files and shutil.which("strace") is None:
errors.append("scene-strace requires strace")
if not testcase.watch.require_new_native_log:
errors.append("Runtime tests must require a current-run native log")
try:
prefix = validate_dedicated_prefix(config)
except (OSError, ValueError, PrefixSafetyError) as exc:
errors.append(str(exc))
else:
linked_game = prefix / "drive_c/Program Files (x86)/Pristontale EU"
try:
if linked_game.resolve(strict=True) != config.paths.live_game.resolve(strict=True):
errors.append("Dedicated prefix game link does not resolve to live_game")
except OSError as exc:
errors.append(f"Dedicated prefix game link is invalid: {exc}")
active = game_processes()
if active:
errors.append(f"Game.exe is already running: {active}")
return errors
def _dry_run_commands(
config: AppConfig,
testcase: Testcase,
logger: CommandLogger,
registry_before: DesktopRegistrySnapshot,
launch_argv: list[str],
env_overrides: Mapping[str, str],
) -> None:
cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True)
if testcase.display.virtual_desktop:
for command in _registry_setup_commands(
config, registry_before.desktop_name, testcase.display.size
):
logger.run(
command,
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
timeout_seconds=15.0,
dry_run=True,
)
logger.run(
launch_argv,
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
dry_run=True,
)
cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True)
if testcase.display.virtual_desktop:
for command in _registry_restore_commands(config, registry_before):
logger.run(
command,
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
timeout_seconds=15.0,
dry_run=True,
)
def _launch_argv(config: AppConfig, testcase: Testcase, result_directory: Path) -> list[str]:
game = f"{_WINDOWS_GAME_DIRECTORY}\\{config.game.exe}"
proton = _proton_command(config, "runinprefix", game, *testcase.game.args)
if not testcase.capture.strace_files:
return proton
raw_directory = result_directory / "raw-strace"
raw_directory.mkdir(exist_ok=False)
strace = shutil.which("strace") or "/usr/bin/strace"
return [
strace,
"-ff",
"-qq",
"-ttt",
"-s",
"1024",
"-e",
"trace=%file",
"-o",
str(raw_directory / "trace"),
*proton,
]
def _proton_command(config: AppConfig, *arguments: str) -> list[str]:
shim = project_root() / "ptlinux/proton_compat.py"
return [
sys.executable,
str(shim),
str(config.proton7.proton),
*arguments,
]
def _runtime_environment(
config: AppConfig,
testcase: Testcase,
result_directory: Path,
) -> dict[str, str]:
environment = _proton_environment(config)
environment.update(testcase.env)
if environment.get("DXVK_STATE_CACHE_PATH") == _PERSISTENT_STATE_CACHE_SENTINEL:
environment["DXVK_STATE_CACHE_PATH"] = str(
(config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute()
)
environment.update(
{
"DXVK_LOG_PATH": str(result_directory),
"PROTON_LOG": "0",
"STEAM_COMPAT_INSTALL_PATH": str(config.paths.live_game),
"SteamAppId": "0",
"SteamGameId": "0",
}
)
return environment
def _state_cache_plan(
config: AppConfig,
environment: Mapping[str, str],
) -> dict[str, Any] | None:
configured = environment.get("DXVK_STATE_CACHE_PATH")
if configured is None:
return None
cache_directory = Path(configured).expanduser().absolute()
cache_file = cache_directory / f"{Path(config.game.exe).stem}.dxvk-cache"
return {
"directory": str(cache_directory),
"cache_file": str(cache_file),
"persistent": True,
"seed_source": str(config.paths.live_game / cache_file.name),
"seeded": False,
"before": _state_cache_file_snapshot(cache_file),
"after": None,
}
def _prepare_state_cache(
config: AppConfig,
environment: Mapping[str, str],
logger: CommandLogger,
) -> dict[str, Any]:
plan = _state_cache_plan(config, environment)
if plan is None:
raise ExperimentError("Persistent DXVK state-cache plan is missing")
cache_directory = Path(str(plan["directory"]))
expected = (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute()
if cache_directory != expected:
raise ExperimentError(
f"Refusing unmanaged DXVK state-cache directory: {cache_directory}; "
f"expected {expected}"
)
_require_command(
logger.run(
["mkdir", "-p", "--", str(cache_directory)],
timeout_seconds=15.0,
),
"create managed DXVK state-cache directory",
)
cache_file = Path(str(plan["cache_file"]))
seed_source = Path(str(plan["seed_source"]))
if not cache_file.exists() and seed_source.is_file():
_require_command(
logger.run(
[
"cp",
"--preserve=mode,timestamps",
"--",
str(seed_source),
str(cache_file),
],
timeout_seconds=30.0,
),
"seed managed DXVK state cache",
)
plan["seeded"] = True
plan["before"] = _state_cache_file_snapshot(cache_file)
return plan
def _state_cache_file_snapshot(path: Path) -> dict[str, Any]:
result: dict[str, Any] = {
"path": str(path),
"exists": path.is_file(),
"size": None,
"mtime_ns": None,
"sha256": None,
}
if not result["exists"]:
return result
try:
state = path.stat()
result.update(
{
"size": state.st_size,
"mtime_ns": state.st_mtime_ns,
"sha256": sha256_file(path),
}
)
except OSError as exc:
result["error"] = str(exc)
return result
def _proton_environment(config: AppConfig) -> dict[str, str]:
return {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(config.proton7.steam_root),
"STEAM_COMPAT_DATA_PATH": str(config.proton7.compat),
"WINEPREFIX": str(config.proton7.prefix),
"WINEDEBUG": "-all",
}
def _registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
) -> list[list[str]]:
return _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)

40
src/ptdiag/logs.py Normal file
View File

@@ -0,0 +1,40 @@
"""Compatibility façade for launcher-owned native game-log parsing."""
from ptlinux.diagnostics.native_logs import (
DEFAULT_NATIVE_LOG_PATTERNS,
AssetReferenceReport,
LogEvent,
LogFileState,
NativeLogAnalysis,
SceneImport,
classify_asset_references,
discover_current_native_logs,
is_current_run_log,
newest_current_native_log,
normalize_asset_reference,
parse_current_run_log,
parse_native_log,
parse_native_log_lines,
read_current_run_log_text,
snapshot_native_logs,
)
__all__ = [
"DEFAULT_NATIVE_LOG_PATTERNS",
"AssetReferenceReport",
"LogEvent",
"LogFileState",
"NativeLogAnalysis",
"SceneImport",
"classify_asset_references",
"discover_current_native_logs",
"is_current_run_log",
"newest_current_native_log",
"normalize_asset_reference",
"parse_current_run_log",
"parse_native_log",
"parse_native_log_lines",
"read_current_run_log_text",
"snapshot_native_logs",
]

View File

@@ -11,7 +11,7 @@ from .filesystem import sha256_file
_VALUE_RE = re.compile(r'^(?P<name>"(?:\\.|[^"\\])*")=(?P<value>.*)$') _VALUE_RE = re.compile(r'^(?P<name>"(?:\\.|[^"\\])*")=(?P<value>.*)$')
_SECTION_RE = re.compile(r"^\[(?P<name>.*)\](?:\s+[0-9]+)?$") _SECTION_RE = re.compile(r"^\[(?P<name>.*)](?:\s+[0-9]+)?$")
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -7,7 +7,12 @@ from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
import tomllib import tomllib
from .config import project_root from .config import repository_root
DEFAULT_TESTCASE_DIRECTORY = Path("diagnostics/testcases")
LEGACY_TESTCASE_DIRECTORY = Path("testcases")
_PATH_ENVIRONMENT_KEYS = frozenset({"DXVK_CONFIG_FILE"})
class TestcaseError(ValueError): class TestcaseError(ValueError):
@@ -29,18 +34,12 @@ class TestDisplay:
@dataclass(frozen=True) @dataclass(frozen=True)
class TestWatch: class TestWatch:
require_new_native_log: bool require_new_native_log: bool
patterns: tuple[str, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class TestCapture: class TestCapture:
process_maps: bool process_maps: bool
cwd: bool
environment: bool
dll_overrides: bool dll_overrides: bool
hashes: bool
dxvk_log: bool
native_log: bool
strace_files: bool strace_files: bool
performance_stats: bool performance_stats: bool
focus_before_seconds: float focus_before_seconds: float
@@ -92,7 +91,15 @@ def resolve_testcase_path(value: str | Path) -> Path:
path = Path(value) path = Path(value)
if path.suffix or path.parent != Path("."): if path.suffix or path.parent != Path("."):
return path.expanduser().absolute() return path.expanduser().absolute()
return (project_root() / "testcases" / f"{path.name}.toml").absolute() root = repository_root()
filename = f"{path.name}.toml"
preferred = root / DEFAULT_TESTCASE_DIRECTORY / filename
legacy = root / LEGACY_TESTCASE_DIRECTORY / filename
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_testcase(value: str | Path) -> Testcase: def load_testcase(value: str | Path) -> Testcase:
@@ -100,7 +107,10 @@ def load_testcase(value: str | Path) -> Testcase:
data, sources = _load_inherited(path, ()) data, sources = _load_inherited(path, ())
game = _section(data, "game") game = _section(data, "game")
display = _section(data, "display") display = _section(data, "display")
environment = _section(data, "env") environment = _resolve_environment_paths(
_string_mapping(_section(data, "env"), "env"),
path.parent,
)
watch = _section(data, "watch") watch = _section(data, "watch")
capture = _section(data, "capture") capture = _section(data, "capture")
modification = _optional_section(data, "modification") modification = _optional_section(data, "modification")
@@ -120,19 +130,13 @@ def load_testcase(value: str | Path) -> Testcase:
virtual_desktop=_boolean(display, "virtual_desktop"), virtual_desktop=_boolean(display, "virtual_desktop"),
size=_string(display, "size"), size=_string(display, "size"),
), ),
env=_string_mapping(environment, "env"), env=environment,
watch=TestWatch( watch=TestWatch(
require_new_native_log=_boolean(watch, "require_new_native_log"), require_new_native_log=_boolean(watch, "require_new_native_log"),
patterns=_string_tuple(watch, "patterns"),
), ),
capture=TestCapture( capture=TestCapture(
process_maps=_boolean(capture, "process_maps"), process_maps=_boolean(capture, "process_maps"),
cwd=_boolean(capture, "cwd"),
environment=_boolean(capture, "environment"),
dll_overrides=_boolean(capture, "dll_overrides"), dll_overrides=_boolean(capture, "dll_overrides"),
hashes=_boolean(capture, "hashes"),
dxvk_log=_boolean(capture, "dxvk_log"),
native_log=_boolean(capture, "native_log"),
strace_files=_boolean(capture, "strace_files"), strace_files=_boolean(capture, "strace_files"),
performance_stats=_optional_boolean(capture, "performance_stats", False), performance_stats=_optional_boolean(capture, "performance_stats", False),
focus_before_seconds=_optional_nonnegative_number( focus_before_seconds=_optional_nonnegative_number(
@@ -161,6 +165,25 @@ def load_testcase(value: str | Path) -> Testcase:
) )
def _resolve_environment_paths(
environment: Mapping[str, str],
testcase_directory: Path,
) -> dict[str, str]:
result = dict(environment)
for key in _PATH_ENVIRONMENT_KEYS:
value = result.get(key)
if value is None:
continue
path = Path(value).expanduser()
if path.is_absolute():
result[key] = str(path)
continue
candidate = (testcase_directory / path).resolve(strict=False)
if candidate.is_file():
result[key] = str(candidate)
return result
def _load_inherited( def _load_inherited(
path: Path, path: Path,
stack: tuple[Path, ...], stack: tuple[Path, ...],

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __all__ = ["AppPaths", "__version__"]
__version__ = "0.6.5" __version__ = "1.1.0"

91
src/ptlinux/__main__.py Normal file
View File

@@ -0,0 +1,91 @@
from __future__ import annotations
import sys
def main() -> int:
"""Dispatch the common GUI path without importing every CLI operation."""
arguments = sys.argv[1:]
if not arguments or arguments == ["gui"]:
from .gui_bootstrap import (
GuiDependencyError, missing_tk_message, recover_gui_python, show_gui_dependency_error,
)
from .container_host import (
container_host_error,
repair_container_host_after_update,
)
from .paths import AppPaths
from .telemetry import initialize_shared_telemetry, shutdown_shared_telemetry
from .umu import provision_umu_for_startup
telemetry = None
telemetry_owner = False
try:
paths = AppPaths.from_environment()
gui_ready = recover_gui_python(arguments)
telemetry, telemetry_owner = initialize_shared_telemetry(
paths,
session_metadata={"interface": "gui", "command": "gui"},
)
if not gui_ready:
raise GuiDependencyError(missing_tk_message())
# Older installed releases cannot run a bootstrap step that only exists
# in their update payload. Their automatic restart enters here instead.
provision_umu_for_startup(paths)
container_host = repair_container_host_after_update(paths)
if container_host.repair_attempted and (
not container_host.ready
or container_host.broken_umu_profile_link
or container_host.repair_error is not None
):
print(
f"Warning: {container_host_error(container_host)}",
file=sys.stderr,
)
from .ui import run_dashboard
run_dashboard(paths)
except KeyboardInterrupt:
return 130
except GuiDependencyError as exc:
if telemetry is not None:
telemetry.send_event(
"dependency_check_failed", severity="error", message=str(exc),
metadata={"command": "gui", "dependencies": ["python-tk"]},
context={"operation": "launcher", "stage": "dependency_check"},
)
print(f"Error: {exc}", file=sys.stderr)
show_gui_dependency_error(str(exc))
return 1
except (ValueError, OSError, RuntimeError) as exc:
if telemetry is not None:
telemetry.report_exception(
"launcher_exception",
exc,
metadata={"command": "gui"},
context={"operation": "launcher", "stage": "startup"},
)
print(f"Error: {exc}", file=sys.stderr)
return 1
except BaseException as exc:
if telemetry is not None:
telemetry.report_exception(
"launcher_exception",
exc,
metadata={"command": "gui"},
context={"operation": "launcher", "stage": "startup"},
severity="critical",
)
raise
finally:
if telemetry is not None and telemetry_owner:
shutdown_shared_telemetry(telemetry)
return 0
from .cli import main as cli_main
return cli_main(arguments, _manage_telemetry=True)
raise SystemExit(main())

View File

@@ -0,0 +1,2 @@
"""Application workflows shared by the CLI and dashboard entry points."""

View File

@@ -0,0 +1,2 @@
"""Feature-oriented command workflows."""

View File

@@ -0,0 +1,190 @@
"""Host health, runtime bootstrap, and login diagnostic commands."""
from __future__ import annotations
from dataclasses import asdict
import sys
from typing import Any
from ...container_host import ContainerHostReport
from ...launcher import LauncherError
from ...paths import AppPaths
def doctor(api: Any, as_json: bool) -> int:
report = api.inspect_host()
if as_json:
api._print_json(report.to_dict())
else:
print(f"Distribution: {report.distribution} {report.version} ({report.architecture})")
print(f"Display: {report.display_backend}")
for tool in report.tools:
status = tool.path or "MISSING"
required = "required" if tool.required else "optional"
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
optional_missing = [
tool.name for tool in report.tools if not tool.required and tool.path is None
]
if optional_missing:
print(
"Optional tools missing (these do not block launch): "
+ ", ".join(optional_missing)
)
print(f"Host readiness: {'READY' if report.ok else 'BLOCKED'}")
if report.blockers:
print("Blocking diagnostics:")
for blocker in report.blockers:
print(f" - {blocker}")
for warning in report.warnings:
print(f"Warning: {warning}")
if report.package_hint and any(
tool.required and tool.path is None for tool in report.tools
):
print(report.package_hint)
return 0 if report.ok else 1
def container_host(api: Any, repair: bool, as_json: bool) -> int:
report = api.repair_container_host() if repair else api.inspect_container_host()
ok = container_host_ok(report)
payload = {"ok": ok, "container_host": report.to_dict()}
if as_json:
api._print_json(payload)
else:
status = "ready" if report.ready else "blocked"
print(f"Bubblewrap: {status} ({report.bwrap_path or 'not found'})")
if report.active_profile_source:
print(f"AppArmor: {report.active_profile_source}")
elif report.apparmor_restrict_unprivileged_userns == "1":
print(
"AppArmor: unprivileged user namespaces are restricted; "
"no bwrap profile found"
)
if report.repair_actions:
for action in report.repair_actions:
print(f"Repair: {action}")
if report.repair_error:
print(f"Repair failed: {report.repair_error}", file=sys.stderr)
elif report.recommendation:
print(f"Action: {report.recommendation}", file=sys.stderr)
return 0 if ok else 1
def container_host_ok(report: ContainerHostReport) -> bool:
return (
report.ready
and not report.broken_umu_profile_link
and report.repair_error is None
)
def container_host_startup_migration(api: Any, paths: AppPaths) -> None:
report = api.repair_container_host_after_update(paths)
if report.repair_attempted and not container_host_ok(report):
print(f"Warning: {api.container_host_error(report)}", file=sys.stderr)
def runtime_bootstrap(api: Any, paths: AppPaths, as_json: bool) -> int:
api._print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…")
result = api.provision_umu(paths)
executable = api.find_umu_launcher(paths)
if executable is None:
raise LauncherError("the Steam Runtime helper is still unavailable")
api._emit(
{
"ok": True,
"runtime_bootstrap": {
"executable": executable,
"managed_download": asdict(result) if result else None,
},
},
as_json,
)
return 0
def login_diagnostics(
api: Any,
paths: AppPaths,
timeout_seconds: float,
runtime_choice: str,
as_json: bool,
) -> int:
settings = api._load_or_default_settings(paths, persist=False)
runner = api._runner(paths, "login-diagnostics", False)
with api._operation_guard(paths, "login-diagnostics", False):
report = api.inspect_login_environment(
paths,
settings,
timeout_seconds=timeout_seconds,
runner=runner,
runtime_override=(
None if runtime_choice == "configured" else runtime_choice
),
)
if as_json:
api._print_json(report.to_dict())
else:
probe = report.login_server
print(f"Launcher: {report.launcher_version} ({report.launcher_module})")
print(f"Python: {report.python_executable}")
print(f"Host: {report.distribution_id} ({report.kernel_release})")
print(
"Runtime test: "
f"{report.runtime_requested} "
f"(configured: {report.runtime_configured}; settings unchanged)"
)
status = "REACHABLE" if probe.reachable else "UNREACHABLE"
latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else ""
print(f"Login server: {status} ({probe.host}:{probe.port}{latency})")
if probe.error:
print(f"Network error: {probe.error}")
print("Authentication: not tested; no credentials or game-protocol data were sent")
print(f"Login responses: {report.login_response_ownership}")
identity_status = (
"ready"
if report.client_identity_ready is True
else "NOT READY"
if report.client_identity_ready is False
else "not probed"
)
print(f"Client identity: {identity_status}; raw identity values were not collected")
print(f"Privacy: {report.client_identity_privacy}")
if not report.login_trace_enabled:
print("Login trace: retired (will not launch or instrument the game)")
print(f"Publisher test: {report.publisher_comparison_command}")
print(f"Game profile: {report.game_profile}")
print(f"Game.exe: {report.game_exe}")
if report.game_exe_sha256:
print(f"Game SHA-256: {report.game_exe_sha256}")
print(f"game.dll: {report.game_dll}")
if report.game_dll_sha256:
print(f"DLL SHA-256: {report.game_dll_sha256}")
print(
"Managed source: "
+ ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS")
)
if report.depot_build is not None:
print(
f"Depot: build {report.depot_build} "
f"({report.depot_version or 'unknown version'})"
)
if report.runtime_kind:
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
else:
print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})")
if report.proton_sync_effective is not None:
print(
"Proton sync: "
f"{report.proton_sync_configured} -> {report.proton_sync_effective}"
)
prefix_status = "ready" if report.prefix_ready else "not ready"
directx_status = "ready" if report.directx_ready else "not ready"
print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}")
print(f"Prefix path: {report.prefix or 'unavailable'}")
print(f"Latest log: {report.latest_game_log or 'not found'}")
if report.latest_game_log_modified_at:
print(f"Log modified: {report.latest_game_log_modified_at}")
for warning in report.warnings:
print(f"Warning: {warning}")
return 0 if report.preflight_ok else 1

View File

@@ -0,0 +1,479 @@
"""Download, setup, depot synchronization, import, and prepare commands."""
from __future__ import annotations
from dataclasses import asdict
import json
from pathlib import Path
import time
from typing import Any
from ...command import CommandOutcome, CommandRunner
from ...depot import DownloadOptions, SyncProgress
from ...importer import GameImportResult
from ...launcher import LauncherError, WineTools
from ...paths import AppPaths
from ...settings import Settings
def fetch(api: Any, paths: AppPaths, dry_run: bool, as_json: bool) -> int:
if not dry_run:
paths.create_base_directories()
if dry_run:
value = {
"ok": True,
"dry_run": True,
"url": api.OFFICIAL_LAUNCHER_URL,
"download": str(paths.launcher_pristine),
"prepared": str(paths.launcher_patched),
}
else:
telemetry = api.current_telemetry()
download_started_at = time.monotonic()
if telemetry is not None:
telemetry.send_event(
"download_started",
metadata={
"resource_type": "launcher",
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
try:
download = api.fetch_official_launcher(
paths.launcher_pristine,
paths.launcher_metadata,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"download_failed",
severity="error",
message=str(exc),
metadata={
"resource_type": "launcher",
"duration_ms": round(
(time.monotonic() - download_started_at) * 1000
),
"failure_type": api.classify_network_failure(exc),
"http_status": api.network_http_status(exc),
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
raise
download_duration = max(0.0, time.monotonic() - download_started_at)
download_bytes = download.size if download.changed else 0
if telemetry is not None:
telemetry.send_event(
"download_completed",
metadata={
"resource_type": "launcher",
"download_bytes": download_bytes,
"content_bytes": download.size,
"duration_ms": round(download_duration * 1000),
"average_mbps": (
round((download_bytes * 8) / download_duration / 1_000_000, 3)
if download_bytes and download_duration > 0
else None
),
"retry_count": 0,
"resume_used": False,
"http_status": 200 if download.changed else 304,
"changed": download.changed,
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
validation_started_at = time.monotonic()
try:
patch = api.prepare_launcher_binary(
paths.launcher_pristine,
paths.launcher_patched,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"file_validation_failed",
severity="error",
message=str(exc),
metadata={
"file_category": "launcher",
"duration_ms": round(
(time.monotonic() - validation_started_at) * 1000
),
},
context={"operation": "download", "stage": "validation"},
)
raise
value = {
"ok": True,
"download": asdict(download),
"patch": asdict(patch),
"warning": None if patch.supported_profile else (
"The launcher is newer than the verified font-patch profile; "
"it was retained byte-for-byte."
),
}
api._emit(value, as_json)
return 0
def setup(
api: Any,
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
as_json: bool,
) -> int:
tools = api.find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = api._runner(paths, "setup", dry_run)
with api._operation_guard(paths, "setup", dry_run):
results = api._configure_launcher_runtime(
paths,
tools,
runner,
force_runtime=force_runtime,
)
api._emit(
{
"ok": True,
"dry_run": dry_run,
"prefix": str(paths.launcher_prefix),
"commands": [asdict(result) for result in results],
},
as_json,
)
return 0
def native_update(
api: Any,
paths: AppPaths,
dry_run: bool,
repair: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
"""Use the native verified depot client for normal installs and updates."""
settings = api._load_or_default_settings(paths, persist=not dry_run)
download_options = api._configured_download_options(settings)
runner = api._runner(paths, "native-update", dry_run)
with api._operation_guard(paths, "native-update", dry_run):
api._print_operation_status(as_json, 1, 3, "Checking local game files…")
game_import = None
if not no_import:
game_import = api._maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
verify_import = game_import is not None or api.game_import_requires_verification(paths)
api._print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
result = api.sync_managed_game(
paths,
repair=repair or verify_import,
quarantine_unmanaged=verify_import,
dry_run=dry_run,
progress=None if as_json else api._print_sync_progress,
**(
{"download_options": download_options}
if download_options is not None
else {}
),
)
if verify_import and not dry_run:
api.mark_game_import_verified(
paths,
build=result.build,
files_quarantined=result.files_quarantined,
quarantine_directory=result.quarantine_directory,
)
overlay = None
if not dry_run and paths.source_game.is_dir():
api._print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
overlay = asdict(api.prepare_runtime_view(paths, paths.source_game))
api._emit(
{
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"native_update": asdict(result),
"runtime_view": overlay,
},
as_json,
)
return 0
def wine_update(
api: Any,
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
api._fetch(paths, dry_run, True)
tools = api.find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = api._runner(paths, "update", dry_run)
settings = api._load_or_default_settings(paths, persist=not dry_run)
download_options = api._configured_download_options(settings)
with api._operation_guard(paths, "update", dry_run):
api._configure_launcher_runtime(
paths,
tools,
runner,
force_runtime=force_runtime,
)
game_import = None
if not no_import:
game_import = api._maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
updater = api.launch_official_updater(paths, tools, runner)
native_verification = None
verify_import = not dry_run and (
game_import is not None or api.game_import_requires_verification(paths)
)
if verify_import:
native_verification = api.sync_managed_game(
paths,
repair=True,
quarantine_unmanaged=True,
progress=None if as_json else api._print_sync_progress,
**(
{"download_options": download_options}
if download_options is not None
else {}
),
)
api.mark_game_import_verified(
paths,
build=native_verification.build,
files_quarantined=native_verification.files_quarantined,
quarantine_directory=native_verification.quarantine_directory,
)
overlay = None
if not dry_run and paths.source_game.is_dir():
overlay = asdict(api.prepare_runtime_view(paths, paths.source_game))
value = {
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"updater": asdict(updater),
"native_verification": (
asdict(native_verification) if native_verification is not None else None
),
"runtime_view": overlay,
}
api._emit(value, as_json)
return 0
def configure_launcher_runtime(
api: Any,
paths: AppPaths,
tools: WineTools,
runner: CommandRunner,
*,
force_runtime: bool,
) -> list[CommandOutcome]:
if not force_runtime or runner.dry_run:
return api.configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=force_runtime,
)
telemetry = api.current_telemetry()
started_at = time.monotonic()
metadata = {
"runtime_type": "dotnet_desktop",
"runtime_version": "6",
}
if telemetry is not None:
telemetry.send_event(
"runtime_reinstall_requested",
metadata={**metadata, "reason": "force_runtime"},
context={"operation": "runtime_repair", "stage": "requested"},
)
telemetry.send_event(
"runtime_repair_started",
metadata=metadata,
context={"operation": "runtime_repair", "stage": "started"},
)
try:
results = api.configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=True,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"runtime_repair_failed",
severity="error",
message=str(exc),
metadata={
**metadata,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "runtime_repair", "stage": "runtime_install"},
)
raise
if telemetry is not None:
telemetry.send_event(
"runtime_repair_success",
metadata={
**metadata,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "runtime_repair", "stage": "completed"},
)
return results
def print_sync_progress(progress: SyncProgress) -> None:
"""Emit line-delimited progress that the native Tk dashboard can consume."""
print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True)
def configured_download_options(settings: Settings) -> DownloadOptions | None:
if (
settings.download_speed_limit_bytes_per_second is None
and not settings.low_bandwidth_mode
):
return None
return DownloadOptions(
bandwidth_limit_bytes_per_second=(
settings.download_speed_limit_bytes_per_second
),
low_bandwidth=settings.low_bandwidth_mode,
)
def print_operation_status(
as_json: bool,
step: int,
total: int,
message: str,
) -> None:
"""Emit a structured operation stage for the native launcher dashboard."""
if not as_json:
print(
f"STATUS {json.dumps({'step': step, 'total': total, 'message': message})}",
flush=True,
)
def import_game(
api: Any,
paths: AppPaths,
source: Path,
dry_run: bool,
as_json: bool,
) -> int:
runner = api._runner(paths, "import-game", dry_run)
if not as_json:
print(f"Importing existing game from {source.expanduser()}…", flush=True)
with api._operation_guard(paths, "import-game", dry_run):
result = api._import_with_telemetry(paths, source, runner)
api._emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json)
return 0
def maybe_import_game(
api: Any,
paths: AppPaths,
settings: Settings,
seed_from: Path | None,
runner: CommandRunner,
*,
announce: bool,
) -> GameImportResult | None:
if paths.source_game.exists() or paths.source_game.is_symlink():
if seed_from is not None:
raise RuntimeError(
f"cannot seed an existing managed game directory: {paths.source_game}"
)
return None
candidate = seed_from
if candidate is None:
candidate = api.discover_existing_game(paths, settings)
if candidate is None:
return None
if announce:
print(f"Bootstrapping from existing game: {candidate}", flush=True)
return api._import_with_telemetry(paths, candidate, runner)
def import_with_telemetry(
api: Any,
paths: AppPaths,
source: Path,
runner: CommandRunner,
) -> GameImportResult:
telemetry = api.current_telemetry()
started_at = time.monotonic()
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_started",
context={"operation": "import", "stage": "copy"},
)
try:
result = api.import_existing_game(paths, source, runner)
except BaseException as exc:
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_failed",
severity="error",
message=str(exc),
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "import", "stage": "copy"},
)
raise
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_success",
metadata={
"files_imported": result.files,
"bytes_imported": result.bytes,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "import", "stage": "completed"},
)
return result
def prepare(api: Any, paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
with api._operation_guard(paths, "prepare", False):
if game_dir is None and api.game_import_requires_verification(paths):
raise LauncherError(
"the imported game has not completed its full publisher verification; "
"run pristontale-eu update"
)
settings = api.ensure_settings(paths)
source = api._source_game(paths, settings, game_dir)
result = api.prepare_runtime_view(paths, source)
api._emit({"ok": True, "runtime_view": asdict(result)}, as_json)
return 0

View File

@@ -0,0 +1,138 @@
"""Launcher self-update command workflow."""
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
import sys
from typing import Any
from ...paths import AppPaths
from ...umu import UmuBootstrapResult
from ...update_resolver import STABLE_CHANNEL
def launcher_update(
api: Any,
paths: AppPaths,
check_only: bool,
verify: bool,
rollback: str | bool | None,
manifest_url: str | None,
public_key: Path | None,
as_json: bool,
*,
history_url: str | None = None,
) -> int:
try:
selected_channel = api.load_settings(paths).update_channel
except (OSError, TypeError, ValueError):
# Keep update recovery usable when settings are absent or damaged.
selected_channel = STABLE_CHANNEL
rollback_requested = rollback is not None and rollback is not False
if rollback_requested:
target_version = rollback if isinstance(rollback, str) and rollback else None
result = api.rollback_launcher_update(
paths,
target_version=target_version,
selected_channel=selected_channel,
history_url=history_url,
public_key_path=public_key,
)
api._emit(
{
"ok": True,
"launcher_rollback": asdict(result),
},
as_json,
)
return 0
if check_only:
check = api.check_for_launcher_update(
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._emit(
{
"ok": True,
"launcher_update": api.update_check_to_dict(check),
},
as_json,
)
return 0
if verify:
api._print_operation_status(as_json, 1, 2, "Authenticating the launcher release…")
result = api.verify_launcher_update_channel(
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._print_operation_status(as_json, 2, 2, "Published launcher release verified.")
api._emit(
{
"ok": True,
"launcher_update_verification": asdict(result),
},
as_json,
)
return 0
api._print_operation_status(as_json, 1, 4, "Verifying the signed launcher release…")
result = api.install_launcher_update(
paths,
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._print_operation_status(
as_json,
2,
4,
"Launcher update installed; preparing the Steam Runtime helper…",
)
umu_result: UmuBootstrapResult | None = None
umu_executable: str | None = None
umu_warning: str | None = None
try:
umu_result = api.provision_umu(paths)
umu_executable = api.find_umu_launcher(paths)
except (OSError, RuntimeError) as exc:
# Activation is already durable. Keep the update successful so the GUI can
# restart into the new release, whose startup hook and Play path both retry.
warning_message = (
"the launcher update was installed, but the Steam Runtime helper could "
f"not be prepared yet: {exc}"
)
umu_warning = warning_message
if not as_json:
print(f"Warning: {warning_message}", file=sys.stderr)
api._print_operation_status(
as_json,
3,
4,
"Checking Steam Runtime sandbox permissions (authentication may be requested)…",
)
container_report = api.repair_container_host()
container_warning = container_report.repair_error
if not container_report.ready and container_warning is None:
container_warning = container_report.recommendation
if container_warning and not as_json:
print(f"Warning: {container_warning}", file=sys.stderr)
api._print_operation_status(as_json, 4, 4, "Launcher update and runtime check complete.")
api._emit(
{
"ok": True,
"launcher_update": asdict(result),
"runtime_bootstrap": {
"executable": umu_executable,
"managed_download": asdict(umu_result) if umu_result else None,
"warning": umu_warning,
},
"container_host": {
**container_report.to_dict(),
"warning": container_warning,
},
},
as_json,
)
return 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
"""Launcher settings command workflow."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from ...paths import AppPaths
def settings(
api: Any,
paths: AppPaths,
initialize: bool,
reset: bool,
as_json: bool,
) -> int:
if reset:
settings = api.reset_settings(paths)
elif initialize:
settings = api.ensure_settings(paths)
else:
settings = api.load_settings(paths)
api._emit(
{
"ok": True,
"path": str(paths.settings_file),
"settings": asdict(settings),
},
as_json,
)
return 0

View File

@@ -0,0 +1,245 @@
"""Shared CLI telemetry, result reporting, and output helpers."""
from __future__ import annotations
import argparse
import json
import signal
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from ..command import CommandError
from ..paths import AppPaths
from ..telemetry import TelemetryService
CommandTelemetryEvents = tuple[str, str, str, str]
@dataclass(frozen=True, slots=True)
class CommandTelemetryLifecycle:
telemetry: TelemetryService | None
events: CommandTelemetryEvents | None
started_at: float
owner: bool
def start_command_telemetry(
api: Any,
paths: AppPaths,
command: str,
arguments: argparse.Namespace,
manage_telemetry: bool,
) -> CommandTelemetryLifecycle:
telemetry: TelemetryService | None = None
telemetry_owner = False
if manage_telemetry:
interface = "gui" if command == "gui" else "cli"
telemetry, telemetry_owner = api.initialize_shared_telemetry(
paths,
session_metadata={"interface": interface, "command": command},
report_launcher_lifecycle=command == "gui",
)
events = api._command_telemetry_events(command, arguments, paths)
started_at = time.monotonic()
if telemetry is not None and events is not None:
operation, started_event, _success_event, _failed_event = events
if operation == "repair":
telemetry.send_event(
"repair_requested",
metadata={"command": command, "reason": "user_requested"},
context={"operation": operation, "stage": "requested"},
)
telemetry.send_event(
started_event,
metadata={"command": command},
context={"operation": operation, "stage": "started"},
)
return CommandTelemetryLifecycle(
telemetry=telemetry,
events=events,
started_at=started_at,
owner=telemetry_owner,
)
def shutdown_command_telemetry(
api: Any,
lifecycle: CommandTelemetryLifecycle,
) -> None:
if lifecycle.telemetry is not None and lifecycle.owner:
api.shutdown_shared_telemetry(lifecycle.telemetry)
def command_telemetry_events(
command: str,
arguments: argparse.Namespace,
paths: AppPaths,
) -> CommandTelemetryEvents | None:
if bool(getattr(arguments, "dry_run", False)):
return None
if command in {"update", "sync", "wine-update"}:
if bool(getattr(arguments, "repair", False)):
return ("repair", "repair_started", "repair_success", "repair_failed")
if not paths.source_game.exists():
return ("install", "install_started", "install_success", "install_failed")
return ("update", "update_started", "update_success", "update_failed")
if command == "launcher-update" and not (
bool(getattr(arguments, "check", False))
or bool(getattr(arguments, "verify", False))
or getattr(arguments, "rollback", None) is not None
):
return (
"launcher_update",
"update_started",
"update_success",
"update_failed",
)
if command in {"setup", "runtime-bootstrap"}:
return (
"runtime_install",
"runtime_install_started",
"runtime_install_success",
"runtime_install_failed",
)
if command == "doctor":
return (
"compatibility_check",
"compatibility_check_started",
"compatibility_check_completed",
"compatibility_check_failed",
)
return None
def report_command_result(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
returncode: int,
) -> None:
if telemetry is None or events is None:
return
operation, _started_event, success_event, failed_event = events
duration_ms = round((time.monotonic() - started_at) * 1000)
telemetry.send_event(
success_event if returncode == 0 else failed_event,
severity="info" if returncode == 0 else "error",
message=None if returncode == 0 else f"Command exited with status {returncode}",
metadata={
"command": command,
"duration_ms": duration_ms,
"exit_code": returncode,
},
context={
"operation": operation,
"stage": "completed" if returncode == 0 else "command_exit",
},
)
def report_command_failure(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
message: str,
*,
stage: str = "failed",
) -> None:
if telemetry is None:
return
if command == "play":
telemetry.send_event(
"game_launch_failed",
severity="error",
message=message,
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "launch_game", "stage": stage},
)
return
if events is None:
return
operation, _started_event, _success_event, failed_event = events
telemetry.send_event(
failed_event,
severity="error",
message=message,
metadata={
"command": command,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": operation, "stage": stage},
)
def report_game_launch_cancelled(
telemetry: TelemetryService | None,
started_at: float,
*,
stage: str,
metadata: dict[str, object] | None = None,
) -> None:
if telemetry is None:
return
event_metadata: dict[str, object] = {
"duration_ms": round((time.monotonic() - started_at) * 1000),
"interrupted": True,
"launcher_exit_code": 130,
"signal": int(signal.SIGINT),
"signal_name": signal.SIGINT.name,
}
if metadata is not None:
event_metadata.update(metadata)
telemetry.send_event(
"game_launch_cancelled",
severity="info",
message="Game launch was cancelled",
metadata=event_metadata,
context={"operation": "launch_game", "stage": stage},
)
def failure_stage(command: str, exception: BaseException) -> str:
if isinstance(exception, ValueError):
return "configuration"
if isinstance(exception, CommandError):
return "subprocess"
message = str(exception).casefold()
if any(value in message for value in ("manifest", "cdn", "request failed")):
return "manifest"
if any(value in message for value in ("download", "incomplete response")):
return "download"
if any(value in message for value in ("sha-1", "checksum", "hash mismatch")):
return "validation"
if any(
value in message
for value in ("permission", "read-only", "cannot write", "writable")
):
return "filesystem"
if any(value in message for value in ("prefix", "wine", "proton", "umu")):
return "runtime_check"
return f"{command}_operation"
def emit(
value: object,
as_json: bool,
*,
json_printer: Callable[[object], None],
) -> None:
if as_json:
json_printer(value)
else:
if isinstance(value, dict) and value.get("ok") is True:
print("Done.")
print(json.dumps(value, indent=2, sort_keys=True))
def print_json(value: object) -> None:
print(json.dumps(value, indent=2, sort_keys=True))

332
src/ptlinux/changelog.py Normal file
View File

@@ -0,0 +1,332 @@
"""Official patch changelog support for the native Linux dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import json
import math
import os
from pathlib import Path
import re
from typing import Callable, Protocol
from urllib.request import Request, urlopen
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
PATCH_NEWS_URL = "https://pristontale.eu/news#"
MAX_PATCHES = 10
MAX_PATCH_BYTES = 2 * 1024 * 1024
MAX_PATCH_SECTIONS = 32
MAX_PATCH_CHANGES = 200
_MAX_PATCH_CANDIDATES = 100
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
class ChangelogError(RuntimeError):
"""Raised when the official patch feed cannot be parsed safely."""
class ChangelogResponse(Protocol):
"""The minimal response interface used by the changelog client."""
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> ChangelogResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
ChangelogOpener = Callable[..., ChangelogResponse]
@dataclass(frozen=True)
class PatchChange:
text: str
type_key: str
@property
def tag(self) -> str:
return patch_change_tag(self.type_key)
@dataclass(frozen=True)
class PatchSection:
title: str
items: tuple[PatchChange, ...]
@dataclass(frozen=True)
class PatchNote:
id: int
slug: str
title: str
version: str | None
tags: tuple[str, ...]
summary: str
sections: tuple[PatchSection, ...]
published_at: str | None
@property
def url(self) -> str:
return f"{PATCH_NEWS_URL}{self.slug}"
def patch_change_tag(type_key: str) -> str:
"""Turn API keys such as ``new-feature`` into compact UI tags."""
label = " ".join(type_key.replace("_", "-").split("-"))
return label.upper() if label else "CHANGE"
def fetch_patch_notes(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: ChangelogOpener = urlopen,
) -> tuple[PatchNote, ...]:
"""Fetch the ten latest patches, falling back to the last valid cache."""
request = Request(
PATCHES_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_PATCH_BYTES + 1)
if len(payload) > MAX_PATCH_BYTES:
raise ChangelogError("official patch response exceeds the 2 MiB limit")
patches = _parse_patch_notes(payload)
_write_patch_cache(cache_path, patches)
return patches
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
return read_cached_patch_notes(cache_path)
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
"""Read the last valid patch feed without performing network I/O."""
try:
value = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return ()
if not isinstance(value, list):
return ()
patches: list[PatchNote] = []
for item in value[:MAX_PATCHES]:
patch = _patch_note_from_cache(item)
if patch is not None:
patches.append(patch)
return tuple(patches)
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
value = json.loads(payload.decode("utf-8-sig"))
if isinstance(value, dict):
value = value.get("data")
if not isinstance(value, list):
raise ChangelogError("official patch response is not a patch list")
parsed: list[tuple[float, int, PatchNote]] = []
for item in value[:_MAX_PATCH_CANDIDATES]:
result = _parse_patch_note(item)
if result is not None:
timestamp, patch = result
parsed.append((timestamp, patch.id, patch))
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
if not patches:
raise ChangelogError("official patch response contains no usable patches")
return patches
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
version = _clean_text(value.get("version"), 64, allow_none=True)
if value.get("version") is not None and version is None:
return None
timestamp = _timestamp(value.get("date"))
patch = PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=_parse_sections(value.get("changes")),
published_at=_format_timestamp(timestamp),
)
return timestamp if timestamp is not None else -1.0, patch
def _parse_tags(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
tags: list[str] = []
seen: set[str] = set()
for item in value[:16]:
tag = _clean_text(item, 48)
if tag is None or tag.casefold() in seen:
continue
seen.add(tag.casefold())
tags.append(tag)
return tuple(tags)
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
if not isinstance(value, dict):
return ()
sections_value = value.get("sections")
if not isinstance(sections_value, list):
return ()
sections: list[PatchSection] = []
changes_seen = 0
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
title = _clean_text(section_value.get("title"), 100) or "Changes"
items_value = section_value.get("items")
if not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if changes_seen >= MAX_PATCH_CHANGES:
break
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is None:
continue
items.append(
PatchChange(
text=text,
type_key=type_key or _type_key_from_title(title),
)
)
changes_seen += 1
if items:
sections.append(PatchSection(title=title, items=tuple(items)))
if changes_seen >= MAX_PATCH_CHANGES:
break
return tuple(sections)
def _patch_note_from_cache(value: object) -> PatchNote | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
version = _clean_text(value.get("version"), 64, allow_none=True)
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
sections_value = value.get("sections")
sections: list[PatchSection] = []
if isinstance(sections_value, list):
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
section_title = _clean_text(section_value.get("title"), 100)
items_value = section_value.get("items")
if section_title is None or not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is not None and type_key is not None:
items.append(PatchChange(text=text, type_key=type_key))
if items:
sections.append(PatchSection(section_title, tuple(items)))
return PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=tuple(sections),
published_at=published_at,
)
def _clean_text(
value: object,
maximum_length: int,
*,
allow_empty: bool = False,
allow_none: bool = False,
) -> str | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
return None
text = " ".join(value.split())
if len(text) > maximum_length or (not text and not allow_empty):
return None
return text
def _timestamp(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
timestamp = float(value)
return timestamp if math.isfinite(timestamp) else None
def _format_timestamp(value: float | None) -> str | None:
if value is None:
return None
try:
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
except (OverflowError, OSError, ValueError):
return None
def _type_key_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)

Some files were not shown because too many files have changed in this diff Show More