Compare commits

21 changed files with 1698 additions and 125 deletions

No files matched your search

+2 -2
View File
@@ -7,9 +7,9 @@
</p>
<p align="center">
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.exe"><strong>DOWNLOAD VR MOD INSTALLER v1.3.4 — WINDOWS EXE</strong></a><br>
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.3.5/Halo-Steam-Frame-Mod-Setup-1.3.5.exe"><strong>DOWNLOAD VR MOD INSTALLER v1.3.5 — WINDOWS EXE</strong></a><br>
<sub>One installer file · No administrator rights needed</sub><br>
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.4">Release notes &amp; checksum</a>
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.3.5">Release notes &amp; checksum</a>
</p>
**Community VR mod installer for Halo: Combat Evolved on Steam Frame.** Installs an experimental native ARM64/OpenXR port with Xbox-style controls and Steam library artwork.
+12 -9
View File
@@ -1,17 +1,20 @@
# Halo CE Steam Frame VR Mod 1.3.4
# Halo CE Steam Frame VR Mod 1.3.5
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Mod-Setup-1.3.4.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.exe)**. This is the complete Windows mod installer; no separate ZIP or Python installation is needed.
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Mod-Setup-1.3.5.exe](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.3.5/Halo-Steam-Frame-Mod-Setup-1.3.5.exe)**. This is the complete Windows mod installer; no separate ZIP or Python installation is needed.
The window title, app text, project description, and documentation now identify this project as a **community Steam Frame VR mod installer**. The downloaded file includes setup code and tools; it does not include or download a retail Halo executable, ISO, maps, product keys, or soundtrack. Supply your own authorized original Xbox game data. Setup builds the pinned community VR source on your Frame.
This patch fixes interrupted uninstall recovery, renamed native Steam entries, and errors during cancellation or connection cleanup.
Commercial game-download site references have been removed from the current project documentation. The remaining references document the community source, official setup tools, and artwork provenance; setup provides no commercial game-download links.
- **Resume an interrupted uninstall.** Running Uninstall again can finish verified unfinished game folders from an earlier attempt, including when the main folder is already gone or Halo has been reinstalled. Setup verifies its private operation record and the remaining folder's identity before deleting files. Verified campaign save backups stay in place and their paths are reported. Unverified or changed folders are kept and reported as removal pending at 99%, rather than success or “already absent.”
- **Remove renamed Steam entries.** Uninstall matches the exact native Halo executable regardless of the entry's title. Unrelated games and similarly named entries remain untouched. Invalid native shortcut IDs stop removal before library-file writes or game deletion; existing backup and artwork-preservation safeguards remain in place.
- **Preserve cancellation and failure messages.** A failed stop request or disconnect no longer hides the operation's original error. Setup tries to stop a prepared build after cancellation, timeout, or a connection failure. If the existing stop request fails, setup makes one bounded recovery connection using the previously approved host key to stop that exact installer run. If remote stopping cannot be confirmed, setup explicitly warns that the build may still be running and displays the exact Frame-terminal command to stop it before retrying.
- **Separate connection cleanup from the game outcome.** If game installation or removal succeeds but connection cleanup fails, setup retains the result and shows a separate actionable warning at 99%. It does not claim complete disconnection or undo a completed game operation. USB cleanup remains limited to this operation's temporary forward; the shared ADB server and unrelated forwards are preserved.
The installer includes Halo identification artwork and third-party software with their existing attribution and license notices. Halo artwork, logos, names, and trademarks retain their rights holders' rights, including Microsoft; details remain available in the [third-party guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.4/docs/THIRD_PARTY.md). The project's code license does not grant rights to commercial Halo game data.
Thanks to [dime-online](https://github.com/dime-online) for contributions [#7](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/7), [#8](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/8), and [#9](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/9), addressing issues [#1](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/1), [#2](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/2), and [#3](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/3). This release builds on those fixes with additional verification and explicit pending states.
This wording patch retains automatic Steam artwork/Notes during Install and Repair, the responsive music loader, Xbox controls, USB/network transfer, repair, uninstall, and the Steam Home safeguards. The native ARM64/OpenXR mod remains experimental. Direct USB transfer, a fresh first install, full uninstall, and the newer Steam banner/logo/icon/Notes still need physical Frame verification.
The pinned native ARM64/OpenXR source, Xbox controls, tutorial patch, automatic Steam artwork/Notes, music preview, USB/network transfer, and Steam Home safeguards remain unchanged. The mod is experimental. Interrupted-removal recovery, cancellation recovery, direct USB transfer, full uninstall, a fresh first install, and newer Steam banner/logo/icon/Notes still need physical Frame verification.
**Original Xbox USA Rev 2 is validated**; other retail revisions need supported map headers. PC, Xbox 360 Anniversary, MCC, and Quest game-data inputs are unsupported.
Supply your own authorized **original Xbox Halo CE** data. **USA Rev 2 is validated**; other retail revisions need supported map headers. PC, Xbox 360 Anniversary, MCC, and Quest game-data inputs are unsupported. No retail game executable, disc image, maps, product keys, or soundtrack is included or downloaded, and setup provides no game-download links. Halo identification artwork and licensed software retain their rights and notices; see the [third-party guide](https://github.com/ClassicWasTaken/HaloSteamFrameMod/blob/v1.3.5/docs/THIRD_PARTY.md).
The EXE is unsigned. Its SHA256 appears below. [Matching source and third-party notices](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.4) remain available for rebuilding. GitHub's automatic source downloads are optional for developers.
The EXE is unsigned. Its SHA256 appears below. [Matching source and third-party notices](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.3.5) remain available for rebuilding. GitHub's automatic source downloads are optional for developers.
[Setup guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.4/docs/SETUP.md) · [Sources and credits](SOURCES.md).
[Setup guide](https://github.com/ClassicWasTaken/HaloSteamFrameMod/blob/v1.3.5/docs/SETUP.md) · [Sources and credits](SOURCES.md).
+12 -5
View File
@@ -6,7 +6,7 @@ This community VR mod installer guides you through installing the experimental n
## Before you begin
Download the single **[Halo-Steam-Frame-Mod-Setup-1.3.4.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.exe)** for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. The [release notes](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.4) include the EXE's SHA256 checksum.
Download the single **[Halo-Steam-Frame-Mod-Setup-1.3.5.exe](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.3.5/Halo-Steam-Frame-Mod-Setup-1.3.5.exe)** for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. The [release notes](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.3.5) include the EXE's SHA256 checksum.
Use your own authorized original Xbox Halo CE ISO/XISO or extracted maps. USA Rev 2 is validated; the supported-build table below explains other retail revisions. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are unsupported. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded. The project provides no game-download sources.
@@ -34,9 +34,11 @@ The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon.
The cable carries encrypted SSH/SFTP traffic between the PC and Frame. **The Frame still needs its own internet connection** to fetch native source, the build container, and compiler dependencies. USB does not provide internet sharing or change Wi-Fi/Steam settings. Cable transfer may help on a weak or congested wireless network; actual speed depends on the cable, USB ports, and device. It does not speed up the native compilation phase. USB behavior has automated software coverage; physical Steam Frame USB transfer verification is pending. [Valve's USB/ADB guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) and [Google's ADB reference](https://developer.android.com/tools/adb) describe the underlying connection and forwarding tools.
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress; a remaining manual Steam or artwork step stays at 99%, and only full success reaches 100%.
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress. Pending Steam information, uninstall files, or connection cleanup stays at 99%, with the remaining action explained; only full success reaches 100%.
Setup explicitly closes its SFTP and command channels and the underlying SSH connection before reporting completion. The activity feed shows disconnecting and disconnected stages; failure and cancellation also close that connection. USB cleanup removes only this operation's temporary ADB forward. Setup does not stop the shared ADB server or change the Frame's Wi-Fi or Steam streaming settings.
Setup closes its SFTP and command channels and the underlying SSH connection after each operation, including failure and cancellation. The activity feed shows disconnecting and disconnected stages when cleanup succeeds. A connection-cleanup failure is shown separately from the game result: an installed game remains installed, and a completed removal remains recorded. Follow the displayed connection-cleanup instructions before closing setup; a remaining cleanup step stays at 99%. USB cleanup removes only this operation's temporary ADB forward. Setup does not stop the shared ADB server or change the Frame's Wi-Fi or Steam streaming settings.
If a prepared build is cancelled, times out, or loses its connection, setup tries to stop that same installer run. If the existing stop request fails, it makes one bounded reconnect attempt using the SSH host key you already approved. It does not approve a different key. If the Frame cannot confirm stopping, setup warns that the build may still be running and provides the exact stop command for the Frame's terminal. Restore the connection and follow that command before retrying installation or uninstalling. The original cancellation or failure remains visible even if its stop request or connection cleanup also fails.
The included ADB executable, two companion DLLs, and unmodified notices are checksum-verified before being copied to `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1`. This versioned cache remains after setup closes so a shared ADB server can keep using it without locking the EXE's temporary extraction folder. The selected client's protocol is checked against an already-running local ADB server; a mismatch stops USB setup without restarting that server. Setup also verifies that the temporary forwarded port listens only on this PC's loopback addresses before sending the SSH password. [Bundled-tool provenance and licenses](THIRD_PARTY.md#adb-for-usb-transfer).
@@ -72,10 +74,12 @@ Open **Notes** on the Halo game's Steam details page or in its overlay to read t
### Uninstall Halo VR
Choose **Uninstall** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup asks the running client to remove the exact native shortcut from its active account, then verifies that removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. An owned installation build still running also stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
Choose **Uninstall** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup matches native shortcuts by their exact executable, so renaming the entry in Steam does not prevent removal. It verifies shortcut removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. Invalid matching shortcut records also stop removal before library-file changes. An owned installation build still running stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
**Keep my campaign saves in a backup (recommended)** is checked by default. Setup backs up saves inside the native game folder and the original config to `~/Games/HaloCENativeVR-saves-<operation ID>`, and reports the exact backup location. The recognized native folder, including its maps, program files, and config, is then removed; configured saves outside that folder remain untouched. Turning off the checkbox allows saves inside the removed folder to be deleted. Uninstall leaves unrelated games, foreign or unrecognized folders, and unrelated settings alone. The build cache remains available for troubleshooting. Setup disconnects its SSH session after the operation and reaches 100% only after completion.
If an earlier uninstall was interrupted, run **Uninstall** again. Setup can finish deleting its verified unfinished game folders even when the main game folder is gone or Halo has since been reinstalled. It checks the earlier operation's private record and the remaining folder's identity before resuming. Verified save backups remain in place, and their paths appear in the result. Changed or unverified folders are preserved and listed for review; setup reports removal pending at 99% instead of claiming the game was already absent. Review **Show activity** and **Save log** for these paths. Do not delete a reported save backup. If setup reports that it reached its recovery limit, run Uninstall again to continue; folders that fail verification need manual review.
With the client running, Steam controls artwork cleanup for the removed entry; setup does not delete its live grid files. With Steam already closed, setup removes only artwork matching the installer's known image hashes and preserves custom images.
## Xbox-style controls
@@ -138,6 +142,9 @@ Full source attribution, decompilation lineage, multiplayer references, and thir
- **Host key changed:** setup refuses the connection. Verify the new device/key independently before removing its old public fingerprint from `%LOCALAPPDATA%/HaloFrameInstaller/hosts.json`.
- **Insufficient disk space or Podman missing:** resolve the failed preflight check and retry. Setup does not disable SteamOS protections or install system packages as root.
- **Build/download fails:** the error dialog shows a short failure headline; open **Show activity** or **Save log** for the full redacted details. Keep the Frame's build log and retry. Version 1.2.1 fixes the container dependency-installation permission errors (`setgroups`, `seteuid`, or APT cache permissions) seen in 1.2.0. Retry can reuse previously uploaded maps only when the complete map set matches the selected local data manifest and passes header and SHA256 verification; otherwise setup uploads fresh data. Reused data stays in its previous owned upload run and is verified again at build and finalization. A failed staging build does not replace an existing game or save.
- **Cancelled, but remote stopping is unconfirmed:** the build may still be running. Restore the connection and run the exact stop command shown by setup in the Frame's terminal before installing or uninstalling again. Setup's recovery connection accepts only your previously approved host key.
- **Game operation finished, connection cleanup pending:** follow the separate cleanup warning and save the activity log. The game outcome is retained; a USB-forward cleanup error does not undo an installation or completed uninstall. Setup leaves the shared ADB server and unrelated forwards alone.
- **Uninstall interrupted or folders preserved:** run Uninstall again to finish verified leftovers. Save backups remain separate and their verified paths are reported. If files cannot be verified, setup lists the preserved folders and remains at 99%; review the log before any manual removal. An invalid matching Steam shortcut must be corrected in Steam before retrying uninstall.
- **Unsupported ISO or maps:** choose original Xbox retail data matching a [supported map build](#supported-xbox-iso-revisions). USA Rev 2 is validated; a filename containing Rev 1 or Rev 2 does not establish compatibility by itself. Keep all required maps from one release together.
- **Flat window instead of VR:** wake the headset and start SteamVR before launching. The port falls back to a flat window if no usable OpenXR session is available.
- **Controls doubled:** check the native shortcut's launch option above. Steam Input may otherwise add a second virtual controller stream.
@@ -158,7 +165,7 @@ python -m venv .venv
.\.venv\Scripts\python scripts/build_release.py
```
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.4 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.4)** so you can modify and rebuild it, including its LGPL Paramiko dependency. GitHub's automatically generated Source code ZIP is optional for developers and is not needed to run setup. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance, and the original notices are retained. The build script packages installer code/resources and verified ADB components with their notices; no retail game executable, user ISO, maps, SSH password, or private key is included. GitHub Actions runs tests and builds the Windows setup executable. A local rebuild also writes a source bundle and checksum file to `dist/`; the public release attaches only the clearly named mod installer executable and prints its checksum in the release description.
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.5 source tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.3.5)** so you can modify and rebuild it, including its LGPL Paramiko dependency. GitHub's automatically generated Source code ZIP is optional for developers and is not needed to run setup. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance, and the original notices are retained. The build script packages installer code/resources and verified ADB components with their notices; no retail game executable, user ISO, maps, SSH password, or private key is included. GitHub Actions runs tests and builds the Windows setup executable. A local rebuild also writes a source bundle and checksum file to `dist/`; the public release attaches only the clearly named mod installer executable and prints its checksum in the release description.
The workflow also includes an ARM64 Linux check, [check-build-container.py](../scripts/check-build-container.py), that uses the same rootless Podman arguments as the Frame build. It checks APT package installation, `setgroups`, `seteuid`, `setegid`, `chown`, and that container output belongs to the ordinary host user. The Windows executable CI job depends on that check as well as the unit tests. This container check uses no game data and does not replace a full Steam Frame installation test.
+6
View File
@@ -4,6 +4,12 @@ This community VR mod installer builds the experimental native ARM64 Linux VR po
Sources were checked on 2026-10-05. Links to pinned files describe the build used here; moving documentation may describe newer behavior.
## Installer contributions
[dime-online](https://github.com/dime-online) contributed the fixes for [interrupted uninstalls (#7)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/7), [renamed native Steam shortcuts (#8)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/8), and [SSH cancellation/error handling (#9)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/9), addressing issues [#1](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/1), [#2](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/2), and [#3](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/3). Release 1.3.5 builds on those contributions with operation-record and directory-identity checks before resuming file removal, explicit pending-removal and cleanup results, refusal of malformed native shortcut IDs before writes, and one bounded cancellation recovery connection using the already-approved host key.
These changes affect installer recovery and library cleanup. The pinned native game source, controller/tutorial patch, and game-data requirements are unchanged. Automated regression coverage does not replace physical Frame verification of interruption, cancellation, and full uninstall.
## Native game and VR
| Source | Contribution or verified requirement |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "halo-steam-frame-installer"
version = "1.3.4"
version = "1.3.5"
description = "Community Halo CE VR mod installer for Steam Frame; original Xbox game data must be supplied separately"
requires-python = ">=3.11"
license = "MIT"
+2 -2
View File
@@ -106,13 +106,13 @@
},
{
"name": "halo-steam-frame-installer",
"version": "1.3.4",
"version": "1.3.5",
"license_expression": "MIT",
"declared_license": null,
"project_urls": [],
"notices": [
{
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.4.dist-info/licenses/LICENSE",
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.5.dist-info/licenses/LICENSE",
"sha256": "b19db6d1e8942380f6a0dc3b9ec39cec802606c9d1c08a6050eae2990568afbb",
"bytes": 1318
}
@@ -0,0 +1,25 @@
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
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.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
+288 -26
View File
@@ -33,6 +33,8 @@ SUPPORTED_BUILDS = frozenset(("01.01.14.2342", "01.10.12.2276", "01.08.15.1749")
MAX_MAP_BYTES = 0x11600000
MAX_TOTAL_BYTES = 4 * 1024 ** 3
MAX_MANIFEST_BYTES = 64 * 1024
MAX_WARNING_CHARS = 4000
MAX_UNINSTALL_RECOVERIES = 64
MAX_RETRY_RUNS = 32
MAX_RETRY_ENTRIES = 256
HOME = pathlib.Path("/home/steamos")
@@ -1111,6 +1113,40 @@ def uninstall_signature(details):
return details.st_dev, details.st_ino, details.st_mode, details.st_uid, details.st_size, details.st_mtime_ns
def uninstall_root_identity(directory):
"""The directory's identity survives its rename and partial file removal."""
details = beneath(directory, HOME).lstat()
if not stat.S_ISDIR(details.st_mode) or details.st_uid != os.getuid():
raise ValueError("The uninstall folder is not an ordinary owned directory.")
return {"device": details.st_dev, "inode": details.st_ino, "uid": details.st_uid}
def write_uninstall_record(directory, metadata, owner=OWNER):
"""Publish a complete bounded journal before renaming the game directory."""
if metadata.get("owner") != owner:
raise ValueError("The uninstall record has an invalid owner.")
uninstall_root_identity(directory)
target = beneath(directory / MARKER, directory)
if target.exists():
current = private_json(target, directory)
if current.get("owner") != owner:
raise ValueError("The uninstall operation record belongs to another application.")
encoded = (json.dumps(metadata) + "\n").encode("utf-8")
if len(encoded) > MAX_MANIFEST_BYTES:
raise ValueError("The uninstall operation record exceeds supported bounds.")
temporary = directory / (".uninstall-record-" + os.urandom(8).hex() + ".tmp")
try:
with temporary.open("xb") as stream:
temporary.chmod(0o600)
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
if temporary.exists():
temporary.unlink()
def uninstall_mounts():
if not sys.platform.startswith("linux"):
return []
@@ -1212,23 +1248,38 @@ def copy_uninstall_save(relative, target, signature):
if details.st_nlink != 1 or uninstall_signature(details) != signature:
raise ValueError("A save file changed while preparing its backup. Game files were kept.")
target.parent.mkdir(parents=True, exist_ok=True)
checksum = hashlib.sha256()
copied = 0
with target.open("xb") as output:
shutil.copyfileobj(stream, output, 1024 * 1024)
while True:
block = stream.read(min(1024 * 1024, details.st_size - copied + 1))
if not block:
break
copied += len(block)
if copied > details.st_size:
raise ValueError("A save file grew while preparing its backup. Game files were kept.")
output.write(block)
checksum.update(block)
output.flush()
os.fsync(output.fileno())
if target.stat().st_size != details.st_size or uninstall_signature(os.fstat(stream.fileno())) != signature:
raise ValueError("A save backup could not be verified. Game files were kept.")
target.chmod(details.st_mode & 0o777)
os.utime(target, ns=(details.st_atime_ns, details.st_mtime_ns))
expected = checksum.hexdigest()
if digest(target) != expected:
raise ValueError("A save backup failed content verification. Game files were kept.")
return {"size": details.st_size, "sha256": expected}
def remove_uninstall_tree(directory, inventory, completed, total):
"""Unlink only validated paths, counting actual removal work for the UI."""
files = [relative for relative, (kind, _) in inventory.items() if kind == "file"]
files.sort(key=lambda relative: (relative in (MARKER, ".codex-halo-native-install.json"), relative))
markers = [relative for relative in files if relative in (MARKER, ".codex-halo-native-install.json")]
files = sorted(relative for relative in files if relative not in markers)
directories = sorted((relative for relative, (kind, _) in inventory.items() if kind == "directory"),
key=lambda relative: len(pathlib.Path(relative).parts), reverse=True)
for relative in files + directories:
for relative in files + directories + markers:
path = beneath(directory / relative, directory)
details = path.lstat()
kind, signature = inventory[relative]
@@ -1248,83 +1299,294 @@ def remove_uninstall_tree(directory, inventory, completed, total):
completed[0] += 1
def same_uninstall_root(expected, directory):
if (not isinstance(expected, dict) or set(expected) != {"device", "inode", "uid"}
or any(type(value) is not int or value < 0 for value in expected.values())
or expected != uninstall_root_identity(directory)):
raise ValueError("The quarantine directory does not match its recorded uninstall identity.")
def uninstall_quarantine_record(path, identifier):
"""A name or copied owner marker is never enough to authorize deletion."""
run_id(identifier)
directory = beneath(CACHE / "runs" / identifier, CACHE)
uninstall_root_identity(directory)
metadata = private_json(directory / MARKER, directory)
if (metadata.get("owner") != OWNER or metadata.get("operation") != "uninstall"
or metadata.get("state") not in ("quarantining", "removing")
or metadata.get("quarantinePath") != str(path)):
raise ValueError("No matching private interrupted-uninstall record was found.")
root = metadata.get("quarantineRoot")
if root is not None:
same_uninstall_root(root, path)
recognized = False
for name, owner in ((MARKER, OWNER), (".codex-halo-native-install.json", "codex-halo-native-frame-20261005")):
marker = path / name
if marker.exists() or marker.is_symlink():
if private_json(marker, path).get("owner") != owner:
raise ValueError("A quarantine marker belongs to another application.")
recognized = True
# Older versions wrote the exact path and operation only after the rename.
# Their private journal plus the surviving native marker can corroborate
# ownership. A markerless legacy tree cannot be adopted automatically.
if root is None and not recognized:
raise ValueError("This older uninstall has no remaining identity proof; its folder was kept.")
return directory, metadata
def verified_uninstall_backup(identifier, metadata):
"""Check a recorded backup without following links or changing any saves."""
location = metadata.get("savedBackupPath")
if location is None:
return None
saved = GAME.parent / ("HaloCENativeVR-saves-" + run_id(identifier))
if not isinstance(location, str) or location != str(saved):
raise ValueError("The uninstall save backup path does not match its operation.")
if metadata.get("savedBackupRoot") is not None:
same_uninstall_root(metadata["savedBackupRoot"], saved)
inventory = uninstall_inventory(saved)
value = private_json(saved / MARKER, saved)
if value.get("owner") != OWNER + "-save-backup" or value.get("gamePath") != str(GAME):
raise ValueError("The recorded save backup has no valid native-game ownership record.")
actual = {relative for relative, (kind, _) in inventory.items() if kind == "file" and relative != MARKER}
if not actual:
raise ValueError("The recorded save backup contains no save or configuration files.")
files = value.get("files")
if files is not None:
if not isinstance(files, dict) or set(files) != actual:
raise ValueError("The recorded save backup files do not match its manifest.")
for relative, expected in files.items():
if (not isinstance(expected, dict) or type(expected.get("size")) is not int
or expected["size"] < 0 or not isinstance(expected.get("sha256"), str)
or not re.fullmatch(r"[a-f0-9]{64}", expected["sha256"])):
raise ValueError("The recorded save backup manifest is invalid.")
target = ordinary(beneath(saved / relative, saved))
descriptor = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0))
with os.fdopen(descriptor, "rb") as stream:
signature = inventory[relative][1]
details = os.fstat(stream.fileno())
if details.st_nlink != 1 or uninstall_signature(details) != signature:
raise ValueError("A save backup changed during verification.")
checksum = hashlib.sha256()
read = 0
while True:
block = stream.read(min(1024 * 1024, details.st_size - read + 1))
if not block:
break
read += len(block)
if read > details.st_size:
raise ValueError("A save backup grew during verification.")
checksum.update(block)
if uninstall_signature(os.fstat(stream.fileno())) != signature:
raise ValueError("A save backup changed during verification.")
if details.st_size != expected["size"] or checksum.hexdigest() != expected["sha256"]:
raise ValueError("A recorded save backup file failed content verification.")
elif metadata.get("savedBackupRoot") is not None:
raise ValueError("The recorded save backup is missing its file verification manifest.")
if uninstall_inventory(saved) != inventory:
raise ValueError("The recorded save backup changed during verification.")
return str(saved)
def recover_interrupted_uninstall():
"""Finish only journaled quarantines; report every bounded unresolved entry."""
recovery = {"notes": [], "removed": 0, "retained": [], "saved": [], "limited": False, "unlisted": 0}
prefix = ".HaloCENativeVR-uninstall-"
candidates = []
# Avoid sorting/materializing an unbounded glob of user-controlled entries.
beneath(GAME.parent, HOME)
if not GAME.parent.exists():
return recovery
with os.scandir(GAME.parent) as entries:
for entry in entries:
if not entry.name.startswith(prefix):
continue
if len(candidates) >= MAX_UNINSTALL_RECOVERIES:
recovery["limited"] = True
break
candidates.append(GAME.parent / entry.name)
for path in sorted(candidates):
identifier = path.name[len(prefix):]
saved = None
try:
directory, metadata = uninstall_quarantine_record(path, identifier)
inventory = uninstall_inventory(path)
root = uninstall_root_identity(path)
saved = verified_uninstall_backup(identifier, metadata)
if saved is not None:
recovery["saved"].append(saved)
game_closed((path / "halo",))
progress("uninstall", "Finishing an earlier interrupted uninstall...", None)
if uninstall_inventory(path) != inventory or uninstall_root_identity(path) != root:
raise ValueError("The interrupted-uninstall folder changed during verification.")
remove_uninstall_tree(path, inventory, [0], 0)
except (OSError, ValueError, RuntimeError) as error:
reportable = (len(path.name) <= 255 and "\\" not in path.name
and not any(ord(character) < 32 or ord(character) == 127 for character in path.name))
if reportable:
recovery["retained"].append(str(path))
displayed = str(path)
else:
recovery["unlisted"] += 1
displayed = str(path.parent) + "/" + ascii(path.name)
recovery["notes"].append("An unfinished uninstall folder could not be safely removed and was kept: "
+ displayed + " (" + str(error)[:500] + "). Review it manually; "
"the installer did not assume ownership of unverified files."
+ (" Verified campaign saves and configuration remain in " + saved + "." if saved else ""))
continue
recovery["removed"] += 1
note = "Finished removing native files left by an interrupted uninstall: " + str(path) + "."
if saved:
note += " Verified campaign saves and configuration from that uninstall remain in " + saved + "."
metadata["state"] = "complete"
try:
write_uninstall_record(directory, metadata)
except (OSError, ValueError):
note += " Its operation record could not be updated."
recovery["notes"].append(note)
if recovery["limited"]:
recovery["notes"].append("More unfinished uninstall folders remain than can be checked in one operation. "
"They were kept; run Uninstall again to continue, or review them manually.")
return recovery
def join_notes(notes):
warning = " ".join(notes)
if len(warning) > MAX_WARNING_CHARS:
suffix = " (additional notes omitted; some folders may still need manual cleanup)"
warning = warning[:MAX_WARNING_CHARS - len(suffix)].rstrip() + suffix
return warning
def uninstall_recovery_result(response, recovery, notes=()):
pending = bool(recovery["retained"] or recovery["limited"] or recovery["unlisted"])
response["removalPending"] = pending
response["retainedQuarantinePaths"] = recovery["retained"]
response["recoveredSaveBackupPaths"] = list(dict.fromkeys(recovery["saved"]))
if pending:
response["uninstalled"] = False
response["alreadyAbsent"] = False
warning = join_notes([*recovery["notes"], *notes])
if warning:
response["warning"] = warning
return response
def uninstall(value, close_steam=False, keep_saves=False):
preflight_uninstall()
directory = run_dir(value)
if directory.exists():
metadata = private_json(directory / MARKER, directory)
if metadata.get("operation") != "uninstall":
if metadata.get("owner") != OWNER or metadata.get("operation") != "uninstall":
raise ValueError("Use a fresh run identifier for uninstall.")
else:
metadata = owned(directory)
metadata["operation"] = "uninstall"
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
write_uninstall_record(directory, metadata)
identity = uninstall_identity()
game_closed()
no_active_build()
inventory = uninstall_inventory(GAME) if identity else {}
save_files, external = uninstall_save_files(inventory) if identity and keep_saves else ([], [])
quarantine = beneath(GAME.parent / (".HaloCENativeVR-uninstall-" + value), HOME)
quarantine = GAME.parent / (".HaloCENativeVR-uninstall-" + value)
saved = beneath(GAME.parent / ("HaloCENativeVR-saves-" + value), HOME)
staging = beneath(GAME.parent / (".HaloCENativeVR-saves-" + value + ".tmp"), HOME)
for path in (quarantine, staging):
if path.exists() or path.is_symlink():
raise ValueError("An unfinished uninstall folder already exists: " + str(path))
if staging.exists() or staging.is_symlink():
raise ValueError("An unfinished save backup already exists and was kept: " + str(staging)
+ ". Use a fresh uninstall run.")
if identity and ((quarantine.exists() or quarantine.is_symlink())
or metadata.get("state") == "complete"):
raise ValueError("A previous uninstall used this run identifier. Use a fresh uninstall run for the installed game.")
if save_files and (saved.exists() or saved.is_symlink()):
raise ValueError("The selected save backup already exists. Use a fresh uninstall run.")
if save_files:
needed = sum(inventory[relative][1][4] for relative in save_files)
if shutil.disk_usage(GAME.parent).free < needed + 1024 * 1024:
raise ValueError("There is not enough space to keep the selected save files. Game files were kept.")
# Completing an earlier removal is still work this uninstall performs, but
# only after every refusal above has cleared.
recovered = recover_interrupted_uninstall()
total = len(save_files) + len(inventory) + (1 if identity else 0)
completed = [0]
staged, saved_path, renamed = False, None, False
progress("uninstall", "Preparing to remove the managed native game and its Steam shortcut...", 0)
try:
if save_files:
needed = sum(inventory[relative][1][4] for relative in save_files)
if shutil.disk_usage(GAME.parent).free < needed + 1024 * 1024:
raise ValueError("There is not enough space to keep the selected save files. Game files were kept.")
staging.mkdir(mode=0o700, exist_ok=False)
staged = True
(staging / MARKER).write_text(json.dumps({"owner": OWNER + "-save-backup", "gamePath": str(GAME),
"externalSavePathsPreserved": external}) + "\n")
backup_metadata = {"owner": OWNER + "-save-backup", "gamePath": str(GAME),
"externalSavePathsPreserved": external, "files": {}}
write_uninstall_record(staging, backup_metadata, OWNER + "-save-backup")
for relative in save_files:
copy_uninstall_save(relative, beneath(staging / relative, staging), inventory[relative][1])
backup_metadata["files"][relative] = copy_uninstall_save(
relative, beneath(staging / relative, staging), inventory[relative][1])
completed[0] += 1
progress("uninstall", f"Keeping campaign saves and configuration: {completed[0]:,} files copied.",
completed[0] * 100 // total)
write_uninstall_record(staging, backup_metadata, OWNER + "-save-backup")
sys.path.insert(0, str(CACHE / "resources"))
from steam_shortcut import remove_native_shortcut
steam = remove_native_shortcut(HOME, GAME, close_steam=close_steam)
if steam.get("status") not in ("removed", "already-absent"):
return {"gamePath": str(GAME), "uninstalled": False, "savedBackupPath": None, "steam": steam}
existing_saved = None
notes = []
if not identity and metadata.get("savedBackupPath") is not None:
try:
existing_saved = verified_uninstall_backup(value, metadata)
except (OSError, ValueError) as error:
notes.append("The current operation's recorded save backup could not be verified and was left unchanged: "
+ str(error)[:500] + ".")
return uninstall_recovery_result({"gamePath": str(GAME), "uninstalled": False,
"savedBackupPath": existing_saved, "steam": steam}, recovered, notes)
if not identity:
return {"gamePath": str(GAME), "uninstalled": True, "alreadyAbsent": True,
"savedBackupPath": metadata.get("savedBackupPath"), "steam": steam}
# Leftovers from an interrupted earlier run count as a removal this
# run finished, not as "already absent": the user still gets the
# completed-removal message and where that run's saves are.
existing_saved = None
notes = []
if metadata.get("savedBackupPath") is not None:
try:
existing_saved = verified_uninstall_backup(value, metadata)
except (OSError, ValueError) as error:
notes.append("The current operation's recorded save backup could not be verified and was left unchanged: "
+ str(error)[:500] + ".")
response = {"gamePath": str(GAME), "uninstalled": True, "alreadyAbsent": not recovered["removed"],
"savedBackupPath": existing_saved, "steam": steam}
return uninstall_recovery_result(response, recovered, notes)
game_closed()
no_active_build()
if uninstall_inventory(GAME) != inventory:
raise ValueError("The native game folder changed during uninstall. Its files were kept.")
# The complete journal is published before either rename: recovery can prove
# a markerless partial removal using this directory's dev/inode/uid.
metadata.update({"quarantinePath": str(quarantine), "quarantineRoot": uninstall_root_identity(GAME),
"savedBackupPath": str(saved) if staged else None,
"savedBackupRoot": uninstall_root_identity(staging) if staged else None,
"keepSaves": keep_saves, "state": "quarantining"})
write_uninstall_record(directory, metadata)
if staged:
rename_noreplace(staging, saved)
staged, saved_path = False, str(saved)
verified_uninstall_backup(value, metadata)
rename_noreplace(GAME, quarantine)
renamed = True
metadata.update({"quarantinePath": str(quarantine), "savedBackupPath": saved_path, "state": "removing"})
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
metadata["state"] = "removing"
write_uninstall_record(directory, metadata)
game_closed((quarantine / "halo",))
remove_uninstall_tree(quarantine, inventory, completed, total)
renamed = False
metadata["state"] = "complete"
warning = None
notes = []
try:
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
write_uninstall_record(directory, metadata)
except OSError:
# Bookkeeping failure after deletion cannot undo a completed removal.
warning = "Halo was removed, but the installer operation record could not be updated."
notes.append("Halo was removed, but the installer operation record could not be updated.")
progress("uninstall", "Native Halo and its Steam shortcut were removed.", 100)
response = {"gamePath": str(GAME), "uninstalled": True, "savedBackupPath": saved_path,
"externalSavePathsPreserved": external, "steam": steam}
if warning:
response["warning"] = warning
return response
return uninstall_recovery_result(response, recovered, notes)
except BaseException as error:
if renamed and quarantine.exists():
removals = completed[0] - len(save_files)
+9 -4
View File
@@ -312,7 +312,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
"""Remove only exact native title/path matches; keep all foreign fields."""
"""Remove only entries that launch the native executable; keep all foreign fields."""
root = loads(data)
shortcuts = root.get("shortcuts")
if shortcuts is None or shortcuts.kind != 0:
@@ -322,10 +322,15 @@ def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
if entry.kind != 0:
raise ValueError("Unexpected Steam shortcut record; no changes were made.")
fields = entry.value
title = fields.get("AppName", fields.get("appname"))
path = fields.get("Exe", fields.get("exe"))
if (title is not None and title.kind == 1 and title.value == NAME
and path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"')):
# Ownership is the native executable, not the display title: Steam
# lets users rename library entries, and the live client path treats
# an entry pointing at the native executable as owned as well. Match
# the path exactly, bare or quoted once, like the live client does.
# A malformed exact-native record blocks the transaction: treating it
# as absent would let uninstall delete the game while leaving its
# shortcut behind. Validate every account before publishing changes.
if path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"'):
appid = fields.get("appid")
if appid is None or appid.kind != 2 or not 2 ** 31 <= appid.value < 2 ** 32:
raise ValueError("The native Halo shortcut has an invalid app ID; no changes were made.")
+1 -1
View File
@@ -9,7 +9,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
VERSION = "1.3.4"
VERSION = "1.3.5"
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
def allowed_source_files():
+1 -1
View File
@@ -1,2 +1,2 @@
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
__version__ = "1.3.4"
__version__ = "1.3.5"
+24 -5
View File
@@ -844,7 +844,7 @@ class App(tk.Tk):
Button(links, "Native VR source", lambda:webbrowser.open(SOURCE_URL),
subtle=True, background=SURFACE).pack(anchor="w")
Button(links, "Installer project & documentation",
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloCE-SteamFrame"),
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloSteamFrameMod"),
subtle=True, background=SURFACE).pack(anchor="w")
def _redact(self, text):
@@ -1212,6 +1212,8 @@ class App(tk.Tk):
elif kind == "complete":
result = event[1]
self.last_result = result
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
cleanup_pending = bool(cleanup_warning)
library_update = getattr(self, "operation", None) == "library"
action = "repaired" if getattr(result, "repaired", False) else "installed"
done = ("Halo's Steam library info has been updated. Your native game and saves were kept." if library_update else
@@ -1239,7 +1241,7 @@ class App(tk.Tk):
info_pending = artwork_pending or icon_pending or notes_pending
if library_update and info_pending and not result.requires_manual_steam_step:
done = "Native Halo VR was kept; some Steam library info needs one more step. Your game and saves are unchanged."
self.progress_state.finish(pending=result.requires_manual_steam_step or info_pending)
self.progress_state.finish(pending=result.requires_manual_steam_step or info_pending or cleanup_pending)
self._display_progress()
if artwork_pending:
done += "\n\nHalo library art needs one more step.\n" + artwork.get("reason", "")
@@ -1254,34 +1256,51 @@ class App(tk.Tk):
done += "\nClick View game description to copy the note."
elif notes.get("status") == "custom":
done += "\n\nYour existing custom game note was kept."
if cleanup_pending:
done += "\n\nThe game operation finished; connection cleanup needs attention.\n" + cleanup_warning
if info_pending:
self.retry_button.pack(anchor="w", pady=(11, 0))
done = self._redact(done)
self.phase.set("One more step in Steam" if result.requires_manual_steam_step
else "Ready to play · Steam info pending" if info_pending
else "Connection cleanup needs attention" if cleanup_pending
else "Steam info updated" if library_update
else "You're ready to play")
self.status.set(done)
self._append(done)
extra = "" if library_update else "\n\nXbox controls and motion aiming are enabled. The native port uses its own multiplayer protocol; legacy PC servers and PC saves are incompatible."
messagebox.showinfo("Steam info updated" if library_update and not info_pending and not result.requires_manual_steam_step
messagebox.showinfo("Connection cleanup needs attention" if cleanup_pending
else "Steam info updated" if library_update and not info_pending and not result.requires_manual_steam_step
else "Steam info needs attention" if library_update else "Setup complete", done + extra, parent=self)
elif kind == "uninstall_complete":
result = event[1]
self.last_result = None
self.last_uninstall_result = result
pending = bool(getattr(result, "requires_manual_steam_step", False))
steam_pending = bool(getattr(result, "requires_manual_steam_step", False))
removal_pending = bool(getattr(result, "removal_pending", False))
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
pending = steam_pending or removal_pending or bool(cleanup_warning)
self.progress_state.finish(pending=pending)
self._display_progress()
if pending:
if steam_pending:
done = "Uninstall needs one more step in Steam.\n" + result.steam.get("reason", "")
done += "\n" + result.steam.get("instructions", STEAM_REMOVE_NOTE)
elif removal_pending:
done = "Some files from an earlier uninstall were kept for manual review. Removal is not complete."
elif result.already_absent:
done = "Native Halo VR was already absent. Uninstall checks are complete."
else:
done = "Native Halo VR has been uninstalled from your Frame."
if result.saved_backup_path:
done += "\n\nCampaign save backup: " + result.saved_backup_path
if getattr(result, "removal_warning", None):
done += "\n\n" + result.removal_warning
for backup in getattr(result, "recovered_save_backup_paths", ()):
done += "\nEarlier campaign save backup: " + backup
for retained in getattr(result, "retained_quarantine_paths", ()):
done += "\nPreserved uninstall folder: " + retained
if cleanup_warning:
done += "\n\nConnection cleanup needs attention.\n" + cleanup_warning
done = self._redact(done)
self.phase.set("Uninstall needs attention" if pending else "Halo VR uninstalled")
self.status.set(done)
+187 -35
View File
@@ -9,11 +9,11 @@ import sys
import tempfile
import threading
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Callable
from .ssh import CancelledError, Settings, SSHConnection, SSHError
from .ssh import CancelledError, RemoteTimeoutError, Settings, SSHConnection, SSHError
SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
EXPECTED_MAPS = frozenset(name + ".map" for name in (
@@ -98,6 +98,11 @@ class InstallResult:
source_commit: str = SOURCE_COMMIT
repaired: bool = False
backup_path: str | None = None
cleanup_warning: str | None = None
@property
def connection_cleanup_pending(self) -> bool:
return bool(self.cleanup_warning)
@property
def steam_appid(self) -> int | None:
@@ -116,12 +121,76 @@ class UninstallResult:
saved_backup_path: str | None
steam: dict
host_fingerprint: str | None
cleanup_warning: str | None = None
removal_pending: bool = False
removal_warning: str | None = None
recovered_save_backup_paths: tuple[str, ...] = ()
retained_quarantine_paths: tuple[str, ...] = ()
@property
def connection_cleanup_pending(self) -> bool:
return bool(self.cleanup_warning)
@property
def requires_manual_steam_step(self) -> bool:
return self.steam.get("status") not in ("removed", "already-absent")
def cleanup_activity(progress: ProgressCallback, message: str) -> None:
try:
progress("detail", message, None)
except Exception:
pass # A display callback cannot replace the operation outcome.
def close_connection(connection, progress: ProgressCallback) -> str | None:
"""Preserve the operation result, while keeping required cleanup visible."""
try:
connection.close()
except Exception as error:
settings = getattr(connection, "settings", None)
warning = (str(error)[:4000] if isinstance(error, SSHError) else
"Setup could not verify that its connection finished closing.")
password = getattr(settings, "password", "")
if password:
warning = warning.replace(password, "[redacted]")
action = ("Unplug the USB cable to end setup's temporary connection before retrying."
if getattr(settings, "transport", None) == "usb" else
"Close this installer before retrying the connection.")
warning = warning + "\n" + action
cleanup_activity(progress, warning)
return warning
return None
def uninstall_history(data: dict) -> tuple[tuple[str, ...], tuple[str, ...], str | None]:
"""Validate direct owned-path references before showing remote recovery history."""
root = "/home/steamos/Games/"
backups, retained = [], []
for key, target in (("recoveredSaveBackupPaths", backups),
("retainedQuarantinePaths", retained)):
values = data.get(key, [])
if not isinstance(values, list) or len(values) > 64:
raise SSHError("The Frame returned an invalid uninstall recovery history.")
for value in values:
if (not isinstance(value, str) or len(value) > 512
or len(value.rsplit("/", 1)[-1]) > 255 or value in target):
raise SSHError("The Frame returned an invalid uninstall recovery history.")
if key == "recoveredSaveBackupPaths":
valid = re.fullmatch(re.escape(root) + r"HaloCENativeVR-saves-[a-f0-9]{32}", value)
else:
prefix = root + ".HaloCENativeVR-uninstall-"
suffix = value[len(prefix):] if value.startswith(prefix) else ""
valid = suffix and not any(c in "/\\" or ord(c) < 32 or ord(c) == 127 for c in suffix)
if not valid:
raise SSHError("The Frame returned an invalid uninstall recovery history.")
target.append(value)
warning = data.get("warning")
if warning is not None and (not isinstance(warning, str) or not 0 < len(warning) <= 4096):
raise SSHError("The Frame returned an invalid uninstall warning.")
return tuple(backups), tuple(retained), warning
class Installer:
def __init__(self, connection_factory=SSHConnection, resource_dir: Path | None = None):
self.connection_factory = connection_factory
@@ -137,7 +206,10 @@ class Installer:
run_identifier = uuid.uuid4().hex
remote_helper: str | None = None
prepared = False
disconnected = False
cleanup_attempted = False
cancel_attempted = False
cancellation_warning = None
cancellation_unconfirmed = False
def check_cancel():
if cancel_event.is_set():
@@ -149,8 +221,55 @@ class Installer:
progress(*activity)
def cancel_remote():
if remote_helper is not None and prepared:
connection.run(["python3", remote_helper, "cancel", "--run-id", run_identifier], timeout=45)
nonlocal cancel_attempted, cancellation_warning, cancellation_unconfirmed
if remote_helper is None or not prepared or cancel_attempted:
return
cancel_attempted = True
argv = ["python3", remote_helper, "cancel", "--run-id", run_identifier]
def request(target):
response = parse_result(target.run(argv, timeout=45))
if response.get("cancelled") is not True:
raise SSHError("The Frame did not confirm this installer run's cancellation.")
try:
active = getattr(connection, "is_active", lambda: True)()
if active:
request(connection)
cleanup_activity(progress, "Remote build cancellation confirmed.")
return
except Exception:
pass
recovery = recovery_settings = None
try:
approved = (connection.host_fingerprint or settings.known_host_fingerprint
or settings.known_host_fingerprints.get(settings.host_identity))
if not isinstance(approved, str) or not approved.startswith("SHA256:"):
raise SSHError("No approved host fingerprint is available for recovery.")
recovery_settings = replace(settings, known_host_fingerprint=approved,
known_host_fingerprints={}, accept_host_key=None)
recovery = self.connection_factory(recovery_settings)
if recovery is connection:
recovery = None
raise SSHError("A fresh recovery connection could not be created.")
cleanup_activity(progress, "Reconnecting once to stop this installer run, using the approved SSH host key...")
recovery.connect(timeout=10)
request(recovery)
cleanup_activity(progress, "Remote build cancellation confirmed.")
except Exception:
cancellation_unconfirmed = True
cancellation_warning = (
"Remote stopping could not be confirmed. The installer build may still be running on the Frame. "
"Restore the connection and stop this run before retrying installation or uninstalling.\n"
"In the Frame's terminal, run: " + " ".join(argv))
cleanup_activity(progress, cancellation_warning)
finally:
if recovery is not None:
warning = close_connection(recovery, progress)
if warning:
cancellation_warning = (cancellation_warning + "\n\n" if cancellation_warning else "") + warning
if recovery_settings is not None:
recovery_settings.password = ""
repair = settings.reinstall_existing and not registration_only
@@ -260,24 +379,34 @@ class Installer:
# Keep the result's public fingerprint before releasing the client.
host_fingerprint = connection.host_fingerprint
progress("disconnect", "Closing the setup SSH connection...", None)
connection.close()
disconnected = True
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native VR game is ready. Launch Halo: Combat Evolved VR (Native) from your Steam library." if steam.get("status") == "added" else "Native VR game is installed. One Steam library step remains.", 100)
cleanup_warning = close_connection(connection, progress)
cleanup_attempted = True
if cleanup_warning:
progress("cleanup-pending", "Native Halo VR is installed; setup's connection cleanup needs attention.", 99)
else:
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native VR game is ready. Launch Halo: Combat Evolved VR (Native) from your Steam library." if steam.get("status") == "added" else "Native VR game is installed. One Steam library step remains.", 100)
return InstallResult(installed["gamePath"], bool(installed.get("reused")),
steam, host_fingerprint, repaired=bool(installed.get("repaired")),
backup_path=installed.get("backupPath"))
except CancelledError:
try:
cancel_remote()
except (SSHError, OSError):
pass
backup_path=installed.get("backupPath"), cleanup_warning=cleanup_warning)
except (CancelledError, RemoteTimeoutError, SSHError) as error:
cancel_remote()
if cancellation_warning:
message = str(error)
if cancellation_unconfirmed:
first, separator, rest = message.partition("\n")
message = first + " Remote stopping could not be confirmed; the build may still be running on the Frame." + (separator + rest if separator else "")
error.args = (message + "\n\n" + cancellation_warning,)
raise
finally:
if not disconnected:
progress("detail", "Closing the setup SSH connection...", None)
connection.close()
progress("detail", "Setup has disconnected from your Frame.", None)
if not cleanup_attempted:
cleanup_activity(progress, "Closing the setup SSH connection...")
warning = close_connection(connection, progress)
if warning is None:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
error.args = (str(error) + "\n\n" + warning,)
def add_to_steam(self, settings: Settings, progress_callback: ProgressCallback | None = None,
cancel_event: threading.Event | None = None) -> InstallResult:
@@ -291,7 +420,7 @@ class Installer:
cancel_event = cancel_event or threading.Event()
connection = self.connection_factory(settings)
run_identifier = uuid.uuid4().hex
disconnected = False
cleanup_attempted = False
def check_cancel():
if cancel_event.is_set():
@@ -332,31 +461,54 @@ class Installer:
steam = data.get("steam")
if not isinstance(steam, dict):
raise SSHError("The Frame returned an invalid uninstall result.")
recovered, retained, warning = uninstall_history(data)
for text in ([warning] if warning else []) + ["Recovered campaign save backup: " + path for path in recovered] + ["Preserved uninstall folder: " + path for path in retained]:
progress("detail", text, None)
if steam.get("status") == "manual":
raise SSHError(steam.get("reason", "Steam could not close safely; the game was kept.")
+ "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated."))
diagnostic = steam.get("reason", "Steam could not close safely; the game was kept.") + "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated.")
if warning:
diagnostic += "\n\n" + warning
if recovered:
diagnostic += "\n\nRecovered campaign save backups:\n" + "\n".join(recovered)
if retained:
diagnostic += "\n\nPreserved uninstall folders:\n" + "\n".join(retained)
raise SSHError(diagnostic)
uninstalled, absent = data.get("uninstalled"), data.get("alreadyAbsent", False)
pending = data.get("removalPending", False)
saved = data.get("savedBackupPath")
if (data.get("gamePath") != info["gamePath"] or type(uninstalled) is not bool
or type(absent) is not bool or not (uninstalled or absent)
or type(absent) is not bool or type(pending) is not bool
or (pending and (uninstalled or absent))
or (not pending and not (uninstalled or absent))
or (retained and not pending)
or steam.get("status") not in ("removed", "already-absent")
or (saved is not None and saved != "/home/steamos/Games/HaloCENativeVR-saves-" + run_identifier)):
raise SSHError("The Frame returned an invalid uninstall result.")
warning = data.get("warning")
if isinstance(warning, str) and 0 < len(warning) <= 4096:
progress("detail", warning, None)
host_fingerprint = connection.host_fingerprint
progress("disconnect", "Closing the setup SSH connection...", None)
connection.close()
disconnected = True
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint)
cleanup_warning = close_connection(connection, progress)
cleanup_attempted = True
if cleanup_warning:
progress("cleanup-pending", "Uninstall results were saved; setup's connection cleanup needs attention.", 99)
else:
progress("disconnected", "Setup has disconnected from your Frame.", None)
if pending:
progress("removal-pending", "Uninstall preserved folders that need attention. See the setup activity and recovery paths.", 99)
elif not cleanup_warning:
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint,
cleanup_warning=cleanup_warning, removal_pending=pending,
removal_warning=warning, recovered_save_backup_paths=recovered,
retained_quarantine_paths=retained)
finally:
if not disconnected:
progress("detail", "Closing the setup SSH connection...", None)
connection.close()
progress("detail", "Setup has disconnected from your Frame.", None)
if not cleanup_attempted:
cleanup_activity(progress, "Closing the setup SSH connection...")
warning = close_connection(connection, progress)
if warning is None:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
error.args = (str(error) + "\n\n" + warning,)
def run(settings: Settings, maps_dir: Path | None, progress_callback=None,
+2
View File
@@ -30,6 +30,8 @@ PHASES = {
"steam": (96, 99, "Updating Halo's Steam library information"),
"disconnect": (99, 99, "Closing the setup connection"),
"disconnected": (99, 99, "Disconnected from your Frame"),
"cleanup-pending": (99, 99, "Connection cleanup needs attention"),
"removal-pending": (99, 99, "Uninstall needs attention"),
"complete": (99, 99, "Finishing setup"),
}
+87 -26
View File
@@ -21,6 +21,10 @@ class SSHError(RuntimeError):
pass
class RemoteTimeoutError(SSHError):
"""A started remote operation exceeded its bounded host wait."""
class CancelledError(RuntimeError):
pass
@@ -116,7 +120,7 @@ class SSHConnection:
def host_fingerprint(self) -> str | None:
return self.policy.accepted_fingerprint
def connect(self) -> None:
def connect(self, *, timeout: float = 15) -> None:
from .usb import USBError, USBForward
try:
host, port = self.settings.host, self.settings.port
@@ -127,7 +131,11 @@ class SSHConnection:
self.client.connect(host, port=port,
username="steamos", password=self.settings.password,
allow_agent=False, look_for_keys=False,
timeout=15, auth_timeout=20, banner_timeout=20)
timeout=timeout, auth_timeout=min(20, timeout + 5),
banner_timeout=min(20, timeout + 5))
transport = self.client.get_transport()
if transport is not None:
transport.set_keepalive(15)
except (paramiko.AuthenticationException, paramiko.PasswordRequiredException) as exc:
self._close_after_failure()
raise SSHError("SSH authentication failed. Check the Frame's steamos password.") from None
@@ -137,16 +145,17 @@ class SSHConnection:
except SSHError:
self._close_after_failure()
raise
except (paramiko.SSHException, OSError, socket.timeout) as exc:
except (paramiko.SSHException, OSError, EOFError) as exc:
self._close_after_failure()
# Avoid displaying a library error that might include submitted credentials.
message = ("Could not connect to the Frame over USB SSH. Check Developer Mode, the USB cable and the Frame's SSH service."
if self.settings.transport == "usb" else
"Could not connect to the Frame over SSH. Check its address, Wi-Fi / Ethernet connection, and SSH service.")
raise SSHError(message) from None
def is_active(self) -> bool:
transport = self.client.get_transport()
if transport is not None:
transport.set_keepalive(15)
return bool(not self._closed and transport is not None and transport.is_active())
def _close_after_failure(self) -> None:
try:
@@ -168,23 +177,43 @@ class SSHConnection:
pass
self._sftp_clients.clear()
self._command_channels.clear()
transport = self.client.get_transport()
failures = []
try:
transport = self.client.get_transport()
except Exception:
transport = None
failures.append("The SSH transport could not be checked during disconnection.")
try:
if transport is not None:
try:
transport.close()
except Exception:
failures.append("The SSH transport could not finish closing.")
finally:
# Transport.close() may return early after a failed or
# already inactive session. Release its socket as well.
sock = getattr(transport, "sock", None)
if sock is not None:
sock.close()
try:
sock.close()
except Exception:
failures.append("The setup connection's socket could not finish closing.")
finally:
try:
self.client.close()
except Exception:
failures.append("The SSH client could not finish closing.")
finally:
self._closed = True
self._close_usb_forward()
try:
self._close_usb_forward()
except SSHError as exc:
failures.append(str(exc))
if failures:
message = "\n".join(failures)
if self.settings.password:
message = message.replace(self.settings.password, "[redacted]")
raise SSHError(message) from None
def _close_usb_forward(self) -> None:
if self._usb_forward is not None:
@@ -200,28 +229,42 @@ class SSHConnection:
on_cancel: Callable[[], None] | None = None,
timeout_message: str | None = None) -> str:
"""Run an argument vector; no secrets are put into a remote command."""
def notify_cancelled():
if on_cancel is not None:
try:
on_cancel()
except Exception:
pass # The primary cancellation/timeout must always survive.
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
command = shlex.join([str(arg) for arg in argv])
_, stdout, _ = self.client.exec_command(command, timeout=30)
started = time.monotonic()
try:
_, stdout, _ = self.client.exec_command(command, timeout=min(30, timeout))
except (paramiko.SSHException, OSError, EOFError):
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The remote command could not be started. Check the Frame's connection and retry.") from None
channel = stdout.channel
self._command_channels.add(channel)
channel.set_combine_stderr(True)
# Build output is delivered live to the activity callback. Keep only a
# bounded response tail for the final result/error, rather than storing
# the entire compilation a second time in memory.
chunks: deque[bytes] = deque()
retained_bytes = 0
pending = bytearray()
started = time.monotonic()
try:
channel.set_combine_stderr(True)
while True:
if cancel_event is not None and cancel_event.is_set():
if on_cancel is not None:
on_cancel()
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if time.monotonic() - started > timeout:
if on_cancel is not None:
on_cancel()
raise SSHError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
notify_cancelled()
raise RemoteTimeoutError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
while channel.recv_ready():
data = channel.recv(65536)
if not data:
@@ -260,10 +303,19 @@ class SSHConnection:
safe = safe.replace(self.settings.password, "[redacted]")
raise SSHError(safe[-12000:].strip() or f"Remote step failed (exit {status}).")
return result
except (paramiko.SSHException, OSError, EOFError):
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The remote connection stopped before this step could be verified. Check the Frame's connection and retry.") from None
finally:
try:
channel.close()
finally:
except Exception:
# Final connection teardown will retry this resource and close
# its transport. A channel-close error cannot erase the result.
pass
else:
self._command_channels.discard(channel)
def put(self, local: Path, remote: str, *, callback=None,
@@ -273,14 +325,23 @@ class SSHConnection:
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if callback:
callback(done, total)
with self.client.open_sftp() as sftp:
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
sftp = None
try:
sftp = self.client.open_sftp()
self._sftp_clients.add(sftp)
sftp.get_channel().settimeout(20)
try:
sftp.put(str(local), remote, callback=update, confirm=True)
except (OSError, EOFError, paramiko.SSHException):
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
finally:
self._sftp_clients.discard(sftp)
sftp.put(str(local), remote, callback=update, confirm=True)
except (OSError, EOFError, paramiko.SSHException):
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
finally:
if sftp is not None:
try:
sftp.close()
except Exception:
pass # Connection teardown still owns and retries it.
else:
self._sftp_clients.discard(sftp)
+69
View File
@@ -1345,3 +1345,72 @@ def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_c
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert float(app.progress['value']) == 0
assert not _disabled(app.primary_button)
@WINDOWS_GUI
@pytest.mark.parametrize('operation', ['install', 'library'])
def test_completed_game_operation_reports_pending_connection_cleanup(app, operation):
from halo_frame_installer.install import InstallResult
app.operation = operation
app.password_to_redact = 'offline-secret'
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'notes': {'status': 'added'}}, None,
cleanup_warning='Unplug the USB cable; cleanup is pending. offline-secret')
app.events.put(('complete', result))
app.events.put(('clear_password',))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert app.phase.get() == 'Connection cleanup needs attention'
assert 'game operation finished' in app.status.get()
assert 'Unplug the USB cable' in app.status.get()
assert not app.retry_button.winfo_manager()
assert 'offline-secret' not in app.status.get()
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert app._test_dialogs[-1][0] == 'Connection cleanup needs attention'
assert not app.busy and not app.installing
@WINDOWS_GUI
@pytest.mark.parametrize('pending', [False, True])
def test_uninstall_recovery_reports_retained_files_and_earlier_save_backups(app, pending):
from halo_frame_installer.install import UninstallResult
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a'*32
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unverified'
app.password_to_redact = 'offline-secret'
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', not pending, False, None,
{'status': 'removed'}, None, removal_pending=pending,
removal_warning='Earlier uninstall files ' + ('were retained' if pending else 'were removed') + '. offline-secret',
recovered_save_backup_paths=(backup,), retained_quarantine_paths=(retained,) if pending else ())
app.events.put(('uninstall_complete', result))
app.events.put(('clear_password',))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == (99 if pending else 100)
assert 'Earlier campaign save backup: ' + backup in app.status.get()
assert 'already absent' not in app.status.get()
assert 'offline-secret' not in app.status.get()
assert 'offline-secret' not in '\n'.join(app.log_lines)
if pending:
assert 'Preserved uninstall folder: ' + retained in app.status.get()
assert 'Removal is not complete' in app.status.get()
assert 'has been uninstalled' not in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
else:
assert 'has been uninstalled' in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall complete'
@WINDOWS_GUI
def test_uninstalled_game_reports_pending_connection_cleanup(app):
from halo_frame_installer.install import UninstallResult
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', True, False, None,
{'status': 'removed'}, None, cleanup_warning='Unplug the USB cable before retrying setup.')
app.events.put(('uninstall_complete', result))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert 'has been uninstalled' in app.status.get()
assert 'Connection cleanup needs attention' in app.status.get()
assert 'Unplug the USB cable' in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
+154 -2
View File
@@ -10,8 +10,8 @@ from unittest.mock import Mock
import pytest
from halo_frame_installer.install import EXPECTED_MAPS, Installer, SOURCE_COMMIT, map_manifest, parse_result
from halo_frame_installer.ssh import CancelledError, Settings, SSHError
from halo_frame_installer.install import EXPECTED_MAPS, Installer, SOURCE_COMMIT, close_connection, map_manifest, parse_result
from halo_frame_installer.ssh import CancelledError, RemoteTimeoutError, Settings, SSHError
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@@ -248,6 +248,158 @@ def test_failed_build_does_not_finalize_or_modify_game(tmp_path):
assert fake.closed
def test_step_failure_is_not_replaced_by_a_failed_disconnect():
class ForwardCleanupFailure(FakeConnection):
def close(self):
raise SSHError("USB forward cleanup failed")
fake = ForwardCleanupFailure(Settings("frame", "private"))
with pytest.raises(SSHError, match="build failed"):
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None)
def test_cancelled_install_is_not_replaced_by_a_failed_disconnect():
class ForwardCleanupFailure(FakeConnection):
def close(self):
raise SSHError("USB forward cleanup failed")
event = threading.Event()
event.set()
fake = ForwardCleanupFailure(Settings("frame", "private"))
with pytest.raises(CancelledError):
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, cancel_event=event)
def test_close_connection_reports_a_swallowed_cleanup_failure():
class FailingClose:
settings = Settings('', 'private', transport='usb')
def close(self):
raise SSHError("SSH has closed, but the temporary USB forward could not be removed.")
seen = []
warning = close_connection(FailingClose(), lambda *args: seen.append(args))
assert warning.startswith('SSH has closed, but the temporary USB forward could not be removed.')
assert 'Unplug the USB cable' in warning
assert seen == [('detail', warning, None)]
def test_successful_install_with_failed_cleanup_retains_result_and_needs_attention():
class ForwardCleanupFailure(FakeConnection):
def close(self):
self.closed = True
raise SSHError('USB forward cleanup failed private')
fake = ForwardCleanupFailure(Settings('', 'private', transport='usb', reinstall_existing=False))
events = []
result = Installer(lambda settings: fake, RESOURCES).run(fake.settings, None,
lambda *event: events.append(event))
assert result.steam_appid == 3732925724 and result.reused
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
assert 'private' not in result.cleanup_warning
assert 'disconnected' not in [event[0] for event in events]
assert 'complete' not in [event[0] for event in events]
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
@pytest.mark.parametrize('primary_error', [CancelledError, RemoteTimeoutError, SSHError])
@pytest.mark.parametrize('original_active', [False, True])
def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, original_active):
class BrokenConnection(FakeConnection):
def is_active(self):
return original_active
def run(self, argv, **kwargs):
if argv[1] != '-c' and argv[2] == 'build':
self.commands.append(argv)
if primary_error is not SSHError:
kwargs['on_cancel']()
messages = {CancelledError: 'Installation cancelled.',
RemoteTimeoutError: 'The remote step timed out.',
SSHError: 'The remote connection stopped before this step could be verified.'}
raise primary_error(messages[primary_error])
if argv[1] != '-c' and argv[2] == 'cancel':
self.commands.append(argv)
raise SSHError('Connection stopped')
return super().run(argv, **kwargs)
class RecoveryConnection(FakeConnection):
def connect(self, *, timeout):
self.connect_timeout = timeout
def run(self, argv, **kwargs):
self.commands.append(argv)
assert kwargs['timeout'] == 45 and 'cancel_event' not in kwargs
return 'HFI_RESULT {"cancelled":true}'
settings = Settings('frame', 'private', accept_host_key=Mock(side_effect=AssertionError('No new approval')))
original = BrokenConnection(settings)
created = []
def factory(current):
if not created:
created.append(original)
return original
recovery = RecoveryConnection(current)
created.append(recovery)
return recovery
events = []
with pytest.raises(primary_error) as error:
Installer(factory, RESOURCES).run(settings, None, lambda *event: events.append(event))
assert len(created) == 2
recovery = created[1]
build = next(command for command in original.commands if command[1] != '-c' and command[2] == 'build')
expected_id = build[build.index('--run-id') + 1]
assert recovery.commands == [['python3', '/home/steamos/.cache/halo-frame-installer/resources/remote_install.py', 'cancel', '--run-id', expected_id]]
assert recovery.connect_timeout == 10
assert recovery.settings.known_host_fingerprint == 'SHA256:public'
assert recovery.settings.accept_host_key is None and recovery.settings.known_host_fingerprints == {}
assert recovery.settings.password == '' and settings.password == 'private'
assert original.closed and recovery.closed
assert 'could not be confirmed' not in str(error.value)
assert not any(command[2] in ('finalize', 'shortcut') for command in original.commands if command[1] != '-c')
assert any(event[1] == 'Remote build cancellation confirmed.' for event in events)
@pytest.mark.parametrize('failure', ['unapproved', 'changed-key', 'unconfirmed-response'])
def test_unconfirmed_remote_stop_is_visible_without_masking_cancel(failure):
class BrokenConnection(FakeConnection):
def is_active(self):
return False
def run(self, argv, **kwargs):
if argv[1] != '-c' and argv[2] == 'build':
self.commands.append(argv)
raise CancelledError('Installation cancelled. Existing games and saves were kept.')
return super().run(argv, **kwargs)
settings = Settings('frame', 'private')
original = BrokenConnection(settings)
if failure == 'unapproved':
original.host_fingerprint = None
created = []
def factory(current):
if not created:
created.append(original)
return original
recovery = FakeConnection(current)
recovery.connect = Mock(side_effect=SSHError('SSH host key has changed.')) if failure == 'changed-key' else Mock()
recovery.run = Mock(return_value='HFI_RESULT {"cancelled":"true"}')
created.append(recovery)
return recovery
with pytest.raises(CancelledError) as error:
Installer(factory, RESOURCES).run(settings, None)
first_line = str(error.value).splitlines()[0]
assert 'cancelled' in first_line and 'Remote stopping could not be confirmed' in first_line
assert 'In the Frame\'s terminal, run: python3 /home/steamos/.cache/' in str(error.value)
assert 'private' not in str(error.value) and original.closed
assert len(created) == (1 if failure == 'unapproved' else 2)
if len(created) == 2:
assert created[1].closed and created[1].settings.password == ''
def test_cleanup_display_failure_cannot_replace_the_primary_error():
fake = FakeConnection(Settings('frame', 'private'))
fake.close = Mock(side_effect=EOFError('private'))
def progress(stage, message, percent=None):
if stage == 'detail':
raise ValueError('Display failed')
with pytest.raises(SSHError, match='build failed') as error:
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, progress)
assert 'private' not in str(error.value)
def test_cancelled_before_connect_has_no_remote_mutation():
event = threading.Event()
event.set()
+100
View File
@@ -0,0 +1,100 @@
"""Keep uninstall atomic when a native Steam record cannot be identified."""
from collections import OrderedDict
import pytest
from test_uninstall import RUN_ID, game_bytes, installation
def config_bytes(configs):
return {
str(path): path.read_bytes()
for config in configs
for path in config.rglob("*")
if path.is_file()
}
@pytest.mark.parametrize("invalid", ["missing", "string", "zero", "below-shortcut-range"])
@pytest.mark.parametrize("only_malformed", [False, True])
@pytest.mark.parametrize("quoted", [False, True])
def test_invalid_native_id_keeps_game_all_accounts_and_artwork(
installation, invalid, only_malformed, quoted):
remote, steam, configs, _, _ = installation
executable = str(remote.GAME / "halo")
if only_malformed:
for config in configs:
path = config / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
del root["shortcuts"].value["1"]
path.write_bytes(steam.dumps(root))
# Put the malformed record in the second account so the first account's
# otherwise valid removal is planned before validation must refuse it.
path = configs[1] / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
fields = OrderedDict([
("AppName", steam.Value(1, "Renamed native Halo")),
("Exe", steam.Value(1, f'"{executable}"' if quoted else executable)),
])
if invalid != "missing":
kind, value = {
"string": (1, "not-a-number"),
"zero": (2, 0),
"below-shortcut-range": (2, 0x7FFFFFFF),
}[invalid]
fields["appid"] = steam.Value(kind, value)
root["shortcuts"].value["3"] = steam.Value(0, fields)
path.write_bytes(steam.dumps(root))
before_game = game_bytes(remote)
before_configs = config_bytes(configs)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"]
assert result["steam"]["status"] == "manual"
assert "invalid app ID" in result["steam"]["reason"]
assert result["savedBackupPath"] is None
assert game_bytes(remote) == before_game
# This also proves no shortcut backups were published and no managed or
# custom artwork was deleted before the second account failed validation.
assert config_bytes(configs) == before_configs
assert not list(remote.GAME.parent.glob("*HaloCENativeVR-saves-*"))
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
@pytest.mark.parametrize("variant", ["leading-quote", "trailing-quote", "double-quotes", "suffix", "arguments"])
def test_nonexact_executable_is_preserved_even_with_bad_id_and_native_title(installation, variant):
remote, steam, configs, appids, _ = installation
executable = str(remote.GAME / "halo")
candidate = {
"leading-quote": '"' + executable,
"trailing-quote": executable + '"',
"double-quotes": '""' + executable + '""',
"suffix": executable + "-backup",
"arguments": executable + " --custom",
}[variant]
path = configs[0] / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
unrelated = OrderedDict([
("appid", steam.Value(1, "not-a-number")),
("AppName", steam.Value(1, steam.NAME)),
("Exe", steam.Value(1, candidate)),
("unknown", steam.Value(7, b"KEEPTHIS")),
])
root["shortcuts"].value["3"] = steam.Value(0, unrelated)
path.write_bytes(steam.dumps(root))
custom_art = (configs[1] / "grid" / f"{appids[1]}.png").read_bytes()
foreign_art = (configs[0] / "grid/10p.jpg").read_bytes()
result = remote.uninstall(RUN_ID)
assert result["uninstalled"]
assert result["steam"]["status"] == "removed"
assert result["steam"]["accounts"] == ["10", "20"]
remaining = steam.loads(path.read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "2", "3"}
assert remaining["3"].value == unrelated
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == custom_art
assert (configs[0] / "grid/10p.jpg").read_bytes() == foreign_art
+157 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import Mock
import paramiko
import pytest
from halo_frame_installer.ssh import Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
from halo_frame_installer.ssh import CancelledError, Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
@pytest.mark.parametrize("host", ["192.168.1.12", "frame.local", "frame", "::1"])
@@ -201,3 +201,159 @@ def test_uninstall_timeout_reports_uncertain_removal_and_closes_channel(monkeypa
assert 'were kept' not in str(error.value)
channel.close.assert_called_once()
assert not connection._command_channels
@pytest.mark.parametrize('failure', [paramiko.SSHException('SSH session not active'), SSHError('cancel command failed'), EOFError('private'), ValueError('private')])
def test_failed_cancel_notice_does_not_mask_the_cancellation(monkeypatch, failure):
channel = Mock()
channel.recv_ready.return_value = False
channel.exit_status_ready.return_value = False
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
cancel = threading.Event()
cancel.set()
on_cancel = Mock(side_effect=failure)
with pytest.raises(CancelledError, match='cancelled'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'],
cancel_event=cancel, on_cancel=on_cancel)
on_cancel.assert_called_once()
def test_failed_command_start_reports_a_connection_problem(monkeypatch):
client = Mock()
client.exec_command.side_effect = paramiko.SSHException('SSH session not active')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='could not be started'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
@pytest.mark.parametrize('cancelled', [False, True])
def test_failed_sftp_open_reports_connection_problem_or_cancellation(monkeypatch, cancelled):
client = Mock()
client.open_sftp.side_effect = paramiko.SSHException('SSH session not active')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
cancel = threading.Event()
if cancelled:
cancel.set()
with pytest.raises(CancelledError if cancelled else SSHError) as error:
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
if not cancelled:
assert 'transfer stopped' in str(error.value)
def test_failed_cancel_notice_does_not_mask_the_timeout_error(monkeypatch):
from halo_frame_installer import ssh
channel = Mock()
channel.recv_ready.return_value = False
channel.exit_status_ready.return_value = False
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
on_cancel = Mock(side_effect=paramiko.SSHException('SSH session not active'))
with pytest.raises(SSHError, match='timed out'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], timeout=600, on_cancel=on_cancel)
on_cancel.assert_called_once()
@pytest.mark.parametrize('failure', [EOFError('private'), paramiko.SSHException('private'), OSError('private')])
def test_command_start_errors_are_redacted_without_exposing_a_cause(monkeypatch, failure):
client = Mock()
client.exec_command.side_effect = failure
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='could not be started') as error:
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
assert 'private' not in str(error.value)
assert error.value.__suppress_context__ and error.value.__cause__ is None
def test_cancel_during_failed_command_start_remains_a_cancellation(monkeypatch):
cancel = threading.Event()
client = Mock()
def fail(*args, **kwargs):
cancel.set()
raise EOFError('private')
client.exec_command.side_effect = fail
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
on_cancel = Mock(side_effect=EOFError('private'))
with pytest.raises(CancelledError, match='cancelled') as error:
SSHConnection(Settings('frame', 'private')).run(['helper'], cancel_event=cancel, on_cancel=on_cancel)
on_cancel.assert_called_once()
assert 'private' not in str(error.value)
@pytest.mark.parametrize('failing_method', ['set_combine_stderr', 'recv_ready', 'recv', 'recv_exit_status'])
def test_channel_setup_and_read_errors_are_redacted_and_closed(monkeypatch, failing_method):
channel = Mock()
channel.recv_ready.return_value = failing_method == 'recv'
channel.exit_status_ready.return_value = True
getattr(channel, failing_method).side_effect = EOFError('private')
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='connection stopped') as error:
SSHConnection(Settings('frame', 'private')).run(['helper'])
assert 'private' not in str(error.value) and error.value.__suppress_context__
channel.close.assert_called_once()
def test_channel_close_failure_cannot_replace_the_timeout(monkeypatch):
from halo_frame_installer import ssh
channel = Mock()
channel.close.side_effect = paramiko.SSHException('private')
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(ssh.RemoteTimeoutError, match='timed out'):
connection.run(['helper'], timeout=600)
assert channel in connection._command_channels
connection.close()
assert not connection._command_channels
client.close.assert_called_once()
@pytest.mark.parametrize('failure_step', ['timeout', 'put'])
def test_sftp_setup_and_transfer_failure_survive_a_failed_close(monkeypatch, failure_step):
client, sftp = Mock(), Mock()
client.open_sftp.return_value = sftp
sftp.close.side_effect = EOFError('private')
target = sftp.get_channel.return_value.settimeout if failure_step == 'timeout' else sftp.put
target.side_effect = paramiko.SSHException('private')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(SSHError, match='transfer stopped') as error:
connection.put('local.txt', 'remote.txt')
assert 'private' not in str(error.value)
assert sftp in connection._sftp_clients
connection.close()
assert not connection._sftp_clients
def test_sftp_callback_cancellation_survives_a_failed_close(monkeypatch):
client, sftp = Mock(), Mock()
client.open_sftp.return_value = sftp
sftp.close.side_effect = EOFError('private')
cancel = threading.Event()
def transfer(*args, **kwargs):
cancel.set()
kwargs['callback'](1, 2)
sftp.put.side_effect = transfer
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(CancelledError, match='cancelled') as error:
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
assert 'private' not in str(error.value)
@pytest.mark.parametrize('failure_step', ['connect', 'keepalive'])
def test_eof_connect_or_keepalive_failure_is_redacted_and_tears_down(monkeypatch, failure_step):
client = Mock()
target = client.connect if failure_step == 'connect' else client.get_transport.return_value.set_keepalive
target.side_effect = EOFError('private')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='Could not connect') as error:
SSHConnection(Settings('frame', 'private')).connect()
assert 'private' not in str(error.value)
client.close.assert_called_once()
+457 -5
View File
@@ -91,6 +91,26 @@ def game_bytes(remote):
if path.is_file() and not path.is_symlink()}
def record_quarantine(remote, identifier, quarantine, saved=None, *, legacy=False):
"""Model the journal made by an actual interrupted uninstall, not a label."""
directory = remote.CACHE / "runs" / identifier
metadata = remote.owned(directory)
metadata.update({"operation": "uninstall", "state": "removing", "quarantinePath": str(quarantine),
"savedBackupPath": str(saved) if saved else None})
if not legacy:
metadata["quarantineRoot"] = remote.uninstall_root_identity(quarantine)
if saved:
backup = {"owner": remote.OWNER + "-save-backup", "gamePath": str(remote.GAME)}
if not legacy:
backup["files"] = {str(path.relative_to(saved)): {"size": path.stat().st_size,
"sha256": remote.digest(path)}
for path in saved.rglob("*") if path.is_file() and path.name != remote.MARKER}
metadata["savedBackupRoot"] = remote.uninstall_root_identity(saved)
(saved / remote.MARKER).write_text(json.dumps(backup))
remote.write_uninstall_record(directory, metadata)
return directory / remote.MARKER
def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(installation, capsys):
remote, steam, configs, appids, foreign = installation
original_config = (remote.GAME / "config.toml").read_bytes()
@@ -124,6 +144,55 @@ def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
def test_uninstall_removes_a_native_entry_the_user_renamed(installation):
remote, steam, configs, appids, _ = installation
config = configs[0]
executable = str(remote.GAME / "halo")
root = steam.loads((config / "shortcuts.vdf").read_bytes())
for entry in root["shortcuts"].value.values():
path = entry.value.get("Exe")
if path is not None and path.value in (executable, f'"{executable}"'):
entry.value["AppName"] = steam.Value(1, "My favorite Halo")
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] and result["steam"]["status"] == "removed"
assert result["steam"]["accounts"] == ["10", "20"]
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "2"}
assert "My favorite Halo" not in {entry.value["AppName"].value for entry in remaining.values()}
assert result["steam"]["appids"] == sorted(set(appids))
assert not (config / "grid" / f"{appids[0]}p.jpg").exists()
assert not (config / "grid" / f"{appids[0]}.png").exists()
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile"
assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art"
backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak"))
assert len(backups) == 1 and b"My favorite Halo" in backups[0].read_bytes()
def test_uninstall_refuses_exact_native_malformed_ids_without_changing_files_or_art(installation):
remote, steam, configs, _, _ = installation
config = configs[0]
executable = str(remote.GAME / "halo")
root = steam.loads((config / "shortcuts.vdf").read_bytes())
root["shortcuts"].value["3"] = steam.Value(0, OrderedDict([
("appid", steam.Value(1, "not-a-number")), ("AppName", steam.Value(1, "Broken id")),
("Exe", steam.Value(1, executable))]))
root["shortcuts"].value["4"] = steam.Value(0, OrderedDict([
("appid", steam.Value(2, 0xF2233445)), ("AppName", steam.Value(1, "Odd quotes")),
("Exe", steam.Value(1, executable + '"'))]))
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
before = game_bytes(remote)
shortcut_bytes = [(path / "shortcuts.vdf").read_bytes() for path in configs]
artwork = {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()}
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
assert game_bytes(remote) == before
assert [(path / "shortcuts.vdf").read_bytes() for path in configs] == shortcut_bytes
assert {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()} == artwork
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "1", "2", "3", "4"}
def test_uninstall_without_keep_saves_deletes_contained_saves_only(installation):
remote, _, _, _, _ = installation
outside = remote.HOME / "external-saves"
@@ -275,6 +344,389 @@ def test_partial_file_removal_reports_quarantine_and_save_backup_honestly(instal
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
assert str(saved) in str(error.value)
assert "game was kept" not in str(error.value).lower()
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
monkeypatch.setattr(Path, "unlink", original_unlink)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] and not result["alreadyAbsent"] and not quarantine.exists()
assert result["savedBackupPath"] == str(saved)
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
@pytest.mark.parametrize("legacy", [False, True])
def test_interrupted_uninstall_leftovers_are_finished_by_the_next_run(installation, legacy):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
(saved / "save").mkdir()
(saved / "save/profile.sav").write_bytes(b"earlier campaign")
record_quarantine(remote, previous, quarantine, saved, legacy=legacy)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
assert not quarantine.exists() and not remote.GAME.exists()
assert (saved / "save/profile.sav").read_bytes() == b"earlier campaign"
assert str(quarantine) in result["warning"] and str(saved) in result["warning"]
assert result["recoveredSaveBackupPaths"] == [str(saved)]
assert result["savedBackupPath"] is None and result["removalPending"] is False
def test_reinstall_after_interrupted_uninstall_still_clears_the_old_quarantine(installation):
remote, _, _, _, _ = installation
previous = "e" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
(saved / "config.toml").write_bytes(b"earlier settings")
record_quarantine(remote, previous, quarantine, saved)
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"rebuilt native executable")
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and not result.get("alreadyAbsent", False)
assert not quarantine.exists() and not remote.GAME.exists()
assert str(saved) in result["warning"]
def test_quarantine_pattern_folders_without_a_valid_run_or_marker_are_kept(installation):
remote, _, _, _, _ = installation
games = remote.GAME.parent
decoy = games / ".HaloCENativeVR-uninstall-not-a-run-id"
decoy.mkdir()
(decoy / "notes.txt").write_bytes(b"user data")
unmarked = games / (".HaloCENativeVR-uninstall-" + "f" * 32)
unmarked.mkdir()
(unmarked / "stray.txt").write_bytes(b"not ours")
forged = games / (".HaloCENativeVR-uninstall-" + "a" * 32)
forged.mkdir()
(forged / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
(forged / "precious.bin").write_bytes(b"user data")
marker_dir = games / (".HaloCENativeVR-uninstall-" + "1" * 32)
marker_dir.mkdir()
(marker_dir / remote.MARKER).mkdir()
(marker_dir / "note.txt").write_bytes(b"kept")
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["removalPending"]
assert "manually" in result["warning"]
assert set(result["retainedQuarantinePaths"]) == {str(decoy), str(unmarked), str(forged), str(marker_dir)}
assert (decoy / "notes.txt").read_bytes() == b"user data"
assert (unmarked / "stray.txt").read_bytes() == b"not ours"
assert (forged / "precious.bin").read_bytes() == b"user data"
assert (marker_dir / "note.txt").read_bytes() == b"kept"
def test_unfinishable_leftover_is_named_and_skipped_instead_of_blocking(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "2" * 32)
quarantine.mkdir()
(quarantine / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
record_quarantine(remote, "2" * 32, quarantine)
outside = remote.HOME / "foreign.map"
outside.write_bytes(b"outside file")
os.link(outside, quarantine / "linked.map")
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["removalPending"]
assert (quarantine / "linked.map").is_file() and outside.read_bytes() == b"outside file"
assert str(quarantine) in result["warning"] and "manually" in result["warning"]
def test_many_leftovers_are_all_removed_with_a_bounded_warning(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "3" * 32)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, "3" * 32, quarantine)
for index in range(40):
folder = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + f"{index:032x}")
folder.mkdir()
(folder / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
(folder / "halo").write_bytes(b"earlier binary")
record_quarantine(remote, f"{index:032x}", folder)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
assert not list((remote.GAME.parent).glob(".HaloCENativeVR-uninstall-*"))
assert len(result["warning"]) <= remote.MAX_WARNING_CHARS and "additional notes omitted" in result["warning"]
def test_saves_note_requires_an_owned_backup_marker(installation):
remote, _, _, _, _ = installation
previous = "4" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
record_quarantine(remote, previous, quarantine)
journal = remote.CACHE / "runs" / previous / remote.MARKER
metadata = json.loads(journal.read_text())
metadata["savedBackupPath"] = str(saved)
journal.write_text(json.dumps(metadata))
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and quarantine.exists() and result["removalPending"]
assert str(quarantine) in result["warning"] and result["recoveredSaveBackupPaths"] == []
@pytest.mark.parametrize("same_run", [False, True])
@pytest.mark.parametrize("keep_saves", [False, True])
def test_retry_finishes_markerless_quarantine_after_root_removal_failure(
installation, monkeypatch, same_run, keep_saves):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + RUN_ID)
real_rmdir = Path.rmdir
def fail_root(path, *args, **kwargs):
if path == quarantine:
raise OSError("root directory removal interrupted")
return real_rmdir(path, *args, **kwargs)
with monkeypatch.context() as patch:
patch.setattr(Path, "rmdir", fail_root)
with pytest.raises(RuntimeError, match="Remaining native files"):
remote.uninstall(RUN_ID, keep_saves=keep_saves)
assert quarantine.is_dir() and not list(quarantine.iterdir())
journal = json.loads((remote.CACHE / "runs" / RUN_ID / remote.MARKER).read_text())
assert journal["quarantineRoot"] == remote.uninstall_root_identity(quarantine)
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
result = remote.uninstall(RUN_ID if same_run else "d" * 32, keep_saves=keep_saves)
assert result["uninstalled"] and not result["alreadyAbsent"] and not result["removalPending"]
assert not quarantine.exists()
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
assert result["recoveredSaveBackupPaths"] == ([str(saved)] if keep_saves else [])
assert result["savedBackupPath"] == (str(saved) if keep_saves and same_run else None)
def test_quarantine_journal_precedes_the_rename_and_survives_that_crash_window(installation, monkeypatch):
remote, _, _, _, _ = installation
real_rename = remote.rename_noreplace
seen = []
def check(source, target):
if source == remote.GAME:
metadata = remote.private_json(remote.CACHE / "runs" / RUN_ID / remote.MARKER,
remote.CACHE / "runs" / RUN_ID)
assert metadata["state"] == "quarantining"
assert metadata["quarantinePath"] == str(target)
assert metadata["quarantineRoot"] == remote.uninstall_root_identity(source)
seen.append(metadata)
real_rename(source, target)
monkeypatch.setattr(remote, "rename_noreplace", check)
assert remote.uninstall(RUN_ID, keep_saves=True)["uninstalled"]
assert len(seen) == 1
# A process killed after rename but before the next journal update leaves
# this exact state. It remains sufficient proof even without a game marker.
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
quarantine.mkdir()
(quarantine / "remaining.bin").write_bytes(b"native remainder")
record = record_quarantine(remote, "d" * 32, quarantine)
metadata = json.loads(record.read_text())
metadata["state"] = "quarantining"
record.write_text(json.dumps(metadata))
assert remote.uninstall("e" * 32)["uninstalled"] and not quarantine.exists()
@pytest.mark.parametrize("damage", ["missing", "corrupt", "foreign-owner", "foreign-operation", "wrong-path",
"complete", "oversize", "hardlink", "invalid-root", "foreign-marker"])
def test_unverified_quarantine_journals_are_reported_and_never_adopted(installation, damage):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record = record_quarantine(remote, previous, quarantine)
metadata = json.loads(record.read_text())
if damage == "missing":
record.unlink()
elif damage == "corrupt":
record.write_text("{broken")
elif damage == "oversize":
record.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
elif damage == "hardlink":
os.link(record, remote.HOME / "copied-journal.json")
elif damage == "foreign-marker":
(quarantine / remote.MARKER).write_text('{"owner":"another-application"}')
else:
key, value = {"foreign-owner": ("owner", "other"), "foreign-operation": ("operation", "install"),
"wrong-path": ("quarantinePath", str(remote.HOME / "unrelated")),
"complete": ("state", "complete"), "invalid-root": ("quarantineRoot", {"device": True})}[damage]
metadata[key] = value
record.write_text(json.dumps(metadata))
before = {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()}
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and not result["alreadyAbsent"] and result["removalPending"]
assert result["retainedQuarantinePaths"] == [str(quarantine)] and str(quarantine) in result["warning"]
assert {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()} == before
def test_copied_known_owner_marker_does_not_authorize_unrelated_folder_deletion(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
quarantine.mkdir()
(quarantine / remote.MARKER).write_bytes((remote.GAME / remote.MARKER).read_bytes())
(quarantine / "precious.bin").write_bytes(b"user data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and not result["uninstalled"]
assert (quarantine / "precious.bin").read_bytes() == b"user data"
assert str(quarantine) in result["warning"]
def test_replaced_quarantine_root_is_kept_even_if_the_old_marker_was_copied(installation):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
intact = remote.GAME.parent / "original-quarantined-game"
os.rename(quarantine, intact)
quarantine.mkdir()
(quarantine / remote.MARKER).write_bytes((intact / remote.MARKER).read_bytes())
(quarantine / "precious.bin").write_bytes(b"replacement data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "identity" in result["warning"]
assert (quarantine / "precious.bin").read_bytes() == b"replacement data"
assert (intact / "halo").read_bytes() == b"damaged native executable"
def test_markerless_legacy_quarantine_is_named_and_kept_for_manual_review(installation):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine, legacy=True)
(quarantine / remote.MARKER).unlink()
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "older uninstall" in result["warning"]
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
@pytest.mark.parametrize("damage", ["missing", "corrupt-marker", "oversize-marker", "foreign-marker",
"hardlink", "changed-content", "empty", "symlink-root", "symlink-child"])
def test_unverifiable_save_backup_preserves_quarantine_and_all_backup_data(installation, damage):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / "campaign.sav").write_bytes(b"previous campaign")
record_quarantine(remote, previous, quarantine, saved)
marker = saved / remote.MARKER
protected = None
if damage == "missing":
(saved / "campaign.sav").unlink()
marker.unlink()
saved.rmdir()
elif damage == "corrupt-marker":
marker.write_text("not-json")
elif damage == "oversize-marker":
marker.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
elif damage == "foreign-marker":
marker.write_text('{"owner":"another-application"}')
elif damage == "hardlink":
protected = remote.HOME / "protected-campaign.sav"
os.link(saved / "campaign.sav", protected)
elif damage == "changed-content":
(saved / "campaign.sav").write_bytes(b"modified campaign")
elif damage == "empty":
(saved / "campaign.sav").unlink()
else:
protected = remote.HOME / "protected-saves"
protected.mkdir()
(protected / "keep.sav").write_bytes(b"external campaign")
if damage == "symlink-root":
os.rename(saved, remote.GAME.parent / "actual-backup")
link = saved
else:
link = saved / "linked"
try:
link.symlink_to(protected, target_is_directory=True)
except OSError:
pytest.skip("Windows symlink creation requires additional privileges.")
before = {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()}
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["removalPending"] and quarantine.exists()
assert result["recoveredSaveBackupPaths"] == []
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
assert {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()} == before
if protected:
assert (protected.read_bytes() if protected.is_file() else (protected / "keep.sav").read_bytes()) in (
b"previous campaign", b"external campaign")
def test_same_device_bind_mount_in_a_quarantine_is_kept(installation, monkeypatch):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [quarantine / "maps"])
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "mount" in result["warning"]
assert (quarantine / "maps/a10.map").read_bytes() == b"damaged or incomplete Xbox maps"
def test_recovery_and_verified_saves_are_reported_when_steam_removal_is_pending(installation, monkeypatch):
remote, steam, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / "campaign.sav").write_bytes(b"earlier campaign")
record_quarantine(remote, previous, quarantine, saved)
monkeypatch.setattr(steam, "steam_running", lambda: True)
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
assert not quarantine.exists() and str(quarantine) in result["warning"]
assert result["savedBackupPath"] is None and result["recoveredSaveBackupPaths"] == [str(saved)]
assert (saved / "campaign.sav").read_bytes() == b"earlier campaign"
def test_recovery_has_bounded_work_and_can_continue_on_another_run(installation, monkeypatch):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
monkeypatch.setattr(remote, "MAX_UNINSTALL_RECOVERIES", 4)
for index in range(5):
identifier = f"{index:032x}"
path = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + identifier)
path.mkdir()
(path / "remaining.bin").write_bytes(b"verified remainder")
record_quarantine(remote, identifier, path)
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["removalPending"] and not result["alreadyAbsent"]
assert len(list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))) == 2
assert "one operation" in result["warning"] and len(result["warning"]) <= remote.MAX_WARNING_CHARS
assert remote.uninstall("e" * 32)["uninstalled"]
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
@pytest.mark.skipif(os.name == "nt", reason="Windows forbids these Unix filename characters.")
@pytest.mark.parametrize("suffix", ["bad\nname", "bad\\name", "bad\x7fname"])
def test_unreportable_quarantine_filenames_stay_pending_without_unsafe_path_fields(installation, suffix):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + suffix)
quarantine.mkdir()
(quarantine / "precious.bin").write_bytes(b"user data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and not result["uninstalled"]
assert result["retainedQuarantinePaths"] == []
assert (quarantine / "precious.bin").read_bytes() == b"user data"
assert ascii(quarantine.name) in result["warning"]
def test_absent_games_parent_needs_no_creation_or_cleanup(installation):
remote, _, _, _, _ = installation
protected = remote.HOME / "original-games"
os.rename(remote.GAME.parent, protected)
result = remote.uninstall(RUN_ID)
assert result["uninstalled"] and result["alreadyAbsent"] and not result["removalPending"]
assert not remote.GAME.parent.exists()
assert (protected / "HaloCEVR/halo.exe").read_bytes() == b"old PC version; this operation must not delete it"
def test_failure_before_first_delete_restores_the_intact_game_folder(installation, monkeypatch):
@@ -364,14 +816,14 @@ def test_save_backup_prerequisite_failure_keeps_game_and_steam_untouched(install
def test_completed_removal_is_not_reported_as_failed_if_operation_bookkeeping_fails(installation, monkeypatch):
remote, _, _, _, _ = installation
real_write = Path.write_text
real_write = remote.write_uninstall_record
def write(path, content, *args, **kwargs):
if path.parent.name == RUN_ID and '"state": "complete"' in content:
def write(directory, metadata, *args, **kwargs):
if directory.name == RUN_ID and metadata.get("state") == "complete":
raise OSError("operation record update failed")
return real_write(path, content, *args, **kwargs)
return real_write(directory, metadata, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", write)
monkeypatch.setattr(remote, "write_uninstall_record", write)
result = remote.uninstall(RUN_ID)
assert result["uninstalled"] is True and not remote.GAME.exists()
assert "operation record" in result["warning"]
+102
View File
@@ -148,3 +148,105 @@ def test_post_removal_bookkeeping_warning_is_visible_without_false_failure():
assert result.uninstalled and fake.closed
assert ('detail', warning, None) in events
assert events[-1][0] == 'complete'
def host_response(fake, **extra):
original = fake.run
def run(argv, **kwargs):
response = original(argv, **kwargs)
if argv[1] != '-c':
data = json.loads(response.split(' ', 1)[1])
data.update(extra)
response = 'HFI_RESULT ' + json.dumps(data)
return response
fake.run = run
def test_successful_uninstall_retains_its_result_when_usb_cleanup_needs_attention():
class CleanupFailure(UninstallConnection):
def close(self):
self.closed = True
raise SSHError('Temporary USB forward cleanup failed private')
fake = CleanupFailure(Settings('', 'private', transport='usb'))
events = []
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert result.uninstalled and result.saved_backup_path
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
assert 'private' not in result.cleanup_warning
assert not any(event[0] in ('disconnected', 'complete') for event in events)
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
def test_uncertain_uninstall_retains_history_without_claiming_removed_or_already_absent():
fake = UninstallConnection(Settings('frame', 'private'))
previous_save = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unsafe-manual-folder'
warning = 'An unsafe uninstall folder was preserved; inspect it before retrying.'
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
removalPending=True, warning=warning,
retainedQuarantinePaths=[retained], recoveredSaveBackupPaths=[previous_save])
events = []
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert result.removal_pending and not result.uninstalled and not result.already_absent
assert result.removal_warning == warning
assert result.retained_quarantine_paths == (retained,)
assert result.recovered_save_backup_paths == (previous_save,)
assert result.saved_backup_path is None
assert not any(event[0] == 'complete' for event in events)
assert events[-1][0] == 'removal-pending' and events[-1][2] == 99
def test_manual_steam_failure_preserves_recovered_save_history_and_warning():
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'b' * 32
warning = 'Recovered saves from an interrupted uninstall.'
host_response(fake, warning=warning, recoveredSaveBackupPaths=[backup])
events = []
with pytest.raises(SSHError, match='game was kept') as error:
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert warning in str(error.value) and backup in str(error.value)
assert ('detail', 'Recovered campaign save backup: ' + backup, None) in events
assert not any(event[0] == 'complete' for event in events)
@pytest.mark.parametrize('extra', [
{'recoveredSaveBackupPaths': ['/home/other/Games/HaloCENativeVR-saves-' + 'a' * 32]},
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-not-a-run']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-../other']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\\other']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\nother']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 256]},
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32] * 2},
{'retainedQuarantinePaths': ['folder'] * 65},
{'warning': 'x' * 4097},
{'removalPending': 'true'},
{'removalPending': True, 'uninstalled': True},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 32]},
])
def test_invalid_uninstall_recovery_history_never_reports_success(extra):
fake = UninstallConnection(Settings('frame', 'private'))
host_response(fake, **extra)
stages = []
with pytest.raises(SSHError, match='invalid uninstall'):
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda stage, message, percent=None: stages.append(stage))
assert 'complete' not in stages and fake.closed
def test_pending_overflow_can_be_reported_without_a_retained_path_list():
fake = UninstallConnection(Settings('frame', 'private'))
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
removalPending=True, warning='Too many interrupted uninstall folders; retry cleanup.')
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
assert result.removal_pending and result.retained_quarantine_paths == ()
def test_uninstall_failure_is_not_masked_by_disconnect_failure():
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
fake.close = lambda: (_ for _ in ()).throw(EOFError('private'))
with pytest.raises(SSHError, match='game was kept') as error:
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
assert 'private' not in str(error.value)