mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-17 19:00:30 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53be44d55f | ||
|
|
2ec7378c6a | ||
|
|
0aba1aa2b5 | ||
|
|
7e7b944e81 | ||
|
|
9b7810ac49 | ||
|
|
d5795d8d8c | ||
|
|
4cda8e5be3 | ||
|
|
cc7b8a4d5e | ||
|
|
b1110a361a | ||
|
|
65fa540845 | ||
|
|
94cf32cbf9 |
No files matched your search
@@ -56,7 +56,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$reportPath = Join-Path $env:RUNNER_TEMP 'halo-installer-smoke.json'
|
||||
$installers = @(Get-ChildItem -LiteralPath 'dist' -Filter 'Halo-Steam-Frame-Setup-*.exe')
|
||||
$installers = @(Get-ChildItem -LiteralPath 'dist' -Filter 'Halo-Steam-Frame-Mod-Setup-*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one standalone installer executable' }
|
||||
$exePath = $installers[0].FullName
|
||||
$process = Start-Process -FilePath $exePath -ArgumentList @('--self-test-report', ('"' + $reportPath + '"')) -WindowStyle Hidden -Wait -PassThru
|
||||
|
||||
@@ -3,184 +3,27 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/ui/app-icon.png" alt="Installer Master Chief helmet icon" width="44" height="44">
|
||||
|
||||
<img src="resources/artwork/halo-ce-logo.png" alt="Halo: Combat Evolved" width="180">
|
||||
<br>
|
||||
<strong>Native VR for Steam Frame</strong><br>
|
||||
Xbox-style controls · USB-C or network transfer · Guided Windows setup
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe"><strong>DOWNLOAD v1.3.1 — WINDOWS EXE</strong></a><br>
|
||||
<sub>One installer file · Windows 10/11 x64 · No administrator rights needed</sub><br>
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.1">Release notes & checksum</a> · <a href="#download-and-run">Setup guide</a> · <a href="#sources-and-credits">Sources & credits</a>
|
||||
<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/HaloSteamFrameMod/releases/tag/v1.3.5">Release notes & 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.
|
||||
|
||||
A Windows setup app for installing the native ARM64/OpenXR Halo: Combat Evolved port on Steam Frame. Its original Xbox dashboard-inspired interface uses dark panels, luminous green accents, and beveled controls to guide you through choosing game data, connecting over USB-C or a network, and installing or repairing the game. Both connection options use encrypted SSH. Setup copies your own original Xbox data, builds the native VR executable on the Frame, applies Xbox-style controls, and guides Steam library registration with Halo CE cover art.
|
||||
You need **Windows 10/11 x64**, a **Steam Frame**, and your own authorized **original Xbox Halo CE ISO/XISO or extracted maps**. **USA Rev 2 is validated**; Halo PC, Xbox 360 Anniversary, and MCC data are unsupported.
|
||||
|
||||
**Version 1.3.1 — Halo library artwork and game Notes.** Adds a CE banner, transparent title logo, and Master Chief shortcut icon alongside the covers. A game description and Xbox-style control guide appear in **Steam Notes**, accessible from the game's details page; Steam does not provide an editable store-style About description for non-Steam games. Choose **Steam info** to update an existing native install without another ISO or rebuild. Custom artwork, icons, and notes are preserved. These additions have automated software coverage; their appearance on a physical Frame still needs verification.
|
||||
No retail game executable, disc image, maps, product keys, or soundtrack is included or downloaded. Halo artwork and trademarks retain their owners' rights; software components keep their licenses. This community project is unaffiliated with Microsoft, Xbox, Bungie, or Valve.
|
||||
|
||||
Choose a direct USB-C cable or Wi-Fi / Ethernet in the connection step. The wired path uses ADB port forwarding for the same encrypted SSH connection, with no Frame IP required. USB handling is software-tested; a physical Steam Frame USB transfer has not yet been verified. Setup retains the 1.2.3 safeguard that keeps Steam Home and SteamVR running; it never shuts down or starts another Steam client. Direct library-file edits require Steam to be already closed, with manual steps shown when needed. The underlying VR implementation is an open upstream pull request. The pinned native game, Xbox controls, and live Steam shortcut/cover-art helper were previously tested on a real Frame. The complete first-install wizard and full game uninstall still need fresh-device hardware tests. Performance varies by scene and device. This is an independent community project, unaffiliated with Microsoft, Bungie, or Valve.
|
||||
1. Run the EXE, choose **Install**, and select your ISO or maps folder.
|
||||
2. Enable Frame Developer Mode, set its user password, and connect over **USB-C** or **Wi-Fi / Ethernet**. The app guides you through SSH setup.
|
||||
3. Save and close games, keep **Steam Home** running, and click **Install Halo VR**. The first build can take tens of minutes; your Frame needs internet access.
|
||||
|
||||
## Download and run
|
||||
Steam artwork and the description/control guide in **Notes** are added automatically during Install and Repair. Already installed? Choose **Repair** to refresh the game, or **Uninstall** to remove it with an optional save backup.
|
||||
|
||||
**[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.1.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe)**
|
||||
The port is experimental; newer USB and library features still need physical Frame verification.
|
||||
|
||||
This is the only installer file you need. It includes the installer runtime and the ADB files needed for USB-C transfer; no installer ZIP, Python installation, or separate Platform Tools download is needed. The bundled ADB files retain their original notices. Run setup on Windows 10/11 x64. You do not need administrator rights. The **[version 1.3.1 release](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.1)** description includes the EXE's SHA256 checksum.
|
||||
|
||||
The executable has a Master Chief helmet icon with olive armor and a gold visor, drawn for this project.
|
||||
|
||||
1. **Game data.** Choose **Install**, then use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
|
||||
2. **Connect Frame.** Enable Developer Mode and set the Frame's user password, then choose **Network (Wi-Fi or Ethernet)** or **USB-C cable**. For network transfer, keep both devices on a network that permits connections between them and enter the Frame's hostname/IP. For USB, connect a data-capable cable directly to the PC and approve device authorization on the headset if prompted; no Frame IP is required. The SSH login remains `steamos`, port 22; username and port are under **Advanced connection settings**. Click **Test connection** and approve the displayed host fingerprint only after identifying your device. Your password is used in memory and is never saved or logged.
|
||||
3. **Install Halo VR.** Save and close games, confirm **I have saved and closed games on my Frame**, then click **Install Halo VR**. Keep Steam Home and SteamVR running, and leave the Frame awake and connected. The app uploads maps, creates a rootless build container, compiles the pinned VR source, applies the controls patch, and registers **Halo: Combat Evolved VR (Native)** through the running Steam client when its verified library interface is available. Any remaining shortcut or artwork step is shown at the end; it does not require rebuilding the game. The first build may take tens of minutes. Activity opens automatically and shows the current phase and live build output; use **Save log** for troubleshooting.
|
||||
|
||||
### Transfer over a USB-C cable
|
||||
|
||||
1. In the Frame's **Steam Settings > System**, enable **Developer Mode**. In **Developer**, set a user password. Leave Steam Home and SteamVR running.
|
||||
2. Connect the Frame directly to your Windows PC with a USB data cable. A charging-only cable cannot transfer files. Keep the Frame awake and allow this computer's device authorization on the headset if prompted.
|
||||
3. In **Connect Frame**, choose **USB-C cable**. Setup first uses ADB already available on your computer's `PATH`, then falls back to its included ADB 37.0.1 files. **Advanced connection settings > Browse adb.exe** can select a specific installation. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
|
||||
4. Enter the same Frame user password used for SSH and click **Test connection**. Setup selects the sole physical USB device automatically. If several physical USB devices are connected, unplug the others or enter the Frame's exact ADB serial in **Advanced connection settings**. If a serial cannot be verified as a physical USB connection, unplug other devices and leave the serial field blank for automatic detection. Emulators and network-connected ADB devices are not USB targets. Review the Frame's SSH host fingerprint when asked.
|
||||
5. Continue with install, repair, library registration, or uninstall. Leave the cable attached until setup reports that it has disconnected. Setup removes only the temporary ADB forward it created and leaves the shared ADB server and other forwards running.
|
||||
|
||||
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%.
|
||||
|
||||
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.
|
||||
|
||||
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](docs/THIRD_PARTY.md#adb-for-usb-transfer).
|
||||
|
||||
These changes apply after you launch the new EXE and start a run. An installation already running in an older EXE keeps its existing behavior; downloading a replacement cannot change that ongoing process.
|
||||
|
||||

|
||||
|
||||
Progress preview with sample compiler output:
|
||||
|
||||

|
||||
|
||||
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
|
||||
|
||||
### Supported Xbox ISO revisions
|
||||
|
||||
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 is validated with this installer.** Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
|
||||
|
||||
| Map-header build | Upstream region |
|
||||
| --- | --- |
|
||||
| `01.10.12.2276` | NTSC |
|
||||
| `01.08.15.1749` | NTSC |
|
||||
| `01.01.14.2342` | PAL |
|
||||
|
||||
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
|
||||
|
||||
### Already have Halo installed?
|
||||
|
||||
Choose **Repair** in the **Game data** step, connect your Frame, and click **Repair Halo VR** in the last step. Setup detects the standard native folder `~/Games/HaloCENativeVR` and reinstalls the native executable, SDL library, controls/tutorial patch, and notices; it restores the required VR/control settings while preserving saves and unrelated settings. Valid original Xbox maps already on the Frame can be reused without selecting another ISO. If maps are missing or damaged, select a valid local image/maps folder and retry. Previous installs made by this installer or the original guided native setup are recognized. A hand-installed native game in the same folder can be adopted only through the explicit Repair action, after its native executable and original Xbox maps validate. Unknown folders and other install locations are not overwritten.
|
||||
|
||||
The old PC installation at `~/Games/HaloCEVR` is detected and explained separately. Its PC maps cannot be used for this native Xbox build. Setup installs the native version separately and does not automatically remove the PC version. Close the native game before repair. Program-file backups are retained in the owned build workspace, and a failed repair rolls back the affected files. **Add to Steam again** repairs library registration and adds missing library artwork; it does not rebuild the game.
|
||||
|
||||
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB of free internal storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
|
||||
|
||||
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select `~/Games/HaloCENativeVR/halo`, apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
|
||||
|
||||
### Update only the Steam library information
|
||||
|
||||
Choose **Steam info** on the first page, connect the Frame, then click **Update Steam info**. No ISO, map transfer, compiler, or rebuild is needed. Setup verifies the recognized native installation and updates its shortcut, missing artwork, and **Halo CE VR — About & controls** note. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects.
|
||||
|
||||
Open **Notes** on the Halo game's Steam details page or in its overlay to read the description and control guide. These are private game notes using Steam's normal notes synchronization. They do not replace the non-Steam About panel or add a store page, achievements, reviews, or another game's Steam identity. Existing notes and edited installer notes remain unchanged. If Notes is unavailable or synchronization cannot be verified, setup shows copyable text for manual use. Missing artwork or an icon awaiting Steam's shortcut-file persistence is reported separately and can be retried with **Add to Steam again**. Notes requires a running, verified Steam client; setup leaves a closed Steam client closed.
|
||||
|
||||
### 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.
|
||||
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
| Frame control | Action |
|
||||
| --- | --- |
|
||||
| A | Jump / accept |
|
||||
| B | Melee / back |
|
||||
| X | Reload / use |
|
||||
| Y | Change weapon |
|
||||
| Right trigger / left trigger | Fire / throw grenade |
|
||||
| Left stick / click | Move / crouch |
|
||||
| Right stick / click | Turn / zoom |
|
||||
| Left bumper / right bumper | Change grenade / flashlight |
|
||||
| Menu / View | Pause / scoreboard |
|
||||
| Both grips held | Recenter |
|
||||
|
||||
Motion aiming stays enabled. Smooth turning defaults to 90 degrees/second, refresh rate to 72 Hz, and render scale to 1.0. Repairs refresh the VR/control defaults and keep unrelated settings and saves. Head movement also satisfies the first-level look tutorial. The patch does not skip the campaign or increase combat difficulty.
|
||||
|
||||
The native shortcut uses this **per-game** launch option to prevent the same physical controller from being read through both OpenXR and Steam's virtual gamepad:
|
||||
|
||||
```text
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%
|
||||
```
|
||||
|
||||
See [controls and patch details](docs/CONTROLS.md).
|
||||
|
||||
## What gets installed
|
||||
|
||||
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New-install saves are in that game's separate `save` folder; repairs preserve existing save locations. Build workspace: `~/.cache/halo-frame-installer`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup does not remove another Halo installation or touch unrelated games.
|
||||
|
||||
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the installer's Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is reported as pending rather than replacing an uncertain user choice.
|
||||
|
||||
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; **Steam info**, repair, and **Add to Steam again** use the same handling. No artwork download is needed. The new bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](resources/artwork/README.md). The user confirmed the older entry and covers on the Frame tested with 1.2.3; the 1.3.1 banner, logo, icon, and Notes still need a physical Frame check.
|
||||
|
||||
The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenXR VR and competitive multiplayer come from the port. The PC-only Chimera, PC Restored maps, and HaloCEVR DLLs do not belong in this native build. Multiplayer requires compatible native/OpenCE protocol builds; Halo PC/Custom Edition/MCC servers and PC save files are incompatible.
|
||||
|
||||
## Sources and credits
|
||||
|
||||
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
|
||||
- [Exact VR source commit `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106), with a documented local controller/tutorial patch.
|
||||
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr).
|
||||
- [Valve's official Frame setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) and [SSH/debugging instructions](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
|
||||
- [Google's ADB connection and forwarding reference](https://developer.android.com/tools/adb) and [official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools).
|
||||
- [Original Xbox dashboard visual references](docs/SOURCES.md#dashboard-visual-references) and [Halo CE artwork attribution and Steam Notes references](docs/SOURCES.md#steam-library-artwork).
|
||||
|
||||
Full source attribution, decompilation lineage, multiplayer references, and third-party licenses are in [SOURCES.md](docs/SOURCES.md) and [THIRD_PARTY.md](docs/THIRD_PARTY.md). Original installer code is MIT licensed. Upstream notices apply separately; no license here grants rights to commercial game data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Cannot resolve `frame`:** for Wi-Fi / Ethernet, use the Frame's IP address from its network settings. Both devices must be on a network that permits connections between clients. USB-C transfer does not need the hostname/IP.
|
||||
- **USB device missing or offline:** verify Developer Mode, keep the Frame awake, use a data-capable cable and a direct PC USB port, and reconnect it. A charging indicator alone does not confirm data transfer. Approve device authorization on the headset if prompted. Setup does not reset shared ADB services or change device settings automatically.
|
||||
- **Several USB devices detected:** unplug other physical USB devices or enter the Frame's exact ADB serial in **Advanced connection settings**. Setup refuses to guess which device to use. If that serial's physical USB path cannot be verified, leave it blank with only the Frame attached and retry automatic detection.
|
||||
- **ADB unavailable:** the release EXE includes ADB. If its files cannot be used, extract [Google's official Windows Platform Tools package](https://developer.android.com/tools/releases/platform-tools), keeping `adb.exe` and its companion DLLs together, then choose it with **Browse adb.exe**. Wi-Fi / Ethernet remains available without ADB.
|
||||
- **ADB server version differs:** setup leaves that server running. Choose the matching `adb.exe` under **Advanced connection settings**, or close the other application's USB tools normally and retry. Network transfer remains available.
|
||||
- **USB forwarding has network access enabled:** setup refuses to send the SSH password over a forward listening beyond loopback. Close the other application's USB tools normally and retry, or choose network transfer. Setup does not reset the shared server automatically.
|
||||
- **USB SSH connection refused:** enable Developer Mode and confirm the SSH port in **Advanced connection settings**. USB still needs the Frame's SSH service and user password; ADB authorization alone does not authenticate SSH. The USB path forwards the Frame's default SSH port 22.
|
||||
- **Connection refused:** enable Developer Mode and verify the address and SSH port. [Valve guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
|
||||
- **Authentication fails:** enter the password set in the Frame's Developer settings; your Steam account password is unrelated.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Library entry absent:** click **Add to Steam again** to retry registration through the running client. If its library interface remains unavailable, use Steam's **Add a Non-Steam Game** with the executable and launch option above. Enable **Include in VR Library** and leave forced compatibility tools disabled in its properties. Setup's direct library-file edits require Steam to be already closed.
|
||||
- **Artwork or description absent:** use **Steam info > Update Steam info**, or **Add to Steam again** after a completed run. The description is under **Notes**, not the non-Steam About panel. Pending artwork, icon, or Notes is reported separately from the shortcut; manual Notes includes copyable text. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
|
||||
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](docs/SOURCES.md#steam-session-safeguard).
|
||||
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
|
||||
|
||||
The release executable is unsigned. Verify its SHA256 against the checksum in the release description; this project does not ask you to disable antivirus or other security protections. Review logs for private information before posting an issue.
|
||||
|
||||
## Develop and rebuild the executable
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\python -m pip install -e ".[dev]"
|
||||
.\.venv\Scripts\python -m pytest
|
||||
.\.venv\Scripts\python -m halo_frame_installer
|
||||
.\.venv\Scripts\python scripts/build_release.py
|
||||
```
|
||||
|
||||
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.1 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.1)** 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 game binary, 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 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.
|
||||
|
||||
Before reporting a hardware test, record the SteamOS/SteamVR version, source commit, install status, and whether menu, campaign, Xbox controls, tutorial, and compatible multiplayer worked. Do not attach game data or credentials.
|
||||
[Detailed setup & troubleshooting](docs/SETUP.md) · [Controls](docs/CONTROLS.md) · [Sources](docs/SOURCES.md) · [Third-party notices](docs/THIRD_PARTY.md) · [Build from source](docs/SETUP.md#develop-and-rebuild-the-executable)
|
||||
+12
-16
@@ -1,24 +1,20 @@
|
||||
# Halo Steam Frame Setup 1.3.1
|
||||
# Halo CE Steam Frame VR Mod 1.3.5
|
||||
|
||||
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.1.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe)**. This is the complete Windows installer, including USB tools and library artwork; 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.
|
||||
|
||||
This patch fills out the native Halo non-Steam library entry with an original Halo CE banner, transparent title logo, cover images, and a Master Chief shortcut icon. It adds a game description and Xbox-style control guide in **Steam Notes**. Steam's non-Steam shortcuts do not expose an editable store-style About description: the patch uses the existing Notes feature instead of claiming a store page, achievements, reviews, or another game's Steam identity.
|
||||
This patch fixes interrupted uninstall recovery, renamed native Steam entries, and errors during cancellation or connection cleanup.
|
||||
|
||||
Already installed? Choose **Steam info**, connect by USB-C or Wi-Fi/Ethernet, and click **Update Steam info**. This verifies the existing native game and updates its library information without reinstalling or compiling it, uploading maps, or requiring another ISO. Steam Home and SteamVR stay running.
|
||||
- **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.
|
||||
|
||||
- Adds missing cover, landscape tile, hero/banner and transparent logo through the running Steam client, or safe library-file registration when Steam is already closed. Custom artwork is preserved.
|
||||
- Stores the shortcut icon in the owned native game folder and uses Steam's shortcut-icon interface. Existing custom icons are kept. If the icon's saved shortcut identity cannot be verified, setup shows a retry/manual step instead of guessing.
|
||||
- Adds one private **Halo CE VR — About & controls** note using Steam's native Notes interface. Existing notes and edits are preserved. Notes use Steam's normal synchronization; if the feature is unavailable or sync cannot be verified, setup provides text to copy manually.
|
||||
- Checks the active Steam account and exact native executable before library changes. Unavailable or unverified artwork, icon, or Notes updates are reported separately from the installed game's status.
|
||||
- Retains Xbox controls, original Xbox data validation, tutorial changes, USB transfer, left-to-right progress, redacted logs, save-preserving repair and uninstall, and the Steam Home session safeguard.
|
||||
- Refreshes the GitHub README with Halo banner, logo, and helmet artwork and a prominent single-file download link.
|
||||
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.
|
||||
|
||||
The new bundled artwork adds less than 1 MB. Hero and logo provenance and file hashes are recorded in the [artwork notice](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.1/resources/artwork/README.md). Promotional artwork remains separate from the project's MIT code license.
|
||||
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.
|
||||
|
||||
**Validation limits:** the additional artwork, icon, and Notes paths have software tests; their appearance and Notes behavior still need verification on a real Steam Frame. The native game, Xbox controls, and earlier cover-art registration were previously checked on one Frame. Direct USB transfer, a fresh first install, and full game uninstall remain unverified on physical hardware. The upstream ARM64/OpenXR port is experimental. The Frame still needs internet for source and compiler downloads during installation; a Steam-info-only update does not build the game.
|
||||
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).
|
||||
|
||||
Use your own authorized original **Xbox** Halo CE data for game installation. USA Rev 2 is validated; other retail revisions are accepted only when their actual map headers match the supported builds. PC, Xbox 360 Anniversary, MCC and Quest inputs remain unsupported. No ISO, game maps, commercial executable, keys or soundtrack is bundled.
|
||||
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.
|
||||
|
||||
The [source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.1) includes the installer source, build scripts and third-party notices. The release attaches one clearly labeled EXE and prints its SHA256 here. The EXE is unsigned.
|
||||
|
||||
Sources: [Valve's library artwork guide](https://partner.steamgames.com/doc/store/assets/libraryassets?language=english), [Valve's Notes announcement](https://store.steampowered.com/news/app/593110/view/3687931965598906184), [Valve's Frame USB/SSH guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging), and the [full acknowledgements](SOURCES.md).
|
||||
[Setup guide](https://github.com/ClassicWasTaken/HaloSteamFrameMod/blob/v1.3.5/docs/SETUP.md) · [Sources and credits](SOURCES.md).
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Halo CE Steam Frame VR mod setup
|
||||
|
||||
[Back to the project README](../README.md)
|
||||
|
||||
This community VR mod installer guides you through installing the experimental native Halo CE VR port. This guide covers USB-C and network connections, repair, Steam library updates, removal, troubleshooting, and rebuilding the Windows executable.
|
||||
|
||||
## Before you begin
|
||||
|
||||
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.
|
||||
|
||||
Halo artwork and trademarks remain copyrighted to their respective owners, and third-party software retains its licenses and notices. This community project is unaffiliated with Microsoft, Xbox, Bungie, or Valve. Its license does not grant rights in retail game data or certify the legal status of upstream source; see [third-party notices](THIRD_PARTY.md).
|
||||
|
||||
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB of free internal storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
|
||||
|
||||
The native game, Xbox controls, and older Steam shortcut/covers were previously tested on a physical Frame. USB transfer, a complete fresh-device install/uninstall, and the newer banner/logo/icon/Notes still need hardware verification. The upstream VR implementation is experimental, and performance varies by scene and device.
|
||||
|
||||
The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon. Its [source and hashes](../resources/ui/README.md) are documented separately from the installer's code license. The Steam library's project-drawn helmet icon is unchanged.
|
||||
|
||||
## Install Halo VR
|
||||
|
||||
1. **Game data.** Choose **Install**, then use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
|
||||
2. **Connect Frame.** Enable Developer Mode and set the Frame's user password, then choose **Network (Wi-Fi or Ethernet)** or **USB-C cable**. For network transfer, keep both devices on a network that permits connections between them and enter the Frame's hostname/IP. For USB, connect a data-capable cable directly to the PC and approve device authorization on the headset if prompted; no Frame IP is required. The SSH login remains `steamos`, port 22; username and port are under **Advanced connection settings**. Click **Test connection** and approve the displayed host fingerprint only after identifying your device. Your password is used in memory and is never saved or logged.
|
||||
3. **Install Halo VR.** Save and close games, confirm **I have saved and closed games on my Frame**, then click **Install Halo VR**. Keep Steam Home and SteamVR running, and leave the Frame awake and connected. The app uploads maps, creates a rootless build container, compiles the pinned VR source, applies the controls patch, and registers **Halo: Combat Evolved VR (Native)** through the running Steam client when its verified library interface is available. Any remaining shortcut or artwork step is shown at the end; it does not require rebuilding the game. The first build may take tens of minutes. Activity opens automatically and shows the current phase and live build output; use **Save log** for troubleshooting.
|
||||
|
||||
### Transfer over a USB-C cable
|
||||
|
||||
1. In the Frame's **Steam Settings > System**, enable **Developer Mode**. In **Developer**, set a user password. Leave Steam Home and SteamVR running.
|
||||
2. Connect the Frame directly to your Windows PC with a USB data cable. A charging-only cable cannot transfer files. Keep the Frame awake and allow this computer's device authorization on the headset if prompted.
|
||||
3. In **Connect Frame**, choose **USB-C cable**. Setup first uses ADB already available on your computer's `PATH`, then falls back to its included ADB 37.0.1 files. **Advanced connection settings > Browse adb.exe** can select a specific installation. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
|
||||
4. Enter the same Frame user password used for SSH and click **Test connection**. Setup selects the sole physical USB device automatically. If several physical USB devices are connected, unplug the others or enter the Frame's exact ADB serial in **Advanced connection settings**. If a serial cannot be verified as a physical USB connection, unplug other devices and leave the serial field blank for automatic detection. Emulators and network-connected ADB devices are not USB targets. Review the Frame's SSH host fingerprint when asked.
|
||||
5. Continue with install, repair, library registration, or uninstall. Leave the cable attached until setup reports that it has disconnected. Setup removes only the temporary ADB forward it created and leaves the shared ADB server and other forwards running.
|
||||
|
||||
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. Pending Steam information, uninstall files, or connection cleanup stays at 99%, with the remaining action explained; only full success reaches 100%.
|
||||
|
||||
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).
|
||||
|
||||
These changes apply after you launch the new EXE and start a run. An installation already running in an older EXE keeps its existing behavior; downloading a replacement cannot change that ongoing process.
|
||||
|
||||
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Validation and music preparation run in the background. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
|
||||
|
||||
### Supported Xbox ISO revisions
|
||||
|
||||
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 is validated with this installer.** Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
|
||||
|
||||
| Map-header build | Upstream region |
|
||||
| --- | --- |
|
||||
| `01.10.12.2276` | NTSC |
|
||||
| `01.08.15.1749` | NTSC |
|
||||
| `01.01.14.2342` | PAL |
|
||||
|
||||
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
|
||||
|
||||
### Already have Halo installed?
|
||||
|
||||
Choose **Repair** in the **Game data** step, connect your Frame, and click **Repair Halo VR** in the last step. Setup detects the standard native folder `~/Games/HaloCENativeVR` and reinstalls the native executable, SDL library, controls/tutorial patch, and notices; it restores the required VR/control settings while preserving saves and unrelated settings. Valid original Xbox maps already on the Frame can be reused without selecting another ISO. If maps are missing or damaged, select a valid local image/maps folder and retry. Previous installs made by this installer or the original guided native setup are recognized. A hand-installed native game in the same folder can be adopted only through the explicit Repair action, after its native executable and original Xbox maps validate. Unknown folders and other install locations are not overwritten.
|
||||
|
||||
The old PC installation at `~/Games/HaloCEVR` is detected and explained separately. Its PC maps cannot be used for this native Xbox build. Setup installs the native version separately and does not automatically remove the PC version. Close the native game before repair. Program-file backups are retained in the owned build workspace, and a failed repair rolls back the affected files. **Add to Steam again** repairs library registration and adds missing library artwork; it does not rebuild the game.
|
||||
|
||||
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select `~/Games/HaloCENativeVR/halo`, apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
|
||||
|
||||
### Automatic Steam library information
|
||||
|
||||
Install and Repair automatically update the native Steam shortcut, missing artwork, and **Halo CE VR — About & controls** note after the game is ready. There is no separate Steam info option to select. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects. If registration, artwork, or Notes remains pending at the end, **Add to Steam again** retries that step without transferring maps or rebuilding the game.
|
||||
|
||||
Open **Notes** on the Halo game's Steam details page or in its overlay to read the description and control guide. These are private game notes using Steam's normal notes synchronization. They do not replace the non-Steam About panel or add a store page, achievements, reviews, or another game's Steam identity. Existing notes and edited installer notes remain unchanged. If Notes is unavailable or synchronization cannot be verified, setup shows copyable text for manual use. Missing artwork or an icon awaiting Steam's shortcut-file persistence is reported separately and can be retried with **Add to Steam again**. Notes requires a running, verified Steam client; setup leaves a closed Steam client closed.
|
||||
|
||||
### 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 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
|
||||
|
||||
| Frame control | Action |
|
||||
| --- | --- |
|
||||
| A | Jump / accept |
|
||||
| B | Melee / back |
|
||||
| X | Reload / use |
|
||||
| Y | Change weapon |
|
||||
| Right trigger / left trigger | Fire / throw grenade |
|
||||
| Left stick / click | Move / crouch |
|
||||
| Right stick / click | Turn / zoom |
|
||||
| Left bumper / right bumper | Change grenade / flashlight |
|
||||
| Menu / View | Pause / scoreboard |
|
||||
| Both grips held | Recenter |
|
||||
|
||||
Motion aiming stays enabled. Smooth turning defaults to 90 degrees/second, refresh rate to 72 Hz, and render scale to 1.0. Repairs refresh the VR/control defaults and keep unrelated settings and saves. Head movement also satisfies the first-level look tutorial. The patch does not skip the campaign or increase combat difficulty.
|
||||
|
||||
The native shortcut uses this **per-game** launch option to prevent the same physical controller from being read through both OpenXR and Steam's virtual gamepad:
|
||||
|
||||
```text
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%
|
||||
```
|
||||
|
||||
See [controls and patch details](CONTROLS.md).
|
||||
|
||||
## What gets installed
|
||||
|
||||
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New-install saves are in that game's separate `save` folder; repairs preserve existing save locations. Build workspace: `~/.cache/halo-frame-installer`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup does not remove another Halo installation or touch unrelated games.
|
||||
|
||||
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the project-drawn Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is reported as pending rather than replacing an uncertain user choice.
|
||||
|
||||
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; Install, Repair, and any completion retry use the same handling. No artwork download is needed. The new bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](../resources/artwork/README.md). The user confirmed the older entry and covers on the Frame tested with 1.2.3; the newer banner, logo, icon, and Notes still need a physical Frame check.
|
||||
|
||||
The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenXR VR and competitive multiplayer come from the port. The PC-only Chimera, PC Restored maps, and HaloCEVR DLLs do not belong in this native build. Multiplayer requires compatible native/OpenCE protocol builds; Halo PC/Custom Edition/MCC servers and PC save files are incompatible.
|
||||
|
||||
## Sources and credits
|
||||
|
||||
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
|
||||
- [Exact VR source commit `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106), with a documented local controller/tutorial patch.
|
||||
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr).
|
||||
- [Valve's official Frame setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) and [SSH/debugging instructions](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
|
||||
- [Google's ADB connection and forwarding reference](https://developer.android.com/tools/adb) and [official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools).
|
||||
- [Original Xbox dashboard visual references](SOURCES.md#dashboard-visual-references) and [Halo CE artwork attribution and Steam Notes references](SOURCES.md#steam-library-artwork).
|
||||
|
||||
Full source attribution, decompilation lineage, multiplayer references, and third-party licenses are in [SOURCES.md](SOURCES.md) and [THIRD_PARTY.md](THIRD_PARTY.md). Original installer code is MIT licensed. Upstream notices apply separately; no license here grants rights to commercial game data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Cannot resolve `frame`:** for Wi-Fi / Ethernet, use the Frame's IP address from its network settings. Both devices must be on a network that permits connections between clients. USB-C transfer does not need the hostname/IP.
|
||||
- **USB device missing or offline:** verify Developer Mode, keep the Frame awake, use a data-capable cable and a direct PC USB port, and reconnect it. A charging indicator alone does not confirm data transfer. Approve device authorization on the headset if prompted. Setup does not reset shared ADB services or change device settings automatically.
|
||||
- **Several USB devices detected:** unplug other physical USB devices or enter the Frame's exact ADB serial in **Advanced connection settings**. Setup refuses to guess which device to use. If that serial's physical USB path cannot be verified, leave it blank with only the Frame attached and retry automatic detection.
|
||||
- **ADB unavailable:** the release EXE includes ADB. If its files cannot be used, extract [Google's official Windows Platform Tools package](https://developer.android.com/tools/releases/platform-tools), keeping `adb.exe` and its companion DLLs together, then choose it with **Browse adb.exe**. Wi-Fi / Ethernet remains available without ADB.
|
||||
- **ADB server version differs:** setup leaves that server running. Choose the matching `adb.exe` under **Advanced connection settings**, or close the other application's USB tools normally and retry. Network transfer remains available.
|
||||
- **USB forwarding has network access enabled:** setup refuses to send the SSH password over a forward listening beyond loopback. Close the other application's USB tools normally and retry, or choose network transfer. Setup does not reset the shared server automatically.
|
||||
- **USB SSH connection refused:** enable Developer Mode and confirm the SSH port in **Advanced connection settings**. USB still needs the Frame's SSH service and user password; ADB authorization alone does not authenticate SSH. The USB path forwards the Frame's default SSH port 22.
|
||||
- **Connection refused:** enable Developer Mode and verify the address and SSH port. [Valve guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
|
||||
- **Authentication fails:** enter the password set in the Frame's Developer settings; your Steam account password is unrelated.
|
||||
- **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.
|
||||
- **Library entry absent:** click **Add to Steam again** to retry registration through the running client. If its library interface remains unavailable, use Steam's **Add a Non-Steam Game** with the executable and launch option above. Enable **Include in VR Library** and leave forced compatibility tools disabled in its properties. Setup's direct library-file edits require Steam to be already closed.
|
||||
- **Artwork or description absent:** Install and Repair add these automatically. If setup reports pending Steam information, click **Add to Steam again** after the run. The description is under **Notes**, not the non-Steam About panel. Pending artwork, icon, or Notes is reported separately from the shortcut; manual Notes includes copyable text. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
|
||||
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](SOURCES.md#steam-session-safeguard).
|
||||
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
|
||||
|
||||
The release executable is unsigned. Verify its SHA256 against the checksum in the release description; this project does not ask you to disable antivirus or other security protections. Review logs for private information before posting an issue.
|
||||
|
||||
## Develop and rebuild the executable
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\python -m pip install -e ".[dev]"
|
||||
.\.venv\Scripts\python -m pytest
|
||||
.\.venv\Scripts\python -m halo_frame_installer
|
||||
.\.venv\Scripts\python scripts/build_release.py
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Before reporting a hardware test, record the SteamOS/SteamVR version, source commit, install status, and whether menu, campaign, Xbox controls, tutorial, and compatible multiplayer worked. Do not attach game data or credentials.
|
||||
+15
-6
@@ -1,9 +1,15 @@
|
||||
# Sources and acknowledgements
|
||||
# VR mod sources and acknowledgements
|
||||
|
||||
The installer builds the experimental native ARM64 Linux VR port of **Halo: Combat Evolved** from the revision below. It provides a guided installation; the decompilation, renderer, OpenXR implementation and networking are the work of the credited upstream contributors.
|
||||
This community VR mod installer builds the experimental native ARM64 Linux VR port of **Halo: Combat Evolved** from the revision below. It provides a guided installation; the decompilation, renderer, OpenXR implementation and networking are the work of the credited upstream contributors. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded; users provide authorized original Xbox game data.
|
||||
|
||||
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 |
|
||||
@@ -95,6 +101,13 @@ These links credit visual and historical references. Dashboard styling is an ind
|
||||
|
||||
## Steam library artwork
|
||||
|
||||
The Windows installer uses the classic Halo PC helmet-and-shoulders icon in
|
||||
1.3.2. [HaloNet's favicon ICO](https://halonet.net/favicon.ico) is byte-identical
|
||||
to icon group 102 of the locally installed retail Halo PC executable. Only the
|
||||
icon is bundled; its [resource notice](../resources/ui/README.md) records the
|
||||
verification and exact hashes. The README displays only the banner and title
|
||||
logo, with the installer icon separate from Steam's retained project drawing.
|
||||
|
||||
The bundled portrait image is the original **Halo: Combat Evolved** cover from [Halopedia's cover-art file page](https://www.halopedia.org/File:HCE_Cover_Art.jpg), with the [original JPEG](https://www.halopedia.org/images/8/8e/HCE_Cover_Art.jpg). Halopedia records the [official Halo Facebook image](https://www.facebook.com/Halo/photos/a.137195553028391/1561119617302637/) as its source. The landscape library image arranges the same cover in a wider layout without stretching it.
|
||||
|
||||
The cover remains copyrighted Microsoft/Halo artwork; the installer's MIT license does not apply to it. Source attribution and exact hashes are retained in the [artwork resource notice](../resources/artwork/README.md) and described in [THIRD_PARTY.md](THIRD_PARTY.md#dashboard-and-library-artwork). A thumbnail appears inside the installer. The library images are applied only to its native Halo shortcut, preserving user-selected custom art. The user confirmed the entry and covers looked correct on the Frame tested with 1.2.3.
|
||||
@@ -114,7 +127,3 @@ The helper matches the current account and exact native executable before writin
|
||||
The preview reads only the `sound\music\title1\loops` sound tag from the user's selected original Xbox `ui.map`, either inside an ISO/XISO or in extracted maps. Cache and sound structures are based on the pinned native port's [cache_files.c](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c) and [sound_definitions.h](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/sound/sound_definitions.h). Xbox ADPCM decoding follows its [dsound_sdl.c implementation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/src/dsound_sdl.c).
|
||||
|
||||
The installer decodes the loop into a private temporary Windows WAV with a fixed 12% sample-amplitude gain. Playback loops automatically after local game data validates. Closing the installer stops playback and clears its temporary audio. No soundtrack is present in the repository or executable, fetched from a website, or uploaded over SSH. Audio-file extensions are rejected by the release packager. Remote-only repair has no local audio preview, and an unavailable preview does not block installation. The original music remains copyrighted game content; the installer's code license does not apply to it. Playback still needs a listening check.
|
||||
|
||||
## General preservation resources
|
||||
|
||||
User-suggested background links: [Vimm's Lair](https://vimm.net/) and [Internet Archive's about page](https://archive.org/about/). These are general references, not Halo file-download links, software dependencies, or assurances of game-data permissions. The installer selects a local original Xbox image/maps supplied by the user; it does not fetch commercial game files. The PC edition is not an input to the native Frame build.
|
||||
+10
-6
@@ -1,10 +1,12 @@
|
||||
# Third-party software notices
|
||||
# VR mod and third-party notices
|
||||
|
||||
The installer code has its own [MIT license](../LICENSE). Each dependency keeps its own license. Original notice text is included under [resources/licenses](../resources/licenses); [manifest.json](../resources/licenses/manifest.json) records package versions, notice hashes and source URLs from the environment used to build the Windows executable.
|
||||
This community VR mod installer's original code has its own [MIT license](../LICENSE). Each dependency keeps its own license. Original notice text is included under [resources/licenses](../resources/licenses); [manifest.json](../resources/licenses/manifest.json) records package versions, notice hashes and source URLs from the environment used to build the Windows executable.
|
||||
|
||||
The repository and executable include no retail game executable, disc image, maps, product keys, or soundtrack, and the installer does not download these files. Users supply authorized original Xbox game data. Copyrighted Halo artwork and trademarks remain separate from the installer code's license, as detailed below. Upstream source licenses are reported as published by their maintainers; this project does not independently certify the source's legal status. This is an unaffiliated community project.
|
||||
|
||||
The manifest includes build and test dependencies as well as runtime dependencies, so an entry does not imply that every file of that package is embedded in the executable. It records no machine paths, passwords, private SSH keys or game data.
|
||||
|
||||
## Windows installer
|
||||
## Windows mod installer
|
||||
|
||||
| Component | Initial build version | License and use |
|
||||
| --- | --- | --- |
|
||||
@@ -37,7 +39,7 @@ ADB binaries are ignored by Git and regenerated by [bundle_usb_tools.py](../scri
|
||||
|
||||
Paramiko remains unmodified by this installer. Its complete official 4.0.0 source distribution is supplied alongside its LGPL notice. The included source archive is verified against the SHA-256 published in [PyPI's version metadata](https://pypi.org/pypi/paramiko/4.0.0/json); its digest and source URL are recorded in the manifest.
|
||||
|
||||
You may modify Paramiko and rebuild the installer with that modified library. This project imposes no restriction on reverse engineering for debugging such library modifications. Obtain the installer source for the matching release and follow the [source build instructions](../README.md). After installing the build dependencies, replace Paramiko in that environment with your modified source before the executable-freezing step, for example:
|
||||
You may modify Paramiko and rebuild the installer with that modified library. This project imposes no restriction on reverse engineering for debugging such library modifications. Obtain the installer source for the matching release and follow the [source build instructions](SETUP.md#develop-and-rebuild-the-executable). After installing the build dependencies, replace Paramiko in that environment with your modified source before the executable-freezing step, for example:
|
||||
|
||||
```powershell
|
||||
python -m pip install --no-deps --force-reinstall C:\path\to\modified-paramiko
|
||||
@@ -47,7 +49,7 @@ Then rebuild using the provided packaging script. The installer source, resource
|
||||
|
||||
## Native game built on the Frame
|
||||
|
||||
The Windows download contains the installer and build instructions, not the commercial game's maps or disc image. The native program is built from pinned upstream source on the Frame. Its upstream license and component notices remain applicable to that build.
|
||||
The Windows download contains the mod installer and build instructions; no retail game executable, disc image, maps, product keys, or soundtrack is included. The native program is built from pinned upstream source on the Frame. Its upstream license and component notices remain applicable to that build, separately from rights in retail game content.
|
||||
|
||||
| Upstream component | Notice/reference |
|
||||
| --- | --- |
|
||||
@@ -66,7 +68,9 @@ The rootless build environment installs [Ubuntu packages](https://ubuntu.com/leg
|
||||
|
||||
The dashboard's circular core, panels, and green menus are independently drawn project resources covered by the project's MIT license. The original Xbox dashboard screenshots and Microsoft's launch-campaign announcement are credited as visual/historical references in [SOURCES.md](SOURCES.md#dashboard-visual-references); the interface is not an official Xbox or Microsoft application.
|
||||
|
||||
The installer icon is an original project drawing of Master Chief's helmet, with olive armor and a gold visor, created in code and supplied as SVG, PNG, and multi-size ICO resources. The project's MIT license covers its drawing code. Microsoft retains the underlying rights in Halo, Master Chief, the character design, and associated trademarks; those rights are separate from the installer code's license.
|
||||
The Windows installer icon in 1.3.2 is the classic Halo PC helmet-and-shoulders application icon. The [unchanged HaloNet ICO](https://halonet.net/favicon.ico) was verified against icon group 102 of the locally installed retail `halo.exe`; only the icon was copied. Its original 12 image entries are embedded in the installer, and its 48-pixel RGBA frame supplies the Tk icon. The [icon resource notice](../resources/ui/README.md) records exact hashes and provenance. This copyrighted Halo artwork is separate from the project's MIT license.
|
||||
|
||||
The earlier original project drawing of Master Chief's helmet remains the Steam shortcut icon. Its SVG source and PNG rendition are in `resources/artwork`. The project's MIT license covers its drawing code. Microsoft retains the underlying rights in Halo, Master Chief, the character design, and associated trademarks; those rights are separate from the installer code's license.
|
||||
|
||||
The bundled Halo CE portrait cover and the landscape library layout retain copyrighted Microsoft/Halo cover artwork. The [Halopedia file page](https://www.halopedia.org/File:HCE_Cover_Art.jpg) identifies it as Halo: Combat Evolved cover art and records an [official Halo Facebook image](https://www.facebook.com/Halo/photos/a.137195553028391/1561119617302637/) as the source. This artwork is separate from the installer code's MIT license and is not represented as public domain or freely licensed. Attribution does not transfer rights in the cover or the Halo/Xbox trademarks.
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "halo-steam-frame-installer"
|
||||
version = "1.3.1"
|
||||
description = "Guided USB and network SSH installer for the native ARM64 Halo CE Steam Frame VR port"
|
||||
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"
|
||||
dependencies = ["paramiko==4.0.0"]
|
||||
|
||||
@@ -68,10 +68,10 @@ This record identifies the copyrighted/trademarked CE logo but does not document
|
||||
an upstream publisher URL. Do not describe this rendition as a newly licensed
|
||||
asset or the project's own original drawing.
|
||||
|
||||
`halo-ce-icon.png` is the exact 256 × 256 PNG rendition of the installer's
|
||||
existing original Master Chief helmet drawing (`resources/ui/app-icon.svg` and
|
||||
`app-icon.png`). It is reused as the shortcut icon, separate from the original
|
||||
game's promotional artwork.
|
||||
`halo-ce-icon.png` is the exact 256 × 256 PNG rendition of the original project
|
||||
Master Chief helmet drawing (`resources/artwork/halo-ce-icon.svg`). It remains
|
||||
the Steam shortcut icon. Version 1.3.2 uses a separate classic Halo PC icon for
|
||||
the Windows installer, documented in [its notice](../ui/README.md).
|
||||
|
||||
Halo promotional artwork and the Halo/Xbox names and logos belong to Microsoft.
|
||||
These images identify the user's Halo game in their library. They are separate
|
||||
|
||||
File renamed without changes.
@@ -106,13 +106,13 @@
|
||||
},
|
||||
{
|
||||
"name": "halo-steam-frame-installer",
|
||||
"version": "1.3.1",
|
||||
"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.1.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
|
||||
}
|
||||
|
||||
+25
@@ -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.
|
||||
+25
@@ -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.
|
||||
+25
@@ -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.
|
||||
+25
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Classic Halo PC installer icon
|
||||
|
||||
Version 1.3.2 uses the classic Halo: Combat Evolved PC helmet-and-shoulders
|
||||
application icon requested by the user. The clean image contains no desktop
|
||||
background, shortcut arrow, or filename label.
|
||||
|
||||
`app-icon.ico` is the unchanged 29,926-byte icon downloaded from
|
||||
[HaloNet's favicon](https://halonet.net/favicon.ico) on 5 October 2026. It was
|
||||
verified byte-for-byte against icon group 102 reconstructed from the locally
|
||||
installed retail Halo PC `halo.exe`. No game executable was copied into this
|
||||
repository or the installer. The ICO contains the original 16, 24, 32, and 48
|
||||
pixel images at 4, 8, and 32-bit color: 12 entries in total.
|
||||
|
||||
`app-icon.png` is an unchanged-pixel PNG export of the ICO's 48 × 48, 32-bit
|
||||
RGBA frame for the installer's Tk window icon. It retains transparency. No
|
||||
resizing, redrawing, or generative image processing was applied. Larger Windows
|
||||
display sizes use the original available artwork; this is not an HD remake.
|
||||
|
||||
SHA256:
|
||||
|
||||
```text
|
||||
aa212eba758abaad8e7a134c4084110c9311ded77c0924865294d7ecf26f0216 app-icon.ico
|
||||
e92525343f2e88f57024a34a1eda22f4de0dfbb16c5fcc9b16c7a1ba298b1ab9 app-icon.png
|
||||
```
|
||||
|
||||
Halo artwork, Master Chief, and associated names and trademarks retain their
|
||||
rights holders' rights, including Microsoft. The icon is separate from the
|
||||
installer code's MIT license; hosting and attribution do not grant a general
|
||||
artwork redistribution license.
|
||||
|
||||
The earlier project-drawn helmet remains the Steam shortcut icon, under
|
||||
`resources/artwork/halo-ce-icon.png` with its original vector source
|
||||
`halo-ce-icon.svg`. Changing the Windows installer icon does not replace
|
||||
existing Steam library icons.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -9,8 +9,8 @@ import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
VERSION = "1.3.1"
|
||||
EXE_NAME = f"Halo-Steam-Frame-Setup-{VERSION}.exe"
|
||||
VERSION = "1.3.5"
|
||||
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
|
||||
|
||||
def allowed_source_files():
|
||||
roots = [ROOT / name for name in ("src", "resources", "docs", "scripts", "tests", ".github")]
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
@@ -55,10 +54,11 @@ def main() -> None:
|
||||
logo = source.resize((1000, 431), Image.Resampling.LANCZOS)
|
||||
logo.save(destination / "halo-ce-logo.png", optimize=True)
|
||||
|
||||
# Reuse the installer's existing original vector-derived helmet drawing.
|
||||
# Keeping the exact rendition also keeps its attribution and checksums clear.
|
||||
shutil.copyfile(repo / "resources" / "ui" / "app-icon.png",
|
||||
destination / "halo-ce-icon.png")
|
||||
# The library's original project drawing is independent of the Windows
|
||||
# installer icon. Retain the checked-in rendition instead of copying a
|
||||
# replacement installer icon into existing Steam library artwork.
|
||||
if sha256(destination / "halo-ce-icon.png") != "184620356407e42a33877528c4f14b702df8b026415a99ed16976565fcfbce36":
|
||||
raise ValueError("The existing project-drawn Steam shortcut icon differs")
|
||||
for name in ("halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
|
||||
path = destination / name
|
||||
with Image.open(path) as image:
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
|
||||
__version__ = "1.3.1"
|
||||
__version__ = "1.3.5"
|
||||
@@ -119,6 +119,7 @@ def _cache_header(header: bytes, name: str, actual_size: int) -> str:
|
||||
|
||||
class _XboxImage:
|
||||
def __init__(self, path: Path, cancel_event=None):
|
||||
_cancel(cancel_event)
|
||||
_regular_file(path)
|
||||
self.path = path
|
||||
self.image: BinaryIO = path.open("rb")
|
||||
@@ -161,6 +162,7 @@ class _XboxImage:
|
||||
ranges.append((offset, end))
|
||||
self.image.seek(offset)
|
||||
builds.add(_cache_header(self.image.read(SECTOR_SIZE), entry.name, entry.size))
|
||||
_cancel(cancel_event)
|
||||
self.info = _info(builds, sum(entry.size for entry in self.maps), self.partition)
|
||||
except BaseException:
|
||||
self.image.close()
|
||||
@@ -170,6 +172,7 @@ class _XboxImage:
|
||||
self.image.close()
|
||||
|
||||
def entries(self, sector: int, length: int) -> list[_Entry]:
|
||||
_cancel(self.cancel_event)
|
||||
if not 0 < length <= MAX_DIRECTORY_BYTES:
|
||||
raise AssetError("Invalid Xbox directory length.")
|
||||
offset = self.partition + sector * SECTOR_SIZE
|
||||
@@ -224,15 +227,19 @@ def _info(builds: set[str], total: int, partition: int | None) -> ImageInfo:
|
||||
return ImageInfo("original-xbox", next(iter(builds)), total, len(REQUIRED_MAPS), partition)
|
||||
|
||||
|
||||
def inspect_image(path: str | Path) -> ImageInfo:
|
||||
image = _XboxImage(Path(path))
|
||||
def inspect_image(path: str | Path, cancel_event=None) -> ImageInfo:
|
||||
"""Inspect image metadata, checking cancellation throughout validation."""
|
||||
_cancel(cancel_event)
|
||||
image = _XboxImage(Path(path), cancel_event)
|
||||
try:
|
||||
_cancel(cancel_event)
|
||||
return image.info
|
||||
finally:
|
||||
image.close()
|
||||
|
||||
|
||||
def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
def _maps_source(path: Path, cancel_event=None) -> tuple[Path, list[Path], ImageInfo]:
|
||||
_cancel(cancel_event)
|
||||
if not path.is_dir() or _is_link(path):
|
||||
raise AssetError("Select a real maps directory, not a link.")
|
||||
candidate = path / "maps"
|
||||
@@ -242,6 +249,7 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
path = candidate
|
||||
names: dict[str, Path] = {}
|
||||
for count, entry in enumerate(path.iterdir(), 1):
|
||||
_cancel(cancel_event)
|
||||
if count > MAX_DIRECTORY_ENTRIES:
|
||||
raise AssetError("The maps directory has too many entries.")
|
||||
_safe_name(entry.name)
|
||||
@@ -251,22 +259,27 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
if folded in names:
|
||||
raise AssetError("Case-insensitive duplicate filenames in the maps directory.")
|
||||
names[folded] = entry
|
||||
_cancel(cancel_event)
|
||||
missing = set(REQUIRED_MAPS) - names.keys()
|
||||
if missing:
|
||||
raise AssetError("Incomplete maps directory: missing " + ", ".join(sorted(missing)))
|
||||
files = [names[name] for name in REQUIRED_MAPS]
|
||||
builds, total = set(), 0
|
||||
for file in files:
|
||||
_cancel(cancel_event)
|
||||
_regular_file(file)
|
||||
with file.open("rb") as source:
|
||||
size = os.fstat(source.fileno()).st_size
|
||||
builds.add(_cache_header(source.read(SECTOR_SIZE), file.name, size))
|
||||
total += size
|
||||
_cancel(cancel_event)
|
||||
return path, files, _info(builds, total, None)
|
||||
|
||||
|
||||
def inspect_maps(path: str | Path) -> ImageInfo:
|
||||
return _maps_source(Path(path))[2]
|
||||
def inspect_maps(path: str | Path, cancel_event=None) -> ImageInfo:
|
||||
"""Inspect extracted map headers without copying, with optional cancellation."""
|
||||
_cancel(cancel_event)
|
||||
return _maps_source(Path(path), cancel_event)[2]
|
||||
|
||||
|
||||
def _new_staging(dest: Path) -> Path:
|
||||
@@ -365,7 +378,7 @@ def copy_maps(source: str | Path, dest: str | Path, progress: Progress | None =
|
||||
cancel_event=None) -> dict:
|
||||
"""Prepare an already extracted retail maps folder using the same validation."""
|
||||
_cancel(cancel_event)
|
||||
source, source_files, info = _maps_source(Path(source))
|
||||
source, source_files, info = _maps_source(Path(source), cancel_event)
|
||||
dest = Path(dest).absolute()
|
||||
stage = _new_staging(dest)
|
||||
try:
|
||||
|
||||
+131
-77
@@ -25,6 +25,9 @@ from .ui import (BG, SIDEBAR, SURFACE, TEXT, MUTED, BORDER,
|
||||
SETUP_URL = "https://partner.steamgames.com/doc/steamhardware/steamframe/setup"
|
||||
PLATFORM_TOOLS_URL = "https://developer.android.com/tools/releases/platform-tools"
|
||||
SOURCE_URL = "https://github.com/OpenCommunityEdition/OpenCE/pull/85"
|
||||
APP_TITLE = "Halo • Community Steam Frame VR Mod Installer"
|
||||
CONTENT_DISCLOSURE = "No retail game executable, ISO, maps, product keys, or soundtrack is provided."
|
||||
ARTWORK_CREDIT = "Halo artwork is included. Halo artwork and trademarks belong to Microsoft."
|
||||
XBOX_REVISION_NOTE = "Original Xbox USA Rev 2 validated; other retail revisions are checked automatically."
|
||||
GAME_CLOSE_NOTE = "Save and close running games before changing Halo files. Keep Steam Home and SteamVR running."
|
||||
STEAM_ADD_NOTE = ("In Steam, choose Add a Game → Add a Non-Steam Game and select the native halo executable. "
|
||||
@@ -50,7 +53,7 @@ class App(tk.Tk):
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
super().__init__()
|
||||
self.title("Halo • Steam Frame Setup")
|
||||
self.title(APP_TITLE)
|
||||
width = min(1040, max(900, self.winfo_screenwidth() - 100))
|
||||
height = min(720, max(620, self.winfo_screenheight() - 100))
|
||||
self.geometry(f"{width}x{height}")
|
||||
@@ -102,6 +105,8 @@ class App(tk.Tk):
|
||||
self.entries = []
|
||||
self.checks = []
|
||||
self.last_result = None
|
||||
self._asset_generation = 0
|
||||
self._inspection_cancel = None
|
||||
self._build()
|
||||
self.source.trace_add("write", lambda *args:self._source_changed())
|
||||
self.mode.trace_add("write", lambda *args:self._mode_changed())
|
||||
@@ -165,7 +170,7 @@ class App(tk.Tk):
|
||||
power_orb(icon, 85, 37, 29)
|
||||
self._label(brand, "HALO", 25, "bold", color=ACCENT,
|
||||
background=SIDEBAR).pack(anchor="w", pady=(6, 0))
|
||||
self._label(brand, "STEAM FRAME SETUP", 9, "bold", color=TEXT,
|
||||
self._label(brand, "STEAM FRAME VR MOD", 9, "bold", color=TEXT,
|
||||
background=SIDEBAR).pack(anchor="w", pady=(2, 0))
|
||||
self._label(brand, f"Version {__version__}", 9, color=MUTED, background=SIDEBAR).pack(anchor="w", pady=(9, 0))
|
||||
self._label(sidebar, "INSTALLATION", 8, "bold", MUTED, SIDEBAR).pack(anchor="w", padx=24, pady=(26, 8))
|
||||
@@ -184,7 +189,7 @@ class App(tk.Tk):
|
||||
background=SIDEBAR, subtle=True, width=170).pack(anchor="w", pady=(2, 0))
|
||||
self._label(help_area, textvariable=self.music_status, size=8, color=MUTED,
|
||||
background=SIDEBAR, wraplength=170).pack(anchor="w", padx=9, pady=(4, 10))
|
||||
self._label(help_area, "ORIGINAL XBOX · NATIVE VR", 8, color=MUTED,
|
||||
self._label(help_area, "COMMUNITY MOD INSTALLER", 8, color=MUTED,
|
||||
background=SIDEBAR).pack(anchor="w", padx=10)
|
||||
|
||||
main = tk.Frame(self, bg=BG)
|
||||
@@ -213,7 +218,7 @@ class App(tk.Tk):
|
||||
self.page_title = self._label(header, "GAME DATA", 24, "bold")
|
||||
self.page_title.pack(anchor="w", pady=(6, 4))
|
||||
self.page_subtitle = self._label(header,
|
||||
"Install, repair, or uninstall native Halo VR.", 10, color=MUTED)
|
||||
"Install, repair, or uninstall the community VR mod.", 10, color=MUTED)
|
||||
self.page_subtitle.pack(anchor="w")
|
||||
page_host = tk.Frame(main, bg=BG)
|
||||
page_host.grid(row=1, column=0, sticky="nsew", padx=32)
|
||||
@@ -308,27 +313,17 @@ class App(tk.Tk):
|
||||
mode_card = self._card(body, "SELECT OPERATION")
|
||||
choices = tk.Frame(mode_card, bg=SURFACE)
|
||||
choices.pack(fill="x")
|
||||
choices.columnconfigure((0, 1, 2, 3), weight=1, uniform="operations")
|
||||
choices.columnconfigure((0, 1, 2), weight=1, uniform="operations")
|
||||
self.mode_choices = []
|
||||
for index, title, subtitle, value in (
|
||||
(0, "Install", "New native game", "install"),
|
||||
(1, "Repair", "Keep saves", "repair"),
|
||||
(2, "Steam info", "Update library", "library"),
|
||||
(3, "Uninstall", "Remove game", "uninstall")):
|
||||
(2, "Uninstall", "Remove game", "uninstall")):
|
||||
choice = Choice(choices, title, subtitle, self.mode, value, self._mode_changed)
|
||||
choice.grid(row=0, column=index, sticky="ew",
|
||||
padx=(0, 6) if index == 0 else (6, 0) if index == 3 else (6, 6))
|
||||
padx=(0, 6) if index == 0 else (6, 0) if index == 2 else (6, 6))
|
||||
self.mode_choices.append(choice)
|
||||
|
||||
library = self._card(body, "UPDATE STEAM INFO")
|
||||
self.library_card = library.master
|
||||
self._label(library,
|
||||
"Add Halo cover, banner, logo, and icon artwork, plus a game description in Steam Notes.\n"
|
||||
"Use the native game already installed at ~/Games/HaloCENativeVR.\n"
|
||||
"No ISO or rebuild is needed. Your game, saves, and custom library content are kept.",
|
||||
10, background=SURFACE, wraplength=620).pack(anchor="w")
|
||||
self.library_card.pack_forget()
|
||||
|
||||
data_panel = self._card(body)
|
||||
self.data_panel = data_panel.master
|
||||
data_panel.columnconfigure(1, weight=1)
|
||||
@@ -372,10 +367,12 @@ class App(tk.Tk):
|
||||
consent = self._card(body)
|
||||
self.data_consent = consent.master
|
||||
self.data_authorization = self._check(consent,
|
||||
"I am authorized to use this original Xbox game data.", self.authorized)
|
||||
"I am authorized to use my original Xbox game data with this mod.", self.authorized)
|
||||
self.data_authorization.pack(anchor="w")
|
||||
self._label(consent, "Use your own game data. PC and Xbox 360 editions are not supported.",
|
||||
9, color=MUTED, background=SURFACE, wraplength=630).pack(anchor="w", pady=(2, 0))
|
||||
self.data_disclosure = self._label(consent,
|
||||
CONTENT_DISCLOSURE + "\nUse your own authorized Xbox data. Included Halo artwork: Microsoft.",
|
||||
9, color=MUTED, background=SURFACE, wraplength=630)
|
||||
self.data_disclosure.pack(anchor="w", pady=(2, 0))
|
||||
uninstall = self._card(body, "REMOVE NATIVE HALO VR")
|
||||
self.uninstall_card = uninstall.master
|
||||
self._label(uninstall,
|
||||
@@ -540,6 +537,13 @@ class App(tk.Tk):
|
||||
pass
|
||||
|
||||
def _source_changed(self):
|
||||
self._asset_generation += 1
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self._inspection_cancel = None
|
||||
if self.busy and getattr(self, "operation", None) == "data":
|
||||
self._set_busy(False)
|
||||
self.status.set("Game data selection changed. Choose an original Xbox image or maps folder to continue.")
|
||||
source = self.source.get()
|
||||
self.source_label.set(Path(source).name if source else "No game data selected")
|
||||
if not source:
|
||||
@@ -550,8 +554,7 @@ class App(tk.Tk):
|
||||
def _mode_changed(self):
|
||||
repair = self.mode.get() == "repair"
|
||||
uninstall = self.mode.get() == "uninstall"
|
||||
library = self.mode.get() == "library"
|
||||
if uninstall or library:
|
||||
if uninstall:
|
||||
self.data_panel.pack_forget()
|
||||
self.data_consent.pack_forget()
|
||||
else:
|
||||
@@ -561,17 +564,12 @@ class App(tk.Tk):
|
||||
self.uninstall_card.pack(fill="x", pady=(0, 10))
|
||||
else:
|
||||
self.uninstall_card.pack_forget()
|
||||
if library:
|
||||
self.library_card.pack(fill="x", pady=(0, 10))
|
||||
else:
|
||||
self.library_card.pack_forget()
|
||||
self.data_title.configure(text="Replacement game data (optional)" if repair else "Your original Xbox game data")
|
||||
self.data_note.configure(text="Use valid installed Xbox maps, or choose data to replace them." if repair
|
||||
else "Choose a disc image or an extracted maps folder.")
|
||||
self.connection_steam_note.configure(text="Steam Home and SteamVR stay running. Uninstall may need a manual library step." if uninstall
|
||||
else "Steam Home and SteamVR stay running. Setup will show any manual library steps.")
|
||||
self.install_note.configure(text="Keep this window open until the uninstall finishes and setup disconnects." if uninstall
|
||||
else "Keep this window open until Steam info is updated and setup disconnects. No rebuild is needed." if library
|
||||
else "Keep this window open during setup. Allow 12 GB free on the Frame.\nThe first native build can take a while.")
|
||||
self._update_summary()
|
||||
self._update_navigation()
|
||||
@@ -583,24 +581,21 @@ class App(tk.Tk):
|
||||
def _update_summary(self):
|
||||
source = self.source.get()
|
||||
uninstall = self.mode.get() == "uninstall"
|
||||
library = self.mode.get() == "library"
|
||||
self.summary_heading.set("UNINSTALL SUMMARY" if uninstall else "STEAM INFO SUMMARY" if library else "INSTALLATION SUMMARY")
|
||||
self.summary_data_heading.set("NATIVE GAME FOLDER" if uninstall or library else "GAME DATA")
|
||||
self.summary_source.set("~/Games/HaloCENativeVR" if uninstall or library else
|
||||
self.summary_heading.set("UNINSTALL SUMMARY" if uninstall else "INSTALLATION SUMMARY")
|
||||
self.summary_data_heading.set("NATIVE GAME FOLDER" if uninstall else "GAME DATA")
|
||||
self.summary_source.set("~/Games/HaloCENativeVR" if uninstall else
|
||||
Path(source).name if source else "Use valid Xbox maps already installed on the Frame")
|
||||
if self.transport.get() == "usb":
|
||||
serial = self.usb_serial.get().strip()
|
||||
self.summary_frame.set("USB-C cable" + (f" · {serial}" if serial else " · Detect connected Frame"))
|
||||
else:
|
||||
self.summary_frame.set(self.host.get().strip() or "Enter your Frame address")
|
||||
self.summary_action_heading.set("CAMPAIGN SAVES" if uninstall else "STEAM LIBRARY" if library else "XBOX CONTROLS")
|
||||
self.summary_action_heading.set("CAMPAIGN SAVES" if uninstall else "XBOX CONTROLS")
|
||||
self.summary_action.set(("Keep saves in a backup on the Frame" if self.keep_saves.get()
|
||||
else "Remove saves contained in the native game folder") if uninstall else
|
||||
"Cover, banner, logo, icon, and description in Steam Notes" if library else
|
||||
"A Jump · B Melee · X Reload / use · Y Change weapon")
|
||||
self.summary_note.set("The native game files and its Steam entry will be removed." if uninstall else
|
||||
"Only Steam library info changes. Existing custom artwork and notes are preserved." if library else
|
||||
"Motion aiming and the full Xbox-style layout are included.")
|
||||
"Motion aiming and Xbox-style controls are included. Steam artwork and game Notes are added automatically.")
|
||||
|
||||
def _show_page(self, index):
|
||||
if not 0 <= index < len(self.pages):
|
||||
@@ -611,16 +606,13 @@ class App(tk.Tk):
|
||||
self.canvas = self.pages[index].canvas
|
||||
self.step_count.set(f"SETUP / 0{index + 1} OF 03")
|
||||
uninstall = self.mode.get() == "uninstall"
|
||||
library = self.mode.get() == "library"
|
||||
titles = ("UNINSTALL HALO VR" if uninstall else "STEAM INFO" if library else "GAME DATA", "CONNECT FRAME",
|
||||
"UNINSTALL HALO VR" if uninstall else "UPDATE STEAM INFO" if library else "INSTALL HALO VR")
|
||||
titles = ("UNINSTALL HALO VR" if uninstall else "GAME DATA", "CONNECT FRAME",
|
||||
"UNINSTALL HALO VR" if uninstall else "INSTALL HALO VR")
|
||||
subtitles = ("Choose your save preference before removing the native game." if uninstall else
|
||||
"Update library art and game notes for your installed native game." if library else
|
||||
"Install, repair, update Steam info, or uninstall native Halo VR.",
|
||||
"Install, repair, or uninstall the community VR mod.",
|
||||
"Prepare SSH, then connect to your Steam Frame.",
|
||||
"Review the native game and saves before removing them." if uninstall else
|
||||
"Refresh the library page without reinstalling the game." if library else
|
||||
"Native VR. Xbox controls. Your Steam library.")
|
||||
"Community VR mod. Xbox controls. Your Steam library.")
|
||||
self.page_title.configure(text=titles[index])
|
||||
self.page_subtitle.configure(text=subtitles[index])
|
||||
self._update_summary()
|
||||
@@ -643,7 +635,7 @@ class App(tk.Tk):
|
||||
return
|
||||
if self.current_page == 0:
|
||||
repair = self.mode.get() == "repair"
|
||||
if self.mode.get() not in ("uninstall", "library") and (not self.authorized.get() or (not repair and not self.source.get())):
|
||||
if self.mode.get() != "uninstall" and (not self.authorized.get() or (not repair and not self.source.get())):
|
||||
messagebox.showinfo("Choose game data",
|
||||
"Confirm that you are authorized to use the installed Xbox game data." if repair else
|
||||
"Choose your original Xbox Halo CE image or maps, and confirm you are authorized to use them.", parent=self)
|
||||
@@ -660,14 +652,11 @@ class App(tk.Tk):
|
||||
messagebox.showinfo("Close games first", GAME_CLOSE_NOTE, parent=self)
|
||||
return
|
||||
self.status.set("Setup removes the native game and its Steam entry, with your selected save preference." if self.mode.get() == "uninstall" else
|
||||
"Setup updates Halo's Steam library art and game notes without rebuilding." if self.mode.get() == "library" else
|
||||
"Setup builds the native game, applies Xbox controls, and adds Halo to Steam.")
|
||||
"Setup builds the native game, applies Xbox controls, and automatically adds Steam artwork and game Notes.")
|
||||
self._show_page(2)
|
||||
else:
|
||||
if self.mode.get() == "uninstall":
|
||||
self._uninstall()
|
||||
elif self.mode.get() == "library":
|
||||
self._retry_steam()
|
||||
else:
|
||||
self._install(repair=self.mode.get() == "repair")
|
||||
|
||||
@@ -679,7 +668,6 @@ class App(tk.Tk):
|
||||
nav.configure(state="disabled" if self.busy or index > self.max_page else "normal")
|
||||
self.back_button.configure(state="disabled" if self.busy or self.current_page == 0 else "normal")
|
||||
label = ("Continue" if self.current_page < 2 else "Uninstall Halo VR" if self.mode.get() == "uninstall"
|
||||
else "Update Steam info" if self.mode.get() == "library"
|
||||
else "Repair Halo VR" if self.mode.get() == "repair" else "Install Halo VR")
|
||||
self.next_button.configure(text=label, state="disabled" if self.busy else "normal")
|
||||
if self.busy:
|
||||
@@ -749,6 +737,8 @@ class App(tk.Tk):
|
||||
self.status.set("Finishing removal; please wait until setup disconnects.")
|
||||
return
|
||||
self.cancel.set()
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self.phase.set("Cancelling setup…")
|
||||
self.status.set("Keep this window open until setup stops. Check activity for the uninstall result." if getattr(self, "operation", None) == "uninstall"
|
||||
else "Keep this window open until setup stops. Existing saves will be kept.")
|
||||
@@ -823,7 +813,12 @@ class App(tk.Tk):
|
||||
9, color=MUTED, wraplength=530).pack(anchor="w", pady=(0, 8))
|
||||
|
||||
def _show_help(self):
|
||||
_, body = self._dialog("Help & sources", "Setup requirements and project references.")
|
||||
_, body = self._dialog("Help & sources", "Community VR mod installer requirements and sources.")
|
||||
disclosure = self._card(body, "COMMUNITY MOD INSTALLER")
|
||||
self._label(disclosure,
|
||||
"This installer builds the pinned Steam Frame VR port from public source using your authorized Xbox data.\n"
|
||||
+ CONTENT_DISCLOSURE + "\n" + ARTWORK_CREDIT + " Full credits are in the project documentation.",
|
||||
10, background=SURFACE, wraplength=530).pack(anchor="w")
|
||||
card = self._card(body, "BEFORE YOU START")
|
||||
self._label(card,
|
||||
"• Use original Xbox Halo CE data you are authorized to use.\n"
|
||||
@@ -838,7 +833,7 @@ class App(tk.Tk):
|
||||
"• Save and close games. Steam Home and SteamVR stay running.\n"
|
||||
"• Install SteamVR and allow at least 12 GB free on your Frame.\n"
|
||||
"• Repair refreshes the program and controls while preserving saves.\n"
|
||||
"• Steam info updates artwork and a Steam Notes description without an ISO or rebuild.\n"
|
||||
"• Install and repair automatically add Steam artwork and a game description in Steam Notes.\n"
|
||||
"• Online play uses the native port's own multiplayer protocol.",
|
||||
10, background=SURFACE, wraplength=530).pack(anchor="w")
|
||||
links = self._card(body, "Project references")
|
||||
@@ -849,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):
|
||||
@@ -877,21 +872,51 @@ class App(tk.Tk):
|
||||
self.adb_path.set(selected)
|
||||
|
||||
def _inspect(self, selected):
|
||||
if self.busy:
|
||||
if self.busy and getattr(self, "operation", None) != "data":
|
||||
return
|
||||
selected = Path(selected)
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self._asset_generation += 1
|
||||
generation = self._asset_generation
|
||||
cancel = self._inspection_cancel = threading.Event()
|
||||
self.operation = "data"
|
||||
self.asset_status.set("Checking original Xbox game data…")
|
||||
self.status.set("Checking the selected Xbox game data…")
|
||||
self.music.load(None)
|
||||
self._set_busy(True)
|
||||
def worker():
|
||||
summary = error = None
|
||||
try:
|
||||
info = inspect_maps(selected) if selected.is_dir() else inspect_image(selected)
|
||||
self.events.put(("asset", str(selected), f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"))
|
||||
info = (inspect_maps(selected, cancel_event=cancel) if selected.is_dir()
|
||||
else inspect_image(selected, cancel_event=cancel))
|
||||
summary = f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"
|
||||
except Exception as exc:
|
||||
self.events.put(("error", str(exc)))
|
||||
error = str(exc)
|
||||
finally:
|
||||
self.events.put(("idle",))
|
||||
# A single generation-bound result cannot clear the busy state
|
||||
# or report an error for a different, newer selection.
|
||||
self.events.put(("asset", generation, cancel, str(selected), summary, error))
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _finish_asset_check(self, generation, cancel, selected, summary, error):
|
||||
if generation != self._asset_generation or cancel is not self._inspection_cancel:
|
||||
return
|
||||
self._inspection_cancel = None
|
||||
self._set_busy(False)
|
||||
if cancel.is_set() or self.cancel.is_set():
|
||||
self.asset_status.set("Game data check cancelled. Choose an original Xbox image or maps folder to retry.")
|
||||
self.status.set("Game data check cancelled. Your previous selection was kept.")
|
||||
self.phase.set("Game data check cancelled")
|
||||
return
|
||||
if error is not None:
|
||||
self._show_setup_error(error)
|
||||
return
|
||||
self.source.set(selected)
|
||||
self.asset_status.set(summary)
|
||||
self.music.load(Path(selected))
|
||||
self.status.set("Game data validated. Connect your Frame next.")
|
||||
|
||||
def _settings(self):
|
||||
usb = self.transport.get() == "usb"
|
||||
if usb:
|
||||
@@ -932,7 +957,12 @@ class App(tk.Tk):
|
||||
choice.configure(state="disabled" if value else "normal")
|
||||
for choice in self.transport_choices:
|
||||
choice.configure(state="disabled" if value else "normal")
|
||||
allow_steam_retry = self.last_result is not None and self.mode.get() != "uninstall"
|
||||
steam = self.last_result.steam if self.last_result is not None else {}
|
||||
pending_steam = (bool(self.last_result and self.last_result.requires_manual_steam_step)
|
||||
or steam.get("artwork", {}).get("status") == "manual"
|
||||
or steam.get("artwork", {}).get("icon", {}).get("status") == "manual"
|
||||
or steam.get("notes", {}).get("status") == "manual")
|
||||
allow_steam_retry = self.last_result is not None and pending_steam and self.mode.get() != "uninstall"
|
||||
self.retry_button.configure(state="normal" if not value and allow_steam_retry else "disabled")
|
||||
if allow_steam_retry:
|
||||
self.retry_button.pack(anchor="w", pady=(11, 0))
|
||||
@@ -1032,7 +1062,8 @@ class App(tk.Tk):
|
||||
result = run(settings, None, report, self.cancel)
|
||||
self.events.put(("complete", result))
|
||||
return
|
||||
info = inspect_maps(source) if source.is_dir() else inspect_image(source)
|
||||
info = (inspect_maps(source, cancel_event=self.cancel) if source.is_dir()
|
||||
else inspect_image(source, cancel_event=self.cancel))
|
||||
if shutil.disk_usage(tempfile.gettempdir()).free < info.estimated_bytes + 512 * 1024**2:
|
||||
raise ValueError("Not enough free temporary space on this computer to prepare the maps.")
|
||||
with tempfile.TemporaryDirectory(prefix="halo-frame-") as temporary:
|
||||
@@ -1143,9 +1174,7 @@ class App(tk.Tk):
|
||||
event = self.events.get_nowait()
|
||||
kind = event[0]
|
||||
if kind == "asset":
|
||||
self.source.set(event[1]); self.asset_status.set(event[2])
|
||||
self.music.load(Path(event[1]))
|
||||
self.status.set("Game data validated. Connect your Frame next.")
|
||||
self._finish_asset_check(*event[1:])
|
||||
elif kind == "music":
|
||||
self.music_status.set(self._redact(event[1]))
|
||||
elif kind == "fingerprint":
|
||||
@@ -1183,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
|
||||
@@ -1210,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", "")
|
||||
@@ -1225,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)
|
||||
@@ -1260,18 +1308,7 @@ class App(tk.Tk):
|
||||
self.retry_button.pack_forget()
|
||||
messagebox.showinfo("Uninstall needs attention" if pending else "Uninstall complete", done, parent=self)
|
||||
elif kind == "error":
|
||||
text = self._redact(event[1])
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
if getattr(self, "operation", None) == "connection":
|
||||
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
|
||||
elif getattr(self, "operation", None) == "data":
|
||||
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
|
||||
self._append(text)
|
||||
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
|
||||
if len(summary) > 500 or "\n" in text:
|
||||
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
|
||||
messagebox.showerror("Setup needs attention", summary, parent=self)
|
||||
self._show_setup_error(event[1])
|
||||
elif kind == "clear_password":
|
||||
self.password.set(""); self.password_to_redact = ""
|
||||
elif kind == "idle":
|
||||
@@ -1284,6 +1321,20 @@ class App(tk.Tk):
|
||||
pass
|
||||
self.after(10 if not self.events.empty() else 80, self._drain)
|
||||
|
||||
def _show_setup_error(self, text):
|
||||
text = self._redact(text)
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
if getattr(self, "operation", None) == "connection":
|
||||
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
|
||||
elif getattr(self, "operation", None) == "data":
|
||||
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
|
||||
self._append(text)
|
||||
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
|
||||
if len(summary) > 500 or "\n" in text:
|
||||
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
|
||||
messagebox.showerror("Setup needs attention", summary, parent=self)
|
||||
|
||||
def _remember(self, host, port, value):
|
||||
identity = host if port is None or host.startswith("usb:") else f"{host}:{port}"
|
||||
self.fingerprints[identity] = value
|
||||
@@ -1316,6 +1367,9 @@ class App(tk.Tk):
|
||||
self.destroy()
|
||||
|
||||
def destroy(self):
|
||||
self._asset_generation += 1
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
music = getattr(self, "music", None)
|
||||
if music is not None:
|
||||
music.close()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,19 @@ STEP = (7,8,9,10,11,12,13,14,16,17,19,21,23,25,28,31,34,37,41,45,
|
||||
INDEX = (-1,-1,-1,-1,2,4,6,8,-1,-1,-1,-1,2,4,6,8)
|
||||
|
||||
|
||||
# The Xbox predictor/index transition depends only on these 89 x 16 states.
|
||||
# Calculate it once instead of doing shifts and clamps for every audio sample.
|
||||
def _transition(index, nibble):
|
||||
step = STEP[index]
|
||||
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
|
||||
return (-difference if nibble & 8 else difference), max(0, min(88, index+INDEX[nibble]))
|
||||
|
||||
|
||||
TRANSITIONS = tuple(tuple(_transition(index, nibble) for nibble in range(16)) for index in range(89))
|
||||
DECODE_BATCH_BLOCKS = 64
|
||||
DECODE_YIELD_SECONDS = 0.001
|
||||
|
||||
|
||||
class MusicError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -182,24 +195,33 @@ def decode_adpcm(source: bytes, channels: int, cancel_event=None, gain=QUIET_GAI
|
||||
block_size = 36*channels
|
||||
samples = array('h', [0]) * (len(source)//block_size*64*channels)
|
||||
for block in range(len(source)//block_size):
|
||||
if block % 128 == 0:
|
||||
if block % DECODE_BATCH_BLOCKS == 0:
|
||||
_cancel(cancel_event)
|
||||
if block:
|
||||
# A background Python thread still competes with Tk for the GIL.
|
||||
# Give the window a regular scheduling opportunity while decoding
|
||||
# long tracks, and promptly abandon a superseded ISO selection.
|
||||
time.sleep(DECODE_YIELD_SECONDS)
|
||||
_cancel(cancel_event)
|
||||
position = block*block_size
|
||||
for channel in range(channels):
|
||||
predictor, index, _ = _fields(source, position+channel*4, '<hBB')
|
||||
if index > 88:
|
||||
raise MusicError('Invalid ADPCM predictor index.')
|
||||
sample_position = block*64*channels+channel
|
||||
for group in range(8):
|
||||
base = position+4*channels+(group*channels+channel)*4
|
||||
for byte in range(4):
|
||||
packed = source[base+byte]
|
||||
for half, nibble in enumerate((packed & 15, packed >> 4)):
|
||||
step = STEP[index]
|
||||
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
|
||||
predictor = max(-32768, min(32767, predictor + (-difference if nibble & 8 else difference)))
|
||||
index = max(0, min(88, index+INDEX[nibble]))
|
||||
frame = block*64+group*8+byte*2+half
|
||||
samples[frame*channels+channel] = int(predictor*gain)
|
||||
for nibble in (packed & 15, packed >> 4):
|
||||
difference, index = TRANSITIONS[index][nibble]
|
||||
predictor += difference
|
||||
if predictor < -32768:
|
||||
predictor = -32768
|
||||
elif predictor > 32767:
|
||||
predictor = 32767
|
||||
samples[sample_position] = int(predictor*gain)
|
||||
sample_position += channels
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
return samples.tobytes()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import subprocess
|
||||
|
||||
def smoke_test(resources: Path) -> dict:
|
||||
from . import __version__
|
||||
from .gui import App
|
||||
from .gui import App, APP_TITLE, CONTENT_DISCLOSURE
|
||||
from .install import SOURCE_COMMIT
|
||||
from .ssh import Settings
|
||||
names = ('remote_install.py', 'steam_shortcut.py', 'steam_live.py', 'steam_notes.py', 'build-native.sh',
|
||||
@@ -49,9 +49,11 @@ def smoke_test(resources: Path) -> dict:
|
||||
pages_initialized.append(name)
|
||||
valid = (bool(app.asset_status.get()) and app.log.winfo_reqheight() > 1
|
||||
and hasattr(app, '_game_cover') and hasattr(app, 'music'))
|
||||
if app.title() != APP_TITLE or CONTENT_DISCLOSURE not in app.data_disclosure.cget('text'):
|
||||
raise RuntimeError('Packaged community mod installer title or content disclosure is missing')
|
||||
if str(app.progress['mode']) != 'determinate':
|
||||
raise RuntimeError('Packaged progress bar is not determinate')
|
||||
if {choice.value for choice in app.mode_choices} != {'install', 'repair', 'library', 'uninstall'}:
|
||||
if {choice.value for choice in app.mode_choices} != {'install', 'repair', 'uninstall'}:
|
||||
raise RuntimeError('Packaged operation choices are incomplete')
|
||||
if {choice.value for choice in app.transport_choices} != {'network', 'usb'}:
|
||||
raise RuntimeError('Packaged connection choices are incomplete')
|
||||
@@ -72,17 +74,12 @@ def smoke_test(resources: Path) -> dict:
|
||||
raise RuntimeError('Packaged USB setup must keep Steam Home running')
|
||||
settings.password = ''
|
||||
app.password.set('')
|
||||
app.mode.set('library')
|
||||
app.source.set('')
|
||||
app.authorized.set(False)
|
||||
app._show_page(0)
|
||||
app._continue()
|
||||
if (app.current_page != 1 or app.data_panel.winfo_manager()
|
||||
or app.data_consent.winfo_manager() or not app.library_card.winfo_manager()
|
||||
or 'No ISO or rebuild' not in '\n'.join(
|
||||
str(widget.cget('text')) for widget in app.library_card.winfo_children()[0].winfo_children()
|
||||
if 'text' in widget.keys())):
|
||||
raise RuntimeError('Packaged Steam info update requires game data or lost its no-rebuild guidance')
|
||||
for mode in ('install', 'repair'):
|
||||
app.mode.set(mode)
|
||||
guidance = app.summary_note.get()
|
||||
if ('Steam artwork and game Notes are added automatically' not in guidance
|
||||
or hasattr(app, 'library_card')):
|
||||
raise RuntimeError('Packaged install/repair lost automatic Steam info setup')
|
||||
finally:
|
||||
for callback in app.tk.call('after', 'info'):
|
||||
app.after_cancel(callback)
|
||||
@@ -96,7 +93,7 @@ def smoke_test(resources: Path) -> dict:
|
||||
'automaticMusicWithoutToggle': True,
|
||||
'keepsSteamSessionRunning': True,
|
||||
'usbTransferAvailable': True,
|
||||
'steamInfoUpdateWithoutRebuild': True,
|
||||
'steamInfoAutomaticOnInstall': True,
|
||||
'steamDescriptionUsesNotes': True,
|
||||
'usbToolsBundled': usb_bundled,
|
||||
'passwordReprRedacted': True, 'networkConnections': 0}
|
||||
@@ -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)
|
||||
@@ -198,6 +198,118 @@ class AssetTests(unittest.TestCase):
|
||||
self.assertFalse(dest.exists())
|
||||
self.assertFalse(list(self.root.glob(".halo-assets-*")))
|
||||
|
||||
def test_cancelled_inspection_does_not_access_source_paths(self):
|
||||
event = threading.Event()
|
||||
event.set()
|
||||
with patch.object(assets, "_XboxImage") as image_reader, \
|
||||
patch.object(assets, "_maps_source") as maps_reader:
|
||||
for inspect in (assets.inspect_image, assets.inspect_maps):
|
||||
with self.subTest(inspect=inspect.__name__):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
inspect(self.root / "does-not-exist", cancel_event=event)
|
||||
image_reader.assert_not_called()
|
||||
maps_reader.assert_not_called()
|
||||
|
||||
def test_image_directory_inspection_cancels_and_closes_its_file(self):
|
||||
event = threading.Event()
|
||||
opened, inspected_names = [], []
|
||||
original_open, original_name = Path.open, assets._safe_name
|
||||
|
||||
def capture_open(path, *args, **kwargs):
|
||||
stream = original_open(path, *args, **kwargs)
|
||||
opened.append(stream)
|
||||
return stream
|
||||
|
||||
def cancel_after_first_name(name):
|
||||
original_name(name)
|
||||
inspected_names.append(name)
|
||||
event.set()
|
||||
|
||||
with patch.object(Path, "open", capture_open), \
|
||||
patch.object(assets, "_safe_name", cancel_after_first_name):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_image(self.image, cancel_event=event)
|
||||
self.assertEqual(inspected_names, ["maps"])
|
||||
self.assertEqual(len(opened), 1)
|
||||
self.assertTrue(opened[0].closed)
|
||||
|
||||
def test_image_header_inspection_cancels_including_after_last_header(self):
|
||||
original_header, original_open = assets._cache_header, Path.open
|
||||
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
|
||||
with self.subTest(cancel_after=cancel_after):
|
||||
event = threading.Event()
|
||||
opened, inspected = [], []
|
||||
|
||||
def capture_open(path, *args, **kwargs):
|
||||
stream = original_open(path, *args, **kwargs)
|
||||
opened.append(stream)
|
||||
return stream
|
||||
|
||||
def cancel_during_headers(header, name, size):
|
||||
value = original_header(header, name, size)
|
||||
inspected.append(name)
|
||||
if len(inspected) == cancel_after:
|
||||
event.set()
|
||||
return value
|
||||
|
||||
with patch.object(Path, "open", capture_open), \
|
||||
patch.object(assets, "_cache_header", cancel_during_headers):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_image(self.image, cancel_event=event)
|
||||
self.assertEqual(len(inspected), cancel_after)
|
||||
self.assertTrue(all(stream.closed for stream in opened))
|
||||
|
||||
def test_maps_inspection_cancels_during_directory_enumeration(self):
|
||||
maps = self.root / "synthetic-maps"
|
||||
maps.mkdir()
|
||||
for name in assets.REQUIRED_MAPS:
|
||||
(maps / name).write_bytes(cache_header())
|
||||
event = threading.Event()
|
||||
inspected_names = []
|
||||
original_name = assets._safe_name
|
||||
|
||||
def cancel_after_first_name(name):
|
||||
original_name(name)
|
||||
inspected_names.append(name)
|
||||
event.set()
|
||||
|
||||
with patch.object(assets, "_safe_name", cancel_after_first_name), \
|
||||
patch.object(assets, "_cache_header") as header_reader:
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_maps(maps, cancel_event=event)
|
||||
header_reader.assert_not_called()
|
||||
self.assertEqual(len(inspected_names), 1)
|
||||
|
||||
def test_maps_inspection_and_copy_cancel_during_source_header_validation(self):
|
||||
maps = self.root / "synthetic-maps"
|
||||
maps.mkdir()
|
||||
for name in assets.REQUIRED_MAPS:
|
||||
(maps / name).write_bytes(cache_header())
|
||||
original_header = assets._cache_header
|
||||
for operation in ("inspect", "copy"):
|
||||
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
|
||||
with self.subTest(operation=operation, cancel_after=cancel_after):
|
||||
event = threading.Event()
|
||||
inspected = []
|
||||
|
||||
def cancel_during_headers(header, name, size):
|
||||
value = original_header(header, name, size)
|
||||
inspected.append(name)
|
||||
if len(inspected) == cancel_after:
|
||||
event.set()
|
||||
return value
|
||||
|
||||
dest = self.root / "not-published"
|
||||
with patch.object(assets, "_cache_header", cancel_during_headers):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
if operation == "inspect":
|
||||
assets.inspect_maps(maps, cancel_event=event)
|
||||
else:
|
||||
assets.copy_maps(maps, dest, cancel_event=event)
|
||||
self.assertEqual(len(inspected), cancel_after)
|
||||
self.assertFalse(dest.exists())
|
||||
self.assertFalse(list(self.root.glob(".halo-assets-*")))
|
||||
|
||||
def test_write_error_removes_owned_staging_and_does_not_publish(self):
|
||||
dest = self.root / "failed"
|
||||
with patch.object(assets.os, "fsync", side_effect=OSError("synthetic disk full")):
|
||||
|
||||
+339
-32
@@ -122,6 +122,29 @@ def test_control_guide_matches_expected_layout():
|
||||
assert action in CONTROLS
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_community_mod_title_and_content_disclosure_are_visible_and_accurate(app):
|
||||
import tkinter as tk
|
||||
from halo_frame_installer.gui import APP_TITLE, CONTENT_DISCLOSURE, ARTWORK_CREDIT
|
||||
assert app.title() == APP_TITLE == 'Halo • Community Steam Frame VR Mod Installer'
|
||||
visible = '\n'.join(_display_text(app))
|
||||
assert 'STEAM FRAME VR MOD' in visible and 'COMMUNITY MOD INSTALLER' in visible
|
||||
assert CONTENT_DISCLOSURE in app.data_disclosure.cget('text')
|
||||
assert 'my original Xbox game data with this mod' in app.data_authorization.cget('text')
|
||||
app._show_help()
|
||||
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
||||
try:
|
||||
assert len(dialogs) == 1
|
||||
text = '\n'.join(_display_text(dialogs[0]))
|
||||
assert CONTENT_DISCLOSURE in text and ARTWORK_CREDIT in text
|
||||
assert 'public source using your authorized Xbox data' in text
|
||||
for claim in ('copyright-free', 'copyright free', 'unstolen', 'vimm', 'archive.org'):
|
||||
assert claim not in text.lower()
|
||||
finally:
|
||||
for dialog in dialogs:
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_long_build_failure_has_readable_dialog_and_complete_redacted_log(app):
|
||||
app.password_to_redact = 'offline-secret'
|
||||
@@ -149,7 +172,7 @@ def test_gui_offline_smoke():
|
||||
from halo_frame_installer import __version__
|
||||
assert result['version'] == __version__
|
||||
assert result['guiPagesInitialized'] == ['gameData', 'connection', 'install']
|
||||
assert result['steamInfoUpdateWithoutRebuild'] and result['steamDescriptionUsesNotes']
|
||||
assert result['steamInfoAutomaticOnInstall'] and result['steamDescriptionUsesNotes']
|
||||
assert result['networkConnections'] == 0
|
||||
|
||||
|
||||
@@ -883,7 +906,7 @@ def test_revision_guidance_names_validated_rev_2_and_checked_cache_builds(app):
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair', 'library', 'uninstall'])
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair', 'uninstall'])
|
||||
@pytest.mark.parametrize('games_closed', [False, True])
|
||||
def test_game_closed_acknowledgment_never_authorizes_steam_shutdown(app, mode, games_closed):
|
||||
app.mode.set(mode)
|
||||
@@ -940,37 +963,30 @@ def test_manual_library_fallback_uses_steam_ui_without_teardown_instructions(app
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_steam_info_navigation_requires_connection_but_no_iso_or_authorization(app):
|
||||
app.mode.set('library')
|
||||
assert app.library_card.winfo_manager()
|
||||
assert not app.data_panel.winfo_manager() and not app.data_consent.winfo_manager()
|
||||
assert not app.uninstall_card.winfo_manager()
|
||||
assert 'No ISO or rebuild' in '\n'.join(_display_text(app.library_card))
|
||||
app._continue()
|
||||
assert app.current_page == 1
|
||||
app._continue()
|
||||
assert app.current_page == 1
|
||||
app.password.set('offline-secret')
|
||||
app._continue()
|
||||
assert app.current_page == 1 # Existing game-close acknowledgement remains required.
|
||||
app.steam_closed.set(True)
|
||||
app._continue()
|
||||
assert app.current_page == 2
|
||||
assert app.primary_button.cget('text') == 'Update Steam info'
|
||||
assert app.summary_source.get() == '~/Games/HaloCENativeVR'
|
||||
assert 'Steam Notes' in app.summary_action.get()
|
||||
assert 'custom artwork and notes are preserved' in app.summary_note.get()
|
||||
app.mode.set('install')
|
||||
app._show_page(0)
|
||||
app._continue()
|
||||
assert app.current_page == 0
|
||||
assert not app.library_card.winfo_manager()
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair'])
|
||||
def test_install_and_repair_describe_automatic_steam_info_without_separate_choice(app, mode):
|
||||
import tkinter as tk
|
||||
app.mode.set(mode)
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
||||
assert not hasattr(app, 'library_card')
|
||||
assert 'Steam artwork and game Notes are added automatically' in app.summary_note.get()
|
||||
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
|
||||
app._show_page(2)
|
||||
assert app.primary_button.cget('text') == ('Install Halo VR' if mode == 'install' else 'Repair Halo VR')
|
||||
app._show_help()
|
||||
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
||||
try:
|
||||
text = '\n'.join(_display_text(dialogs[0]))
|
||||
assert 'Install and repair automatically add Steam artwork and a game description in Steam Notes' in text
|
||||
assert 'Steam info updates' not in text
|
||||
finally:
|
||||
for dialog in dialogs:
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('transport', ['network', 'usb'])
|
||||
def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
|
||||
def test_pending_steam_retry_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
|
||||
from halo_frame_installer import gui, install
|
||||
calls = []
|
||||
|
||||
@@ -990,12 +1006,15 @@ def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentia
|
||||
for name in ('inspect_image', 'inspect_maps', 'extract_image', 'copy_maps'):
|
||||
monkeypatch.setattr(gui, name, lambda *args, **kwargs: pytest.fail('Steam info accessed game data.'))
|
||||
monkeypatch.setattr(app.music, 'load', lambda *args: pytest.fail('Steam info read game music.'))
|
||||
app.mode.set('library')
|
||||
app.transport.set(transport)
|
||||
app.steam_closed.set(True)
|
||||
app.password.set('offline-secret')
|
||||
app.last_result = install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
||||
{'status': 'added', 'notes': {'status': 'manual', 'title': 'About Halo', 'content': 'Campaign.'}}, None)
|
||||
app._show_page(2)
|
||||
app._continue()
|
||||
app._set_busy(False)
|
||||
assert app.retry_button.winfo_manager() and not _disabled(app.retry_button)
|
||||
app.retry_button.invoke()
|
||||
_drain_events(app)
|
||||
assert len(calls) == 1
|
||||
settings, password = calls[0]
|
||||
@@ -1007,13 +1026,13 @@ def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentia
|
||||
assert 'native game and saves were kept' in app.status.get()
|
||||
assert 'installed.' not in app.status.get()
|
||||
assert app._test_dialogs[-1][0] == 'Steam info updated'
|
||||
assert not app.retry_button.winfo_manager()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('pending', ['icon', 'notes', 'artwork'])
|
||||
def test_steam_info_partial_results_stay_actionable_without_claiming_complete_update(app, pending):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.mode.set('library')
|
||||
app.operation = 'library'
|
||||
app.password_to_redact = 'offline-secret'
|
||||
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
|
||||
@@ -1100,10 +1119,298 @@ def test_all_operation_tile_captions_fit_minimum_width(app):
|
||||
app.deiconify()
|
||||
app._show_page(0)
|
||||
app.update()
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'library', 'uninstall'}
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
||||
for choice in app.mode_choices:
|
||||
for item in choice.find_all():
|
||||
if choice.type(item) == 'text':
|
||||
left, top, right, bottom = choice.bbox(item)
|
||||
assert 0 <= left < right <= choice.winfo_width()
|
||||
assert 0 <= top < bottom <= choice.winfo_height()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_iso_inspection_runs_off_tk_thread_and_window_remains_responsive(app, monkeypatch, tmp_path):
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from halo_frame_installer import gui
|
||||
real_thread = threading.Thread
|
||||
started, release = threading.Event(), threading.Event()
|
||||
workers, inspected_threads, music_loads = [], [], []
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
inspected_threads.append(threading.get_ident())
|
||||
assert path == tmp_path / 'Halo.iso'
|
||||
assert not cancel_event.is_set()
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
return SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276')
|
||||
|
||||
def schedule(**kwargs):
|
||||
thread = real_thread(**kwargs)
|
||||
workers.append(thread)
|
||||
return thread
|
||||
|
||||
monkeypatch.setattr(gui.threading, 'Thread', schedule)
|
||||
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
||||
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
||||
try:
|
||||
app._inspect(tmp_path / 'Halo.iso')
|
||||
assert started.wait(2)
|
||||
assert app.busy and not app.source.get()
|
||||
assert inspected_threads == [workers[0].ident]
|
||||
assert workers[0].ident != threading.get_ident()
|
||||
heartbeat = []
|
||||
app.after(0, lambda: heartbeat.append(True))
|
||||
app.update()
|
||||
assert heartbeat == [True] # Tk processed a timer while validation was blocked.
|
||||
assert music_loads == [None] # The old preview stops before reading the new ISO.
|
||||
finally:
|
||||
release.set()
|
||||
for worker in workers:
|
||||
worker.join(3)
|
||||
_drain_events(app)
|
||||
assert not app.busy
|
||||
assert app.source.get() == str(tmp_path / 'Halo.iso')
|
||||
assert 'Validated 24 original Xbox maps' in app.asset_status.get()
|
||||
assert music_loads == [None, tmp_path / 'Halo.iso']
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued_inspection(app, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from halo_frame_installer import gui
|
||||
workers, music_loads = [], []
|
||||
|
||||
class DeferredThread:
|
||||
def __init__(self, target, **kwargs):
|
||||
self.target = target
|
||||
workers.append(self)
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(gui.threading, 'Thread', DeferredThread)
|
||||
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
||||
return SimpleNamespace(workers=workers, music_loads=music_loads,
|
||||
info=SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276'))
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('old_error', [False, True])
|
||||
@pytest.mark.parametrize('old_finishes_first', [False, True])
|
||||
def test_late_inspection_results_do_not_replace_new_selection_or_clear_its_busy_state(app, monkeypatch, tmp_path, queued_inspection, old_error, old_finishes_first):
|
||||
from halo_frame_installer import gui
|
||||
old, new = tmp_path / 'old.iso', tmp_path / 'new.iso'
|
||||
cancellations = {}
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
cancellations[path] = cancel_event
|
||||
if path == old and old_error:
|
||||
raise ValueError('The old image was invalid.')
|
||||
return queued_inspection.info
|
||||
|
||||
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
||||
app._inspect(old)
|
||||
old_cancel = app._inspection_cancel
|
||||
app._inspect(new)
|
||||
assert old_cancel.is_set()
|
||||
if old_finishes_first:
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert app.busy and not app.source.get()
|
||||
assert app.asset_status.get() == 'Checking original Xbox game data…'
|
||||
queued_inspection.workers[1].target()
|
||||
_drain_events(app)
|
||||
if not old_finishes_first:
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert app.source.get() == str(new)
|
||||
assert app.status.get() == 'Game data validated. Connect your Frame next.'
|
||||
assert not app.busy
|
||||
assert cancellations[old].is_set() and not cancellations[new].is_set()
|
||||
assert queued_inspection.music_loads == [None, None, new]
|
||||
assert not app._test_dialogs
|
||||
assert 'old image' not in '\n'.join(app.log_lines)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_clearing_source_cancels_inspection_and_ignores_its_late_success(app, monkeypatch, tmp_path, queued_inspection):
|
||||
from halo_frame_installer import gui
|
||||
monkeypatch.setattr(gui, 'inspect_image', lambda path, **kwargs: queued_inspection.info)
|
||||
app._inspect(tmp_path / 'Halo.iso')
|
||||
cancel = app._inspection_cancel
|
||||
app.source.set('')
|
||||
assert cancel.is_set() and not app.busy
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert not app.source.get()
|
||||
assert app.asset_status.get() == gui.XBOX_REVISION_NOTE
|
||||
assert queued_inspection.music_loads == [None, None]
|
||||
assert not app._test_dialogs
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('folder', [False, True])
|
||||
def test_cancelled_source_inspection_keeps_previous_selection_and_does_not_start_music(app, monkeypatch, tmp_path, queued_inspection, folder):
|
||||
from halo_frame_installer import gui
|
||||
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
||||
if folder:
|
||||
selected.mkdir()
|
||||
calls = []
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
calls.append((path, cancel_event.is_set()))
|
||||
# Even a reader finishing after cancellation cannot accept its result.
|
||||
return queued_inspection.info
|
||||
|
||||
monkeypatch.setattr(gui, 'inspect_maps' if folder else 'inspect_image', inspect)
|
||||
app.source.set('previous.iso')
|
||||
app._inspect(selected)
|
||||
app._cancel_setup()
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert calls == [(selected, True)]
|
||||
assert app.source.get() == 'previous.iso'
|
||||
assert not app.busy
|
||||
assert app.phase.get() == 'Game data check cancelled'
|
||||
assert 'previous selection was kept' in app.status.get()
|
||||
assert queued_inspection.music_loads == [None]
|
||||
assert not app._test_dialogs
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_current_inspection_failure_preserves_selection_and_readable_redacted_error(app, monkeypatch, tmp_path, queued_inspection):
|
||||
from halo_frame_installer import gui
|
||||
def invalid(path, *, cancel_event):
|
||||
raise ValueError('Invalid original Xbox image. offline-secret\nHeader validation failed.')
|
||||
monkeypatch.setattr(gui, 'inspect_image', invalid)
|
||||
app.source.set('previous.iso')
|
||||
app.password_to_redact = 'offline-secret'
|
||||
app._inspect(tmp_path / 'invalid.iso')
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert not app.busy
|
||||
assert app.source.get() == 'previous.iso'
|
||||
assert "Couldn't validate this data" in app.asset_status.get()
|
||||
assert len(app._test_dialogs) == 1
|
||||
assert 'Header validation failed.' in '\n'.join(app.log_lines)
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
assert 'offline-secret' not in app._test_dialogs[0][1]
|
||||
assert queued_inspection.music_loads == [None]
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('folder', [False, True])
|
||||
def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_credentials(app, monkeypatch, tmp_path, folder):
|
||||
from halo_frame_installer import gui, install
|
||||
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
||||
if folder:
|
||||
selected.mkdir()
|
||||
name = 'inspect_maps' if folder else 'inspect_image'
|
||||
inspect_source = getattr(gui, name)
|
||||
inspections, settings_used = [], []
|
||||
get_settings = app._settings
|
||||
|
||||
def remember_settings():
|
||||
settings = get_settings()
|
||||
settings_used.append(settings)
|
||||
return settings
|
||||
|
||||
def cancel_validation(path, *, cancel_event):
|
||||
assert cancel_event is app.cancel
|
||||
inspections.append(path)
|
||||
# Model Cancel arriving as the worker starts its second validation.
|
||||
cancel_event.set()
|
||||
return inspect_source(path, cancel_event=cancel_event)
|
||||
|
||||
monkeypatch.setattr(app, '_settings', remember_settings)
|
||||
monkeypatch.setattr(gui, name, cancel_validation)
|
||||
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
||||
for action in ('extract_image', 'copy_maps'):
|
||||
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
|
||||
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Cancelled validation began installation.'))
|
||||
app.source.set(str(selected))
|
||||
app.authorized.set(True)
|
||||
app.steam_closed.set(True)
|
||||
app.password.set('offline-secret')
|
||||
app._install()
|
||||
_drain_events(app)
|
||||
assert inspections == [selected]
|
||||
assert app.cancel.is_set()
|
||||
assert not app.busy and not app.installing
|
||||
assert not app.password.get() and not settings_used[0].password
|
||||
assert not app.password_to_redact
|
||||
assert app.source.get() == str(selected)
|
||||
assert 'cancelled' in '\n'.join(app.log_lines).lower()
|
||||
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'
|
||||
+208
-2
@@ -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"
|
||||
|
||||
@@ -173,6 +173,60 @@ def test_setup_reports_success_only_after_ssh_disconnects():
|
||||
assert seen[-3:] == ['disconnect', 'disconnected', 'complete']
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["fresh", "repair"])
|
||||
def test_normal_install_and_repair_automatically_publish_steam_information(tmp_path, mode):
|
||||
steam_info = {"status": "added", "appid": 3732925724, "libraryAdded": True,
|
||||
"artwork": {"status": "added", "installed": ["cover", "header", "hero", "logo"],
|
||||
"icon": {"status": "added"}},
|
||||
"notes": {"ok": True, "status": "added", "title": "About and controls"}}
|
||||
|
||||
class SuccessfulConnection(FakeConnection):
|
||||
def __init__(self, settings):
|
||||
super().__init__(settings, existing=mode == "repair")
|
||||
self.events = []
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
self.events.append(("upload", Path(local).name))
|
||||
super().put(local, remote, **kwargs)
|
||||
|
||||
def run(self, argv, **kwargs):
|
||||
if argv[1] != "-c" and argv[2] in ("build", "finalize", "shortcut"):
|
||||
self.commands.append(argv)
|
||||
self.events.append(("step", argv[2]))
|
||||
responses = {"build": {"built": True},
|
||||
"finalize": {"gamePath": "/home/steamos/Games/HaloCENativeVR",
|
||||
"reused": False, "repaired": mode == "repair"},
|
||||
"shortcut": steam_info}
|
||||
return "HFI_RESULT " + json.dumps(responses[argv[2]])
|
||||
return super().run(argv, **kwargs)
|
||||
|
||||
settings = Settings("frame", "private", reinstall_existing=mode == "repair",
|
||||
close_steam_for_shortcut=True)
|
||||
fake = SuccessfulConnection(settings)
|
||||
maps = None
|
||||
if mode == "fresh":
|
||||
maps = tmp_path / "maps"
|
||||
maps.mkdir()
|
||||
for name in EXPECTED_MAPS:
|
||||
(maps / name).write_bytes(name.encode())
|
||||
result = Installer(lambda _: fake, RESOURCES).run(settings, maps)
|
||||
|
||||
steps = [argv[2] for argv in fake.commands if argv[1] != "-c"]
|
||||
assert steps == ["prepare", "build", "finalize", "shortcut"]
|
||||
assert fake.commands[0][3] == "preflight"
|
||||
assert steps.count("shortcut") == 1
|
||||
assert fake.events.index(("step", "finalize")) < fake.events.index(("step", "shortcut"))
|
||||
for filename in ("steam_notes.py", "halo-ce-cover.jpg", "halo-ce-landscape.png",
|
||||
"halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
|
||||
assert fake.events.index(("upload", filename)) < fake.events.index(("step", "build"))
|
||||
assert result.steam == steam_info
|
||||
assert result.repaired is (mode == "repair")
|
||||
assert fake.closed
|
||||
assert not any("--close-steam" in argv for argv in fake.commands)
|
||||
uploaded_maps = [remote for _, remote in fake.uploads if remote.endswith(".map")]
|
||||
assert len(uploaded_maps) == (len(EXPECTED_MAPS) if mode == "fresh" else 0)
|
||||
|
||||
|
||||
def test_default_existing_install_rebuilds_without_uploading_maps():
|
||||
fake = FakeConnection(Settings("frame", "private"))
|
||||
with pytest.raises(SSHError, match="build failed"):
|
||||
@@ -194,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()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Synthetic cache/audio tests; no Halo recordings or game data are fixtures."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import io
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
@@ -89,6 +91,43 @@ def test_stereo_channel_headers_and_interleaving_remain_distinct():
|
||||
assert samples[1::2] == (-120,) * 128
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channels,expected", [
|
||||
(1, "08af960a705a06d131940dc54952955876058e449f7f37f92727a9ddf37188df"),
|
||||
(2, "62a98442266869a1c78911817c0ab5f4685fc01d5ad42649f60151675baf9d83"),
|
||||
])
|
||||
def test_batched_decoder_preserves_pcm_for_every_predictor_index(channels, expected):
|
||||
# Golden hashes from the original decoder: varied nibbles, all valid index
|
||||
# states, both clipping limits, and mono/stereo sample ordering.
|
||||
randomizer = random.Random(516)
|
||||
blocks = []
|
||||
for index in range(89):
|
||||
headers = b"".join(struct.pack("<hBB", -32768 if channel % 2 else 32767, index, 0)
|
||||
for channel in range(channels))
|
||||
blocks.append(headers + bytes(randomizer.randrange(256) for _ in range(32*channels)))
|
||||
output = music.decode_adpcm(b"".join(blocks), channels)
|
||||
assert hashlib.sha256(output).hexdigest() == expected
|
||||
|
||||
|
||||
def test_long_decode_yields_regularly_and_checks_cancellation_after_yield(monkeypatch):
|
||||
block = struct.pack("<hBB", 1000, 0, 0) + bytes(32)
|
||||
batches = 3
|
||||
pauses = []
|
||||
monkeypatch.setattr(music.time, "sleep", pauses.append)
|
||||
output = music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS*batches+1), 1)
|
||||
assert len(output) == (music.DECODE_BATCH_BLOCKS*batches+1)*128
|
||||
assert pauses == [music.DECODE_YIELD_SECONDS]*batches
|
||||
|
||||
cancel = threading.Event()
|
||||
|
||||
def cancel_while_yielded(seconds):
|
||||
assert seconds == music.DECODE_YIELD_SECONDS
|
||||
cancel.set()
|
||||
|
||||
monkeypatch.setattr(music.time, "sleep", cancel_while_yielded)
|
||||
with pytest.raises(music.MusicError, match="cancelled"):
|
||||
music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS+1), 1, cancel)
|
||||
|
||||
|
||||
def test_quiet_gain_is_bounded_and_zero_gain_is_silent():
|
||||
block = struct.pack("<hBB", 32767, 88, 0) + b"\x77" * 32
|
||||
samples = struct.unpack("<64h", music.decode_adpcm(block, 1))
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user