diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 2c27f48..136ed3d 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "ilspycmd" ] + }, + "vpk": { + "version": "1.2.0", + "commands": [ + "vpk" + ] } } } diff --git a/.github/workflows/ci-installer.yml b/.github/workflows/ci-installer.yml new file mode 100644 index 0000000..007e8ae --- /dev/null +++ b/.github/workflows/ci-installer.yml @@ -0,0 +1,118 @@ +name: Installer CI + +on: + push: + paths: &installer_paths + - 'Optimum.Bootstrap.Core/**' + - 'Optimum.Bootstrap.Core.Tests/**' + - 'Optimum.Cli/**' + - 'Optimum.Cli.Tests/**' + - 'Optimum.Installer/**' + - 'Optimum.Installer.Tests/**' + - 'Optimum.Installer.slnf' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'global.json' + - 'NuGet.config' + - '.github/workflows/ci-installer.yml' + pull_request: + paths: *installer_paths + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: Build and test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-installer-${{ hashFiles('Optimum.Bootstrap.Core/**/*.csproj', 'Optimum.Cli/**/*.csproj', 'Optimum.Installer/**/*.csproj', 'Optimum.*.Tests/**/*.csproj') }} + restore-keys: | + nuget-installer- + + - name: Test + run: dotnet test Optimum.Installer.slnf -c Release --nologo + + cli-contract: + name: CLI contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Build the CLI + run: dotnet build Optimum.Cli/Optimum.Cli.csproj -c Release --nologo + + - name: Verbs answer and the consent gate holds + run: | + set -euo pipefail + cli() { dotnet exec Optimum.Cli/bin/Release/net10.0/optimum.dll "$@"; } + + test "$(cli --version)" = "$(cat VERSION)" + + cli capabilities --json | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['pinnedVersion']" + cli preflight --json | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(x['id']=='Dotnet' for x in d)" + + # build without --acknowledge-decompile must refuse and still emit a terminal result. + set +e + cli build --json --output /tmp/should-not-build > stream.ndjson + code=$? + set -e + test "$code" -eq 2 + python3 scripts/check-ndjson-stream.py < stream.ndjson + python3 -c "import json; r=json.loads(open('stream.ndjson').read().splitlines()[-1]); assert r['reason']=='bad-input', r" + test ! -e /tmp/should-not-build + + velopack-smoke: + name: Velopack on .NET 10 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore local tools + run: dotnet tool restore + + - name: Pack the real installer, then a delta + run: | + set -euo pipefail + rel=$(mktemp -d) + + dotnet publish Optimum.Installer/Optimum.Installer.csproj -c Release -r linux-x64 \ + --self-contained -o publish/linux-x64 --nologo + dotnet vpk pack -u Optimum.Installer -v 0.0.1 -p publish/linux-x64 \ + -e Optimum.Installer --channel linux -o "$rel" + + # A no-op change so the second pack produces a real delta. + date > publish/linux-x64/velopack-smoke-marker.txt + dotnet vpk pack -u Optimum.Installer -v 0.0.2 -p publish/linux-x64 \ + -e Optimum.Installer --channel linux -o "$rel" + + ls -la "$rel" + test -f "$rel"/Optimum.Installer-0.0.2-linux-delta.nupkg + test -f "$rel"/Optimum.Installer-0.0.2-linux-full.nupkg + test -f "$rel"/Optimum.Installer.AppImage + echo "Velopack packs the real Optimum.Installer for linux-x64 and builds a delta." diff --git a/.github/workflows/ci-platform-bootstrap.yml b/.github/workflows/ci-platform-bootstrap.yml index 29cc669..975e13b 100644 --- a/.github/workflows/ci-platform-bootstrap.yml +++ b/.github/workflows/ci-platform-bootstrap.yml @@ -312,7 +312,7 @@ jobs: bootstrap-linux: name: Bootstrap linux runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 60 if: inputs.platform == 'all' || inputs.platform == 'linux' steps: - uses: actions/checkout@v4 @@ -389,6 +389,20 @@ jobs: shell: bash run: dotnet test Optimum.Launcher.Tests/Optimum.Launcher.Tests.csproj -c Release --no-build --nologo + - name: Optimum.Cli build drives the pipeline and emits a conformant stream + timeout-minutes: 25 + shell: bash + run: | + set -euo pipefail + out="$RUNNER_TEMP/optimum-package" + dotnet run --project Optimum.Cli -c Release -- build \ + --json --acknowledge-decompile \ + --client-archive "$PWD/${{ steps.client.outputs.path }}" \ + --output "$out" | tee "$RUNNER_TEMP/build-stream.ndjson" + python3 scripts/check-ndjson-stream.py < "$RUNNER_TEMP/build-stream.ndjson" + pkg="$(ls -d "$out"/Optimum-v*/ | head -n1)" + dotnet run --project Optimum.Cli -c Release -- validate --package "$pkg" + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@v4 @@ -397,6 +411,7 @@ jobs: path: | .vanilla/*/vintagestory/Logs/ *.log + ${{ runner.temp }}/build-stream.ndjson retention-days: 7 bootstrap-linux-arm: diff --git a/.github/workflows/release-installer.yml b/.github/workflows/release-installer.yml new file mode 100644 index 0000000..9f4b25c --- /dev/null +++ b/.github/workflows/release-installer.yml @@ -0,0 +1,188 @@ +name: Release installer + +# Packages the Avalonia installer with Velopack for Windows and Linux and +# publishes the feed. macOS binaries are built for archival but not published: +# there is no Apple Developer Program account yet (INSTALLER-PLAN.md section 2). +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (defaults to the VERSION file)' + required: false + type: string + publish: + description: 'Upload the Velopack feed to GitHub releases' + required: true + default: false + type: boolean + +concurrency: + group: release-installer + cancel-in-progress: false + +permissions: + contents: write + +jobs: + pack: + strategy: + fail-fast: true + matrix: + include: + # `label` is only the artifact name; vpk picks the Velopack channel + # from the RID (win / linux / osx). + - { os: windows-latest, rid: win-x64, exe: Optimum.Installer.exe, label: win } + - { os: ubuntu-latest, rid: linux-x64, exe: Optimum.Installer, label: linux } + - { os: macos-14, rid: osx-arm64, exe: Optimum.Installer, label: osx-arm64 } + - { os: macos-13, rid: osx-x64, exe: Optimum.Installer, label: osx-x64 } + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }} + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Resolve version + id: v + shell: bash + run: | + version="${{ inputs.version }}" + [ -n "$version" ] || version="$(cat VERSION)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Restore local tools + run: dotnet tool restore + + - name: Publish self-contained + run: > + dotnet publish Optimum.Installer/Optimum.Installer.csproj -c Release + -r ${{ matrix.rid }} --self-contained + -o publish/${{ matrix.rid }} --nologo + + - name: Pack with Velopack + shell: bash + run: | + set -euo pipefail + dotnet vpk pack \ + --packId Optimum.Installer \ + --packVersion "${{ steps.v.outputs.version }}" \ + --packDir publish/${{ matrix.rid }} \ + --mainExe ${{ matrix.exe }} \ + --outputDir releases + + # vpk has no name-override flag. Rename the user-facing artifacts (setup, + # portable, AppImage) to Optimum-v--Installer. and fix + # the reference in assets..json so `vpk upload` still finds them. + # The .nupkg / releases..json / RELEASES- feed files keep + # their Velopack names - the updater fetches those by name and users never + # see them. "Installer" in the name keeps these distinct from the + # ready-to-run game packages (Optimum-v-.{zip,AppImage,dmg}). + - name: Rename artifacts to the Optimum scheme + shell: bash + run: | + set -euo pipefail + v="${{ steps.v.outputs.version }}" + rid="${{ matrix.rid }}" + case "$rid" in + win-x64) channel=win ;; + linux-x64) channel=linux ;; + osx-*) channel=osx ;; + *) echo "unknown rid: $rid" >&2; exit 1 ;; + esac + cd releases + assets="assets.$channel.json" + + rename() { + local old="$1" new="$2" tmp + [ -f "$old" ] || { echo "skip (absent): $old"; return 0; } + mv "$old" "$new" + tmp="$(mktemp)" + jq --arg o "$old" --arg n "$new" \ + 'map(if .RelativeFileName == $o then .RelativeFileName = $n else . end)' \ + "$assets" > "$tmp" + mv "$tmp" "$assets" + echo "renamed: $old -> $new" + } + + case "$channel" in + win) + rename "Optimum.Installer-win-Setup.exe" "Optimum-v$v-$rid-Setup.exe" + rename "Optimum.Installer-win-Portable.zip" "Optimum-v$v-$rid-Installer-Portable.zip" + ;; + linux) + rename "Optimum.Installer.AppImage" "Optimum-v$v-$rid-Installer.AppImage" + ;; + osx) + rename "Optimum.Installer-osx-Setup.pkg" "Optimum-v$v-$rid-Installer.pkg" + rename "Optimum.Installer-osx-Portable.zip" "Optimum-v$v-$rid-Installer-Portable.zip" + ;; + esac + + echo "--- releases/ ---"; ls -la + echo "--- $assets ---"; cat "$assets" + + - name: Sign (Windows) + if: matrix.rid == 'win-x64' && env.WINDOWS_CERT_BASE64 != '' + run: echo "signtool step goes here once a certificate secret exists" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: installer-${{ matrix.label }} + path: releases/ + retention-days: 14 + + publish: + if: ${{ inputs.publish }} + needs: pack + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore local tools + run: dotnet tool restore + + - name: Resolve version + id: v + shell: bash + run: | + version="${{ inputs.version }}" + [ -n "$version" ] || version="$(cat VERSION)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Download the feeds + uses: actions/download-artifact@v4 + with: + pattern: installer-* + path: feeds + + # One upload per channel, in sequence, into the same tag. macOS is not + # published (INSTALLER-PLAN.md section 2). + - name: Publish to GitHub releases + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + version="${{ steps.v.outputs.version }}" + tag="installer-v$version" + for channel in win linux; do + dotnet vpk upload github \ + --repoUrl "https://github.com/${{ github.repository }}" \ + --outputDir "feeds/installer-$channel" \ + --channel "$channel" \ + --tag "$tag" \ + --releaseName "Optimum installer v$version" \ + --publish + done diff --git a/.gitignore b/.gitignore index c4e7f4e..848b2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ Optimum-v*-mac-*/ bin/ obj/ dist/ +publish/ +/releases/ # IDE .vs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6484356..3486304 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,20 @@ make run # launch and test in-game See the [Building from Source](https://github.com/StratumServer/Optimum/wiki/Building-from-Source) wiki page for prerequisites and details. +### Installer + +The installer stack (`Optimum.Bootstrap.Core`, `Optimum.Cli`, `Optimum.Installer`) +builds and tests without a bootstrap, so it has its own commands: + +```bash +make installer-test # dotnet test Optimum.Installer.slnf +make installer-pack INSTALLER_RID=linux-x64 # Velopack package for one RID +``` + +The design and phase status are in `INSTALLER-PLAN.md`. New installer code is +tested against an in-memory `ISystemProbe`; the shell scripts under `scripts/` +stay as the execution layer the engine drives. + ## Architecture Optimum has three components: diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md new file mode 100644 index 0000000..ef5c5c0 --- /dev/null +++ b/INSTALLER-PLAN.md @@ -0,0 +1,1174 @@ +# Avalonia installer: implementation plan + +Optimum ships three installers that have drifted apart. `scripts/install-linux.sh` +(921 lines) is an interactive terminal wizard with prerequisite auto-install and +path guards. `scripts/install-windows.ps1` (2195 lines) is a WinForms wizard with +a transactional install, a runtime preflight, a registered uninstaller, and an +EULA. `scripts/install-macos.sh` (282 lines) is a plain prompt loop with none of +that. This plan replaces all three with one C# codebase on .NET 10: a reusable +library (`Optimum.Bootstrap.Core`), a machine-readable command line front end +(`Optimum.Cli`), and an Avalonia GUI (`Optimum.Installer`). The licensing +constraint recorded in `README.md:260` and `NOTICE:11-14` means Optimum can never +ship a prebuilt patched game, so every install must decompile and compile on the +user's own machine, and the installer is a build appliance rather than a file +copier. Two consumers drive the design: the Avalonia GUI, which links the library +in-process, and RiftLauncher, which spawns `Optimum.Cli` as a subprocess and reads +a stable NDJSON stream. + +## 1. Background and problem statement + +### The three installers do not do the same things + +Every capability below exists in at least one installer and is missing from at +least one other. The gaps are not stylistic. They are the difference between a +failed install that rolls back and a failed install that leaves the user with an +empty directory where their game used to be. + +| Capability | Linux | Windows | macOS | +| --- | --- | --- | --- | +| Graphical UI | terminal TUI | WinForms wizard | none | +| Prerequisite detection | yes | yes | none | +| Prerequisite auto-install | dotnet, ilspycmd, distro packages | ilspycmd only | none | +| NixOS / non-FHS routing | yes | not applicable | no | +| Version selection | yes, when a bridge patch set exists | `-Version` parameter | none | +| Install-directory guard | `guard_install_dir` | `Assert-SafeInstallerPaths` | none | +| Session-aware data-path detection | yes | no | no | +| Transactional install with rollback | no | yes | no | +| Runtime preflight before commit | no | yes | no | +| Registered uninstaller | no | yes, `Optimum_is1` | no | +| Upgrade detection and version compare | no | yes | partial and broken | +| EULA | no | yes | no | +| Persistent install log | no | yes | no | +| Shortcuts and menu entries | yes | yes | none | +| Install model | standalone package | standalone package | overlay onto a copy | + +`scripts/install-linux.sh:732` calls `rm -rf "$INSTALL_DIR"` and then copies the +staged package in. If the copy fails halfway (disk full, a permission change, a +process holding a file open), the user's previous install is gone and the new one +is incomplete. `scripts/install-windows.ps1:718` (`Install-StagedPackage`) does +not have that problem: it copies to `.optimum-stage-`, moves the existing +target to `.optimum-backup-`, moves the stage into place, and only then +deletes the backup, with a rollback in the `catch` block. That function is the +one piece of installer code in the repository worth porting verbatim, and it +exists on exactly one of three platforms. + +### The decision already made + +RiftLauncher issue #18 settled the toolkit question for new desktop UI in this +ecosystem, and Zaldaryon voted for Avalonia and C# on .NET 10 on 2026-08-17. +Optimum, Stratum, and Nimbus are all C# on .NET 10 already, so an Avalonia +installer shares the language, the SDK pin in `global.json`, the test framework, +and the reviewer pool with the code it installs. Avalonia also ships its own Skia +renderer, so headless CI exercises the same drawing path the user gets. + +Electron and Tauri both lose on that second point: Electron would add a Node and +Chromium toolchain to a repository whose only build input today is the .NET SDK, +and Tauri would put the UI in a system webview whose behavior varies per machine, +which is exactly the class of divergence this plan exists to remove. + +## 2. Constraints and non-goals + +### The licensing constraint + +`README.md:260` states that no game binaries or symbols are stored in this +repository or produced by GitHub CI. `NOTICE:11-14` records that the Anego +upstream license files identify the software as proprietary and that the notice +"does not grant permission to redistribute Anego-owned material." +`LICENSE-SCOPE.md:34-45` keeps `patches/**`, `sources/**`, and `Vintagestory/**` +outside the MIT grant. + +The consequence is absolute and shapes everything below. Optimum cannot publish a +patched `VintagestoryLib.dll`, cannot publish a donor DLL, and cannot publish a +game archive. The user supplies the official client, and the machine in front of +the user does the decompile and the compile. No GUI removes the roughly 570 MB +client download, the .NET SDK requirement, the ILSpy decompile of +`VintagestoryLib.dll` and `Vintagestory.dll`, or the multi-minute Release build of +`VintageStory.slnx`. A GUI can only make that process legible, interruptible, and +safe to retry. + +### Non-goals for this effort + +- No reimplementation of `scripts/bootstrap.sh` or `scripts/bootstrap.ps1` in C#. + Those two files are 1568 and 1756 lines of accumulated decompiler workarounds, + perl fixups, and patch-application fallbacks. They stay as the execution layer. + `Optimum.Bootstrap.Core` drives them as subprocesses. +- No change to the Cecil runtime model. `Optimum.Launcher/Program.cs` and + `Optimum.Patcher` are out of scope except where the installer calls + `Optimum.exe --validate-only`. +- No redistribution of donors, ever, including inside an installer package. +- The RiftLauncher feature slice lives in the RiftLauncher repository and is a + separate effort. This plan owes RiftLauncher a stable contract, nothing more. + Section 5 is that contract. +- No attempt to make the installer work offline on a machine with no .NET SDK and + no network. That combination cannot produce a build. + +### Decisions taken 2026-08-27 + +Four open questions from an earlier draft are now settled and the sections below +reflect them. + +- **The EULA text is rewritten to match `LICENSE-SCOPE.md`.** The current text in + `scripts/install-windows.ps1:1964` is wrong and does not move into Core as is. + Phase 1 produces the corrected resource, and it gets a legal review pass before + it ships. +- **Consent posture is C: a hard click-through gate everywhere.** Local + decompilation of the user's own Vintage Story copy needs explicit consent, so + the notice is not enough. The GUI shows a mandatory modal with an acceptance + checkbox that gates Continue. `Optimum.Cli build` requires + `--acknowledge-decompile` and refuses with `bad-input` if it is absent, which + means CI, scripts, and `--non-interactive` runs must all pass it. RiftLauncher + renders the text in its own UI, collects the acknowledgment, and passes the flag + when it spawns the engine. The consent covers the license terms and the fact + that Optimum decompiles a proprietary game on the user's machine to build the + patch. +- **macOS distribution is deferred.** The project is not obtaining an Apple + Developer Program account yet, so no signed macOS installer ships. `Optimum.Cli` + and `Optimum.Installer` still build and run on macOS from source for anyone who + wants them, and `scripts/install-macos.sh` and `scripts/package-macos.sh` stay + as the macOS path until an account exists and a signed build ships. Revisit when + macOS demand justifies the 99 USD per year and the D-U-N-S lead time. +- **macOS packaging, when it does ship, is a Velopack `.pkg`.** Not Avalonia + Parcel. A recurring Avalonia subscription is not justified for the current macOS + audience, and Velopack does macOS signing and notarization at no license cost. +- **Velopack is confirmed on .NET 10.** A local spike on 2026-08-27 packed a + net10.0 self-contained console app with Velopack 1.2.0 for `linux-x64` (AppImage + plus a 44 KB delta from 1.0.0 to 1.0.1 against a 37 MB full package) and + `win-x64` (`Setup.exe` plus portable zip). Phase 0's `ci-installer.yml` + `velopack-smoke` job packs two versions of `Optimum.Cli` and asserts the delta + package builds. Applying an update at runtime needs a running app and is + verified in Phase 5. + +## 3. Architecture + +Three new projects join `VintageStory.slnx`, all MIT, all .NET 10. + +```mermaid +graph TD + RL["RiftLauncher (separate repo)"] -->|"spawn, argv array, NDJSON on stdout"| CLI["Optimum.Cli (console)"] + GUI["Optimum.Installer (Avalonia)"] -->|"in-process, IProgress<T>, typed results"| CORE["Optimum.Bootstrap.Core (library)"] + CLI -->|"in-process"| CORE + CORE -->|"CliWrap subprocess"| SCRIPTS["scripts/bootstrap.sh, bootstrap.ps1, package-*.sh, package.ps1"] + CORE -->|"CliWrap subprocess"| DOTNET["dotnet build VintageStory.slnx -c Release"] + CORE -->|"CliWrap subprocess"| VALIDATE["Optimum.exe --validate-only"] + SCRIPTS --> ARTIFACT["staged package directory"] + CORE -->|"stage, backup, swap, rollback"| INSTALL["install directory"] +``` + +Dependency direction is one way. `Optimum.Bootstrap.Core` references nothing in +this repository. `Optimum.Cli` and `Optimum.Installer` both reference Core and +never each other. The GUI does not shell out to the CLI, because doing so would +force every typed result through a serialization round trip and would make GUI +error reporting depend on parsing its own output. RiftLauncher does spawn the CLI, +because a process boundary is the only isolation an Electron main process can get +against a build that takes twenty minutes and allocates gigabytes. + +### What lives where + +`Optimum.Bootstrap.Core` owns all logic and no presentation: + +- The prerequisite model. One record per tool with an id, a detection strategy, an + acquisition method, and a flag for whether the installer may install it without + the user leaving the app. +- Detection ported from `scripts/check-prereqs.sh`, from the Linux installer's + NixOS and non-FHS routing (`scripts/install-linux.sh:119` `detect_nixos`, + `scripts/install-linux.sh:123` `nixos_dotnet_install_cmd`), and from the Windows + installer's `Resolve-DotNetPath` (`scripts/install-windows.ps1:336`), + `Find-AllVintageStory` (`:204`), and `Find-ILSpyCmd` (`:523`). +- The ilspycmd pin and accepted range, read from `.config/dotnet-tools.json` + (`10.1.1.8388`) and `.config/ilspycmd-compat.json` (`10.1.0.8386` through + `10.1.1.8388`). The Windows installer already reads both files in + `Get-Pinned-ILSpyVersion` (`:565`) and `Get-Accepted-ILSpyVersionRange` (`:580`). + Core reads them once and both front ends share the result. +- Acquisition: the `dotnet-install` script runner, `dotnet tool install -g + ilspycmd --version `, and the distro package hints from + `scripts/install-linux.sh:260-267`. +- A build driver that runs `make`, `scripts/bootstrap.*`, and `scripts/package-*` + through CliWrap with streamed stdout and stderr and a cancellation token. +- The staged-package transactional installer, ported from `Install-StagedPackage` + and made to work on all three operating systems. +- Path guards that consolidate `guard_install_dir` + (`scripts/install-linux.sh:661`) and `Assert-SafeInstallerPaths` + (`scripts/install-windows.ps1:152`). The guard rejects a symlinked install or + data directory (the transactional install would otherwise operate on the link's + target), but not a symlinked parent: an install directory legitimately sits + under a symlinked home or a mounted second drive. RiftLauncher's full + `assertNoSymlinkComponents` walk stays available in `SymlinkComponentCheck` for + a path that is expected to stay within a trusted base. Resolving symlinks in the + well-known Vintage Story directories before the overlap check is Phase 4 work, + when the transactional installer lands and it starts to matter. +- Session-aware data-path detection, generalized from + `scripts/install-linux.sh:580-633`. +- Shortcut writers: Windows `.lnk` and Start Menu, Linux `.desktop` plus a hicolor + icon, macOS `.app` registration. +- Uninstaller generation and registration, plus an install manifest. +- One EULA text resource. +- The `IProgress` model and the NDJSON emitter. + +`Optimum.Cli` owns argument parsing, NDJSON serialization, POSIX signal handling, +and exit codes. It contains no detection logic, no path logic, and no install +logic. If a behavior can be tested without a process boundary, it belongs in Core. + +`Optimum.Installer` owns views, view models, and the screen state machine. It +contains no path validation and no subprocess handling of its own. + +## 4. The engine contract + +This section is normative. RiftLauncher, or any other caller, may rely on +everything in it. Changing it requires a major version bump of `Optimum.Cli` and a +note in `capabilities`. + +### Invocation + +``` +optimum [--json] --input --output [flags] +``` + +Callers spawn the binary with `shell: false` and an explicit argv array, a fixed +working directory, and a sanitized environment. The caller should `lstat` the +binary before spawning and refuse to run it if it is a symlink. All path arguments +must be absolute. The engine rejects a relative path with `bad-input` rather than +resolving it against an ambient working directory. + +`build`, `preflight`, and `capabilities` use an Optimum checkout because the +engine drives `scripts/` there. They find it by walking up from the working +directory for `forks.json` next to `scripts/bootstrap.sh`, or take `--repo-root +`. `build` also accepts `--acquire-source`: when no checkout is found, Core +performs a shallow HTTPS clone at the matching release tag into the platform's +user cache. `--source-cache ` overrides the cache root. `preflight` and +`capabilities` remain read-only and never acquire a checkout. + +### Verbs + +| Verb | Arguments | Effect | +| --- | --- | --- | +| `preflight` | `[--repo-root ]` `[--json]` | Detect prerequisites. No side effects, no writes, no network. | +| `build` | `--acknowledge-decompile` `--output ` `[--client-archive ]` `[--version ]` `[--repo-root ]` `[--acquire-source]` `[--source-cache ]` `[--json]` | Bootstrap, check patches, build, package into `--output`, and validate the runtime. `--output` must be empty or absent. Refuses with `bad-input` if `--acknowledge-decompile` is absent. When no checkout is available, `--acquire-source` clones the matching source into the user cache. | +| `install` | `--package ` `--install-dir ` `[--data-path ]` `[--shortcuts menu,desktop]` `[--json]` | Transactional deploy: stage the new tree beside the target, move an existing Optimum install aside, swap with one rename, delete the backup, roll back on any failure. Writes an install manifest, the requested shortcuts, and on Windows the uninstall registry entry. A non-empty directory that is not an Optimum install is refused. | +| `validate` | `--package ` `[--json]` | Run the runtime validation described in section 7. | +| `uninstall` | `--install-dir ` `[--json]` | Remove an install by its manifest. Manifest entries that resolve outside the install directory are refused, not followed. | +| `capabilities` | `[--repo-root ]` `[--json]` | Report supported game versions and patch set ids. | +| `--version` | none | Print one plain line and exit 0. | + +`build` is the verb RiftLauncher calls. Everything else exists for the GUI, for +scripting, and for CI. + +### NDJSON schema + +The operation verbs (`build`, `install`, `validate`, `uninstall`) carry a stream: +with `--json`, stdout is one JSON object per line and nothing else, ending in +exactly one terminal `result`. Without `--json` it is human-readable text. The +query verbs (`preflight`, `capabilities`) answer with a single JSON document +(`preflight` an array, `capabilities` an object) and do not use the stream shape. +stderr is always free-form human log, including a subprocess's own output, and +callers must not parse it. + +Progress: + +```json +{"type":"progress","phase":"decompile","progress":42,"detail":"VintagestoryLib.dll"} +``` + +`phase` is one of `decompile`, `patch`, `verify`, `assemble`. `progress` is an +integer. `detail` is a human string and carries no contract. + +Log: + +```json +{"type":"log","level":"info","message":"ilspycmd 10.1.1.8388 accepted"} +``` + +`level` is one of `info`, `warn`, `error`. + +Terminal result, exactly one per run, always the last line: + +```json +{"type":"result","ok":true,"runtimePath":"/abs/path/to/Optimum-v0.3.14-linux-x64"} +``` + +```json +{"type":"result","ok":false,"reason":"patch-conflict","message":"patches/vsapi/0007-...patch did not apply"} +``` + +### Progress rules + +`progress` is a monotonic non-decreasing integer in the range 0 to 99. The engine +never emits 100. The caller owns 100 and emits it after its own post-validation +of the output. This mirrors what RiftLauncher's `runTrackedWorker` in +`src/ipc/handlers/pathsHandlers.ts` already expects, and it exists because a task +that reports 100 before the caller has verified the artifact produces a UI that +says "done" while the caller is still deciding whether to reject the result. + +The engine emits at least one progress line per phase and should emit at intervals +short enough that a stalled build is distinguishable from a slow one. A build that +emits nothing for ten minutes during `dotnet build` is indistinguishable from a +hang, and the caller will arm a timeout and kill it. + +`NdjsonWriter` enforces the range and the monotonicity: a value below the last +one or above 99 is adjusted to fit, and the writer emits a `warn` log and +increments an anomaly count when it does. A clean run triggers neither, so the +Phase 2 conformance test asserts the anomaly count stayed zero against a real +build stream. + +### The reason enum + +Closed, kebab-case, stable. The caller maps each value to a localized string +through an exhaustive switch, so adding a value is a breaking change for the +caller and must be announced through `capabilities`. + +| Reason | Meaning | +| --- | --- | +| `bad-input` | An argument is missing, relative, malformed, or points at something that is not what it claims to be. | +| `unsupported-version` | The requested game version is not in the set `capabilities` reports. | +| `patch-conflict` | A file under `patches/` failed to apply against the decompiled or cloned source. | +| `decompile-failed` | ilspycmd failed, produced no output, or produced output the fixup passes rejected. | +| `assemble-failed` | `dotnet build` or a packaging script failed. | +| `verification-failed` | The package built but failed the runtime validation in section 7. | +| `output-exists` | `--output` already contains an artifact and the engine will not overwrite it. | +| `source-unavailable` | The requested Optimum source could not be cloned, verified, or promoted into the cache. | +| `cancelled` | The engine received SIGTERM and stopped. Partial output was rolled back. | +| `engine-internal` | An unexpected fault in the engine. Always accompanied by a `message`. | + +### Exit codes and signals + +Exit 0 when the terminal result has `"ok":true`, non-zero otherwise. The result +line is authoritative. A caller that sees `"ok":true` and a non-zero exit code +should treat the run as successful and log the discrepancy, because a non-zero +exit from a wrapper, a shell, or a signal after the work completed is a more +likely explanation than a lying result line. A caller that sees a non-zero exit +and no result line at all must synthesize `engine-internal`. + +On SIGTERM or SIGINT the engine stops the current phase and cleans up. Because +`--output` was required to be empty or absent, cleanup removes the directory when +the engine created it and removes only the new contents when it pre-existed; +either way nothing the engine did not write is touched. It then emits +`{"type":"result","ok":false,"reason":"cancelled"}` and exits non-zero. If the +process cannot emit the line (SIGKILL, or a crash inside the handler), the caller +falls back to `engine-internal`. `PosixSignalRegistration` handles both signals on +Windows as well. + +### Path discipline + +Every path the engine accepts and every path it emits is absolute. The engine +writes only inside `--output` and inside its own temporary directory. It never +writes inside `--input`, never writes to the user's game directory during `build`, +and never follows a symlink out of `--output`. The caller re-validates the output +before registering it, because the engine's guarantee is a promise and the +caller's check is a fact. + +### Consent + +`build` decompiles a proprietary game on the user's machine, which needs the +user's explicit consent (see the decisions block in section 2). The engine does +not carry the consent text or a UI for it. The caller owns both: it shows the +license and decompilation notice, collects an affirmative acknowledgment, and only +then spawns `build` with `--acknowledge-decompile`. The engine treats a missing +flag as `bad-input` and does no work. `preflight`, `install`, `validate`, +`uninstall`, and `capabilities` do not decompile anything and do not take the +flag. + +### Division of labour with RiftLauncher + +RiftLauncher downloads every input through its verified downloader, which already +allowlists `cdn.vintagestory.at`, and hands Optimum local absolute paths. Optimum +performs an offline transform confined to `--output`. RiftLauncher re-validates +the output and registers it. + +`--client-archive` is the handoff point. `scripts/bootstrap.sh:30` and `:43` +already accept `--client-archive PATH`, and `scripts/bootstrap.ps1:44` accepts +`-ClientArchive`, so the plumbing exists. `Optimum.Cli build --client-archive` +forwards the path and the engine performs no network access for the client +download. The engine still needs network for the fork clones listed in +`forks.json` and for NuGet restore, and this plan does not propose to change that. +An engine run with `--client-archive` is not fully offline, and the contract must +not claim otherwise. + +### Discovery + +`optimum --version` prints one plain line, for example `0.3.14`, and exits 0. +`optimum capabilities --json` prints a single JSON object naming the supported +game versions and the patch set ids, so a caller can decide whether to invoke +`build` at all rather than discovering `unsupported-version` after a 570 MB +download. + +### Worked example + +A `build` run against a cached client archive, abbreviated: + +``` +{"type":"log","level":"info","message":"optimum 0.3.14"} +{"type":"log","level":"info","message":"client archive accepted: /var/cache/rl/vs_client_linux-x64_1.22.7.tar.gz"} +{"type":"progress","phase":"decompile","progress":2,"detail":"extracting client archive"} +{"type":"progress","phase":"decompile","progress":18,"detail":"ilspycmd VintagestoryLib.dll"} +{"type":"progress","phase":"decompile","progress":31,"detail":"ilspycmd Vintagestory.dll"} +{"type":"progress","phase":"patch","progress":40,"detail":"cloning vsapi at 63d33f7"} +{"type":"progress","phase":"patch","progress":55,"detail":"applying patches/vsapi"} +{"type":"progress","phase":"assemble","progress":62,"detail":"dotnet build VintageStory.slnx -c Release"} +{"type":"log","level":"warn","message":"innoextract not present; Windows package skipped"} +{"type":"progress","phase":"assemble","progress":88,"detail":"package-linux.sh"} +{"type":"progress","phase":"verify","progress":96,"detail":"runtime validation"} +{"type":"result","ok":true,"runtimePath":"/var/lib/rl/out/Optimum-v0.3.14-linux-x64"} +``` + +The caller emits its own 100 after it has checked the directory. + +## 5. The GUI + +`Optimum.Installer` uses the `avalonia.mvvm` template with CommunityToolkit.Mvvm +and CompiledBindings enabled from the first commit. The screen flow copies the +Windows WinForms wizard, because that flow has already survived contact with users +and its ordering constraints are real: prerequisites gate the Install button, the +EULA gates the build, and the log pane exists because builds fail and the user +needs the reason. + +### Screens + +**Prerequisites.** One row per tool: name, status (`OK`, `MISSING`, `OLD`, +`optional`), and an action button whose label depends on what the installer can +actually do. `Install` when the tool can be acquired without leaving the app, +`Download` when it cannot, `Browse` when the tool exists but the installer cannot +find it. `Continue` stays disabled while any required tool is missing, matching +`Get-MissingRequiredTools` at `scripts/install-windows.ps1:509`. On Linux the row +for the .NET SDK changes its label and its action on NixOS and other non-FHS +systems, as `scripts/install-linux.sh:336-346` already does. The Vintage Story +row shows the detected install path and its version, read from the executable +rather than from a registry key, because the in-game updater rewrites the +executable and leaves the registry stale (`Get-VsExeVersion`, +`scripts/install-windows.ps1:191`). + +A standalone installer starts this screen without a repository root. It offers +to clone the matching Optimum release into the platform's user cache, reports +git progress in the screen, verifies the checkout markers, and then runs the +normal prerequisite scan. A clone lands in a unique staging sibling and replaces +the cache only after verification, so cancellation or a failed promotion does +not destroy a previously usable checkout. + +**Install options.** Install folder with a Browse button and live validation. +Optional separate data folder, defaulting to the detected session folder from +section 7. Menu entry and desktop shortcut toggles. A version selector, shown only +when a `patches--bridge/` directory offers an alternate, matching +`scripts/install-linux.sh:532-552`. Validation runs on every change and reports +inline, not on Continue, so the user does not fill in three fields and then learn +the first one was wrong. + +**Review and consent.** A full wizard step summarizes the install directory, +data path, target version, and shortcuts before any work starts. The local build +notice remains mandatory and scrollable, with an acceptance checkbox that gates +the Start installation button. Back returns to Install options without losing +the user's choices. + +**Progress and log.** A phase label driven by the `BootstrapProgress` phase, a +determinate progress bar, and honest elapsed and estimated remaining times. The +filtered technical log is collapsed by default so the current action remains +primary. The filter reproduces the Windows behavior at +`scripts/install-windows.ps1:1281-1301`: phase markers drive the status label, a +whitelist of progress prefixes shows verbatim, and any line matching `error`, +`FAILED`, `ERROR`, or `throw` always shows regardless of the whitelist. Cancel +requires confirmation before it issues the two-tier CliWrap cancellation +(graceful token, then forceful). + +**Completion.** On success, a Launch button and the install path. On failure, the +reason, the message, and a View Log button that opens the saved log. + +### State machine + +``` +Prerequisites --Continue--> Options --Review--> ReviewAndConsent +ReviewAndConsent --Start--> Progress +ReviewAndConsent --Back--> Options +Progress --success--> Completion(ok) +Progress --failure--> Completion(error) +Progress --Cancel--> Completion(cancelled) +Completion(error) --Retry--> Prerequisites +Completion(cancelled) --Retry--> Prerequisites +``` + +Backwards navigation is allowed from Options to Prerequisites and blocked once +Progress starts, because the build is already writing to disk. + +### Feature migration table + +| Current behavior | File | Lands in | +| --- | --- | --- | +| `--install-dir`, `--data-path`, `--version`, `--no-menu-entry`, `--desktop-shortcut` | `scripts/install-linux.sh:73-80` | `Optimum.Cli install` flags and the Options screen | +| `--package-dir` (install from a prebuilt folder) | `scripts/install-linux.sh:75` | `Optimum.Cli install --package` | +| `--skip-build` | `scripts/install-linux.sh:76` | `install` verb used without a preceding `build` | +| `--non-interactive` | `scripts/install-linux.sh:80` | the CLI itself; the GUI has no silent mode | +| Prereq checklist and per-tool auto-install | `scripts/install-linux.sh:260-346` | Core prerequisite model, Prerequisites screen | +| NixOS / non-FHS routing | `scripts/install-linux.sh:95-124` | Core detection, surfaced as a different action on the SDK row | +| Bridge version prompt | `scripts/install-linux.sh:532-552` | Options screen version selector | +| `guard_install_dir` | `scripts/install-linux.sh:661` | Core path guards | +| Session-aware data-path detection | `scripts/install-linux.sh:580-633` | Core, on all three operating systems | +| `optimum-launch.sh` and `datapath.cfg` | `scripts/install-linux.sh:742-764` | Core shortcut and launcher writers | +| `.desktop` entry and hicolor icon | `scripts/install-linux.sh:766-790, 876-886` | Core shortcut writers | +| WinForms wizard sections and dark/light detection | `scripts/install-windows.ps1` GUI block | Avalonia views with theme-aware resources | +| EULA gate | `scripts/install-windows.ps1:1953-2027` | Core EULA resource, full Review and consent step with a real checkbox gate, posture C per section 2 | +| Vintage Story auto-detection | `scripts/install-windows.ps1:204-294` | Core detection | +| `Resolve-DotNetPath` probes | `scripts/install-windows.ps1:336` | Core detection | +| `Assert-SafeInstallerPaths`, `Assert-DirectoryWritable` | `scripts/install-windows.ps1:152, 123` | Core path guards | +| Upgrade and reinstall prompts | `scripts/install-windows.ps1:327` | Core install manifest read, Options screen | +| Short build path to dodge MAX_PATH | `scripts/install-windows.ps1:926-945` | Core build driver, Windows only | +| `robocopy` workspace copy and vanilla junction | `scripts/install-windows.ps1:1011-1029` | Core build driver, Windows only | +| `Invoke-RuntimePreflight` | `scripts/install-windows.ps1:669` | `Optimum.Cli validate`, all platforms, see section 7 | +| `Install-StagedPackage` | `scripts/install-windows.ps1:718` | Core transactional installer, all platforms | +| Uninstaller registry registration | `scripts/install-windows.ps1:1142` | Core uninstaller registration, all platforms | +| Detached log tail and saved raw log | `scripts/install-windows.ps1:1265-1301, 1912` | Core streamed output, Installer log pane, saved log on all platforms | +| macOS VS candidate paths and picker | `scripts/install-macos.sh:66-132` | Core detection | +| macOS version-mismatch guard | `scripts/install-macos.sh:179-199` | Core, generalized as a pre-build check on all platforms | + +## 6. Cross-OS unification + +| Gap | Resolution | +| --- | --- | +| macOS has no GUI, no prerequisites, no shortcuts, no version selection, no data-path prompt | `Optimum.Installer` runs on macOS with the same screens and the same Core | +| Windows lacks session-aware data-path detection | Core implements it once; the Windows candidate list adds `%APPDATA%\VintagestoryData` and `%APPDATA%\OptimumData` | +| Linux and macOS have no transactional install | Core's ported `Install-StagedPackage` runs everywhere | +| Linux and macOS have no runtime preflight | See section 7; this one is not free | +| Linux and macOS have no registered uninstaller | Core writes an install manifest at the install root and registers it: Windows registry under `HKCU:\...\Uninstall\Optimum_is1`, Linux a `.desktop` action plus the manifest, macOS the manifest inside the bundle | +| Linux and macOS have no upgrade detection | Core reads the manifest, compares versions, and the Options screen offers upgrade, reinstall, or cancel | +| Only Windows shows an EULA | One EULA resource in Core, shown by the Installer on every platform | +| Only Windows persists an install log | Core writes the raw log to a per-platform application data directory on every platform | +| macOS uses an overlay model, the others use standalone packages | macOS moves to the standalone-package model | + +### The macOS overlay retirement + +`scripts/install-macos.sh` currently copies the user's whole vanilla install to a +sibling `Optimum/` directory (`:277`, `:205`) and overlays Cecil-patched engine +DLLs onto the copy. The file's own header at `:4-5` claims it "Installs Optimum +INTO the Vintage Story directory" and does not modify vanilla files, which no +longer describes what the script does. Worse, `--uninstall` at `:262-266` operates +on `$VS_DIR`, not on `$INSTALL_DIR`, so it cannot remove a sibling install at all, +and the upgrade branch at `:269-275` deletes files from `$VS_DIR` while the +install writes to `$INSTALL_DIR`. The script also requires build outputs from a +`make dist` target that does not exist in the `Makefile`. + +`scripts/package-macos.sh:138` already assembles `Optimum.app` and `:275-323` +already produces a `.dmg` or a `.tar.gz` fallback. The new installer consumes that +`.app` and installs it transactionally, which makes macOS structurally identical to +Linux and Windows. What migrates from the old script: the five VS candidate paths +at `:68-74`, the numbered picker at `:117-131`, and the version-mismatch guard at +`:179-199`, which caught a real shader `KeyNotFoundException` during 1.22.6 +verification and is worth generalizing to every platform. What is retired: the +overlay copy, the eleven-name `OPTIMUM_FILES` list, and the `--uninstall` branch. + +This retirement lands when macOS gets a signed release, which is deferred (see the +decisions block in section 2). Until then `scripts/install-macos.sh` stays and the +new installer runs on macOS only from a source build. + +Users of the old overlay model need a migration path. Section 12 records this as a +risk, and the concrete answer is that `Optimum.Cli uninstall` detects a legacy +overlay by the presence of `Optimum.dll` and `.optimum/version` next to a +`VintagestoryLib.dll` and removes it using the old file list before the new +install proceeds. + +## 7. Prerequisite handling + +### The tool list + +`scripts/check-prereqs.sh:15-30` is the authoritative list and Core ports it +directly. Required: `dotnet`, `git`, `perl`, `python3`, `curl`, `tar`, `pwsh`, +`chmod`. Optional: `unzip`, `ilspycmd`, `make`, `cmake`, `mkisofs`, `innoextract` +at 1.11 or newer. + +Two notes on that list, because it is easy to get wrong. `pwsh` is marked required +at `scripts/check-prereqs.sh:23`, not optional, because `package-linux.ps1`, +`package-macos.ps1`, and `package.ps1` need it. That is stricter than a Linux user +building only a Linux package actually needs, and Core should model `pwsh` as +required-for-packaging rather than required-for-everything so a Linux user is not +told to install PowerShell to produce a `tar.gz`. And `appimagetool` is not in +`check-prereqs.sh` at all; `scripts/package-linux.sh:58-91` detects it separately, +falls back to `.tools/appimagetool`, and offers its own install. Core should fold +that detection into the same model rather than leaving it in one packaging script. + +### Detection per platform + +Linux and macOS use `command -v` equivalents plus the version probes already in +the shell scripts. Windows cannot rely on `PATH` alone: `Resolve-DotNetPath` +(`scripts/install-windows.ps1:336`) probes Visual Studio's bundled `dotnet\` +directory, Scoop, and Chocolatey, and `Find-AllVintageStory` (`:204`) walks Inno +Setup uninstall registry keys and roughly forty filesystem locations across +`%APPDATA%`, `%LOCALAPPDATA%`, Program Files, and every drive root, in both the +`Vintagestory` and `Vintage Story` spellings. All of that ports to Core as data, +not as code: a list of probe locations per platform, evaluated by one shared +walker. + +ilspycmd detection reads the pin from `.config/dotnet-tools.json` and the accepted +range from `.config/ilspycmd-compat.json` and rejects a version outside the range, +because `scripts/bootstrap.sh:446` calls `ilspycmd "$dll_path" --project` and a +decompiler outside the tested range produces source the fixup passes in +`scripts/fix-base-ctor-calls.py` and `scripts/fix-closure-class.pl` were not +written against. + +### What auto-installs + +- ilspycmd, through `dotnet tool install -g ilspycmd --version `, matching + `scripts/bootstrap.sh:146`. This is the only tool the Windows installer installs + today. +- The .NET SDK, through the official `dotnet-install` scripts from + `https://dot.net/v1/`, into a private per-application directory with + `--install-dir` and `--no-path`. The Linux installer does this today at + `scripts/install-linux.sh:288` with `--channel 10.0`. Core should instead pass + `--jsonfile global.json` so the acquired SDK matches the `10.0.100` pin with + `rollForward: latestFeature` rather than whatever the channel currently serves. + Core then invokes that `dotnet` by absolute path with `DOTNET_ROOT` set on the + child process only, and never mutates the user's `PATH`. +- On Windows, winget is a fast path for the SDK and for Git when it is present. +- Distro packages are offered as a hint with a copyable command, not run. The + Linux installer builds those commands at `scripts/install-linux.sh:260-267` for + apt-get, dnf, pacman, and zypper. An installer that runs `sudo` on the user's + behalf is a support burden and a security question this plan declines to open. + +### NixOS and non-FHS routing + +`scripts/install-linux.sh:95-124` detects a missing standard glibc dynamic linker +and refuses to run the `dot.net` installer, because the SDK it downloads hardcodes +an interpreter path that does not exist on NixOS. Core keeps that refusal and +keeps the substitute instruction `nix profile install nixpkgs#dotnet-sdk_10`. The +completion screen keeps the warning at `scripts/install-linux.sh:913-914` that the +resulting binaries need an FHS environment such as `steam-run`. + +### The SDK bootstrapping paradox + +`Optimum.Installer` is a .NET application whose job includes installing .NET. If +the installer ships framework-dependent, a user with no .NET cannot run the thing +that installs .NET. The resolution is that `Optimum.Installer` and `Optimum.Cli` +ship self-contained per RID, so they carry their own runtime and depend on nothing +preinstalled. The SDK they then acquire is for the build, not for themselves. The +cost is roughly 55 to 60 MB on disk per RID for an untrimmed self-contained +Avalonia application, about 25 MB compressed, which is negligible next to the +570 MB client download the user is about to make anyway. + +### Runtime validation on Linux and macOS + +This gap needs its own paragraph, because the plan cannot close it by porting +code. `Invoke-RuntimePreflight` (`scripts/install-windows.ps1:669`) runs +`Optimum.exe --validate-only` from the staged package and requires a +`.optimum/package-complete` marker at `:680`. Neither the marker nor the managed +launcher exists in a Linux or macOS package. `scripts/package.ps1:298` and `:401` +write `.optimum/standalone-install` and `.optimum/package-complete`; +`scripts/package-linux.sh` and `scripts/package-macos.sh` write neither. More +fundamentally, `scripts/package-linux.sh:341` produces the `Optimum` binary by +copying the vanilla apphost, and neither Linux nor macOS packaging stages +`Optimum.dll`, `Optimum.Patcher.dll`, or the `Mono.Cecil` assemblies. Those +packages ship pre-patched DLLs and never run the Cecil transplant at launch, which +means the `.optimum/donors/` directory that `scripts/package-linux.sh:267-274` +carefully populates has no consumer on those platforms. + +Two options were on the table: + +1. Ship `Optimum.Launcher` in the Linux and macOS packages, add the two markers, + and get true parity plus a real `--validate-only`. This is the larger change and + it alters what those packages contain. +2. Implement `validate` in Core over the staged DLLs, without the launcher. + +Phase 4 took option 2. `RuntimeValidator` checks the package layout, that the +three engine assemblies parse, and then loads `VintagestoryLib.dll` into a +`MetadataLoadContext` (metadata only, no execution, no native dependencies) and +confirms `Vintagestory.Client.ClientProgram` still has a static `Main`. That +catches a patch that removed the entry point without the risk of loading game +code into the installer process. The full JIT probe from `Optimum.exe +--validate-only` stays available for a later proposal that changes what the +Linux and macOS packages contain. + +## 8. Packaging and distribution of the installer + +Velopack 1.2 or newer handles Windows and Linux with one toolchain and one +release feed, and it supports delta updates. On Windows it integrates signtool and +Azure Trusted Signing and produces a `Setup.exe` plus a portable zip. On Linux its +only output format is AppImage. The spike on 2026-08-27 confirmed both for a +net10.0 self-contained app. + +If `.deb` or `.rpm` packages are required, PupNet Deploy produces them, and that +path has no auto-update. This plan does not propose `.deb` or `.rpm` for the first +release: AppImage matches what `scripts/package-linux.sh --format appimage` +already produces for the game package, so the installer and the thing it installs +use the same Linux distribution format. + +macOS is not part of the first distributed release. Signing and notarizing a +macOS bundle requires a paid Apple Developer Program membership, and the project +has decided not to obtain one yet. An unsigned installer is not an acceptable +artifact: `README.md:254` records that an unsigned bundle makes Gatekeeper warn, +and an installer is exactly the kind of binary a user should refuse to run when +the operating system warns about it. So `Optimum.Installer` and `Optimum.Cli` +build for `osx-arm64` and `osx-x64` and run for anyone who builds them, but the +release workflow publishes nothing for macOS. `scripts/install-macos.sh` and +`scripts/package-macos.sh` stay as the macOS path in the meantime. + +When macOS does ship, the format is a Velopack `.pkg`. Velopack handles +`codesign` and notarization at no license cost. Avalonia Parcel, which would +produce a `.dmg` and automate the `Info.plist` and bundle assembly, is rejected: +its full signing feature sits behind a recurring Avalonia subscription that the +current macOS audience does not justify. The cost of the `.pkg` choice is that +macOS users get a guided installer where some expect a drag-to-Applications +window, which is a reasonable trade for a tool that then runs a long build. + +Ship untrimmed. Avalonia's XAML loader uses reflection heavily and trimming +removes types the loader resolves by name, which fails at runtime rather than at +build time. An installer that crashes on its second screen because the linker +removed a converter is worse than an installer that is 30 MB larger. + +## 9. Testing strategy + +### Optimum.Bootstrap.Core.Tests + +Plain xUnit, no UI. The existing shell tests are the behavior specification to +port: `scripts/tests/install-linux-prerequisites.sh` and +`scripts/tests/install-linux-nixos.sh`. The second one is not currently wired into +any C# test, so porting it is a net gain in coverage, not a like-for-like move. + +Coverage targets, in rough priority order: the path guards, with cases for `/`, +`$HOME`, `$XDG_DATA_HOME`, `$HOME/.local`, a drive root, a path inside the Vintage +Story directory, a directory holding a vanilla `Vintagestory` binary with no +Optimum marker, and a symlinked install directory. The transactional installer, +with an injected failure at each of the four steps and an assertion that the +previous install came back. Prerequisite detection against fixture filesystems for +each platform. The ilspycmd version-range comparison. Session-aware data-path +detection, including the case where two candidate directories exist and only the +second has a `playeruid` in its `clientsettings.json`. + +### Optimum.Cli.Tests + +Contract conformance. The central test is a fixture that consumes the NDJSON +stream the same way RiftLauncher's `runTrackedWorker` does and asserts: + +- Every stdout line under `--json` parses as JSON and has a known `type`. +- `progress` values are integers, non-decreasing across the whole run, and never + exceed 99. +- Exactly one `result` line exists and it is the last line. +- A `result` with `"ok":false` carries a `reason` from the closed enum and a + non-empty `message`. +- The exit code agrees with `ok`. +- Nothing that is not NDJSON reaches stdout, including from a subprocess. This is + the one that will actually catch a regression, because the moment Core forgets to + redirect a script's stdout, a line of shell output lands in the middle of the + stream and the caller's parser throws. +- SIGTERM mid-run produces `{"ok":false,"reason":"cancelled"}` and leaves + `--output` empty. + +Each failure `reason` gets a test that induces it. `patch-conflict` is inducible +with a deliberately corrupted patch fixture. `unsupported-version` is inducible by +asking for a version `capabilities` does not list. `output-exists` is inducible by +pre-creating the directory. + +### Optimum.Installer.Tests + +`Avalonia.Headless.XUnit` with `[AvaloniaFact]` and `[AvaloniaTheory]`. This runs +on stock `ubuntu-latest` with plain `dotnet test` and needs no xvfb, as long as no +test enables real Skia rendering for pixel assertions. Coverage: the screen state +machine transitions, the Continue-button gating on prerequisite status, the EULA +gate, inline validation on the Options screen, and the log filter, which should get +a test asserting that a line containing `error` shows even when it is not on the +whitelist. + +View models are also testable with plain xUnit where they have no visual tree +dependency, and that is the preferred form when it is available. + +### Existing tests + +`Optimum.Tests/installer-release-coverage-tests.cs` (624 lines) and +`Optimum.Tests/installer-path-normalization-tests.cs` (88 lines) are mostly +source-text regression pins against the PowerShell installer, plus real subprocess +runs of the shell tests and of `scripts/runtime-donor-patch-gate.sh` and +`scripts/validate-patch-syntax.sh`. They stay green for as long as the scripts they +pin exist. When Phase 6 turns `scripts/install-*.sh` into shims, the source-text +pins in that file are deleted alongside the code they pin, and the subprocess runs +of the gate scripts move to `Optimum.Bootstrap.Core.Tests`. +`Optimum.Launcher.Tests/DataPathArgumentTests.cs` pins the `--dataPath` and +`datapath.cfg` contract and is untouched by this plan. + +### Definition of done + +1. A real end-to-end install on Linux and Windows from a clean machine that + finishes and launches the game into a world, verified by a person, not by a + script. On macOS the same run from a source build of `Optimum.Installer`, + unsigned, accepted through the Gatekeeper right-click bypass, since macOS has + no signed release yet. +2. `Optimum.Cli build --json` green on every job of the extended + `.github/workflows/ci-platform-bootstrap.yml`, with the NDJSON conformance + assertion running against the real stream. +3. All three new test projects green on the push workflow. +4. Every test that passes today still passing. + +## 10. CI changes + +Today `.github/workflows/ci-platform-bootstrap.yml` is the only workflow, it is +`workflow_dispatch` only, and it has five jobs: `bootstrap-windows` +(`windows-latest`), `bootstrap-macos-intel` (`macos-15-intel`), +`bootstrap-macos-arm` (`macos-14`), `bootstrap-linux` (`ubuntu-24.04`), and +`bootstrap-linux-arm` (`ubuntu-24.04-arm`). Each sets up .NET `10.0.x`, resolves +and caches the client archive, bootstraps with `--client-archive`, builds +`VintageStory.slnx -c Release`, runs `check-patches.sh --strict-unavailable`, and +runs the two test projects. The Windows job also runs `scripts/package.ps1`. + +Three changes: + +**A new push and pull-request workflow.** Runs on `ubuntu-latest` only. Builds +`Optimum.Bootstrap.Core`, `Optimum.Cli`, and `Optimum.Installer`, then runs +`dotnet test` for all three new test projects. This must not require a bootstrap, +which means the three new projects must not reference any project that depends on +decompiled sources. That constraint is worth stating explicitly because it is easy +to violate: the moment `Optimum.Bootstrap.Core.Tests` references +`Optimum.Launcher`, the workflow needs a 570 MB download and stops being a fast +pull-request gate. + +**An extension to the platform workflow.** The `bootstrap-linux` job runs +`Optimum.Cli build --json --acknowledge-decompile --client-archive ` end +to end after its existing build and pipes the stream through +`scripts/check-ndjson-stream.py`, then `Optimum.Cli validate` on the produced +package. It keeps the manual bootstrap and build steps as well, so a driver bug +is a distinct signal from a pipeline bug; the job timeout moved to 60 minutes to +cover the second pipeline run. The cached archive is already resolved by the +existing `Resolve client archive` and `Cache client archive` steps, so this adds +compute time and no new download. The other four platform jobs get the same step +once the driver has proven itself on Linux. + +**A release workflow.** Runs `vpk pack` for `win-x64` and `linux-x64` and +publishes the Velopack feed. Signing credentials for Windows come from repository +secrets. This workflow is the only one that touches signing. It builds the +`osx-arm64` and `osx-x64` binaries for archival but publishes nothing for macOS +until an Apple Developer Program account and a signing certificate exist. The +`velopack-smoke` job in `ci-installer.yml` already packs two versions and checks +the delta builds; Phase 5 adds the runtime apply check once there is an app to run +it against. + +## 11. Rollout plan + +Each phase ships independently and leaves the repository in a working state. No +phase depends on a later phase to be useful. + +**Phase 0: scaffold.** Done. `Optimum.Bootstrap.Core`, `Optimum.Cli`, +`Optimum.Installer`, and their three test projects exist, sit in a `/Installer/` +folder in `VintageStory.slnx`, and build and test through `Optimum.Installer.slnf` +without a bootstrap. `Optimum.Bootstrap.Core` carries the `ProgressPhase` and +`FailureReason` contract types; `Optimum.Cli` answers `--version`; +`Optimum.Installer` is a one-window Avalonia app with a headless render test. +`.github/workflows/ci-installer.yml` runs the tests and the `velopack-smoke` job +on push and pull request. +*Verification:* `dotnet test Optimum.Installer.slnf -c Release` is green (eleven +tests, one a headless Avalonia render) in about three seconds locally; the +workflow is expected green under five minutes. + +**Phase 1: Core fundamentals.** Done. `Optimum.Bootstrap.Core` now carries the +prerequisite model and per-platform detection (`PrerequisiteScanner`, +`DotnetSdkProbe`, the `.config/` readers, `NixEnvironment`), acquisition planning +(`SdkAcquisition` with the NixOS and non-FHS refusals, `IlspycmdAcquisition`), the +path guards (`InstallPathGuard`, `SymlinkComponentCheck`), session-aware data-path +detection (`DataPathProbe`), the NDJSON emitter (`NdjsonWriter`), and the consent +notice resource rewritten to match `LICENSE-SCOPE.md`. Every detection path goes +through the `ISystemProbe` seam so tests use an in-memory host. No build driver +yet. +*Verification:* `Optimum.Bootstrap.Core.Tests` has 75 tests covering every +path-guard case in section 9, the exact ilspycmd accept and reject values from +`scripts/tests/install-linux-prerequisites.sh`, and the NixOS and non-FHS +behaviors from `scripts/tests/install-linux-nixos.sh`. An adversarial pass against +the shell sources drove three refinements: `command -v` detection now checks the +execute bit and keeps searching past a non-executable match, the path guard +rejects a symlinked leaf rather than any symlinked ancestor, and `NdjsonWriter` +emits a `warn` and counts it when it has to adjust a caller's progress value. +`dotnet test Optimum.Installer.slnf -c Release` is green (79 tests, about six +seconds). + +**Phase 2: the CLI.** Done. The seven verbs are in `Optimum.Cli` over a Core +build layer. `ScriptBuildDriver` drives, in order, `scripts/bootstrap.*`, `dotnet +build VintageStory.slnx`, `scripts/check-patches.sh --strict-unavailable`, the +platform packaging script, and a header-level `RuntimeValidator` on the produced +package, mapping each step to a `ProgressPhase` and a `FailureReason` +(`BootstrapFailureClassifier` splits a failed bootstrap into `patch-conflict` and +`decompile-failed`). It forwards `--client-archive` to both bootstrap and +packaging so neither half re-downloads the client, locates the package per +platform (an `Optimum-v*` directory on Windows and Linux, `Optimum.app` on +macOS), and on cancellation removes only what it wrote. `build` requires +`--acknowledge-decompile`. `install` runs the Phase 1 path guard then a copy into +an empty directory plus an `InstallManifest` (it refuses a non-empty target; +in-place replace with rollback is Phase 4); `uninstall` reverses it by that +manifest and refuses an entry that resolves outside the install directory; +`validate` reads the staged assemblies' headers; `capabilities` and `preflight` +answer with a single JSON document. SIGTERM and SIGINT cancel a `build` and +produce a `cancelled` result. `scripts/check-ndjson-stream.py` is the reusable +conformance check, the twin of `Optimum.Cli.Tests/NdjsonStream.cs`. +*Verification:* `Optimum.Cli.Tests` has 13 tests including the NDJSON contract +against a scripted driver, the `patch-conflict` and `cancelled` reasons, the +no-flag gate, and a clean run with no progress anomalies. An adversarial pass +against the shell scripts drove the client-archive forwarding, the macOS package +location, the empty-output guard and the scoped cancellation cleanup, the +manifest-entry containment in `uninstall`, and `install` refusing to overwrite. +`ci-installer.yml` gained a `cli-contract` job. The `bootstrap-linux` job in +`ci-platform-bootstrap.yml` now runs `Optimum.Cli build --json +--acknowledge-decompile --client-archive` end to end through +`check-ndjson-stream.py`, then `Optimum.Cli validate`. The other four platform +jobs get the same step incrementally. + +**Phase 3: the GUI.** Done. `Optimum.Installer` is an Avalonia 12 MVVM app that +drives Core in-process, never the CLI. `MainWindowViewModel` is the wizard shell +and state machine: Prerequisites, Options, Review and consent, Progress, and +Completion. A left step rail ("Strata": four stacked layers, current lit in +copper, done layers checked) and a content header explain the user's position +and the next decision; the header resolves away on Completion so the finished +rail carries the state. Stepping back to Prerequisites and forward again keeps +the Options choices. `PrerequisitesViewModel` renders `PrerequisiteScanner` +rows, gates Continue on `BlocksBuild`, and folds every ready tool into one +summary line so only the rows that need a decision show as cards. +`OptionsViewModel` defaults the install directory per platform, runs +`InstallPathGuard` on every keystroke into an inline error, prefills a detected +data folder from `DataPathProbe` without opting into it, and shows a version +selector only when `Capabilities` reports a bridge set. `EulaViewModel` gates +accept on a scroll-to-end plus a checkbox. `ProgressViewModel` is its own +`IBuildObserver`, runs `ScriptBuildDriver` then `PackageDeployer`, and filters +raw subprocess lines through `InstallerLogFilter` into an always-visible, +auto-scrolling log pane. `CompletionViewModel` offers Launch, Try again, or +Open log. A `ViewLocator` resolves each view model to its view. `FakeSystemProbe` +moved to a plain `Optimum.Bootstrap.Core.TestSupport` project so the v2 and v3 +test projects can both use it. + +The visual layer is the "Strata" design system in `App.axaml`: a paper/ink +palette with an oxidised-copper accent and moss/clay status colours, both +themes; Spectral (SIL OFL, bundled under `Assets/Fonts/`, see `LICENSE-SCOPE.md`) +for step titles with Inter for body and a monospace face for the log; three +button tiers (primary `.accent`, secondary outline, tertiary `.ghost`) on a low +corner radius; Fluent's checkbox and text-field accents remapped onto the copper. + +*Verification:* `Optimum.Installer.Tests` has 55 tests (53 plain xUnit v3 on the +view models plus two `Avalonia.Headless.XUnit` render tests), green on +`ubuntu-latest` with no xvfb, covering the state machine transitions, the +Continue gating, the EULA gate, inline validation, the log filter, the +build-then-deploy flow against fakes, re-entrancy on double-accept, retry from a +cancelled run, temp-directory cleanup, the rail step states, the Options choices +surviving a step back, review summary, cancel confirmation, a late Cancel click +after the run finishes being a no-op (not an `ObjectDisposedException`), a +mid-run cancel leaving no unobserved task exception, and the update banner +hiding on dismiss and staying off the Review screen. An adversarial pass drove: +the Launch button running the launcher directly rather than through `xdg-open`, +the temporary build tree being deleted after the deploy, an elapsed clock that +ticks on its own timer, two-tier graceful-then-forceful cancellation through +`IBuildDriver.RunAsync`, a re-entrancy guard on accept, and a scroll-read gate +extracted to a pure `ScrollReadGate` so it is tested directly. The consent +`ScrollChanged` wiring and a real install per platform are still manual checks; +all five screens were captured headless (light and dark) through a +`CaptureRenderedFrame` harness for the visual pass, and the packaged `linux-x64` +AppImage was launched and confirmed to render with the bundled font and all +converters resolved. + +**Phase 4: unification.** Done. `PackageDeployer` is transactional on every +platform: it stages the whole new tree beside the target (so the final swap is +one rename), moves an existing Optimum install to `.optimum-backup-`, +swaps the stage in, and deletes the backup; any failure rolls back to the +previous install and the `finally` clears the stage and backup directories. A +`FailAtStep` hook drives the rollback tests. `ShortcutWriter` writes the +menu and desktop shortcuts per platform (Linux `.desktop` plus a hicolor icon, +Windows `.lnk` through `WScript.Shell`, macOS a symlink into `~/Applications`), +records their paths in the manifest, and removes them on uninstall. +`UninstallRegistration` writes and removes the Windows `Optimum_is1` uninstall +key, a no-op elsewhere. `RuntimeValidator` took option 2 from section 7: +`MetadataLoadContext` over `VintagestoryLib.dll`, no game code executed. +Session-aware data-path detection was already cross-platform (`DataPathProbe`, +Phase 1) and is used by the GUI. +*Verification:* `Optimum.Bootstrap.Core.Tests` has 106 tests. The transactional +install has a `committed` flag: a failure before the swap rolls back (tested at +the backup and swap steps, both with and without an existing install, with a +user-added file preserved across it); a failure after the swap keeps the new +install and downgrades the cleanup error to a warning; a rollback that itself +cannot restore the backup is reported as such with the backup path, not as a +success. `uninstall` runs the shortcut, registry, and `.optimum` cleanup even +when a listed entry will not delete. `RuntimeValidator` never fails a build it +cannot inspect. Windows `.lnk` and registry paths and the macOS bundle symlink +are covered by construction and need a manual check on those platforms. + +**Phase 5: distribution.** Done for `win-x64` and `linux-x64`. `Optimum.Installer` +calls `VelopackApp.Build().Run()` first thing in `Main` and carries a thin +`IUpdateService` that checks the installer's own GitHub release feed on startup +(channel per RID, so the Windows and Linux feeds do not cross) and surfaces a +non-blocking "a newer installer is available" banner, shown only on the +Prerequisites and Options screens (restarting for an update once the build is +running would abandon a half-written install). The check is a no-op when the app +is not running from a Velopack install. `vpk` is a repo-local tool +(`.config/dotnet-tools.json`). `make installer-pack INSTALLER_RID=` publishes +self-contained and packs. `.github/workflows/release-installer.yml` packs Windows, +Linux, and macOS in a matrix, uploads all four as artifacts, and a separate +`publish` job that `needs` the matrix uploads one channel at a time to a single +`installer-v` tag, so the platform jobs never race the GitHub API. +Windows signing is a placeholder gated on a job-level secret that is not yet set. +The `ci-installer.yml` `velopack-smoke` job packs the real `Optimum.Installer` for +`linux-x64` and asserts the AppImage and a delta build. +*Verification:* the local spike packed the real app (48 MB AppImage, 51 KB delta). +`Optimum.Installer.Tests` covers the banner appearing for an available update, the +update command applying it, no banner otherwise, and the banner hiding once the +build starts (`FakeUpdateService`). A signed Windows installer, the +Gatekeeper-free first run, and a runtime delta-apply still need a clean Windows +and Linux machine. + +Open risk: installer releases land in the game repository's release list on the +`installer-v` tag, and the installer version tracks the shared `VERSION` +file. A dedicated releases repository would separate them; that is a follow-up. + +**Phase 6: documentation and deprecation.** Mostly done. `LICENSE-SCOPE.md` now +lists all six installer projects, the test-support project, `Optimum.Installer.slnf`, +and this file under MIT. `README.md` leads with a graphical-installer and a +command-line section, with the per-platform scripts kept below as the original +path. `CONTRIBUTING.md` documents `make installer-test` and `make installer-pack`. +`scripts/uninstall.sh` is rewritten: it delegates to `optimum uninstall` for a +manifest-based install and falls back to the legacy overlay removal, so it +actually removes an install now. `scripts/install-linux.sh` and +`scripts/install-windows.ps1` carry a notice pointing at the maintained path but +stay functional. + +Deferred: turning `install-linux.sh` and `install-windows.ps1` into thin shims +over `Optimum.Cli`. Replacing a working installer with an unverified shim is +exactly what section 13's own risk notes warn against; the swap waits for a green +`Optimum.Cli build` end to end on every platform CI job (the `bootstrap-linux` +job runs it today; the other four are pending). `scripts/install-macos.sh` and +the legacy `install-*-legacy` files also stay until then. +*Verification:* a fresh clone's `README.md` documents the installer and CLI first; +`scripts/uninstall.sh` removes a manifest-based install by delegating to +`optimum uninstall` and a legacy install by its file list. + +**Standalone source acquisition.** Done after the first packaged AppImage test +showed that requiring the current directory to be an Optimum checkout made the +published installer unusable. Core now owns a reusable `ISourceProvider` and a +transactional shallow Git clone into the per-version user cache. The GUI offers +the clone on its Prerequisites screen and carries the resolved root through the +rest of the wizard. The CLI exposes the same path through `build +--acquire-source` and `--source-cache`. The Vintage Story client and the pinned +Anego forks are still fetched later by `scripts/bootstrap.*`; they are never +added to the installer package or the Optimum source cache. + +**Phase 7 (out of scope, documented only).** The RiftLauncher managed-tool slice, +built in the RiftLauncher repository against the contract in section 4. It is a +standard feature slice there: a domain service, a port, an IPC channel group, a +handler, and a renderer adapter, with the engine spawned through the existing +worker pool driver. It also needs a consent screen that shows the decompilation +notice and collects an acknowledgment before the first `build`, because +`Optimum.Cli` refuses `build` without `--acknowledge-decompile`. That screen is +part of the slice, not an afterthought. + +## 12. Risks and open questions + +**The SDK bootstrapping paradox.** Resolved by shipping self-contained, at a cost +of roughly 55 to 60 MB per RID. Recorded here because a future contributor will +propose framework-dependent publishing to shrink the download and will be right +about the size and wrong about the outcome. + +**Velopack on .NET 10.** A local spike on 2026-08-27 packed a net10.0 +self-contained console app with Velopack 1.2.0 for `linux-x64` and `win-x64`, +including a delta package, and the `velopack-smoke` job in `ci-installer.yml` +repeats that check on every relevant push. The spike did not apply an update at +runtime; that check waits for Phase 5 and a running app. If Velopack proves +unworkable, the fallback is per-platform packaging with no auto-update, which is +what the project has today. + +**macOS is deferred.** The project has decided not to obtain an Apple Developer +Program account yet, so there is no signed macOS release. The risk is that a macOS +user finds `scripts/install-macos.sh`, which is broken in the ways section 6 +lists. Mitigation: `Optimum.Installer` builds and runs on macOS from source and +uses the standalone-package model, so a macOS user who builds it gets a working +install; the broken script stays only because removing it before a replacement +ships would leave macOS with nothing. Revisit the account when downloads or issues +show macOS demand. + +**Parcel versus Velopack for macOS.** Decided: Velopack `.pkg`. See section 8. + +**The scripts remain a dependency.** After Phase 6 the installer still needs bash +on Linux and macOS and PowerShell on Windows, because `scripts/bootstrap.sh` and +`scripts/bootstrap.ps1` are the execution layer. That is acceptable: both are +present on their platforms by default, and Windows already needs Windows +PowerShell 5.1 for other reasons (`Test-WindowsPowerShell51`, +`scripts/install-windows.ps1:456`). It does mean the installer is not a single +self-contained binary and should not be described as one. + +**Trimming.** Do not trim. Recorded in section 8. + +**Scope.** This is a large piece of work and the phases must stay independently +shippable. A half-finished Phase 4 that leaves the transactional installer on +Windows only is exactly the state the repository is in today, which is survivable. +A half-finished Phase 2 that leaves the NDJSON contract partly implemented is not, +because RiftLauncher would build against it. + +**The build is heavy.** A 570 MB download plus a multi-minute compile. The +progress screen needs honest estimates rather than a marquee, and the download +cache in `.vanilla/archives/` (`scripts/bootstrap.sh:321`) needs to survive a +cancelled install so a retry does not re-download. The Windows bootstrap already +writes to a `.partial` file and moves it into place on completion +(`scripts/bootstrap.ps1:559`, `:568`); the same discipline should apply everywhere. + +**Legacy macOS overlay users.** Migration path described in section 6. The risk is +that a user who installed with the old script and then installs with the new one +ends up with two copies of the game and no obvious way to tell which is which. + +**The EULA is legally load-bearing.** Resolved: local decompilation needs the +user's explicit consent, so posture C applies. The GUI gates on a checkbox, +`Optimum.Cli build` requires `--acknowledge-decompile`, and RiftLauncher renders +the text and passes the flag. The remaining work is drafting the consent text in +Phase 1 and getting it a legal review before the first release. This is the one +item on the list that puts a hard dependency on another team's feature: the +RiftLauncher slice cannot ship until it has a consent UI, so Phase 7 has to plan +for that rather than treating the spawn as a bare process call. + +**The EULA text is stale.** `scripts/install-windows.ps1:1964` tells the user that +"Optimum is licensed under the GNU General Public License v3.0 with the Commons +Clause restriction." `LICENSE-SCOPE.md:5-30` says the MIT license in `LICENSE-MIT` +applies to a listed set of paths and only the remainder falls under +`LICENSE-OPTIMUM-LEGACY-GPL-COMMONS`. The EULA text must be rewritten to match the +audit before it is copied into Core. Separately, +`scripts/install-windows.ps1:2008` sets `$script:eulaScrolledToEnd = $true` +unconditionally, so the scroll-to-end gate the surrounding code implies is inert. +The new modal should either gate on scroll properly or drop the pretense. + +## 13. Files and surfaces touched + +### New + +- `Optimum.Bootstrap.Core/` (class library, MIT) +- `Optimum.Bootstrap.Core.TestSupport/` (shared fakes, no test framework, MIT) +- `Optimum.Bootstrap.Core.Tests/` (xUnit v2) +- `Optimum.Cli/` (console application, MIT, `AssemblyName` `optimum`) +- `Optimum.Cli.Tests/` (xUnit v2, NDJSON conformance) +- `Optimum.Installer/` (Avalonia 12 MVVM application, MIT) +- `Optimum.Installer.Tests/` (Avalonia.Headless.XUnit, xUnit v3) +- `Optimum.Installer.slnf` (solution filter over the installer projects, for a + bootstrap-free build) +- `.github/workflows/ci-installer.yml` (push and pull request: the test job, the + `cli-contract` job, and the `velopack-smoke` job) +- `scripts/check-ndjson-stream.py` (the reusable NDJSON conformance check) +- `.github/workflows/release-installer.yml` (Velopack pack for Windows, Linux, + macOS; publishes Windows and Linux) +- `INSTALLER-PLAN.md` (this file) + +### Modified + +- `VintageStory.slnx`: a new `/Installer/` folder with the six new projects. +- `Makefile`: new targets for building, testing, and packaging the installer. + While in there, fix `Makefile:36-40`. Line 37 conditionally appends + `--client-archive` to `BOOTSTRAP_ARGS`, and line 40 then unconditionally + reassigns `BOOTSTRAP_ARGS := --version $(VERSION)`, so `make bootstrap + CLIENT_ARCHIVE=...` and `make refresh CLIENT_ARCHIVE=...` silently drop the + archive and re-download 570 MB. +- `.github/workflows/ci-platform-bootstrap.yml`: an `Optimum.Cli build --json` + step plus a conformance assertion in each of the five jobs. +- `README.md`: one documented install path per platform. +- `LICENSE-SCOPE.md`: add `Optimum.Bootstrap.Core/**`, + `Optimum.Bootstrap.Core.Tests/**`, `Optimum.Cli/**`, `Optimum.Cli.Tests/**`, + `Optimum.Installer/**`, and `Optimum.Installer.Tests/**` to the MIT list. +- `CONTRIBUTING.md`: the new build and test commands. + +### Kept as the execution layer + +`scripts/bootstrap.sh`, `scripts/bootstrap.ps1`, `scripts/package-linux.sh`, +`scripts/package-macos.sh`, `scripts/package.ps1`, `scripts/package-all.sh`, +`scripts/prepare-runtime-donors.ps1`, `scripts/prepare-runtime-donors.sh`, +`scripts/check-prereqs.sh`, `scripts/check-patches.sh`, +`scripts/validate-patch-syntax.sh`, `scripts/runtime-donor-patch-gate.sh`, and the +fixup scripts `scripts/fix-base-ctor-calls.py`, `scripts/fix-closure-class.pl`, and +`scripts/fix-event-reads.py`. + +### Deprecation status + +- `scripts/uninstall.sh` is done: it delegates to `optimum uninstall` for a + manifest-based install and keeps the legacy overlay removal as a fallback. +- `scripts/install-linux.sh` and `scripts/install-windows.ps1` carry a notice but + stay functional. They become shims over `Optimum.Cli` once `Optimum.Cli build` + is green end to end on every platform CI job. +- `scripts/install-macos.sh` stays until a signed macOS release exists; its + removal and the overlay-model retirement wait for the Apple Developer account. +- `scripts/uninstall.ps1` stays as long as it is byte-identical to the copy the + Windows package ships. Core's uninstaller generation should produce that file + rather than keeping two copies in sync by hand. +- `scripts/install-linux-legacy.sh` and `scripts/install-windows-legacy.ps1` go + with the shim swap. diff --git a/LICENSE-SCOPE.md b/LICENSE-SCOPE.md index 54dbc1e..130be9b 100644 --- a/LICENSE-SCOPE.md +++ b/LICENSE-SCOPE.md @@ -9,6 +9,13 @@ project files in these paths: - `.config/**` - `.github/**` +- `Optimum.Bootstrap.Core/**` +- `Optimum.Bootstrap.Core.TestSupport/**` +- `Optimum.Bootstrap.Core.Tests/**` +- `Optimum.Cli/**` +- `Optimum.Cli.Tests/**` +- `Optimum.Installer/**` +- `Optimum.Installer.Tests/**` - `Optimum.Launcher/**` - `Optimum.Launcher.Tests/**` - `Optimum.Patcher/**` @@ -18,8 +25,10 @@ project files in these paths: - `scripts/**` - `Directory.Build.props` - `Directory.Build.targets` +- `INSTALLER-PLAN.md` - `Makefile` - `NuGet.config` +- `Optimum.Installer.slnf` - `VERSION` - `VintageStory-core.slnf` - `VintageStory.slnx` @@ -29,6 +38,13 @@ project files in these paths: - `install-windows.cmd` - `install-windows.ps1` +## Bundled fonts + +- `Optimum.Installer/Assets/Fonts/Spectral-*.ttf` — Spectral, © 2017 The Spectral + Project Authors, licensed under the SIL Open Font License 1.1. The full licence + text is alongside the files in `Optimum.Installer/Assets/Fonts/OFL-Spectral.txt`. + The MIT grant above does not extend to these files. + ## Historical and upstream terms These paths remain outside the MIT grant: diff --git a/Makefile b/Makefile index a5ba463..2f346e8 100644 --- a/Makefile +++ b/Makefile @@ -200,3 +200,26 @@ package-macos: build ## Package macOS (.dmg/.app); ARCH=arm64 or x64 package-win: build ## Package Windows x64 (folder + zip); needs innoextract >= 1.11 off-Windows without package-client cache pwsh scripts/package.ps1 -Zip -Version $(VERSION) $(if $(CLIENT_ARCHIVE),-ClientArchive "$(CLIENT_ARCHIVE)") + +# --------------------------------------------------------------------------- +# The Avalonia installer app (INSTALLER-PLAN.md). Independent of the game +# build: no bootstrap, no decompile. +# --------------------------------------------------------------------------- + +INSTALLER_RID ?= linux-x64 + +installer-test: ## Build and test the installer projects (no bootstrap) + dotnet test Optimum.Installer.slnf -c Release --nologo + +installer-publish: ## Publish the installer app self-contained for INSTALLER_RID + dotnet publish Optimum.Installer/Optimum.Installer.csproj -c Release \ + -r $(INSTALLER_RID) --self-contained -o dist/installer/$(INSTALLER_RID) --nologo + +installer-pack: installer-publish ## Package the installer with Velopack for INSTALLER_RID + dotnet tool restore + dotnet vpk pack \ + --packId Optimum.Installer \ + --packVersion $(shell cat VERSION) \ + --packDir dist/installer/$(INSTALLER_RID) \ + --mainExe $(if $(filter win-x64,$(INSTALLER_RID)),Optimum.Installer.exe,Optimum.Installer) \ + --outputDir dist/installer/releases diff --git a/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs new file mode 100644 index 0000000..b7d3149 --- /dev/null +++ b/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs @@ -0,0 +1,97 @@ +using System.Runtime.InteropServices; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Tests; + +/// In-memory for detection tests. +public sealed class FakeSystemProbe : ISystemProbe +{ + public OsKind Os { get; set; } = OsKind.Linux; + public Architecture Arch { get; set; } = Architecture.X64; + public string HomeDirectory { get; set; } = "/home/tester"; + + public Dictionary Environment { get; } = new(); + public List Path { get; } = []; + public HashSet Files { get; } = new(); + public HashSet Directories { get; } = new(); + public HashSet Symlinks { get; } = new(); + + /// Files that exist but lack an execute bit. Everything else in is executable. + public HashSet NonExecutable { get; } = new(); + public Dictionary FileContents { get; } = new(); + + /// Keyed on "exe|arg1 arg2". Falls back to . + public Dictionary Commands { get; } = new(); + + public FakeSystemProbe AddFile(string path, string? content = null) + { + Files.Add(path); + if (content is not null) + FileContents[path] = content; + return this; + } + + public FakeSystemProbe AddDirectory(string path) + { + Directories.Add(path); + return this; + } + + public FakeSystemProbe AddSymlink(string path) + { + Symlinks.Add(path); + return this; + } + + public FakeSystemProbe AddNonExecutableFile(string path) + { + Files.Add(path); + NonExecutable.Add(path); + return this; + } + + public FakeSystemProbe OnCommand(string exe, string args, string stdout = "", int exitCode = 0) + { + Commands[$"{exe}|{args}"] = new ProcessOutcome(true, exitCode, stdout, string.Empty); + return this; + } + + string? ISystemProbe.GetEnvironmentVariable(string name) => + Environment.TryGetValue(name, out string? value) ? value : null; + + IReadOnlyList ISystemProbe.PathDirectories => Path; + + bool ISystemProbe.FileExists(string path) => Files.Contains(path); + + bool ISystemProbe.IsExecutable(string path) => Files.Contains(path) && !NonExecutable.Contains(path); + + bool ISystemProbe.DirectoryExists(string path) => Directories.Contains(path); + + bool ISystemProbe.PathExists(string path) => + Files.Contains(path) || Directories.Contains(path) || Symlinks.Contains(path); + + bool ISystemProbe.IsSymbolicLink(string path) => Symlinks.Contains(path); + + string? ISystemProbe.ReadText(string path) => + FileContents.TryGetValue(path, out string? content) ? content : null; + + IEnumerable ISystemProbe.EnumerateFiles(string directory, string searchPattern) => + Files.Where(f => System.IO.Path.GetDirectoryName(f) == directory && Matches(f, searchPattern)); + + IEnumerable ISystemProbe.EnumerateDirectories(string directory, string searchPattern) => + Directories.Where(d => System.IO.Path.GetDirectoryName(d) == directory && Matches(d, searchPattern)); + + private static bool Matches(string path, string searchPattern) + { + if (searchPattern == "*") + return true; + string name = System.IO.Path.GetFileName(path); + string regex = "^" + System.Text.RegularExpressions.Regex.Escape(searchPattern).Replace("\\*", ".*") + "$"; + return System.Text.RegularExpressions.Regex.IsMatch(name, regex); + } + + ProcessOutcome ISystemProbe.Run(string executable, IReadOnlyList arguments, TimeSpan timeout) => + Commands.TryGetValue($"{executable}|{string.Join(' ', arguments)}", out ProcessOutcome outcome) + ? outcome + : ProcessOutcome.NotStarted; +} diff --git a/Optimum.Bootstrap.Core.TestSupport/Optimum.Bootstrap.Core.TestSupport.csproj b/Optimum.Bootstrap.Core.TestSupport/Optimum.Bootstrap.Core.TestSupport.csproj new file mode 100644 index 0000000..b71689e --- /dev/null +++ b/Optimum.Bootstrap.Core.TestSupport/Optimum.Bootstrap.Core.TestSupport.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Optimum.Bootstrap.Core.TestSupport + enable + enable + false + Shared fakes for the installer test projects. No test framework dependency, so v2 and v3 test projects can both use it. + + + + + diff --git a/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs new file mode 100644 index 0000000..af1ef80 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs @@ -0,0 +1,93 @@ +using Optimum.Bootstrap.Core.Acquisition; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class AcquisitionTests +{ + [Fact] + public void AppimagetoolDownloadUsesRedirectsAndFailsOnHttpErrors() + { + var acquisition = new AppimagetoolAcquisition(new FakeSystemProbe(), "https://example.test/tool.AppImage"); + + Assert.Equal( + ["--location", "--fail", "--show-error", "--output", "/tmp/tool.partial", "https://example.test/tool.AppImage"], + acquisition.DownloadArguments("/tmp/tool.partial")); + } + + [Fact] + public async Task AppimagetoolDownloadProducesAnExecutableTool() + { + if (!OperatingSystem.IsLinux() + || System.Runtime.InteropServices.RuntimeInformation.OSArchitecture + != System.Runtime.InteropServices.Architecture.X64) + return; + + string testRoot = Path.Combine(Path.GetTempPath(), "optimum-appimagetool-test-" + Guid.NewGuid().ToString("N")); + string source = Path.Combine(testRoot, "source.AppImage"); + string repoRoot = Path.Combine(testRoot, "repo"); + + try + { + Directory.CreateDirectory(repoRoot); + await File.WriteAllTextAsync(source, "appimagetool test payload"); + var acquisition = new AppimagetoolAcquisition(SystemProbe.Default, new Uri(source).AbsoluteUri); + + ToolAcquisitionResult result = await acquisition.InstallAsync( + repoRoot, NullBuildObserver.Instance, CancellationToken.None); + + string target = AppimagetoolAcquisition.TargetPath(repoRoot); + Assert.True(result.Ok, result.Message); + Assert.Equal(target, result.InstalledPath); + Assert.Equal("appimagetool test payload", await File.ReadAllTextAsync(target)); + Assert.True(SystemProbe.Default.IsExecutable(target)); + Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(target)!, "*.partial-*")); + } + finally + { + if (Directory.Exists(testRoot)) + Directory.Delete(testRoot, recursive: true); + } + } + + [Fact] + public void IlspycmdToolArgumentsMatchTheLoggedInvocation() + { + // scripts/tests/install-linux-prerequisites.sh asserts exactly this line. + Assert.Equal( + "tool update -g ilspycmd --version 10.1.1.8388 --allow-downgrade", + string.Join(' ', IlspycmdAcquisition.ToolArguments("10.1.1.8388"))); + } + + [Fact] + public void SdkPlanHonoursGlobalJsonWhenItIsPresent() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + probe.AddFile("/repo/global.json"); + + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(probe, "/repo"); + + Assert.True(decision.CanRunScript); + Assert.NotNull(decision.Plan); + Assert.Contains("--jsonfile", decision.Plan!.Arguments); + Assert.Contains("/repo/global.json", decision.Plan.Arguments); + Assert.Contains("--no-path", decision.Plan.Arguments); + Assert.EndsWith("dotnet-install.sh", decision.Plan.ScriptUrl); + } + + [Fact] + public void SdkPlanFallsBackToTheChannelWhenGlobalJsonIsAbsent() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(probe, "/repo"); + + Assert.NotNull(decision.Plan); + Assert.Contains("--channel", decision.Plan!.Arguments); + Assert.Contains("10.0", decision.Plan.Arguments); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs new file mode 100644 index 0000000..2f52c21 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs @@ -0,0 +1,203 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Platform; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class CapabilitiesTests +{ + [Fact] + public void ReportsThePinnedVersionBridgeVersionsAndPatchSets() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + probe.AddDirectory("/repo/patches-1.22.6-bridge"); + probe.AddDirectory("/repo/patches/vsapi"); + probe.AddDirectory("/repo/patches/runtime"); + + EngineCapabilities caps = Capabilities.Read(probe, "/repo"); + + Assert.Equal("1.22.7", caps.PinnedVersion); + Assert.Equal(["1.22.7", "1.22.6"], caps.SupportedVersions); + Assert.Equal(["runtime", "vsapi"], caps.PatchSets); + } + + [Fact] + public void FallsBackWhenForksJsonIsMissing() + { + Assert.Equal("1.22.7", Capabilities.Read(new FakeSystemProbe(), "/repo").PinnedVersion); + } +} + +public class PackageLayoutTests +{ + [Fact] + public void AcceptsADirectoryWithALauncherAndTheOptimumMarker() + { + var probe = new FakeSystemProbe(); + probe.AddDirectory("/pkg"); + probe.AddFile("/pkg/run.sh"); + probe.AddDirectory("/pkg/.optimum"); + + Assert.True(PackageLayout.Validate(probe, "/pkg").Ok); + } + + [Fact] + public void FlagsAMissingMarkerDirectory() + { + var probe = new FakeSystemProbe(); + probe.AddDirectory("/pkg"); + probe.AddFile("/pkg/Optimum"); + + PackageLayoutResult result = PackageLayout.Validate(probe, "/pkg"); + Assert.False(result.Ok); + Assert.Contains(result.Problems, p => p.Contains(".optimum")); + } + + [Fact] + public void FlagsAMissingDirectory() + { + Assert.False(PackageLayout.Validate(new FakeSystemProbe(), "/nowhere").Ok); + } +} + +public class InstallManifestTests +{ + [Fact] + public void RoundTrips() + { + var manifest = new InstallManifest + { + OptimumVersion = "0.3.14", + InstalledAtUtc = DateTimeOffset.Parse("2026-08-27T12:00:00Z"), + InstallDirectory = "/home/tester/games/optimum", + DataPath = "/home/tester/.config/VintagestoryData", + Launcher = "/home/tester/games/optimum/optimum-launch.sh", + Entries = ["run.sh", "assets", ".optimum"], + }; + + InstallManifest? back = InstallManifest.Deserialize(manifest.Serialize()); + + Assert.NotNull(back); + Assert.Equal(manifest.OptimumVersion, back!.OptimumVersion); + Assert.Equal(manifest.Entries, back.Entries); + Assert.Equal(manifest.DataPath, back.DataPath); + } + + [Fact] + public void DeserializeReturnsNullOnGarbage() + { + Assert.Null(InstallManifest.Deserialize("{ not json")); + } +} + +public class BootstrapFailureClassifierTests +{ + [Theory] + [InlineData("error: patch failed: build/Vintagestory/foo.cs:12")] + [InlineData("Checking patch ...\nhunk #3 FAILED at 210")] + [InlineData("Saved rejects in patches/vsapi/0007-x.patch.rej")] + [InlineData("error: patches/vssurvivalmod/0002-thing.patch: No such file")] + public void PatchDiagnosticsClassifyAsPatchConflict(string output) + { + Assert.Equal(FailureReason.PatchConflict, BootstrapFailureClassifier.Classify(output)); + } + + [Theory] + [InlineData("curl: (22) The requested URL returned error: 404")] + [InlineData("ilspycmd: could not decompile VintagestoryLib.dll")] + [InlineData("")] + public void EverythingElseClassifiesAsDecompileFailed(string output) + { + Assert.Equal(FailureReason.DecompileFailed, BootstrapFailureClassifier.Classify(output)); + } +} + +public class ScriptBuildDriverPreconditionTests +{ + private static FakeSystemProbe ReadyProbe() + { + var probe = new FakeSystemProbe(); + probe.Path.Add("/usr/bin"); + foreach (string tool in new[] { "git", "perl", "python3", "curl", "tar", "chmod", "pwsh", "bash" }) + probe.AddFile($"/usr/bin/{tool}"); + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/usr/bin/dotnet"; + probe.AddFile("/usr/bin/dotnet"); + probe.OnCommand("/usr/bin/dotnet", "--list-sdks", "10.0.100 [/x]\n"); + probe.OnCommand("/usr/bin/dotnet", "--version", "10.0.100\n"); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + return probe; + } + + [Fact] + public async Task RefusesWithBadInputWhenRequiredToolsAreMissing() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + + BuildResult result = await new ScriptBuildDriver(probe).RunAsync( + new BuildRequest("/repo", "/tmp/does-not-run"), NullBuildObserver.Instance, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.BadInput, result.Reason); + Assert.Contains(".NET SDK", result.Message); + } + + [Fact] + public async Task RefusesAnOutputDirectoryThatHoldsOnlyASubdirectory() + { + FakeSystemProbe probe = ReadyProbe(); + probe.AddDirectory("/out"); + probe.AddDirectory("/out/Optimum-v0.3.13-linux-x64"); + + BuildResult result = await new ScriptBuildDriver(probe).RunAsync( + new BuildRequest("/repo", "/out"), NullBuildObserver.Instance, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.OutputExists, result.Reason); + } + + [Fact] + public async Task AnUnspawnableStepExecutableFailsTheBuildInsteadOfHangingTheWizard() + { + // A Windows-shaped probe whose PowerShell "exists" only on the fake + // PATH: the build passes preconditions, then RunStep tries to spawn a + // real `powershell` that is not on this (Linux) test host. The spawn + // failure must come back as a classified BuildResult, not an exception. + string root = Path.Combine(Path.GetTempPath(), "optimum-spawn-" + Guid.NewGuid().ToString("N")[..8]); + string repo = Path.Combine(root, "repo"); + string output = Path.Combine(root, "out"); + Directory.CreateDirectory(repo); + File.WriteAllText(Path.Combine(repo, "forks.json"), """{ "vintageStoryVersion": "1.22.7" }"""); + + var probe = new FakeSystemProbe { Os = OsKind.Windows }; + probe.Path.Add("C:/ps"); + probe.AddFile("C:/ps/powershell.exe"); + probe.Path.Add("C:/git"); + probe.AddFile("C:/git/git.exe"); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "C:/dotnet/dotnet.exe"; + probe.AddFile("C:/dotnet/dotnet.exe"); + probe.OnCommand("C:/dotnet/dotnet.exe", "--list-sdks", "10.0.100 [C:\\sdk]\n"); + probe.OnCommand("C:/dotnet/dotnet.exe", "--version", "10.0.100\n"); + probe.AddFile(Path.Combine(repo, "forks.json"), """{ "vintageStoryVersion": "1.22.7" }"""); + + try + { + BuildResult result = await new ScriptBuildDriver(probe).RunAsync( + new BuildRequest(repo, output), NullBuildObserver.Instance, CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.False(result.Ok); + Assert.NotNull(result.Message); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/Optimum.Bootstrap.Core.Tests/ConsentNoticeTests.cs b/Optimum.Bootstrap.Core.Tests/ConsentNoticeTests.cs new file mode 100644 index 0000000..d82b3b8 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/ConsentNoticeTests.cs @@ -0,0 +1,36 @@ +using Optimum.Bootstrap.Core.Licensing; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class ConsentNoticeTests +{ + [Fact] + public void TheNoticeLoadsAndNamesTheDecompilation() + { + Assert.Contains("decompil", ConsentNotice.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TheNoticeMatchesTheLicenseAudit() + { + // LICENSE-SCOPE.md grants MIT to a listed path set; the whole-project + // "GPLv3 with the Commons Clause" claim in install-windows.ps1 is wrong + // and must not survive into Core. + Assert.Contains("MIT", ConsentNotice.Text); + Assert.Contains("LICENSE-SCOPE.md", ConsentNotice.Text); + Assert.DoesNotContain("Commons Clause restriction", ConsentNotice.Text); + } + + [Fact] + public void TheNoticeStatesOptimumDoesNotRedistributeGameCode() + { + Assert.Contains("redistribute", ConsentNotice.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TheAcknowledgeFlagIsTheOneTheCliRequires() + { + Assert.Equal("--acknowledge-decompile", ConsentNotice.AcknowledgeFlag); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/DataPathProbeTests.cs b/Optimum.Bootstrap.Core.Tests/DataPathProbeTests.cs new file mode 100644 index 0000000..6a1215c --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/DataPathProbeTests.cs @@ -0,0 +1,42 @@ +using Optimum.Bootstrap.Core.DataPath; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// Ports the prompt_data_path heuristic from scripts/install-linux.sh. +public class DataPathProbeTests +{ + [Fact] + public void PrefersACandidateWithAnActiveSessionOverOneThatMerelyExists() + { + var probe = new FakeSystemProbe(); + probe.AddDirectory("/home/tester/.config/VintagestoryData"); + probe.AddDirectory("/home/tester/.config/OptimumVintagestoryData"); + probe.AddFile("/home/tester/.config/OptimumVintagestoryData/clientsettings.json", + """{ "playeruid": "abc123" }"""); + + DataPathDetection detection = DataPathProbe.Detect(probe); + + Assert.Equal("/home/tester/.config/OptimumVintagestoryData", detection.Path); + Assert.True(detection.HasActiveSession); + } + + [Fact] + public void FallsBackToTheFirstDirectoryThatExists() + { + var probe = new FakeSystemProbe(); + probe.AddDirectory("/home/tester/.config/VintagestoryData"); + + DataPathDetection detection = DataPathProbe.Detect(probe); + + Assert.Equal("/home/tester/.config/VintagestoryData", detection.Path); + Assert.False(detection.HasActiveSession); + } + + [Fact] + public void ReturnsNothingWhenNoCandidateExists() + { + DataPathDetection detection = DataPathProbe.Detect(new FakeSystemProbe()); + Assert.Null(detection.Path); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs new file mode 100644 index 0000000..6d5b6cd --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs @@ -0,0 +1,287 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Platform; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// +/// PackageDeployer and Uninstaller do real filesystem work, so these run against +/// temp directories with a real . +/// +public sealed class DeployRoundTripTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("optimum-deploy-test").FullName; + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private string StagePackage() + { + string package = Path.Combine(_root, "staged", "Optimum-v0.3.14-linux-x64"); + Directory.CreateDirectory(Path.Combine(package, ".optimum")); + Directory.CreateDirectory(Path.Combine(package, "assets")); + File.WriteAllText(Path.Combine(package, "run.sh"), "#!/bin/sh\nexec ./Optimum\n"); + File.WriteAllText(Path.Combine(package, "Optimum"), "binary"); + File.WriteAllText(Path.Combine(package, "assets", "gameicon.png"), "png"); + File.WriteAllText(Path.Combine(package, ".optimum", "version"), "0.3.14"); + return package; + } + + [Fact] + public void DeployThenUninstallLeavesNothingBehind() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + string dataPath = Path.Combine(_root, "data"); + + DeployResult deploy = new PackageDeployer(probe).Deploy( + new DeployRequest(package, installDir, dataPath)); + + Assert.True(deploy.Ok, deploy.Message); + Assert.True(File.Exists(Path.Combine(installDir, "run.sh"))); + Assert.True(File.Exists(Path.Combine(installDir, "assets", "gameicon.png"))); + Assert.True(File.Exists(Path.Combine(installDir, InstallManifest.RelativePath))); + Assert.Equal(dataPath, File.ReadAllText(Path.Combine(installDir, "datapath.cfg"))); + + string launcherName = probe.Os == OsKind.Windows ? "optimum-launch.cmd" : "optimum-launch.sh"; + Assert.True(File.Exists(Path.Combine(installDir, launcherName))); + + InstallManifest manifest = InstallManifest.Deserialize( + File.ReadAllText(Path.Combine(installDir, InstallManifest.RelativePath)))!; + Assert.Equal("0.3.14", manifest.OptimumVersion); + + UninstallResult uninstall = new Uninstaller(probe).Uninstall(installDir); + + Assert.True(uninstall.Ok); + Assert.False(Directory.Exists(installDir)); + } + + [Fact] + public void DeployRefusesANonEmptyDirectoryWithNoManifest() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string occupied = Path.Combine(_root, "occupied"); + Directory.CreateDirectory(occupied); + File.WriteAllText(Path.Combine(occupied, "someone-elses-file"), "x"); + + DeployResult result = new PackageDeployer(probe).Deploy(new DeployRequest(package, occupied)); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.OutputExists, result.Reason); + Assert.True(File.Exists(Path.Combine(occupied, "someone-elses-file"))); + } + + [Fact] + public void DeployReplacesAnExistingOptimumInstallInPlace() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + File.WriteAllText(Path.Combine(installDir, "stale-from-old-install"), "old"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + + Assert.False(File.Exists(Path.Combine(installDir, "stale-from-old-install"))); + Assert.True(File.Exists(Path.Combine(installDir, "run.sh"))); + // No stage or backup directories left behind. + Assert.Empty(Directory.EnumerateDirectories(Path.GetDirectoryName(installDir)!, ".optimum-*")); + } + + [Fact] + public void AFailureDuringTheSwapRollsBackToThePreviousInstall() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + string manifestBefore = File.ReadAllText(Path.Combine(installDir, InstallManifest.RelativePath)); + File.WriteAllText(Path.Combine(installDir, "user-added-mod"), "keep across a failed reinstall"); + + var deployer = new PackageDeployer(probe) + { + FailAtStep = step => { if (step == "swap") throw new IOException("simulated swap failure"); }, + }; + DeployResult result = deployer.Deploy(new DeployRequest(package, installDir)); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.EngineInternal, result.Reason); + Assert.Contains("rolled back", result.Message); + // The previous install is intact, including the user's file. + Assert.True(File.Exists(Path.Combine(installDir, "user-added-mod"))); + Assert.Equal(manifestBefore, File.ReadAllText(Path.Combine(installDir, InstallManifest.RelativePath))); + Assert.Empty(Directory.EnumerateDirectories(Path.GetDirectoryName(installDir)!, ".optimum-*")); + } + + [Fact] + public void AFailureAfterTheSwapKeepsTheNewInstall() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + File.WriteAllText(Path.Combine(installDir, "from-the-old-install"), "old"); + + var deployer = new PackageDeployer(probe) + { + FailAtStep = step => { if (step == "commit") throw new IOException("simulated post-swap failure"); }, + }; + DeployResult result = deployer.Deploy(new DeployRequest(package, installDir)); + + // Past the swap the install is complete; a cleanup failure is not fatal. + Assert.True(result.Ok, result.Message); + Assert.False(File.Exists(Path.Combine(installDir, "from-the-old-install"))); + Assert.True(File.Exists(Path.Combine(installDir, "run.sh"))); + Assert.True(File.Exists(Path.Combine(installDir, InstallManifest.RelativePath))); + } + + [Fact] + public void AFailureBeforeTheSwapOnAFreshInstallLeavesNothing() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + + var deployer = new PackageDeployer(probe) + { + FailAtStep = step => { if (step == "backup") throw new IOException("simulated early failure"); }, + }; + DeployResult result = deployer.Deploy(new DeployRequest(package, installDir)); + + Assert.False(result.Ok); + Assert.False(Directory.Exists(installDir)); + Assert.Empty(Directory.EnumerateDirectories(Path.Combine(_root, "install"), ".optimum-*")); + } + + [Fact] + public void ManifestRecordsTheVersionFromThePackageDirectoryName() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + + InstallManifest manifest = InstallManifest.Deserialize( + File.ReadAllText(Path.Combine(installDir, InstallManifest.RelativePath)))!; + Assert.Equal("0.3.14", manifest.OptimumVersion); + } + + [Fact] + public void UninstallSkipsAManifestEntryThatEscapesTheInstallDirectory() + { + var probe = SystemProbe.Default; + string installDir = Path.Combine(_root, "install", "optimum"); + Directory.CreateDirectory(Path.Combine(installDir, ".optimum")); + string outside = Path.Combine(_root, "outside.txt"); + File.WriteAllText(outside, "do not touch"); + + var manifest = new InstallManifest + { + OptimumVersion = "0.3.14", + InstalledAtUtc = DateTimeOffset.UtcNow, + InstallDirectory = installDir, + Entries = ["../outside.txt", "run.sh"], + }; + File.WriteAllText(Path.Combine(installDir, InstallManifest.RelativePath), manifest.Serialize()); + File.WriteAllText(Path.Combine(installDir, "run.sh"), "x"); + + UninstallResult result = new Uninstaller(probe).Uninstall(installDir); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.BadInput, result.Reason); + Assert.True(File.Exists(outside)); + } + + [Fact] + public void MenuShortcutIsRecordedInTheManifestAndRemovedByUninstall() + { + if (OperatingSystem.IsWindows()) + return; // this path exercises the Linux .desktop writer + + var probe = SystemProbe.Default; + string package = StagePackage(); + string installDir = Path.Combine(_root, "install", "optimum"); + string dataHome = Path.Combine(_root, "xdg-data"); + string? previous = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", dataHome); + try + { + Assert.True(new PackageDeployer(probe).Deploy( + new DeployRequest(package, installDir, DataPath: null, ShortcutKinds.Menu)).Ok); + + string entry = Path.Combine(dataHome, "applications", "optimum.desktop"); + Assert.True(File.Exists(entry)); + + InstallManifest manifest = InstallManifest.Deserialize( + File.ReadAllText(Path.Combine(installDir, InstallManifest.RelativePath)))!; + Assert.Contains(entry, manifest.Shortcuts); + + Assert.True(new Uninstaller(probe).Uninstall(installDir).Ok); + Assert.False(File.Exists(entry)); + } + finally + { + Environment.SetEnvironmentVariable("XDG_DATA_HOME", previous); + } + } + + [Fact] + public void DeployRejectsAnUnsafeInstallPath() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + + DeployResult deploy = new PackageDeployer(probe).Deploy( + new DeployRequest(package, probe.HomeDirectory)); + + Assert.False(deploy.Ok); + Assert.Equal(FailureReason.BadInput, deploy.Reason); + } + + [Fact] + public void UninstallStillRemovesShortcutsWhenAListedEntryIsAlreadyGone() + { + var probe = SystemProbe.Default; + string installDir = Path.Combine(_root, "install", "optimum"); + Directory.CreateDirectory(Path.Combine(installDir, ".optimum")); + string shortcut = Path.Combine(_root, "menu", "optimum.desktop"); + Directory.CreateDirectory(Path.GetDirectoryName(shortcut)!); + File.WriteAllText(shortcut, "[Desktop Entry]"); + + var manifest = new InstallManifest + { + OptimumVersion = "0.3.14", + InstalledAtUtc = DateTimeOffset.UtcNow, + InstallDirectory = installDir, + Entries = ["run.sh"], // never created + Shortcuts = [shortcut], + }; + File.WriteAllText(Path.Combine(installDir, InstallManifest.RelativePath), manifest.Serialize()); + + UninstallResult result = new Uninstaller(probe).Uninstall(installDir); + + Assert.True(result.Ok); + Assert.False(File.Exists(shortcut)); + Assert.False(Directory.Exists(installDir)); + } + + [Fact] + public void UninstallRefusesADirectoryWithNoManifest() + { + string bare = Path.Combine(_root, "bare"); + Directory.CreateDirectory(bare); + File.WriteAllText(Path.Combine(bare, "important.txt"), "keep me"); + + UninstallResult result = new Uninstaller(SystemProbe.Default).Uninstall(bare); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.BadInput, result.Reason); + Assert.True(File.Exists(Path.Combine(bare, "important.txt"))); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs new file mode 100644 index 0000000..1a1afc4 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs @@ -0,0 +1,60 @@ +using Optimum.Bootstrap.Core.Prerequisites; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// +/// Ports the check_dotnet10 selection from +/// scripts/tests/install-linux-prerequisites.sh: given a system dotnet on +/// SDK 9 and a user dotnet on SDK 10, detection picks the user one. +/// +public class DotnetSdkProbeTests +{ + [Fact] + public void PicksTheCandidateThatReportsANet10Sdk() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/t/bin/dotnet:/home/tester/.dotnet/dotnet"; + probe.AddFile("/t/bin/dotnet"); + probe.AddFile("/home/tester/.dotnet/dotnet"); + probe.OnCommand("/t/bin/dotnet", "--list-sdks", "9.0.100 [/system/sdk]\n"); + probe.OnCommand("/home/tester/.dotnet/dotnet", "--list-sdks", "10.0.100 [/user/sdk]\n"); + + Assert.Equal("/home/tester/.dotnet/dotnet", DotnetSdkProbe.Find(probe)); + } + + [Fact] + public void ReturnsNullWhenNoCandidateReportsNet10() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/t/bin/dotnet"; + probe.AddFile("/t/bin/dotnet"); + probe.OnCommand("/t/bin/dotnet", "--list-sdks", "9.0.100 [/system/sdk]\n"); + + Assert.Null(DotnetSdkProbe.Find(probe)); + } + + [Fact] + public void PrefersDotnetOnPathBeforeTheCandidateList() + { + var probe = new FakeSystemProbe(); + probe.Path.Add("/usr/bin"); + probe.AddFile("/usr/bin/dotnet"); + probe.OnCommand("/usr/bin/dotnet", "--list-sdks", "10.0.203 [/usr/lib/dotnet/sdk]\n"); + + Assert.Equal("/usr/bin/dotnet", DotnetSdkProbe.Find(probe)); + } + + [Fact] + public void SkipsANonExecutableFileEarlierOnPathAndKeepsSearching() + { + var probe = new FakeSystemProbe(); + probe.Path.Add("/broken"); + probe.Path.Add("/usr/bin"); + probe.AddNonExecutableFile("/broken/dotnet"); + probe.AddFile("/usr/bin/dotnet"); + probe.OnCommand("/usr/bin/dotnet", "--list-sdks", "10.0.100 [/usr/lib/dotnet/sdk]\n"); + + Assert.Equal("/usr/bin/dotnet", DotnetSdkProbe.Find(probe)); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/EngineProtocolTests.cs b/Optimum.Bootstrap.Core.Tests/EngineProtocolTests.cs new file mode 100644 index 0000000..738f54a --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/EngineProtocolTests.cs @@ -0,0 +1,40 @@ +using Optimum.Bootstrap.Core; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class EngineProtocolTests +{ + [Fact] + public void EngineProgressCeilingIs99() + { + Assert.Equal(99, BootstrapProgress.MaxEnginePercent); + } + + [Theory] + [InlineData(FailureReason.BadInput, "bad-input")] + [InlineData(FailureReason.PatchConflict, "patch-conflict")] + [InlineData(FailureReason.Cancelled, "cancelled")] + [InlineData(FailureReason.EngineInternal, "engine-internal")] + public void FailureReasonWireTokensAreKebabCase(FailureReason reason, string expected) + { + Assert.Equal(expected, reason.Wire()); + } + + [Fact] + public void EveryFailureReasonHasAWireToken() + { + foreach (FailureReason reason in Enum.GetValues()) + { + string wire = reason.Wire(); + Assert.False(string.IsNullOrWhiteSpace(wire)); + Assert.Equal(wire.ToLowerInvariant(), wire); + } + } + + [Fact] + public void CoreVersionIsNotEmpty() + { + Assert.False(string.IsNullOrWhiteSpace(CoreInfo.Version)); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs b/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs new file mode 100644 index 0000000..0e71fec --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs @@ -0,0 +1,64 @@ +using Optimum.Bootstrap.Core.Prerequisites; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// +/// Ports the ilspycmd version cases from +/// scripts/tests/install-linux-prerequisites.sh. These are the exact +/// accept and reject values that script pins. +/// +public class IlspycmdVersionTests +{ + private static readonly IlspycmdCompatibility Range = IlspycmdCompatibility.Fallback; + + [Theory] + [InlineData("10.1.0.8386")] + [InlineData("10.1.0.8387")] + [InlineData("10.1.1.0")] + [InlineData("10.1.1.8387")] + [InlineData("10.1.1.8388")] + public void AcceptsVersionsInsideTheRange(string version) + { + Assert.True(Range.Supports(version)); + } + + [Theory] + [InlineData("10.1.0.8385")] + [InlineData("10.1.1.8389")] + [InlineData("10.1.2.9000")] + [InlineData("10.0.1.8346")] + [InlineData("10.2.0.1")] + [InlineData("10.0.0.8323-preview3")] + [InlineData("10.1.1.8388-rc1")] + [InlineData("")] + [InlineData("not-a-version")] + [InlineData("10.1.1")] + public void RejectsEverythingElse(string version) + { + Assert.False(Range.Supports(version)); + } + + [Fact] + public void ReadsTheRangeAndPinFromConfigFiles() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/repo/.config/ilspycmd-compat.json", + """{ "minimumVersion": "10.1.0.8386", "maximumVersion": "10.1.1.8388" }"""); + probe.AddFile("/repo/.config/dotnet-tools.json", + """{ "version": 1, "tools": { "ilspycmd": { "version": "10.1.1.8388" } } }"""); + + IlspycmdCompatibility compat = ConfigFiles.ReadIlspycmdCompatibility(probe, "/repo"); + + Assert.Equal("10.1.1.8388", compat.Pin); + Assert.Equal(new IlspycmdVersion(10, 1, 0, 8386), compat.Minimum); + Assert.Equal(new IlspycmdVersion(10, 1, 1, 8388), compat.Maximum); + } + + [Fact] + public void FallsBackWhenConfigFilesAreAbsent() + { + IlspycmdCompatibility compat = ConfigFiles.ReadIlspycmdCompatibility(new FakeSystemProbe(), "/repo"); + Assert.Equal(IlspycmdCompatibility.Fallback, compat); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs b/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs new file mode 100644 index 0000000..d4f5b8d --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs @@ -0,0 +1,113 @@ +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Paths; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// +/// Every case INSTALLER-PLAN.md section 9 lists for the path guard, plus the +/// overlap and data-path rules from Assert-SafeInstallerPaths. +/// +public class InstallPathGuardTests +{ + private static FakeSystemProbe Linux() + { + var probe = new FakeSystemProbe { Os = OsKind.Linux, HomeDirectory = "/home/tester" }; + return probe; + } + + private static void AssertRejected(InstallPathVerdict verdict, string fragment) + { + Assert.False(verdict.Ok); + Assert.Contains(fragment, verdict.Rejection, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RejectsTheFilesystemRoot() => + AssertRejected(InstallPathGuard.Check(Linux(), new InstallPathRequest("/")), "root"); + + [Fact] + public void RejectsTheHomeDirectory() => + AssertRejected(InstallPathGuard.Check(Linux(), new InstallPathRequest("/home/tester")), "home"); + + [Fact] + public void RejectsTheXdgDataHome() + { + FakeSystemProbe probe = Linux(); + probe.Environment["XDG_DATA_HOME"] = "/home/tester/.local/share"; + AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/.local/share")), ".local/share"); + } + + [Fact] + public void RejectsDotLocal() => + AssertRejected(InstallPathGuard.Check(Linux(), new InstallPathRequest("/home/tester/.local")), ".local"); + + [Fact] + public void RejectsAWindowsDriveRoot() + { + var probe = new FakeSystemProbe { Os = OsKind.Windows, HomeDirectory = @"C:\Users\tester" }; + AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest(@"C:\")), "root"); + } + + [Fact] + public void RejectsAPathInsideAVintageStoryInstall() => + AssertRejected( + InstallPathGuard.Check(Linux(), new InstallPathRequest("/home/tester/.local/share/vintagestory/mods")), + "Vintage Story"); + + [Fact] + public void RejectsADirectoryHoldingAVanillaGameWithNoOptimumMarker() + { + FakeSystemProbe probe = Linux(); + probe.AddFile("/opt/games/vs/Vintagestory"); + AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest("/opt/games/vs")), "vanilla Vintage Story"); + } + + [Fact] + public void RejectsAnInstallDirectoryThatIsItselfASymlink() + { + FakeSystemProbe probe = Linux(); + probe.AddSymlink("/home/tester/games/optimum"); + AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/games/optimum")), "symbolic link"); + } + + [Fact] + public void AllowsAnInstallDirectoryUnderASymlinkedParent() + { + FakeSystemProbe probe = Linux(); + probe.AddSymlink("/home/tester/Games"); // a second drive mounted here + Assert.True(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/Games/optimum")).Ok); + } + + [Fact] + public void AllowsACleanSeparateDirectory() + { + InstallPathVerdict verdict = InstallPathGuard.Check(Linux(), + new InstallPathRequest("/home/tester/games/optimum")); + Assert.True(verdict.Ok); + Assert.Null(verdict.Rejection); + } + + [Fact] + public void AllowsADirectoryHoldingAnExistingOptimumInstall() + { + FakeSystemProbe probe = Linux(); + probe.AddFile("/home/tester/games/optimum/Vintagestory"); + probe.AddFile("/home/tester/games/optimum/Optimum"); + Assert.True(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/games/optimum")).Ok); + } + + [Fact] + public void RejectsAnInstallDirectoryThatOverlapsTheVintageStoryDirectory() => + AssertRejected( + InstallPathGuard.Check(Linux(), new InstallPathRequest( + "/home/tester/opt", VintageStoryDirectory: "/home/tester/opt/vs")), + "overlap"); + + [Fact] + public void RejectsADataPathInsideTheInstallDirectory() => + AssertRejected( + InstallPathGuard.Check(Linux(), new InstallPathRequest( + "/home/tester/opt", DataPath: "/home/tester/opt/data")), + "data path"); +} diff --git a/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs new file mode 100644 index 0000000..16b1f52 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Ndjson; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class NdjsonWriterTests +{ + private static JsonElement[] Parse(string stream) => + stream.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + [Fact] + public void ProgressIsMonotonicAndCappedAt99() + { + var sw = new StringWriter(); + var writer = new NdjsonWriter(sw); + + writer.Progress(ProgressPhase.Decompile, 10, "a"); + writer.Progress(ProgressPhase.Decompile, 5, "b"); // non-increasing, held at 10 + writer.Progress(ProgressPhase.Assemble, 250, "c"); // over the ceiling, held at 99 + writer.Success("/out/Optimum-v0.3.14-linux-x64"); + + int[] progress = Parse(sw.ToString()) + .Where(l => l.GetProperty("type").GetString() == "progress") + .Select(l => l.GetProperty("progress").GetInt32()) + .ToArray(); + + Assert.Equal([10, 10, 99], progress); + Assert.Equal(2, writer.AnomalyCount); + } + + [Fact] + public void AClampEmitsAWarnSoTheAnomalyIsNotSilent() + { + var sw = new StringWriter(); + var writer = new NdjsonWriter(sw); + + writer.Progress(ProgressPhase.Patch, 60, "a"); + writer.Progress(ProgressPhase.Patch, 40, "b"); // regression + + JsonElement warn = Parse(sw.ToString()) + .First(l => l.GetProperty("type").GetString() == "log"); + Assert.Equal("warn", warn.GetProperty("level").GetString()); + Assert.Contains("40", warn.GetProperty("message").GetString()); + } + + [Fact] + public void TheTerminalResultIsTheLastLineAndCarriesTheKebabReason() + { + var sw = new StringWriter(); + var writer = new NdjsonWriter(sw); + + writer.Log(LogLevel.Warn, "innoextract not present"); + writer.Failure(FailureReason.PatchConflict, "patches/vsapi/0007 did not apply"); + + JsonElement[] lines = Parse(sw.ToString()); + JsonElement result = lines[^1]; + Assert.Equal("result", result.GetProperty("type").GetString()); + Assert.False(result.GetProperty("ok").GetBoolean()); + Assert.Equal("patch-conflict", result.GetProperty("reason").GetString()); + Assert.True(writer.ResultWritten); + } + + [Fact] + public void WritingAfterTheResultThrows() + { + var writer = new NdjsonWriter(new StringWriter()); + writer.Success("/out"); + Assert.Throws(() => writer.Log(LogLevel.Info, "too late")); + Assert.Throws(() => writer.Progress(ProgressPhase.Verify, 50, "too late")); + } + + [Fact] + public void LinesAreDelimitedWithABareNewline() + { + var sw = new StringWriter(); + var writer = new NdjsonWriter(sw); + writer.Progress(ProgressPhase.Patch, 1, "x"); + writer.Success("/out"); + Assert.DoesNotContain('\r', sw.ToString()); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/NixEnvironmentTests.cs b/Optimum.Bootstrap.Core.Tests/NixEnvironmentTests.cs new file mode 100644 index 0000000..a41c32b --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/NixEnvironmentTests.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using Optimum.Bootstrap.Core.Acquisition; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +/// Ports scripts/tests/install-linux-nixos.sh. +public class NixEnvironmentTests +{ + [Fact] + public void DownloadedSdkRunsOnADefaultGlibcHost() + { + var probe = new FakeSystemProbe { Arch = Architecture.X64 }; + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + + Assert.True(NixEnvironment.DownloadedSdkRunnable(probe)); + } + + [Theory] + [InlineData(OsKind.Windows)] + [InlineData(OsKind.MacOs)] + public void TheNonFhsCheckIsLinuxOnly(OsKind os) + { + var probe = new FakeSystemProbe { Os = os }; + // A leftover NIX_STORE / interpreter override must not make a native + // Windows or macOS host look non-FHS. + probe.Environment["NIX_STORE"] = "/nix/store"; + probe.Environment["OPTIMUM_GLIBC_INTERPRETER"] = "/tmp/missing-ld-linux"; + + Assert.False(NixEnvironment.IsNixOs(probe)); + Assert.Equal(string.Empty, NixEnvironment.GlibcInterpreterPath(probe)); + Assert.True(NixEnvironment.DownloadedSdkRunnable(probe)); + } + + [Fact] + public void SdkAcquisitionBuildsAWindowsPlan() + { + var probe = new FakeSystemProbe { Os = OsKind.Windows, HomeDirectory = @"C:\Users\tester" }; + + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(probe, @"C:\repo"); + + Assert.True(decision.CanRunScript); + Assert.NotNull(decision.Plan); + Assert.EndsWith("dotnet-install.ps1", decision.Plan!.ScriptUrl); + Assert.Contains("-NoPath", decision.Plan.Arguments); + } + + [Fact] + public void DownloadedSdkDoesNotRunWhenTheInterpreterIsMissing() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_GLIBC_INTERPRETER"] = "/tmp/missing-ld-linux"; + + Assert.Equal("/tmp/missing-ld-linux", NixEnvironment.GlibcInterpreterPath(probe)); + Assert.False(NixEnvironment.DownloadedSdkRunnable(probe)); + } + + [Fact] + public void DetectNixOsFollowsNixStoreAndTheMarkerFile() + { + var probe = new FakeSystemProbe(); + Assert.False(NixEnvironment.IsNixOs(probe)); + + probe.Environment["NIX_STORE"] = "/nix/store"; + Assert.True(NixEnvironment.IsNixOs(probe)); + + probe.Environment.Remove("NIX_STORE"); + probe.AddFile("/etc/NIXOS"); + Assert.True(NixEnvironment.IsNixOs(probe)); + } + + [Fact] + public void NixInstallCommandNamesNixpkgsAndTheSdk() + { + Assert.Contains("nixpkgs", NixEnvironment.DotnetSdkInstallCommand); + Assert.Contains("dotnet-sdk_10", NixEnvironment.DotnetSdkInstallCommand); + } + + [Fact] + public void SdkAcquisitionRefusesOnNixOs() + { + var probe = new FakeSystemProbe(); + probe.Environment["NIX_STORE"] = "/nix/store"; + + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(probe, "/repo"); + + Assert.False(decision.CanRunScript); + Assert.Null(decision.Plan); + Assert.Contains("NixOS", decision.RefusalReason); + } + + [Fact] + public void SdkAcquisitionRefusesOnANonFhsHost() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_GLIBC_INTERPRETER"] = "/tmp/missing-ld-linux"; + + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(probe, "/repo"); + + Assert.False(decision.CanRunScript); + Assert.Contains("non-FHS", decision.RefusalReason); + } + + [Fact] + public void PrerequisiteScannerRoutesTheSdkRowThroughNixpkgsOnNixOs() + { + var probe = new FakeSystemProbe(); + probe.Environment["NIX_STORE"] = "/nix/store"; + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + + PrerequisiteResult dotnet = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Dotnet); + + Assert.Equal(PrerequisiteState.Missing, dotnet.State); + Assert.Contains("nixpkgs", dotnet.Label); + Assert.Equal(NixEnvironment.DotnetSdkInstallCommand, dotnet.AcquisitionCommand); + } + + [Fact] + public void PrerequisiteScannerFlagsANonFhsHostWithNoInstallCommand() + { + var probe = new FakeSystemProbe(); + probe.Environment["OPTIMUM_GLIBC_INTERPRETER"] = "/tmp/missing-ld-linux"; + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + + PrerequisiteResult dotnet = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Dotnet); + + Assert.Equal(PrerequisiteState.Missing, dotnet.State); + Assert.Contains("non-FHS", dotnet.Label); + Assert.Null(dotnet.AcquisitionCommand); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj new file mode 100644 index 0000000..a5e385c --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs new file mode 100644 index 0000000..8f8acfe --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs @@ -0,0 +1,187 @@ +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class PrerequisiteScannerTests +{ + private static FakeSystemProbe LinuxWithCoreTools() + { + var probe = new FakeSystemProbe { Os = OsKind.Linux }; + probe.Path.Add("/usr/bin"); + foreach (string tool in new[] { "git", "perl", "python3", "curl", "tar", "chmod", "pwsh", "apt-get" }) + probe.AddFile($"/usr/bin/{tool}"); + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + return probe; + } + + [Fact] + public void OnlyTheSdkBlocksTheBuildWhenTheDecompilerIsAlsoMissing() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + + IReadOnlyList results = new PrerequisiteScanner(probe, "/repo").Scan(); + + PrerequisiteId[] blocking = results.Where(r => r.BlocksBuild).Select(r => r.Definition.Id).ToArray(); + Assert.Equal([PrerequisiteId.Dotnet], blocking); + + PrerequisiteResult ilspy = results.Single(r => r.Definition.Id == PrerequisiteId.Ilspycmd); + Assert.Equal(PrerequisiteState.OptionalMissing, ilspy.State); + Assert.Equal(AcquisitionKind.Automatic, ilspy.Acquisition); + } + + [Fact] + public void PowerShellMissingDoesNotBlockTheBuild() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Files.Remove("/usr/bin/pwsh"); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + + PrerequisiteResult pwsh = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Pwsh); + + Assert.Equal(RequirementLevel.RequiredForPackaging, pwsh.Definition.Level); + Assert.Equal(PrerequisiteState.Missing, pwsh.State); + Assert.False(pwsh.BlocksBuild); + } + + [Fact] + public void AppimagetoolInThePrivateToolDirectoryMustBeExecutable() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + probe.AddNonExecutableFile("/repo/.tools/appimagetool"); + + PrerequisiteResult appimagetool = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Appimagetool); + + Assert.Equal(PrerequisiteState.OptionalMissing, appimagetool.State); + Assert.Equal(AcquisitionKind.Automatic, appimagetool.Acquisition); + } + + [Fact] + public void AllRequiredPresentWhenTheSdkAndAnInRangeDecompilerAreThere() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/home/tester/.dotnet/dotnet"; + probe.AddFile("/home/tester/.dotnet/dotnet"); + probe.OnCommand("/home/tester/.dotnet/dotnet", "--list-sdks", "10.0.100 [/user/sdk]\n"); + probe.OnCommand("/home/tester/.dotnet/dotnet", "--version", "10.0.100\n"); + probe.AddFile("/home/tester/.dotnet/tools/ilspycmd"); + probe.OnCommand("/home/tester/.dotnet/tools/ilspycmd", "--version", "ilspycmd: 10.1.1.8388\n"); + + var scanner = new PrerequisiteScanner(probe, "/repo"); + Assert.True(scanner.AllRequiredPresent()); + + PrerequisiteResult ilspy = scanner.Scan().Single(r => r.Definition.Id == PrerequisiteId.Ilspycmd); + Assert.Equal(PrerequisiteState.Ok, ilspy.State); + Assert.Equal("10.1.1.8388", ilspy.DetectedVersion); + } + + [Fact] + public void AnOutOfRangeDecompilerIsReportedOutdatedWithTheUpdateCommand() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + probe.AddFile("/home/tester/.dotnet/tools/ilspycmd"); + probe.OnCommand("/home/tester/.dotnet/tools/ilspycmd", "--version", "ilspycmd: 10.2.0.1\n"); + + PrerequisiteResult ilspy = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Ilspycmd); + + Assert.Equal(PrerequisiteState.Outdated, ilspy.State); + Assert.Equal( + "dotnet tool update -g ilspycmd --version 10.1.1.8388 --allow-downgrade", + ilspy.AcquisitionCommand); + } + + [Fact] + public void InnoextractBelowElevenIsOutdated() + { + FakeSystemProbe probe = LinuxWithCoreTools(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "/absent/dotnet"; + probe.AddFile("/usr/bin/innoextract"); + probe.OnCommand("/usr/bin/innoextract", "--version", "innoextract 1.9\n"); + + PrerequisiteResult inno = new PrerequisiteScanner(probe, "/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Innoextract); + + Assert.Equal(PrerequisiteState.Outdated, inno.State); + } + + [Theory] + [InlineData("innoextract 1.11\n", 1, 11)] + [InlineData("innoextract 1.9-gcc\n", 1, 9)] + [InlineData("innoextract 2.0.1\n", 2, 0)] + public void InnoextractVersionParse(string output, int major, int minor) + { + Assert.Equal((major, minor), PrerequisiteScanner.ParseInnoextractVersion(output)); + } + + // Forward-slash paths: FakeSystemProbe matches literal strings and the code + // under test joins with Path.Combine, which uses the host separator when the + // tests run on Linux. .NET on Windows accepts forward slashes anyway. + private static FakeSystemProbe WindowsHost() + { + var probe = new FakeSystemProbe { Os = OsKind.Windows, HomeDirectory = "C:/Users/tester" }; + probe.Path.Add("C:/Windows/System32/WindowsPowerShell/v1.0"); + probe.AddFile("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); + return probe; + } + + private static void AddWindowsDotnet(FakeSystemProbe probe) + { + probe.Path.Add("C:/Program Files/dotnet"); + probe.AddFile("C:/Program Files/dotnet/dotnet.exe"); + probe.OnCommand("C:/Program Files/dotnet/dotnet.exe", "--list-sdks", "10.0.100 [C:\\sdk]\n"); + probe.OnCommand("C:/Program Files/dotnet/dotnet.exe", "--version", "10.0.100\n"); + } + + [Fact] + public void WindowsDoesNotDemandUnixToolsAndClearsWithJustDotnetAndGit() + { + FakeSystemProbe probe = WindowsHost(); + probe.Path.Add("C:/Program Files/Git/cmd"); + probe.AddFile("C:/Program Files/Git/cmd/git.exe"); + AddWindowsDotnet(probe); + + IReadOnlyList results = new PrerequisiteScanner(probe, @"C:\repo").Scan(); + + Assert.DoesNotContain(results, r => r.Definition.Id is PrerequisiteId.Perl + or PrerequisiteId.Python3 or PrerequisiteId.Chmod or PrerequisiteId.Tar + or PrerequisiteId.Curl or PrerequisiteId.Appimagetool); + Assert.Empty(results.Where(r => r.BlocksBuild)); + Assert.Equal(PrerequisiteState.Ok, results.Single(r => r.Definition.Id == PrerequisiteId.Pwsh).State); + } + + [Fact] + public void WindowsWithoutTheSdkOffersAnAutomaticInstallNotANonFhsRefusal() + { + FakeSystemProbe probe = WindowsHost(); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = "C:/absent/dotnet.exe"; + + PrerequisiteResult dotnet = new PrerequisiteScanner(probe, "C:/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Dotnet); + + Assert.Equal(PrerequisiteState.Missing, dotnet.State); + Assert.Equal(AcquisitionKind.Automatic, dotnet.Acquisition); + Assert.DoesNotContain("non-FHS", dotnet.Label); + } + + [Fact] + public void WindowsWithoutGitPointsAtGitForWindows() + { + FakeSystemProbe probe = WindowsHost(); + AddWindowsDotnet(probe); + + PrerequisiteResult git = new PrerequisiteScanner(probe, "C:/repo").Scan() + .Single(r => r.Definition.Id == PrerequisiteId.Git); + + Assert.Equal(PrerequisiteState.Missing, git.State); + Assert.Equal(AcquisitionKind.DownloadPage, git.Acquisition); + Assert.Equal(PrerequisiteScanner.GitForWindowsUrl, git.DownloadUrl); + Assert.True(git.BlocksBuild); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs b/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs new file mode 100644 index 0000000..80ddb92 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs @@ -0,0 +1,61 @@ +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Platform; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public sealed class ShortcutWriterTests : IDisposable +{ + private readonly string _home = Directory.CreateTempSubdirectory("optimum-shortcut-test").FullName; + + public void Dispose() => Directory.Delete(_home, recursive: true); + + private FakeSystemProbe LinuxProbe() + { + var probe = new FakeSystemProbe { Os = OsKind.Linux, HomeDirectory = _home }; + probe.Environment["XDG_DATA_HOME"] = Path.Combine(_home, ".local", "share"); + return probe; + } + + [Fact] + public void WritesAndRemovesTheLinuxMenuAndDesktopEntries() + { + FakeSystemProbe probe = LinuxProbe(); + string installDir = Path.Combine(_home, "games", "optimum"); + string launcher = Path.Combine(installDir, "optimum-launch.sh"); + Directory.CreateDirectory(installDir); + + var writer = new ShortcutWriter(probe); + IReadOnlyList created = writer.Create(installDir, launcher, ShortcutKinds.Menu | ShortcutKinds.Desktop); + + string menuEntry = Path.Combine(_home, ".local", "share", "applications", "optimum.desktop"); + string desktopEntry = Path.Combine(_home, "Desktop", "Optimum.desktop"); + Assert.Contains(menuEntry, created); + Assert.Contains(desktopEntry, created); + Assert.True(File.Exists(menuEntry)); + Assert.Contains($"Exec=\"{launcher}\"", File.ReadAllText(menuEntry)); + + writer.Remove(created); + Assert.False(File.Exists(menuEntry)); + Assert.False(File.Exists(desktopEntry)); + } + + [Fact] + public void NoneWritesNothing() + { + Assert.Empty(new ShortcutWriter(LinuxProbe()).Create("/x", "/x/launch", ShortcutKinds.None)); + } + + [Fact] + public void CopiesTheHicolorIconWhenThePackageHasOne() + { + FakeSystemProbe probe = LinuxProbe(); + string installDir = Path.Combine(_home, "games", "optimum"); + Directory.CreateDirectory(Path.Combine(installDir, "assets")); + File.WriteAllText(Path.Combine(installDir, "assets", "gameicon.png"), "PNG"); + + new ShortcutWriter(probe).Create(installDir, Path.Combine(installDir, "optimum-launch.sh"), ShortcutKinds.Menu); + + Assert.True(File.Exists(Path.Combine(_home, ".local", "share", "icons", "hicolor", "256x256", "apps", "optimum.png"))); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs b/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs new file mode 100644 index 0000000..105366a --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs @@ -0,0 +1,133 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class SourceCacheTests +{ + [Theory] + [InlineData("0.3.14", "v0.3.14")] + [InlineData("1.0.0", "v1.0.0")] + [InlineData("0.3.14+abc123", "v0.3.14")] + [InlineData("dev", "dev")] + [InlineData("", "dev")] + [InlineData("../evil", "evil")] + public void SanitizeVersionIsFilesystemSafeAndTagShaped(string version, string expected) + { + Assert.Equal(expected, SourceCache.SanitizeVersion(version)); + } + + [Theory] + [InlineData("0.3.14", "v0.3.14")] + [InlineData("1.2.3", "v1.2.3")] + public void TagRefIsTheReleaseTagForARealVersion(string version, string expected) + { + Assert.Equal(expected, SourceCache.TagRef(version)); + } + + [Theory] + [InlineData("dev")] + [InlineData("")] + public void TagRefIsNullForADevBuild(string version) + { + Assert.Null(SourceCache.TagRef(version)); + } + + [Fact] + public void DirectoryHonoursXdgCacheHome() + { + var probe = new FakeSystemProbe(); + probe.Environment["XDG_CACHE_HOME"] = "/xdg/cache"; + Assert.Equal("/xdg/cache/optimum/src-v0.3.14", SourceCache.Directory(probe, "0.3.14")); + } + + [Fact] + public void DirectoryFallsBackToDotCache() + { + var probe = new FakeSystemProbe { HomeDirectory = "/home/tester" }; + Assert.Equal("/home/tester/.cache/optimum/src-v0.3.14", SourceCache.Directory(probe, "0.3.14")); + } + + [Fact] + public void DirectoryHonoursAnExplicitOverride() + { + var probe = new FakeSystemProbe(); + Assert.Equal("/custom/optimum/src-dev", SourceCache.Directory(probe, "dev", "/custom")); + } + + [Fact] + public void CloneArgumentsAreShallowSingleBranchAndPinnedToTheTag() + { + var args = SourceCache.CloneArguments("v0.3.14", "/dest"); + Assert.Equal( + new[] { "clone", "--depth", "1", "--single-branch", "--branch", "v0.3.14", SourceRequest.RepositoryUrl, "/dest" }, + args); + } + + [Fact] + public void CloneArgumentsOmitTheBranchWhenThereIsNoTag() + { + var args = SourceCache.CloneArguments(null, "/dest"); + Assert.DoesNotContain("--branch", args); + Assert.Equal(new[] { "clone", "--depth", "1", "--single-branch", SourceRequest.RepositoryUrl, "/dest" }, args); + } +} + +public class GitSourceProviderTests +{ + [Fact] + public void PromotionReplacesTheOldCheckoutAndRemovesItsBackup() + { + string root = System.IO.Directory.CreateTempSubdirectory("optimum-source-promote").FullName; + string staging = Path.Combine(root, "source.partial"); + string target = Path.Combine(root, "source"); + try + { + System.IO.Directory.CreateDirectory(staging); + System.IO.Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(staging, "new.txt"), "new"); + File.WriteAllText(Path.Combine(target, "old.txt"), "old"); + + GitSourceProvider.Promote(staging, target); + + Assert.True(File.Exists(Path.Combine(target, "new.txt"))); + Assert.False(File.Exists(Path.Combine(target, "old.txt"))); + Assert.Empty(System.IO.Directory.EnumerateDirectories(root, "source.previous-*")); + } + finally + { + System.IO.Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task ReusesACachedCheckoutWithoutTouchingGit() + { + var probe = new FakeSystemProbe { HomeDirectory = "/home/tester" }; + string cached = "/home/tester/.cache/optimum/src-v0.3.14"; + probe.AddFile($"{cached}/forks.json"); + probe.AddFile($"{cached}/scripts/bootstrap.sh"); + // no git on PATH: proves the cached path never shells out. + + var result = await new GitSourceProvider(probe) + .EnsureAsync(new SourceRequest("0.3.14"), NullBuildObserver.Instance, CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal(cached, result.RepoRoot); + } + + [Fact] + public async Task FailsWithSourceUnavailableWhenGitIsMissing() + { + var probe = new FakeSystemProbe { HomeDirectory = "/home/tester" }; + + var result = await new GitSourceProvider(probe) + .EnsureAsync(new SourceRequest("0.3.14"), NullBuildObserver.Instance, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal(FailureReason.SourceUnavailable, result.Reason); + Assert.Contains("git", result.Message); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/SymlinkComponentCheckTests.cs b/Optimum.Bootstrap.Core.Tests/SymlinkComponentCheckTests.cs new file mode 100644 index 0000000..7fef5e0 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/SymlinkComponentCheckTests.cs @@ -0,0 +1,33 @@ +using Optimum.Bootstrap.Core.Paths; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class SymlinkComponentCheckTests +{ + [Fact] + public void CleanPathHasNoSymlinkComponent() + { + var probe = new FakeSystemProbe(); + probe.AddDirectory("/home/tester/games"); + Assert.Null(SymlinkComponentCheck.FirstSymlinkComponent(probe, "/home/tester/games/optimum")); + } + + [Fact] + public void ReturnsTheSymlinkedComponentWhenOneIsInThePath() + { + var probe = new FakeSystemProbe(); + probe.AddSymlink("/home/tester/games"); + + Assert.Equal("/home/tester/games", + SymlinkComponentCheck.FirstSymlinkComponent(probe, "/home/tester/games/optimum/bin")); + } + + [Fact] + public void RequireExistsThrowsWhenAComponentIsMissing() + { + var probe = new FakeSystemProbe(); + Assert.Throws(() => + SymlinkComponentCheck.FirstSymlinkComponent(probe, "/nowhere/at/all", requireExists: true)); + } +} diff --git a/Optimum.Bootstrap.Core/Acquisition/AcquisitionProcess.cs b/Optimum.Bootstrap.Core/Acquisition/AcquisitionProcess.cs new file mode 100644 index 0000000..c1945bd --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/AcquisitionProcess.cs @@ -0,0 +1,66 @@ +using System.Text; +using CliWrap; +using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Build; + +namespace Optimum.Bootstrap.Core.Acquisition; + +/// +/// Runs a short acquisition subprocess (a dotnet-install script, a +/// dotnet tool update) and streams its output to an +/// . A spawn failure comes back as a negative exit +/// code with the message on rather than an +/// exception, so callers map every failure the same way. +/// +internal static class AcquisitionProcess +{ + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + internal readonly record struct Outcome(int ExitCode, string? Message) + { + public bool Ok => ExitCode == 0; + } + + internal static async Task RunAsync( + string executable, + IReadOnlyList arguments, + string workingDirectory, + IReadOnlyDictionary? environment, + IBuildObserver observer, + CancellationToken cancellationToken) + { + int exitCode = -1; + Command command = Cli.Wrap(executable) + .WithArguments(arguments) + .WithWorkingDirectory(workingDirectory) + .WithValidation(CommandResultValidation.None); + if (environment is not null) + command = command.WithEnvironmentVariables(environment); + + try + { + await foreach (CommandEvent commandEvent in + command.ListenAsync(Utf8, Utf8, cancellationToken, CancellationToken.None)) + { + switch (commandEvent) + { + case StandardOutputCommandEvent stdout: + observer.RawOutput(false, stdout.Text); + break; + case StandardErrorCommandEvent stderr: + observer.RawOutput(false, stderr.Text); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + break; + } + } + } + catch (System.ComponentModel.Win32Exception ex) + { + return new Outcome(-1, $"could not start '{executable}': {ex.Message}"); + } + + return new Outcome(exitCode, exitCode == 0 ? null : $"'{executable}' exited {exitCode}"); + } +} diff --git a/Optimum.Bootstrap.Core/Acquisition/AppimagetoolAcquisition.cs b/Optimum.Bootstrap.Core/Acquisition/AppimagetoolAcquisition.cs new file mode 100644 index 0000000..7bcf361 --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/AppimagetoolAcquisition.cs @@ -0,0 +1,155 @@ +using System.Runtime.InteropServices; +using System.Text; +using CliWrap; +using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Acquisition; + +public sealed record ToolAcquisitionResult(bool Ok, string? InstalledPath, FailureReason? Reason, string? Message) +{ + public static ToolAcquisitionResult Success(string installedPath) => new(true, installedPath, null, null); + + public static ToolAcquisitionResult Failure(FailureReason reason, string message) => + new(false, null, reason, message); +} + +public interface IAppimagetoolAcquisition +{ + Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken); +} + +/// +/// Installs the upstream x86-64 appimagetool AppImage into the checkout's +/// private tool directory. The download is staged beside the final file so an +/// interruption never replaces a previously usable tool with a partial file. +/// +public sealed class AppimagetoolAcquisition : IAppimagetoolAcquisition +{ + public const string DownloadUrl = + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"; + + private static readonly Encoding Utf8 = new UTF8Encoding(false); + private readonly ISystemProbe _probe; + private readonly string _downloadUrl; + + public AppimagetoolAcquisition(ISystemProbe probe) : this(probe, DownloadUrl) { } + + internal AppimagetoolAcquisition(ISystemProbe probe, string downloadUrl) + { + _probe = probe; + _downloadUrl = downloadUrl; + } + + public async Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + if (!OperatingSystem.IsLinux() || _probe.Os != OsKind.Linux || _probe.Arch != Architecture.X64) + { + return ToolAcquisitionResult.Failure(FailureReason.UnsupportedVersion, + "the automatic appimagetool install currently supports Linux x86-64 only"); + } + + string target = TargetPath(repoRoot); + if (_probe.IsExecutable(target)) + return ToolAcquisitionResult.Success(target); + + string? curl = CommandSearch.Which(_probe, "curl"); + if (curl is null) + { + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + "curl was not found on PATH; install curl and retry"); + } + + string toolDirectory = Path.GetDirectoryName(target)!; + string staging = target + ".partial-" + Guid.NewGuid().ToString("N")[..8]; + + try + { + Directory.CreateDirectory(toolDirectory); + observer.Log(LogLevel.Info, "Downloading appimagetool..."); + + int exitCode = await DownloadAsync(curl, staging, observer, cancellationToken); + if (exitCode != 0) + { + TryDelete(staging); + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"appimagetool download failed (curl exit {exitCode})"); + } + + File.SetUnixFileMode(staging, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + File.Move(staging, target, overwrite: true); + + if (!_probe.IsExecutable(target)) + { + return ToolAcquisitionResult.Failure(FailureReason.VerificationFailed, + $"the downloaded appimagetool is not executable: {target}"); + } + + observer.Log(LogLevel.Info, $"Installed appimagetool at {target}"); + return ToolAcquisitionResult.Success(target); + } + catch (OperationCanceledException) + { + TryDelete(staging); + return ToolAcquisitionResult.Failure(FailureReason.Cancelled, + "the appimagetool download was cancelled"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or System.ComponentModel.Win32Exception) + { + TryDelete(staging); + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"could not install appimagetool: {ex.Message}"); + } + } + + public static string TargetPath(string repoRoot) => Path.Combine(repoRoot, ".tools", "appimagetool"); + + internal IReadOnlyList DownloadArguments(string destination) => + ["--location", "--fail", "--show-error", "--output", destination, _downloadUrl]; + + private async Task DownloadAsync( + string curl, string destination, IBuildObserver observer, CancellationToken cancellationToken) + { + int exitCode = -1; + Command command = Cli.Wrap(curl) + .WithArguments(DownloadArguments(destination)) + .WithValidation(CommandResultValidation.None); + + await foreach (CommandEvent commandEvent in + command.ListenAsync(Utf8, Utf8, cancellationToken, CancellationToken.None)) + { + switch (commandEvent) + { + case StandardOutputCommandEvent stdout: + observer.RawOutput(false, stdout.Text); + break; + case StandardErrorCommandEvent stderr: + observer.RawOutput(stderr.Text.Contains("error", StringComparison.OrdinalIgnoreCase), stderr.Text); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + break; + } + } + + return exitCode; + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } +} diff --git a/Optimum.Bootstrap.Core/Acquisition/IlspycmdAcquisition.cs b/Optimum.Bootstrap.Core/Acquisition/IlspycmdAcquisition.cs new file mode 100644 index 0000000..1486e45 --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/IlspycmdAcquisition.cs @@ -0,0 +1,16 @@ +namespace Optimum.Bootstrap.Core.Acquisition; + +/// +/// The command that installs or realigns the pinned decompiler. Matches the +/// invocation the Linux installer logs and the shell test asserts: +/// tool update -g ilspycmd --version <pin> --allow-downgrade, run +/// through the discovered dotnet. +/// +public static class IlspycmdAcquisition +{ + public static IReadOnlyList ToolArguments(string pin) => + ["tool", "update", "-g", "ilspycmd", "--version", pin, "--allow-downgrade"]; + + public static string CommandLine(string dotnetExecutable, string pin) => + $"{dotnetExecutable} {string.Join(' ', ToolArguments(pin))}"; +} diff --git a/Optimum.Bootstrap.Core/Acquisition/IlspycmdInstaller.cs b/Optimum.Bootstrap.Core/Acquisition/IlspycmdInstaller.cs new file mode 100644 index 0000000..16b5616 --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/IlspycmdInstaller.cs @@ -0,0 +1,66 @@ +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Bootstrap.Core.Acquisition; + +/// +/// Installs or realigns the pinned ilspycmd through +/// dotnet tool update -g ilspycmd --version <pin> --allow-downgrade, +/// matching scripts/bootstrap.sh:146. The pin comes from +/// .config/dotnet-tools.json. +/// +public interface IIlspycmdAcquisition +{ + Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken); +} + +public sealed class IlspycmdInstaller(ISystemProbe probe) : IIlspycmdAcquisition +{ + public async Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + string? dotnet = DotnetSdkProbe.Find(probe); + if (dotnet is null) + { + return ToolAcquisitionResult.Failure(FailureReason.BadInput, + "install the .NET SDK first; ilspycmd is a global dotnet tool"); + } + + IlspycmdCompatibility compat = ConfigFiles.ReadIlspycmdCompatibility(probe, repoRoot); + observer.Phase(ProgressPhase.Decompile, 1, $"installing ilspycmd {compat.Pin}"); + + // `dotnet tool update -g` writes into DOTNET_CLI_HOME/.dotnet/tools; keep + // it under the user profile and off any machine-wide location. + var environment = new Dictionary + { + ["DOTNET_ROOT"] = Path.GetDirectoryName(dotnet), + }; + + AcquisitionProcess.Outcome outcome = await AcquisitionProcess.RunAsync( + dotnet, IlspycmdAcquisition.ToolArguments(compat.Pin), repoRoot, + environment, observer, cancellationToken); + if (!outcome.Ok) + { + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + outcome.Message ?? "the ilspycmd install failed"); + } + + string? installed = CommandSearch.Which(probe, "ilspycmd") ?? ToolPath(); + if (installed is null || !probe.FileExists(installed)) + { + return ToolAcquisitionResult.Failure(FailureReason.VerificationFailed, + "the ilspycmd install reported success but the tool was not found"); + } + + observer.Log(LogLevel.Info, $"Installed ilspycmd at {installed}"); + return ToolAcquisitionResult.Success(installed); + } + + private string ToolPath() + { + string name = probe.Os == OsKind.Windows ? "ilspycmd.exe" : "ilspycmd"; + return Path.Combine(probe.HomeDirectory, ".dotnet", "tools", name); + } +} diff --git a/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs b/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs new file mode 100644 index 0000000..6329ba2 --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs @@ -0,0 +1,58 @@ +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Bootstrap.Core.Acquisition; + +/// +/// Plans a .NET SDK acquisition through the official dotnet-install +/// scripts. Ports the refusal in install_dotnet10: the glibc installer is +/// not attempted on NixOS or on any host whose dynamic linker is missing, and +/// the plan honours the global.json pin with --jsonfile rather than +/// the wider --channel the shell script uses. +/// +public static class SdkAcquisition +{ + public sealed record Plan( + string ScriptUrl, + string ScriptExecutable, + IReadOnlyList Arguments, + string InstallDirectory); + + public sealed record Decision(bool CanRunScript, string? RefusalReason, Plan? Plan); + + public static Decision Evaluate(ISystemProbe probe, string repoRoot) + { + if (NixEnvironment.IsNixOs(probe)) + { + return new Decision(false, + $"NixOS: install the SDK with `{NixEnvironment.DotnetSdkInstallCommand}` instead.", null); + } + + if (!NixEnvironment.DownloadedSdkRunnable(probe)) + { + return new Decision(false, + "This is a non-FHS system: the SDK from dot.net is a glibc build whose dynamic linker is not present here.", null); + } + + string installDir = Path.Combine(probe.HomeDirectory, ".dotnet"); + string globalJson = Path.Combine(repoRoot, "global.json"); + bool windows = probe.Os == OsKind.Windows; + + var args = windows + ? new List { "-InstallDir", installDir, "-NoPath" } + : new List { "--install-dir", installDir, "--no-path" }; + + if (probe.FileExists(globalJson)) + args.AddRange(windows ? ["-JSonFile", globalJson] : ["--jsonfile", globalJson]); + else + args.AddRange(windows ? ["-Channel", "10.0"] : ["--channel", "10.0"]); + + var plan = new Plan( + windows ? "https://dot.net/v1/dotnet-install.ps1" : "https://dot.net/v1/dotnet-install.sh", + windows ? PowerShellHost.Resolve(probe) ?? "powershell" : "bash", + args, + installDir); + + return new Decision(true, null, plan); + } +} diff --git a/Optimum.Bootstrap.Core/Acquisition/SdkInstaller.cs b/Optimum.Bootstrap.Core/Acquisition/SdkInstaller.cs new file mode 100644 index 0000000..4c9aa40 --- /dev/null +++ b/Optimum.Bootstrap.Core/Acquisition/SdkInstaller.cs @@ -0,0 +1,152 @@ +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Bootstrap.Core.Acquisition; + +/// +/// Acquires a .NET SDK the build can use, without touching the user's PATH. +/// Downloads the official dotnet-install script and runs it with +/// --install-dir ~/.dotnet and --no-path, honouring the +/// global.json pin. The GUI Prerequisites screen and +/// Optimum.Cli preflight --install both drive one. +/// +public interface ISdkAcquisition +{ + Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken); +} + +public sealed class SdkInstaller : ISdkAcquisition +{ + private readonly ISystemProbe _probe; + private readonly Func> _fetchScript; + + public SdkInstaller(ISystemProbe probe) : this(probe, DownloadScriptAsync) { } + + /// Test seam: writes the script to a + /// temp file and returns its path, or null on failure. + internal SdkInstaller(ISystemProbe probe, Func> fetchScript) + { + _probe = probe; + _fetchScript = fetchScript; + } + + public async Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + SdkAcquisition.Decision decision = SdkAcquisition.Evaluate(_probe, repoRoot); + if (!decision.CanRunScript || decision.Plan is null) + { + return ToolAcquisitionResult.Failure(FailureReason.UnsupportedVersion, + decision.RefusalReason ?? "a .NET SDK cannot be installed automatically on this system"); + } + + SdkAcquisition.Plan plan = decision.Plan; + observer.Phase(ProgressPhase.Decompile, 1, "downloading the .NET SDK installer"); + + string? scriptPath; + try + { + scriptPath = await _fetchScript(plan.ScriptUrl, cancellationToken); + } + catch (OperationCanceledException) + { + return ToolAcquisitionResult.Failure(FailureReason.Cancelled, "the SDK download was cancelled"); + } + catch (Exception ex) + { + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"could not download the .NET SDK installer: {ex.Message}"); + } + + if (scriptPath is null) + { + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"could not download the .NET SDK installer from {plan.ScriptUrl}"); + } + + try + { + observer.Phase(ProgressPhase.Decompile, 2, "installing the .NET SDK"); + var arguments = BuildArguments(plan, scriptPath); + AcquisitionProcess.Outcome outcome = await AcquisitionProcess.RunAsync( + plan.ScriptExecutable, arguments, repoRoot, + environment: null, observer, cancellationToken); + if (!outcome.Ok) + { + return ToolAcquisitionResult.Failure(FailureReason.SourceUnavailable, + outcome.Message ?? "the .NET SDK installer failed"); + } + + string? found = DotnetSdkProbe.Find(_probe); + if (found is null) + { + return ToolAcquisitionResult.Failure(FailureReason.VerificationFailed, + $"the .NET SDK installer reported success but no SDK 10 was found under {plan.InstallDirectory}"); + } + + observer.Log(LogLevel.Info, $"Installed the .NET SDK at {found}"); + return ToolAcquisitionResult.Success(found); + } + catch (OperationCanceledException) + { + return ToolAcquisitionResult.Failure(FailureReason.Cancelled, "the SDK install was cancelled"); + } + finally + { + TryDelete(scriptPath); + } + } + + /// + /// bash dotnet-install.sh <args> on Unix; + /// powershell -NoProfile -ExecutionPolicy Bypass -File dotnet-install.ps1 <args> + /// on Windows, where the script's own -InstallDir-style flags are + /// already in . + /// + internal static IReadOnlyList BuildArguments(SdkAcquisition.Plan plan, string scriptPath) + { + bool powershell = plan.ScriptUrl.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + List args = powershell + ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath] + : [scriptPath]; + args.AddRange(plan.Arguments); + return args; + } + + private static async Task DownloadScriptAsync(string url, CancellationToken cancellationToken) + { + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; + using HttpResponseMessage response = await http.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + + string extension = url.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase) ? ".ps1" : ".sh"; + string path = Path.Combine(Path.GetTempPath(), "dotnet-install-" + Guid.NewGuid().ToString("N")[..8] + extension); + await using (FileStream file = File.Create(path)) + { + await response.Content.CopyToAsync(file, cancellationToken); + } + + if (extension == ".sh" && !OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + } + + return path; + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } +} diff --git a/Optimum.Bootstrap.Core/Build/BootstrapFailureClassifier.cs b/Optimum.Bootstrap.Core/Build/BootstrapFailureClassifier.cs new file mode 100644 index 0000000..b885074 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/BootstrapFailureClassifier.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace Optimum.Bootstrap.Core.Build; + +/// +/// Decides whether a failed bootstrap run failed while applying patches +/// (so the caller gets ) or earlier, +/// during download or decompile (). +/// The distinction matters to RiftLauncher, which maps the reason to a message. +/// +public static partial class BootstrapFailureClassifier +{ + public static FailureReason Classify(string bootstrapOutput) => + PatchFailure().IsMatch(bootstrapOutput) + ? FailureReason.PatchConflict + : FailureReason.DecompileFailed; + + [GeneratedRegex( + @"patch (failed|does not apply)|hunk\s.*FAILED|error:\s.*\.patch|\.rej\b|patch application (failed|aborted)|failed to apply|Applying .* patch .* failed", + RegexOptions.IgnoreCase)] + private static partial Regex PatchFailure(); +} diff --git a/Optimum.Bootstrap.Core/Build/BuildDriver.cs b/Optimum.Bootstrap.Core/Build/BuildDriver.cs new file mode 100644 index 0000000..8ee5f47 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/BuildDriver.cs @@ -0,0 +1,55 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Build; + +public sealed record BuildRequest( + string RepoRoot, + string OutputDirectory, + string? ClientArchive = null, + string? Version = null); + +public sealed record BuildResult(bool Ok, FailureReason? Reason, string? Message, string? RuntimePath) +{ + public static BuildResult Success(string runtimePath) => new(true, null, null, runtimePath); + + public static BuildResult Failure(FailureReason reason, string message) => new(false, reason, message, null); +} + +/// +/// The engine's build pipeline. The GUI drives one in-process; the CLI wraps one +/// per verb. Progress and log go to the observer so the front end owns the +/// presentation. Cancellation is two-tier: asks the +/// running subprocess to stop (SIGINT), kills it. +/// Passing only is a straight kill. +/// +public interface IBuildDriver +{ + Task RunAsync( + BuildRequest request, + IBuildObserver observer, + CancellationToken forceful, + CancellationToken graceful = default); +} + +/// Receives everything a running build has to say. +public interface IBuildObserver +{ + void Phase(ProgressPhase phase, int percent, string detail); + + void Log(LogLevel level, string message); + + /// A verbatim line from a subprocess. Not part of any contract. + void RawOutput(bool isError, string line); +} + +/// Discards everything. Useful in tests that only care about the result. +public sealed class NullBuildObserver : IBuildObserver +{ + public static readonly NullBuildObserver Instance = new(); + + public void Phase(ProgressPhase phase, int percent, string detail) { } + + public void Log(LogLevel level, string message) { } + + public void RawOutput(bool isError, string line) { } +} diff --git a/Optimum.Bootstrap.Core/Build/Capabilities.cs b/Optimum.Bootstrap.Core/Build/Capabilities.cs new file mode 100644 index 0000000..0ba6a91 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/Capabilities.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Build; + +public sealed record EngineCapabilities( + string PinnedVersion, + IReadOnlyList SupportedVersions, + IReadOnlyList PatchSets); + +/// +/// What optimum capabilities reports so a caller can gate the UI before a +/// 570 MB download: the pinned Vintage Story version from forks.json, the +/// alternate versions that have a patches-<version>-bridge/ set, and the +/// top-level patch set ids under patches/. +/// +public static class Capabilities +{ + public static EngineCapabilities Read(ISystemProbe probe, string repoRoot) + { + string pinned = ReadPinnedVersion(probe, repoRoot); + + var supported = new List { pinned }; + foreach (string dir in probe.EnumerateDirectories(repoRoot, "patches-*-bridge")) + { + string name = Path.GetFileName(dir); + string version = name["patches-".Length..^"-bridge".Length]; + if (version.Length > 0 && !supported.Contains(version)) + supported.Add(version); + } + + var patchSets = probe.EnumerateDirectories(Path.Combine(repoRoot, "patches"), "*") + .Select(Path.GetFileName) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + return new EngineCapabilities(pinned, supported, patchSets); + } + + private static string ReadPinnedVersion(ISystemProbe probe, string repoRoot) + { + const string fallback = "1.22.7"; + string? json = probe.ReadText(Path.Combine(repoRoot, "forks.json")); + if (json is null) + return fallback; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("vintageStoryVersion", out var v) + && v.GetString() is { Length: > 0 } version) + return version; + } + catch (JsonException) { /* fall through */ } + + return fallback; + } +} diff --git a/Optimum.Bootstrap.Core/Build/RepoRoot.cs b/Optimum.Bootstrap.Core/Build/RepoRoot.cs new file mode 100644 index 0000000..47cbeb5 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/RepoRoot.cs @@ -0,0 +1,28 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Build; + +/// +/// Finds the Optimum checkout the engine has to drive: the nearest directory at +/// or above a starting point that holds forks.json next to +/// scripts/bootstrap.sh. Both front ends need this because the build +/// pipeline is still the shell scripts (INSTALLER-PLAN.md section 2). +/// +public static class RepoRoot +{ + public static string? Discover(ISystemProbe probe, string? explicitRoot = null) + { + string start = explicitRoot is not null + ? Path.GetFullPath(explicitRoot) + : Directory.GetCurrentDirectory(); + + for (string? dir = start; dir is not null; dir = Path.GetDirectoryName(dir)) + { + if (probe.FileExists(Path.Combine(dir, "forks.json")) + && probe.FileExists(Path.Combine(dir, "scripts", "bootstrap.sh"))) + return dir; + } + + return null; + } +} diff --git a/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs new file mode 100644 index 0000000..d9447d4 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs @@ -0,0 +1,286 @@ +using System.Text; +using CliWrap; +using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Bootstrap.Core.Build; + +file static class Encodings +{ + public static readonly Encoding Utf8 = new UTF8Encoding(false); +} + +/// +/// The real build pipeline: it drives scripts/bootstrap.*, +/// dotnet build VintageStory.slnx, scripts/check-patches.sh, and +/// the platform packaging script through CliWrap, the same sequence +/// .github/workflows/ci-platform-bootstrap.yml runs by hand. It never +/// reimplements those scripts. +/// +public sealed class ScriptBuildDriver(ISystemProbe probe) : IBuildDriver +{ + public async Task RunAsync( + BuildRequest request, + IBuildObserver observer, + CancellationToken forceful, + CancellationToken graceful = default) + { + var scanner = new PrerequisiteScanner(probe, request.RepoRoot); + string[] missing = scanner.Scan().Where(r => r.BlocksBuild).Select(r => r.Definition.DisplayName).ToArray(); + if (missing.Length > 0) + return BuildResult.Failure(FailureReason.BadInput, "Required tools missing: " + string.Join(", ", missing)); + + bool outputPreexisted = probe.DirectoryExists(request.OutputDirectory); + if (outputPreexisted + && (probe.EnumerateFiles(request.OutputDirectory, "*").Any() + || probe.EnumerateDirectories(request.OutputDirectory, "*").Any())) + { + return BuildResult.Failure(FailureReason.OutputExists, + $"The output directory must be empty or absent: {request.OutputDirectory}"); + } + + Directory.CreateDirectory(request.OutputDirectory); + + try + { + StepOutcome bootstrap = await RunStep( + BootstrapCommand(request), request.RepoRoot, ProgressPhase.Decompile, 2, 48, observer, + clearPlatformEnv: false, forceful, graceful); + if (!bootstrap.Ok) + { + return BuildResult.Failure( + BootstrapFailureClassifier.Classify(bootstrap.Output), + $"bootstrap exited {bootstrap.ExitCode}"); + } + + observer.Phase(ProgressPhase.Patch, 50, "patches applied"); + + StepOutcome build = await RunStep( + (DotnetExecutable(), ["build", "VintageStory.slnx", "-c", "Release", "--nologo"]), + request.RepoRoot, ProgressPhase.Assemble, 52, 82, observer, + clearPlatformEnv: true, forceful, graceful); + if (!build.Ok) + return BuildResult.Failure(FailureReason.AssembleFailed, $"dotnet build exited {build.ExitCode}"); + + // check-patches.sh is a POSIX shell script with no PowerShell port. + // On Windows the only `bash` on a stock machine is System32\bash.exe + // (WSL), which would run the script against a different filesystem + // view with a different dotnet and git — silently wrong. Skip it + // there; install-windows.ps1 never ran this check either. + if (probe.Os == OsKind.Windows) + { + observer.Phase(ProgressPhase.Patch, 86, + "skipped the decompile round-trip check (needs a POSIX shell)"); + } + else + { + StepOutcome checkPatches = await RunStep( + ("bash", ["scripts/check-patches.sh", "--strict-unavailable"]), + request.RepoRoot, ProgressPhase.Patch, 82, 86, observer, + clearPlatformEnv: false, forceful, graceful); + if (!checkPatches.Ok) + return BuildResult.Failure(FailureReason.PatchConflict, + $"check-patches.sh exited {checkPatches.ExitCode}: a patch did not survive the decompile round trip"); + } + + StepOutcome package = await RunStep( + PackageCommand(request), request.RepoRoot, ProgressPhase.Assemble, 86, 95, observer, + clearPlatformEnv: false, forceful, graceful); + if (!package.Ok) + return BuildResult.Failure(FailureReason.AssembleFailed, $"packaging exited {package.ExitCode}"); + + string? produced = LocatePackage(request.OutputDirectory); + if (produced is null) + return BuildResult.Failure(FailureReason.AssembleFailed, + $"the packaging script produced no package under {request.OutputDirectory}"); + + observer.Phase(ProgressPhase.Verify, 96, "validating the runtime"); + RuntimeValidationResult validation = new RuntimeValidator(probe).Validate(produced); + if (!validation.Ok) + return BuildResult.Failure(FailureReason.VerificationFailed, validation.Detail ?? "runtime validation failed"); + + observer.Phase(ProgressPhase.Verify, 98, "package produced"); + return BuildResult.Success(produced); + } + catch (OperationCanceledException) + { + CleanOutput(request.OutputDirectory, outputPreexisted); + return BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); + } + } + + private (string Exe, IReadOnlyList Args) BootstrapCommand(BuildRequest request) + { + if (probe.Os == OsKind.Windows) + { + List win = ["-File", "scripts/bootstrap.ps1"]; + if (request.ClientArchive is not null) + win.AddRange(["-ClientArchive", request.ClientArchive]); + if (request.Version is not null) + win.AddRange(["-Version", request.Version]); + return (PwshExecutable(), win); + } + + List unix = ["scripts/bootstrap.sh"]; + if (request.ClientArchive is not null) + unix.AddRange(["--client-archive", request.ClientArchive]); + if (request.Version is not null) + unix.AddRange(["--version", request.Version]); + return ("bash", unix); + } + + private (string Exe, IReadOnlyList Args) PackageCommand(BuildRequest request) + { + string output = request.OutputDirectory; + switch (probe.Os) + { + case OsKind.Windows: + List win = ["-File", "scripts/package.ps1", "-OutputDir", output]; + if (request.ClientArchive is not null) win.AddRange(["-ClientArchive", request.ClientArchive]); + return (PwshExecutable(), win); + case OsKind.MacOs: + string arch = probe.Arch == System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64"; + List mac = ["scripts/package-macos.sh", "--output", output, "--arch", arch]; + if (request.ClientArchive is not null) mac.AddRange(["--client-archive", request.ClientArchive]); + if (request.Version is not null) mac.AddRange(["--version", request.Version]); + return ("bash", mac); + default: + List linux = ["scripts/package-linux.sh", "--output", output]; + if (request.ClientArchive is not null) linux.AddRange(["--client-archive", request.ClientArchive]); + if (request.Version is not null) linux.AddRange(["--version", request.Version]); + return ("bash", linux); + } + } + + private string DotnetExecutable() => DotnetSdkProbe.Find(probe) ?? "dotnet"; + + /// + /// The PowerShell interpreter for the bootstrap and packaging scripts: + /// pwsh when present, Windows PowerShell 5.1 as the Windows fallback. + /// + private string PwshExecutable() => + PowerShellHost.Resolve(probe) ?? (probe.Os == OsKind.Windows ? "powershell" : "pwsh"); + + /// + /// The package artifact the platform's packaging script produces: a + /// Optimum-v* directory on Windows and Linux, an Optimum.app + /// bundle on macOS. + /// + private string? LocatePackage(string outputDirectory) + { + if (!Directory.Exists(outputDirectory)) + return null; + + if (probe.Os == OsKind.MacOs) + { + string app = Path.Combine(outputDirectory, "Optimum.app"); + return Directory.Exists(app) ? app : null; + } + + return Directory.EnumerateDirectories(outputDirectory, "Optimum-v*") + .Where(d => !Path.GetFileName(d).StartsWith('.')) + .OrderBy(d => d, StringComparer.Ordinal) + .LastOrDefault(); + } + + private async Task RunStep( + (string Exe, IReadOnlyList Args) command, + string workingDirectory, + ProgressPhase phase, + int startPercent, + int endPercent, + IBuildObserver observer, + bool clearPlatformEnv, + CancellationToken forceful, + CancellationToken graceful) + { + observer.Phase(phase, startPercent, $"{command.Exe} {string.Join(' ', command.Args)}"); + + var collected = new StringBuilder(); + int exitCode = -1; + int reported = startPercent; + int linesSincePhase = 0; + + Command cmd = Cli.Wrap(command.Exe) + .WithArguments(command.Args) + .WithWorkingDirectory(workingDirectory) + .WithValidation(CommandResultValidation.None); + if (clearPlatformEnv) + cmd = cmd.WithEnvironmentVariables(env => env.Set("Platform", null).Set("PLATFORM", null)); + + try + { + await foreach (CommandEvent commandEvent in + cmd.ListenAsync(Encodings.Utf8, Encodings.Utf8, forceful, graceful)) + { + switch (commandEvent) + { + case StandardOutputCommandEvent stdout: + observer.RawOutput(false, stdout.Text); + collected.AppendLine(stdout.Text); + if (++linesSincePhase >= 25 && reported < endPercent - 1) + { + reported++; + linesSincePhase = 0; + observer.Phase(phase, reported, Trim(stdout.Text)); + } + break; + case StandardErrorCommandEvent stderr: + observer.RawOutput(true, stderr.Text); + collected.AppendLine(stderr.Text); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + break; + } + } + } + catch (System.ComponentModel.Win32Exception ex) + { + // The executable is not on PATH / could not be spawned. Report it as + // a failed step so RunAsync classifies it instead of the exception + // escaping unobserved and hanging the wizard on the Progress screen. + string message = $"could not start '{command.Exe}': {ex.Message}"; + observer.RawOutput(true, message); + observer.Phase(phase, endPercent, message); + return new StepOutcome(false, -1, message); + } + + observer.Phase(phase, endPercent, exitCode == 0 ? "done" : $"exited {exitCode}"); + return new StepOutcome(exitCode == 0, exitCode, collected.ToString()); + } + + private static string Trim(string line) => line.Length <= 120 ? line : line[..120]; + + /// + /// Removes what the build wrote. The output guard guarantees the directory + /// was empty or absent, so when it pre-existed only its new contents are + /// removed and the directory itself is left in place. + /// + private static void CleanOutput(string directory, bool preexisted) + { + try + { + if (!Directory.Exists(directory)) + return; + if (!preexisted) + { + Directory.Delete(directory, recursive: true); + return; + } + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + if (Directory.Exists(entry)) + Directory.Delete(entry, recursive: true); + else + File.Delete(entry); + } + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } + + private readonly record struct StepOutcome(bool Ok, int ExitCode, string Output); +} diff --git a/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs b/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs new file mode 100644 index 0000000..b93ca7c --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs @@ -0,0 +1,275 @@ +using System.Text; +using CliWrap; +using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Build; + +/// +/// What the front end asks for when there is no Optimum checkout on the machine: +/// the version to fetch (the installer's own version), an optional cache +/// location, and whether to re-clone even if a cached tree is already there. +/// +public sealed record SourceRequest(string Version, string? CacheRoot = null, bool Refresh = false) +{ + /// The public repository the installer clones. HTTPS, so an end user + /// needs no SSH key. + public const string RepositoryUrl = "https://github.com/StratumServer/Optimum.git"; +} + +public sealed record SourceAcquisitionResult(bool Ok, string? RepoRoot, FailureReason? Reason, string? Message) +{ + public static SourceAcquisitionResult Success(string repoRoot) => new(true, repoRoot, null, null); + + public static SourceAcquisitionResult Failure(FailureReason reason, string message) => + new(false, null, reason, message); +} + +/// +/// Obtains an Optimum checkout the build pipeline can drive. The GUI runs one on +/// the Prerequisites screen; the CLI runs one for build --acquire-source. +/// +public interface ISourceProvider +{ + Task EnsureAsync( + SourceRequest request, IBuildObserver observer, CancellationToken cancellationToken); +} + +/// +/// Pure helpers that decide where a downloaded checkout lives and what ref to +/// fetch. Split out from so they are testable +/// without a git process. +/// +public static class SourceCache +{ + /// + /// The directory a checkout for is cached in: + /// <cache>/optimum/src-<version>, where the cache root is + /// the platform's per-user cache location unless + /// is given. + /// + public static string Directory(ISystemProbe probe, string version, string? overrideRoot = null) + { + string root = overrideRoot ?? DefaultRoot(probe); + return Path.Combine(root, "optimum", "src-" + SanitizeVersion(version)); + } + + private static string DefaultRoot(ISystemProbe probe) => probe.Os switch + { + OsKind.Windows => probe.GetEnvironmentVariable("LOCALAPPDATA") + ?? Path.Combine(probe.HomeDirectory, "AppData", "Local"), + OsKind.MacOs => Path.Combine(probe.HomeDirectory, "Library", "Caches"), + _ => probe.GetEnvironmentVariable("XDG_CACHE_HOME") + ?? Path.Combine(probe.HomeDirectory, ".cache"), + }; + + /// + /// A filesystem-safe token for the version, prefixed v when it starts + /// with a digit so it matches the release tag naming (v0.3.14). + /// + internal static string SanitizeVersion(string version) + { + string core = (version ?? string.Empty).Trim().Split('+', 2)[0]; + var safe = new string(core.Select(c => char.IsLetterOrDigit(c) || c is '.' or '-' ? c : '_').ToArray()) + .Trim('.', '-', '_'); + if (safe.Length == 0) + return "dev"; + return char.IsDigit(safe[0]) ? "v" + safe : safe; + } + + /// + /// The git ref to clone: the v<version> release tag for a real + /// version, or null for a dev build (clone the default branch instead). + /// + public static string? TagRef(string version) + { + string v = SanitizeVersion(version); + return v.Length > 1 && v[0] == 'v' && char.IsDigit(v[1]) ? v : null; + } + + /// True when a directory holds the two files the pipeline needs. + public static bool IsUsableCheckout(ISystemProbe probe, string directory) => + probe.FileExists(Path.Combine(directory, "forks.json")) + && probe.FileExists(Path.Combine(directory, "scripts", "bootstrap.sh")); + + internal static IReadOnlyList CloneArguments(string? tagRef, string targetDirectory) + { + List args = ["clone", "--depth", "1", "--single-branch"]; + if (tagRef is not null) + args.AddRange(["--branch", tagRef]); + args.Add(SourceRequest.RepositoryUrl); + args.Add(targetDirectory); + return args; + } +} + +/// +/// Clones at the release tag with a +/// shallow, single-branch clone. A cached tree is reused as is; a clone that +/// fails on the tag retries once on the default branch (for a version whose tag +/// is not published yet). The clone lands in a staging sibling and is swapped +/// into place only once it verifies, so an interrupted download never leaves a +/// half-tree that looks usable. +/// +public sealed class GitSourceProvider(ISystemProbe probe) : ISourceProvider +{ + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + public async Task EnsureAsync( + SourceRequest request, IBuildObserver observer, CancellationToken cancellationToken) + { + string targetDir = SourceCache.Directory(probe, request.Version, request.CacheRoot); + + if (!request.Refresh && SourceCache.IsUsableCheckout(probe, targetDir)) + { + observer.Log(LogLevel.Info, $"Using the cached Optimum source at {targetDir}"); + return SourceAcquisitionResult.Success(targetDir); + } + + string? git = CommandSearch.Which(probe, "git"); + if (git is null) + { + return SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, + "git was not found on PATH; install git so the installer can download the Optimum source"); + } + + try + { + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"could not create the source cache directory: {ex.Message}"); + } + + string staging = targetDir + ".partial-" + Guid.NewGuid().ToString("N")[..8]; + string? tagRef = SourceCache.TagRef(request.Version); + + try + { + observer.Phase(ProgressPhase.Decompile, 1, $"downloading the Optimum source ({tagRef ?? "default branch"})"); + + int exit = await Clone(git, tagRef, staging, observer, cancellationToken); + if (exit != 0 && tagRef is not null) + { + observer.Log(LogLevel.Warn, $"no {tagRef} tag upstream yet; downloading the default branch"); + TryDelete(staging); + exit = await Clone(git, null, staging, observer, cancellationToken); + } + + if (exit != 0) + { + TryDelete(staging); + return SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"git clone failed (exit {exit}); check the network connection and retry"); + } + + if (!SourceCache.IsUsableCheckout(probe, staging)) + { + TryDelete(staging); + return SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, + "the downloaded source is missing forks.json or scripts/bootstrap.sh"); + } + + Promote(staging, targetDir); + + observer.Phase(ProgressPhase.Decompile, 2, $"Optimum source ready at {targetDir}"); + return SourceAcquisitionResult.Success(targetDir); + } + catch (OperationCanceledException) + { + TryDelete(staging); + return SourceAcquisitionResult.Failure(FailureReason.Cancelled, "the source download was cancelled"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or System.ComponentModel.Win32Exception) + { + TryDelete(staging); + return SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, + $"could not place the downloaded source: {ex.Message}"); + } + } + + private static async Task Clone( + string git, string? tagRef, string destination, IBuildObserver observer, CancellationToken cancellationToken) + { + int exitCode = -1; + Command cmd = Cli.Wrap(git) + .WithArguments(SourceCache.CloneArguments(tagRef, destination)) + .WithValidation(CommandResultValidation.None); + + await foreach (CommandEvent commandEvent in + cmd.ListenAsync(Utf8, Utf8, cancellationToken, CancellationToken.None)) + { + switch (commandEvent) + { + case StandardOutputCommandEvent stdout: + observer.RawOutput(false, stdout.Text); + break; + case StandardErrorCommandEvent stderr: + // git writes its clone progress to stderr; it is not an error. + observer.RawOutput(false, stderr.Text); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + break; + } + } + + return exitCode; + } + + /// + /// Swaps a verified clone into place without first deleting a usable cache. + /// The old checkout is restored if the promotion fails. + /// + internal static void Promote(string staging, string target) + { + string? backup = null; + try + { + if (System.IO.Directory.Exists(target)) + { + backup = target + ".previous-" + Guid.NewGuid().ToString("N")[..8]; + System.IO.Directory.Move(target, backup); + } + + System.IO.Directory.Move(staging, target); + } + catch (Exception promotionError) when (promotionError is IOException or UnauthorizedAccessException) + { + if (backup is not null + && !System.IO.Directory.Exists(target) + && System.IO.Directory.Exists(backup)) + { + try + { + System.IO.Directory.Move(backup, target); + } + catch (Exception restoreError) when (restoreError is IOException or UnauthorizedAccessException) + { + throw new IOException( + $"source promotion failed and the previous checkout could not be restored; it remains at {backup}", + new AggregateException(promotionError, restoreError)); + } + } + + throw; + } + + if (backup is not null) + TryDelete(backup); + } + + private static void TryDelete(string directory) + { + try + { + if (System.IO.Directory.Exists(directory)) + System.IO.Directory.Delete(directory, recursive: true); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } +} diff --git a/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs b/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs new file mode 100644 index 0000000..464688f --- /dev/null +++ b/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs @@ -0,0 +1,64 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.DataPath; + +public sealed record DataPathDetection(string? Path, bool HasActiveSession); + +/// +/// Session-aware detection of an existing Vintage Story data folder. Ports +/// prompt_data_path from scripts/install-linux.sh (which Windows and +/// macOS never had) and widens the candidate list per platform: a folder whose +/// clientsettings.json carries a playeruid wins over one that merely +/// exists. +/// +public static class DataPathProbe +{ + public static DataPathDetection Detect(ISystemProbe probe) + { + string[] candidates = Candidates(probe); + + foreach (string dir in candidates) + { + string settings = System.IO.Path.Combine(dir, "clientsettings.json"); + string? content = probe.ReadText(settings); + if (content is not null && content.Contains("\"playeruid\"", StringComparison.Ordinal)) + return new DataPathDetection(dir, HasActiveSession: true); + } + + foreach (string dir in candidates) + { + if (probe.DirectoryExists(dir)) + return new DataPathDetection(dir, HasActiveSession: false); + } + + return new DataPathDetection(null, HasActiveSession: false); + } + + private static string[] Candidates(ISystemProbe probe) + { + string home = probe.HomeDirectory; + return probe.Os switch + { + OsKind.Windows => + [ + Combine(probe.GetEnvironmentVariable("APPDATA"), "VintagestoryData"), + Combine(probe.GetEnvironmentVariable("APPDATA"), "OptimumData"), + ], + OsKind.MacOs => + [ + System.IO.Path.Combine(home, "Library", "Application Support", "VintagestoryData"), + System.IO.Path.Combine(home, "Library", "Application Support", "OptimumVintagestoryData"), + System.IO.Path.Combine(home, ".config", "VintagestoryData"), + ], + _ => + [ + System.IO.Path.Combine(home, ".config", "VintagestoryData"), + System.IO.Path.Combine(home, ".config", "OptimumVintagestoryData"), + System.IO.Path.Combine(home, "ApplicationData", "vintagestorydata"), + ], + }; + + static string Combine(string? root, string child) => + root is { Length: > 0 } ? System.IO.Path.Combine(root, child) : child; + } +} diff --git a/Optimum.Bootstrap.Core/EngineProtocol.cs b/Optimum.Bootstrap.Core/EngineProtocol.cs new file mode 100644 index 0000000..d5e7eb5 --- /dev/null +++ b/Optimum.Bootstrap.Core/EngineProtocol.cs @@ -0,0 +1,88 @@ +using System.Reflection; + +namespace Optimum.Bootstrap.Core; + +/// +/// The build phases the engine reports through . +/// The set is part of the engine contract in INSTALLER-PLAN.md section 4 and a +/// caller may switch on it exhaustively. +/// +public enum ProgressPhase +{ + Decompile, + Patch, + Verify, + Assemble, +} + +/// +/// One progress observation. is a monotonic +/// non-decreasing integer in the range 0 to 99. The engine never emits 100: +/// the caller owns the terminal 100 after its own post-validation. +/// +public readonly record struct BootstrapProgress(ProgressPhase Phase, int Percent, string Detail) +{ + public const int MaxEnginePercent = 99; +} + +/// +/// The closed set of failure reasons a terminal result may carry. Kebab-case on +/// the wire (see ). Adding a value is a +/// breaking change for a caller that switches on it exhaustively. +/// +public enum FailureReason +{ + BadInput, + UnsupportedVersion, + PatchConflict, + DecompileFailed, + AssembleFailed, + VerificationFailed, + OutputExists, + SourceUnavailable, + Cancelled, + EngineInternal, +} + +public static class FailureReasonExtensions +{ + /// The kebab-case token used on the NDJSON wire. + public static string Wire(this FailureReason reason) => reason switch + { + FailureReason.BadInput => "bad-input", + FailureReason.UnsupportedVersion => "unsupported-version", + FailureReason.PatchConflict => "patch-conflict", + FailureReason.DecompileFailed => "decompile-failed", + FailureReason.AssembleFailed => "assemble-failed", + FailureReason.VerificationFailed => "verification-failed", + FailureReason.OutputExists => "output-exists", + FailureReason.SourceUnavailable => "source-unavailable", + FailureReason.Cancelled => "cancelled", + FailureReason.EngineInternal => "engine-internal", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null), + }; +} + +/// Severity of a log event, shared by the build pipeline and the NDJSON stream. +public enum LogLevel +{ + Info, + Warn, + Error, +} + +/// Assembly-level facts shared by both front ends. +public static class CoreInfo +{ + /// + /// The Optimum version, from the informational version attribute, falling + /// back to the assembly version. Matches how Optimum.Launcher resolves + /// its own version. + /// + public static string Version { get; } = + typeof(CoreInfo).Assembly + .GetCustomAttribute() + ?.InformationalVersion?.Split('+')[0] + ?? typeof(CoreInfo).Assembly.GetName().Version?.ToString() + ?? "dev"; +} diff --git a/Optimum.Bootstrap.Core/Install/InstallManifest.cs b/Optimum.Bootstrap.Core/Install/InstallManifest.cs new file mode 100644 index 0000000..1afec2b --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/InstallManifest.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Optimum.Bootstrap.Core.Install; + +/// +/// The record an install leaves behind so optimum uninstall and the +/// upgrade check know exactly what was placed and where. Written to +/// <installDir>/.optimum/install-manifest.json. +/// +public sealed record InstallManifest +{ + public const string RelativePath = ".optimum/install-manifest.json"; + + [JsonPropertyName("optimumVersion")] + public required string OptimumVersion { get; init; } + + [JsonPropertyName("installedAtUtc")] + public required DateTimeOffset InstalledAtUtc { get; init; } + + [JsonPropertyName("installDirectory")] + public required string InstallDirectory { get; init; } + + [JsonPropertyName("dataPath")] + public string? DataPath { get; init; } + + [JsonPropertyName("launcher")] + public string? Launcher { get; init; } + + /// Top-level entries the install created, relative to the install directory. + [JsonPropertyName("entries")] + public required IReadOnlyList Entries { get; init; } + + /// Absolute paths of shortcuts and menu entries the install wrote outside the install directory. + [JsonPropertyName("shortcuts")] + public IReadOnlyList Shortcuts { get; init; } = []; + + /// A Windows uninstall registry key to remove, if one was registered. + [JsonPropertyName("uninstallRegistryKey")] + public string? UninstallRegistryKey { get; init; } + + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public string Serialize() => JsonSerializer.Serialize(this, Json); + + public static InstallManifest? Deserialize(string json) + { + try { return JsonSerializer.Deserialize(json, Json); } + catch (JsonException) { return null; } + } +} diff --git a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs new file mode 100644 index 0000000..106b3bb --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs @@ -0,0 +1,341 @@ +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Paths; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +[Flags] +public enum ShortcutKinds +{ + None = 0, + Menu = 1, + Desktop = 2, +} + +public sealed record DeployRequest( + string PackageDirectory, + string InstallDirectory, + string? DataPath = null, + ShortcutKinds Shortcuts = ShortcutKinds.None); + +public sealed record DeployResult(bool Ok, FailureReason? Reason, string? Message, string? InstallDirectory, string? Launcher) +{ + public static DeployResult Failure(FailureReason reason, string message) => new(false, reason, message, null, null); + + public static DeployResult Success(string installDirectory, string? launcher) => + new(true, null, null, installDirectory, launcher); +} + +/// +/// Deploys a staged package transactionally, ported from Install-StagedPackage +/// in scripts/install-windows.ps1: build the whole new tree next to the +/// target, move an existing install aside, swap the new tree in with one rename, +/// then delete the backup. Any failure rolls back to the previous install. An +/// existing Optimum install (one with a manifest) is replaced this way; a +/// non-empty directory that is not an Optimum install is refused. +/// +public sealed class PackageDeployer(ISystemProbe probe) : IPackageInstaller +{ + /// Test hook: throws for the named step to exercise rollback. + internal Action? FailAtStep { get; set; } + + public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = null) + { + InstallPathVerdict guard = InstallPathGuard.Check(probe, new InstallPathRequest( + request.InstallDirectory, request.DataPath)); + if (!guard.Ok) + return DeployResult.Failure(FailureReason.BadInput, guard.Rejection!); + + PackageLayoutResult layout = PackageLayout.Validate(probe, request.PackageDirectory); + if (!layout.Ok) + return DeployResult.Failure(FailureReason.BadInput, + "the package directory is not a staged Optimum package: " + string.Join("; ", layout.Problems)); + + string installDir = Path.GetFullPath(request.InstallDirectory); + string? parent = Path.GetDirectoryName(installDir); + if (parent is null) + return DeployResult.Failure(FailureReason.BadInput, $"the install directory has no parent: {installDir}"); + + bool hasExisting = Directory.Exists(installDir) && Directory.EnumerateFileSystemEntries(installDir).Any(); + if (hasExisting && !File.Exists(Path.Combine(installDir, InstallManifest.RelativePath))) + return DeployResult.Failure(FailureReason.OutputExists, $"the install directory is not empty: {installDir}"); + + Directory.CreateDirectory(parent); + string token = Guid.NewGuid().ToString("N")[..12]; + string stageDir = Path.Combine(parent, $".optimum-stage-{token}"); + string backupDir = Path.Combine(parent, $".optimum-backup-{token}"); + bool backedUp = false; + bool committed = false; + string? launcher = null; + + try + { + Checkpoint("stage"); + observer?.Log(LogLevel.Info, $"staging {Path.GetFileName(request.PackageDirectory)} beside {installDir}"); + CopyDirectory(request.PackageDirectory, stageDir); + + MakeExecutable(Path.Combine(stageDir, "Optimum")); + MakeExecutable(Path.Combine(stageDir, "run.sh")); + + var entries = Directory.EnumerateFileSystemEntries(stageDir) + .Select(Path.GetFileName).Where(n => n is not null).Select(n => n!).ToList(); + launcher = WriteLauncher(stageDir, installDir, request.DataPath); + if (launcher is not null) + entries.Add(Path.GetFileName(launcher)); + if (request.DataPath is not null) + entries.Add("datapath.cfg"); + + WriteManifest(stageDir, installDir, request, launcher, entries); + + Checkpoint("backup"); + if (hasExisting || Directory.Exists(installDir)) + { + MoveDirectory(installDir, backupDir); + backedUp = true; + } + + try + { + Checkpoint("swap"); + MoveDirectory(stageDir, installDir); + committed = true; + } + catch + { + // The swap itself failed: the target is still absent (or the + // move is all-or-nothing), so restore the backup and give up. + RestoreBackup(installDir, backupDir, backedUp); + throw; + } + + // Past this point the install is complete and correct. Deleting the + // backup and registering shortcuts are cleanup, not the transaction. + Checkpoint("commit"); + string? finalLauncher = launcher is null ? null : Path.Combine(installDir, Path.GetFileName(launcher)); + try + { + if (backedUp && Directory.Exists(backupDir)) + Directory.Delete(backupDir, recursive: true); + RegisterInstall(installDir, request, finalLauncher, observer); + } + catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) + { + observer?.Log(LogLevel.Warn, + $"the install is complete but a post-install step did not finish: {cleanup.Message}"); + } + + return DeployResult.Success(installDir, finalLauncher); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + if (committed) + return DeployResult.Success(installDir, launcher is null ? null : Path.Combine(installDir, Path.GetFileName(launcher))); + + (bool restored, string? note) = RestoreBackup(installDir, backupDir, backedUp); + string message = restored || !backedUp + ? $"install failed and was rolled back: {ex.Message}" + : $"install failed and the previous install could not be restored: {ex.Message}. {note}"; + return DeployResult.Failure(FailureReason.EngineInternal, message); + } + finally + { + TryDelete(stageDir); + if (committed && Directory.Exists(backupDir)) + TryDelete(backupDir); + } + } + + private void Checkpoint(string step) => FailAtStep?.Invoke(step); + + /// + /// After the swap: write shortcuts, register the Windows uninstall entry, and + /// fold both into the manifest so can undo them. If + /// the manifest cannot be updated the shortcuts and registry entry are undone + /// so nothing is left that the manifest does not record. + /// + private void RegisterInstall(string installDir, DeployRequest request, string? launcher, IBuildObserver? observer) + { + string manifestPath = Path.Combine(installDir, InstallManifest.RelativePath); + string? manifestJson; + try { manifestJson = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) : null; } + catch (IOException) { return; } + catch (UnauthorizedAccessException) { return; } + + if (manifestJson is null || InstallManifest.Deserialize(manifestJson) is not { } manifest) + return; + + IReadOnlyList shortcuts = launcher is not null && request.Shortcuts != ShortcutKinds.None + ? new ShortcutWriter(probe).Create(installDir, launcher, request.Shortcuts) + : []; + if (shortcuts.Count > 0) + observer?.Log(LogLevel.Info, $"created {shortcuts.Count} shortcut(s)"); + + string? registryKey = UninstallRegistration.Register(installDir, manifest.OptimumVersion); + + try + { + File.WriteAllText(manifestPath, (manifest with + { + Shortcuts = shortcuts, + UninstallRegistryKey = registryKey, + }).Serialize()); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The manifest is the only record the uninstaller reads, so undo + // what it will not know about. + new ShortcutWriter(probe).Remove(shortcuts); + UninstallRegistration.Unregister(registryKey); + observer?.Log(LogLevel.Warn, "could not record shortcuts in the manifest; they were removed"); + } + } + + /// + /// Restores the pre-install tree. Returns whether it is now back in place and, + /// if not, a note telling the user where the backup is. + /// + private static (bool Restored, string? Note) RestoreBackup(string installDir, string backupDir, bool backedUp) + { + if (!backedUp) + { + TryDelete(installDir); + return (true, null); + } + + if (!Directory.Exists(backupDir)) + return (!Directory.Exists(installDir) || IsOptimumInstall(installDir), null); + + if (Directory.Exists(installDir)) + TryDelete(installDir); + + if (Directory.Exists(installDir)) + return (false, $"the previous install is at {backupDir}"); + + try + { + Directory.Move(backupDir, installDir); + return (true, null); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return (false, $"the previous install is at {backupDir}: {ex.Message}"); + } + } + + private static bool IsOptimumInstall(string directory) => + File.Exists(Path.Combine(directory, InstallManifest.RelativePath)); + + /// + /// that turns a cross-filesystem failure into a + /// clear message. This bites only when the install directory is itself a + /// mount point, since the stage and backup always share its parent. + /// + private static void MoveDirectory(string source, string destination) + { + try + { + Directory.Move(source, destination); + } + catch (IOException ex) when (ex.Message.Contains("different", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("cross-device", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "the install directory is on a different filesystem from its parent; choose a directory whose parent is on the same filesystem", ex); + } + } + + private void WriteManifest( + string stageDir, string installDir, DeployRequest request, string? launcher, IEnumerable entries) + { + var manifest = new InstallManifest + { + OptimumVersion = ResolveVersion(probe, request.PackageDirectory), + InstalledAtUtc = DateTimeOffset.UtcNow, + InstallDirectory = installDir, + DataPath = request.DataPath, + Launcher = launcher is null ? null : Path.Combine(installDir, Path.GetFileName(launcher)), + Entries = entries.Distinct().OrderBy(e => e, StringComparer.Ordinal).ToArray(), + }; + Directory.CreateDirectory(Path.Combine(stageDir, ".optimum")); + File.WriteAllText(Path.Combine(stageDir, InstallManifest.RelativePath), manifest.Serialize()); + } + + private string? WriteLauncher(string stageDir, string finalInstallDir, string? dataPath) + { + if (dataPath is not null) + Directory.CreateDirectory(dataPath); + + if (probe.Os == OsKind.Windows) + { + string cmd = Path.Combine(stageDir, "optimum-launch.cmd"); + File.WriteAllText(cmd, dataPath is not null + ? $"@echo off\r\ncd /d \"%~dp0\"\r\nOptimum.exe --dataPath \"{dataPath}\" %*\r\n" + : "@echo off\r\ncd /d \"%~dp0\"\r\nOptimum.exe %*\r\n"); + if (dataPath is not null) + File.WriteAllText(Path.Combine(stageDir, "datapath.cfg"), dataPath); + return cmd; + } + + string sh = Path.Combine(stageDir, "optimum-launch.sh"); + File.WriteAllText(sh, dataPath is not null + ? $"#!/usr/bin/env bash\nset -euo pipefail\ncd \"$(dirname \"${{BASH_SOURCE[0]}}\")\"\nexec ./run.sh --dataPath {ShellQuote(dataPath)} \"$@\"\n" + : "#!/usr/bin/env bash\nset -euo pipefail\ncd \"$(dirname \"${BASH_SOURCE[0]}\")\"\nexec ./run.sh \"$@\"\n"); + MakeExecutable(sh); + if (dataPath is not null) + File.WriteAllText(Path.Combine(stageDir, "datapath.cfg"), dataPath); + _ = finalInstallDir; + return sh; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'"; + + private static string ResolveVersion(ISystemProbe probe, string packageDirectory) + { + string name = Path.GetFileName(packageDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (name.StartsWith("Optimum-v", StringComparison.Ordinal)) + { + string rest = name["Optimum-v".Length..]; + int dash = rest.IndexOf('-'); + string version = dash > 0 ? rest[..dash] : rest; + if (version.Length > 0) + return version; + } + + return probe.ReadText(Path.Combine(packageDirectory, ".optimum", "version"))?.Trim() is { Length: > 0 } fromFile + ? fromFile + : "dev"; + } + + private static void MakeExecutable(string path) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + return; + try + { + File.SetUnixFileMode(path, File.GetUnixFileMode(path) + | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (string dir in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, dir))); + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + File.Copy(file, Path.Combine(destination, Path.GetRelativePath(source, file)), overwrite: true); + } + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } +} diff --git a/Optimum.Bootstrap.Core/Install/PackageInstaller.cs b/Optimum.Bootstrap.Core/Install/PackageInstaller.cs new file mode 100644 index 0000000..4138a76 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/PackageInstaller.cs @@ -0,0 +1,9 @@ +using Optimum.Bootstrap.Core.Build; + +namespace Optimum.Bootstrap.Core.Install; + +/// The deploy step, behind an interface so the GUI can fake it in a headless test. +public interface IPackageInstaller +{ + DeployResult Deploy(DeployRequest request, IBuildObserver? observer = null); +} diff --git a/Optimum.Bootstrap.Core/Install/PackageLayout.cs b/Optimum.Bootstrap.Core/Install/PackageLayout.cs new file mode 100644 index 0000000..453750e --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/PackageLayout.cs @@ -0,0 +1,39 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +public sealed record PackageLayoutResult(bool Ok, IReadOnlyList Problems) +{ + public static readonly PackageLayoutResult Good = new(true, []); +} + +/// +/// A shallow check that a directory is a staged Optimum package rather than an +/// arbitrary folder: it must carry a launcher entry point and the .optimum +/// marker directory the packaging scripts write. +/// +public static class PackageLayout +{ + public static PackageLayoutResult Validate(ISystemProbe probe, string packageDirectory) + { + var problems = new List(); + + if (!probe.DirectoryExists(packageDirectory)) + { + problems.Add($"the package directory does not exist: {packageDirectory}"); + return new PackageLayoutResult(false, problems); + } + + bool hasLauncher = + probe.FileExists(Path.Combine(packageDirectory, "run.sh")) + || probe.FileExists(Path.Combine(packageDirectory, "Optimum")) + || probe.FileExists(Path.Combine(packageDirectory, "Optimum.exe")); + if (!hasLauncher) + problems.Add("no launcher entry point (run.sh, Optimum, or Optimum.exe)"); + + if (!probe.DirectoryExists(Path.Combine(packageDirectory, ".optimum"))) + problems.Add("no .optimum marker directory"); + + return problems.Count == 0 ? PackageLayoutResult.Good : new PackageLayoutResult(false, problems); + } +} diff --git a/Optimum.Bootstrap.Core/Install/RuntimeValidator.cs b/Optimum.Bootstrap.Core/Install/RuntimeValidator.cs new file mode 100644 index 0000000..01ac887 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/RuntimeValidator.cs @@ -0,0 +1,102 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +public sealed record RuntimeValidationResult(bool Ok, string? Detail); + +/// +/// Checks that a staged package is a complete runtime without running any game +/// code (INSTALLER-PLAN.md section 7, option 2). The layout holds, the patched +/// engine assemblies exist and parse, and a metadata-only load of +/// VintagestoryLib.dll still exposes Vintagestory.Client.ClientProgram +/// with a static Main. The full JIT probe stays with +/// Optimum.exe --validate-only, which those packages could ship later. +/// +public sealed class RuntimeValidator(ISystemProbe probe) +{ + private static readonly string[] RequiredAssemblies = + [ + "VintagestoryLib.dll", + "VintagestoryAPI.dll", + "Vintagestory.dll", + ]; + + public RuntimeValidationResult Validate(string packageDirectory) + { + PackageLayoutResult layout = PackageLayout.Validate(probe, packageDirectory); + if (!layout.Ok) + return new RuntimeValidationResult(false, string.Join("; ", layout.Problems)); + + foreach (string name in RequiredAssemblies) + { + string path = Path.Combine(packageDirectory, name); + if (!probe.FileExists(path)) + return new RuntimeValidationResult(false, $"missing assembly: {name}"); + try + { + if (new FileInfo(path).Length == 0) + return new RuntimeValidationResult(false, $"empty assembly: {name}"); + _ = AssemblyName.GetAssemblyName(path); + } + catch (BadImageFormatException) + { + return new RuntimeValidationResult(false, $"not a managed assembly: {name}"); + } + catch (Exception ex) when (ex is IOException or FileLoadException) + { + return new RuntimeValidationResult(false, $"could not read {name}: {ex.Message}"); + } + } + + return CheckEntryPoint(packageDirectory); + } + + private static RuntimeValidationResult CheckEntryPoint(string packageDirectory) + { + // Inspecting the entry point is best effort: a positive "the type is + // gone" fails the build, but an inability to inspect at all (an + // unresolvable reference, a trimmed runtime directory) does not, because + // the header checks above already passed. + try + { + var assemblies = new List(); + assemblies.AddRange(Directory.EnumerateFiles(packageDirectory, "*.dll")); + string libDir = Path.Combine(packageDirectory, "Lib"); + if (Directory.Exists(libDir)) + assemblies.AddRange(Directory.EnumerateFiles(libDir, "*.dll")); + try { assemblies.AddRange(Directory.EnumerateFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll")); } + catch (Exception ex) when (ex is IOException or ArgumentException) { /* trimmed publish */ } + + using var context = new MetadataLoadContext( + new PathAssemblyResolver(assemblies.Distinct(StringComparer.OrdinalIgnoreCase))); + Assembly libAssembly = context.LoadFromAssemblyPath(Path.Combine(packageDirectory, "VintagestoryLib.dll")); + + IEnumerable types; + try { types = libAssembly.GetTypes(); } + catch (ReflectionTypeLoadException partial) { types = partial.Types; } + + Type? clientProgram = types.FirstOrDefault(t => t?.FullName == "Vintagestory.Client.ClientProgram"); + if (clientProgram is null) + { + // Only fail if we could enumerate types and the one we need is + // absent; if the enumeration was empty we could not inspect. + return types.Any(t => t is not null) + ? new RuntimeValidationResult(false, + "the patched VintagestoryLib.dll no longer contains Vintagestory.Client.ClientProgram") + : new RuntimeValidationResult(true, "entry point not inspected: VintagestoryLib.dll types would not enumerate"); + } + + MethodInfo? main = clientProgram.GetMethod("Main", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + return main is null + ? new RuntimeValidationResult(false, "Vintagestory.Client.ClientProgram has no static Main") + : new RuntimeValidationResult(true, null); + } + catch (Exception ex) + { + return new RuntimeValidationResult(true, $"entry point not inspected: {ex.Message}"); + } + } +} diff --git a/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs b/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs new file mode 100644 index 0000000..f71b202 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs @@ -0,0 +1,212 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +/// +/// Writes and removes the application-menu and desktop shortcuts for an install. +/// Every operation is best effort: a shortcut that will not write is logged, not +/// fatal. Ports the shortcut handling from all three current installers. +/// +public sealed class ShortcutWriter(ISystemProbe probe) +{ + /// Creates the requested shortcuts and returns the paths that were written. + public IReadOnlyList Create(string installDirectory, string launcherPath, ShortcutKinds kinds) + { + if (kinds == ShortcutKinds.None) + return []; + + return probe.Os switch + { + OsKind.Windows => CreateWindows(installDirectory, launcherPath, kinds), + OsKind.MacOs => CreateMac(installDirectory, kinds), + _ => CreateLinux(installDirectory, launcherPath, kinds), + }; + } + + public void Remove(IEnumerable shortcutPaths) + { + foreach (string path in shortcutPaths) + { + try + { + if (File.Exists(path)) + File.Delete(path); + else if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } + } + + private List CreateLinux(string installDirectory, string launcherPath, ShortcutKinds kinds) + { + var written = new List(); + string home = probe.HomeDirectory; + string dataHome = probe.GetEnvironmentVariable("XDG_DATA_HOME") is { Length: > 0 } x + ? x + : Path.Combine(home, ".local", "share"); + + string? icon = InstallIcon(installDirectory, Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps", "optimum.png")); + string entry = DesktopEntry(launcherPath, installDirectory, icon); + + if (kinds.HasFlag(ShortcutKinds.Menu)) + written.AddRange(WriteText(Path.Combine(dataHome, "applications", "optimum.desktop"), entry, executable: true)); + if (kinds.HasFlag(ShortcutKinds.Desktop)) + written.AddRange(WriteText(Path.Combine(home, "Desktop", "Optimum.desktop"), entry, executable: true)); + + return written; + } + + private List CreateMac(string installDirectory, ShortcutKinds kinds) + { + var written = new List(); + string home = probe.HomeDirectory; + + // The link target is a real .app bundle: a nested Optimum.app, or the + // install directory itself when the bundle contents were laid there. + string nested = Path.Combine(installDirectory, "Optimum.app"); + string target = File.Exists(Path.Combine(nested, "Contents", "Info.plist")) + ? nested + : installDirectory; + bool isBundle = File.Exists(Path.Combine(target, "Contents", "Info.plist")); + string linkName = isBundle ? "Optimum.app" : "Optimum"; + + if (kinds.HasFlag(ShortcutKinds.Menu)) + written.AddRange(Symlink(Path.Combine(home, "Applications", linkName), target)); + if (kinds.HasFlag(ShortcutKinds.Desktop)) + written.AddRange(Symlink(Path.Combine(home, "Desktop", linkName), target)); + + return written; + } + + private List CreateWindows(string installDirectory, string launcherPath, ShortcutKinds kinds) + { + var written = new List(); + if (!OperatingSystem.IsWindows()) + return written; + + string? appData = probe.GetEnvironmentVariable("APPDATA"); + string? userProfile = probe.GetEnvironmentVariable("USERPROFILE") ?? probe.HomeDirectory; + string exe = Path.Combine(installDirectory, "Optimum.exe"); + string linkTarget = File.Exists(exe) ? exe : launcherPath; + + if (kinds.HasFlag(ShortcutKinds.Menu) && appData is not null) + { + string dir = Path.Combine(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Optimum"); + written.AddRange(WriteWindowsLink(Path.Combine(dir, "Optimum.lnk"), linkTarget, installDirectory)); + } + if (kinds.HasFlag(ShortcutKinds.Desktop) && userProfile is not null) + written.AddRange(WriteWindowsLink(Path.Combine(userProfile, "Desktop", "Optimum.lnk"), linkTarget, installDirectory)); + + return written; + } + + private static string DesktopEntry(string launcherPath, string workingDirectory, string? icon) => + $""" + [Desktop Entry] + Type=Application + Name=Optimum + Comment=High-performance client for Vintage Story + Exec="{EscapeExec(launcherPath)}" + Path={workingDirectory} + Icon={icon ?? "optimum"} + Terminal=false + Categories=Game; + StartupWMClass=Optimum + + """; + + /// + /// Escapes a value for a quoted Exec per the Desktop Entry spec: + /// backslash, double quote, backtick, and dollar are backslash-escaped. + /// + private static string EscapeExec(string value) + { + var sb = new System.Text.StringBuilder(value.Length + 8); + foreach (char c in value) + { + if (c is '\\' or '"' or '`' or '$') + sb.Append('\\'); + sb.Append(c); + } + + return sb.ToString(); + } + + private string? InstallIcon(string installDirectory, string destination) + { + string[] sources = + [ + Path.Combine(installDirectory, "assets", "gameicon.png"), + Path.Combine(installDirectory, "logo.png"), + ]; + foreach (string source in sources) + { + if (!File.Exists(source)) + continue; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(source, destination, overwrite: true); + return destination; + } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + return null; + } + + private static IEnumerable WriteText(string path, string contents, bool executable) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + if (executable && !OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, File.GetUnixFileMode(path) | UnixFileMode.UserExecute); + return [path]; + } + catch (IOException) { return []; } + catch (UnauthorizedAccessException) { return []; } + } + + private static IEnumerable Symlink(string link, string target) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + if (File.Exists(link) || Directory.Exists(link)) + File.Delete(link); + File.CreateSymbolicLink(link, target); + return [link]; + } + catch (IOException) { return []; } + catch (UnauthorizedAccessException) { return []; } + } + + private static IEnumerable WriteWindowsLink(string linkPath, string target, string workingDirectory) + { + if (!OperatingSystem.IsWindows()) + return []; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!); + Type? shellType = Type.GetTypeFromProgID("WScript.Shell"); + if (shellType is null) + return []; + dynamic shell = Activator.CreateInstance(shellType)!; + dynamic link = shell.CreateShortcut(linkPath); + link.TargetPath = target; + link.WorkingDirectory = workingDirectory; + link.IconLocation = target + ",0"; + link.Save(); + return [linkPath]; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.COMException) + { + return []; + } + } +} diff --git a/Optimum.Bootstrap.Core/Install/UninstallRegistration.cs b/Optimum.Bootstrap.Core/Install/UninstallRegistration.cs new file mode 100644 index 0000000..5cdf116 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/UninstallRegistration.cs @@ -0,0 +1,67 @@ +using System.Runtime.Versioning; + +namespace Optimum.Bootstrap.Core.Install; + +/// +/// Registers and removes the Windows "Apps & features" uninstall entry +/// (HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Optimum_is1), +/// matching scripts/install-windows.ps1. A no-op on Linux and macOS, where +/// the install manifest and the .desktop entry are the record. +/// +public static class UninstallRegistration +{ + public const string KeyPath = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\Optimum_is1"; + + /// Returns the registry key path when it was written, null otherwise. + public static string? Register(string installDirectory, string version) + { + if (!OperatingSystem.IsWindows()) + return null; + return RegisterWindows(installDirectory, version); + } + + public static void Unregister(string? keyPath) + { + if (keyPath is null || !OperatingSystem.IsWindows()) + return; + UnregisterWindows(keyPath); + } + + [SupportedOSPlatform("windows")] + private static string? RegisterWindows(string installDirectory, string version) + { + try + { + using Microsoft.Win32.RegistryKey key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(KeyPath); + string exe = Path.Combine(installDirectory, "Optimum.exe"); + string uninstaller = Path.Combine(installDirectory, "uninstall.ps1"); + key.SetValue("DisplayName", "Optimum"); + key.SetValue("DisplayVersion", version); + key.SetValue("Publisher", "Zaldaryon"); + key.SetValue("InstallLocation", installDirectory); + key.SetValue("DisplayIcon", exe); + key.SetValue("UninstallString", + $"powershell -NoProfile -ExecutionPolicy Bypass -File \"{uninstaller}\" -InstallDir \"{installDirectory}\" -Force"); + key.SetValue("NoModify", 1, Microsoft.Win32.RegistryValueKind.DWord); + key.SetValue("NoRepair", 1, Microsoft.Win32.RegistryValueKind.DWord); + return KeyPath; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + return null; + } + } + + [SupportedOSPlatform("windows")] + private static void UnregisterWindows(string keyPath) + { + try + { + Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + /* best effort */ + } + } +} diff --git a/Optimum.Bootstrap.Core/Install/Uninstaller.cs b/Optimum.Bootstrap.Core/Install/Uninstaller.cs new file mode 100644 index 0000000..2e7272e --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/Uninstaller.cs @@ -0,0 +1,105 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +public sealed record UninstallResult(bool Ok, FailureReason? Reason, string? Message, int RemovedEntries) +{ + public static UninstallResult Failure(FailureReason reason, string message) => new(false, reason, message, 0); + + public static UninstallResult Success(int removed) => new(true, null, null, removed); +} + +/// +/// Removes an install by its . It refuses a +/// directory with no manifest, removes only manifest entries that resolve inside +/// the install directory, and always attempts the shortcut, registry, and +/// .optimum cleanup even when an individual file will not delete, so a +/// locked file cannot strand the "Apps and features" entry or a menu shortcut. +/// +public sealed class Uninstaller(ISystemProbe probe) +{ + public UninstallResult Uninstall(string installDirectory) + { + string installDir = Path.GetFullPath(installDirectory); + string manifestPath = Path.Combine(installDir, InstallManifest.RelativePath); + + string? json = probe.ReadText(manifestPath); + if (json is null) + return UninstallResult.Failure(FailureReason.BadInput, $"no Optimum install manifest at {manifestPath}"); + + if (InstallManifest.Deserialize(json) is not { } manifest) + return UninstallResult.Failure(FailureReason.BadInput, $"the install manifest is unreadable: {manifestPath}"); + + string prefix = installDir + Path.DirectorySeparatorChar; + + string[] escaping = manifest.Entries + .Where(e => + { + string t = Path.GetFullPath(Path.Combine(installDir, e)); + return t != installDir && !t.StartsWith(prefix, StringComparison.Ordinal); + }) + .ToArray(); + if (escaping.Length > 0) + return UninstallResult.Failure(FailureReason.BadInput, + "the manifest names entries outside the install directory: " + string.Join(", ", escaping)); + + int removed = 0; + var problems = new List(); + + foreach (string entry in manifest.Entries) + { + string target = Path.GetFullPath(Path.Combine(installDir, entry)); + if (TryRemove(target)) + removed++; + else if (Directory.Exists(target) || File.Exists(target)) + problems.Add($"could not remove {entry}"); + } + + // The rest runs regardless of a locked entry above. + if (manifest.Shortcuts.Count > 0) + { + new ShortcutWriter(probe).Remove(manifest.Shortcuts); + removed += manifest.Shortcuts.Count; + } + + UninstallRegistration.Unregister(manifest.UninstallRegistryKey); + + if (TryRemove(Path.Combine(installDir, ".optimum"))) + removed++; + else if (Directory.Exists(Path.Combine(installDir, ".optimum"))) + problems.Add("could not remove .optimum"); + + if (Directory.Exists(installDir) && !Directory.EnumerateFileSystemEntries(installDir).Any()) + { + try { Directory.Delete(installDir); } + catch (IOException) { /* a non-empty leftover is reported below */ } + } + + return problems.Count == 0 + ? UninstallResult.Success(removed) + : new UninstallResult(false, FailureReason.EngineInternal, + "uninstall removed the shortcuts and registry entry but could not remove everything: " + + string.Join("; ", problems), removed); + } + + private static bool TryRemove(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + return true; + } + if (File.Exists(path)) + { + File.Delete(path); + return true; + } + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + + return false; + } +} diff --git a/Optimum.Bootstrap.Core/Licensing/ConsentNotice.cs b/Optimum.Bootstrap.Core/Licensing/ConsentNotice.cs new file mode 100644 index 0000000..98ee751 --- /dev/null +++ b/Optimum.Bootstrap.Core/Licensing/ConsentNotice.cs @@ -0,0 +1,29 @@ +using System.Reflection; + +namespace Optimum.Bootstrap.Core.Licensing; + +/// +/// The decompilation and license notice a user must accept before a build. +/// Posture C in INSTALLER-PLAN.md: the GUI gates on a checkbox and +/// Optimum.Cli build refuses without --acknowledge-decompile. The +/// text is a draft pending a legal review before the first release; it must stay +/// consistent with LICENSE-SCOPE.md and NOTICE. +/// +public static class ConsentNotice +{ + private const string ResourceName = "Optimum.Bootstrap.Core.Licensing.consent-notice.md"; + + /// The flag name the CLI requires and RiftLauncher passes. + public const string AcknowledgeFlag = "--acknowledge-decompile"; + + public static string Text { get; } = Load(); + + private static string Load() + { + Assembly assembly = typeof(ConsentNotice).Assembly; + using Stream? stream = assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded consent notice '{ResourceName}' is missing."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd().Replace("\r\n", "\n").TrimEnd() + "\n"; + } +} diff --git a/Optimum.Bootstrap.Core/Licensing/consent-notice.md b/Optimum.Bootstrap.Core/Licensing/consent-notice.md new file mode 100644 index 0000000..6260752 --- /dev/null +++ b/Optimum.Bootstrap.Core/Licensing/consent-notice.md @@ -0,0 +1,39 @@ +# Before Optimum builds + +Optimum is an independent project. It is not affiliated with or endorsed by +Anego Studios, the developers of Vintage Story. + +## What this installer does on your computer + +1. Downloads the official Vintage Story client (about 570 MB) from Anego's + content server, or uses a copy you already have. +2. Decompiles that client on this computer with ILSpy. +3. Applies Optimum's source patches to the decompiled code and compiles a + patched runtime here. +4. Installs the result to a directory you choose. + +Optimum never uploads, publishes, or redistributes any Vintage Story code, +symbols, or assets. Every build is produced locally from a client you supply. +You need a legitimate copy of Vintage Story, which stays under Anego Studios' +own terms. + +## Licensing + +Optimum's own tooling (the launcher, the patcher, the build and packaging +scripts, and the project configuration listed in `LICENSE-SCOPE.md`) is under +the MIT license in `LICENSE-MIT`. The patch sets, the source overlays, and the +decompiled material remain under their upstream and historical terms as +`LICENSE-SCOPE.md` and `NOTICE` record. This installer does not relicense any +of it. + +## No warranty + +Optimum modifies a game installation. It is provided as is, without warranty of +any kind. You run it at your own risk. Back up your worlds before pointing any +build tool at an existing installation. + +## What you are agreeing to + +By continuing you confirm that you have read this notice, that you own a +legitimate copy of Vintage Story, and that you agree to Optimum decompiling +that copy on this computer to build the patched runtime. diff --git a/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs new file mode 100644 index 0000000..eda8c2d --- /dev/null +++ b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs @@ -0,0 +1,121 @@ +using System.Text.Json; + +namespace Optimum.Bootstrap.Core.Ndjson; + +/// +/// Emits the engine's NDJSON stream from INSTALLER-PLAN.md section 4: one JSON +/// object per line on stdout, progress that never decreases and never reaches +/// 100, and exactly one terminal result line. The writer enforces those +/// invariants so a caller's parser never has to defend against the engine. When +/// it has to adjust a caller's progress value it also emits a warn log and +/// counts it, so an engine-side miscalculation is visible rather than silent. +/// +public sealed class NdjsonWriter(TextWriter output) +{ + private static readonly JsonWriterOptions WriterOptions = new() { Indented = false }; + + private int _lastPercent; + private bool _resultWritten; + + public bool ResultWritten => _resultWritten; + + /// How many times had to rewrite a caller's value. + public int AnomalyCount { get; private set; } + + public void Progress(ProgressPhase phase, int percent, string detail) + { + GuardOpen(); + + int clamped = Math.Clamp(percent, _lastPercent, BootstrapProgress.MaxEnginePercent); + if (clamped != percent) + { + AnomalyCount++; + WriteLog(LogLevel.Warn, + $"progress {percent} for phase {WirePhase(phase)} adjusted to {clamped}: it must be monotonic and in 0 to 99"); + } + + _lastPercent = clamped; + Write(writer => + { + writer.WriteString("type", "progress"); + writer.WriteString("phase", WirePhase(phase)); + writer.WriteNumber("progress", clamped); + writer.WriteString("detail", detail); + }); + } + + public void Log(LogLevel level, string message) + { + GuardOpen(); + WriteLog(level, message); + } + + public void Success(string runtimePath) + { + GuardOpen(); + _resultWritten = true; + Write(writer => + { + writer.WriteString("type", "result"); + writer.WriteBoolean("ok", true); + writer.WriteString("runtimePath", runtimePath); + }); + } + + public void Failure(FailureReason reason, string message) + { + GuardOpen(); + _resultWritten = true; + Write(writer => + { + writer.WriteString("type", "result"); + writer.WriteBoolean("ok", false); + writer.WriteString("reason", reason.Wire()); + writer.WriteString("message", message); + }); + } + + private void WriteLog(LogLevel level, string message) => Write(writer => + { + writer.WriteString("type", "log"); + writer.WriteString("level", level switch + { + LogLevel.Info => "info", + LogLevel.Warn => "warn", + LogLevel.Error => "error", + _ => "info", + }); + writer.WriteString("message", message); + }); + + private void GuardOpen() + { + if (_resultWritten) + throw new InvalidOperationException("The NDJSON stream already carries a terminal result line."); + } + + private void Write(Action body) + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, WriterOptions)) + { + writer.WriteStartObject(); + body(writer); + writer.WriteEndObject(); + } + + // NDJSON is newline-delimited with a bare '\n', never the platform newline. + output.Write(System.Text.Encoding.UTF8.GetString(buffer.ToArray())); + output.Write('\n'); + output.Flush(); + } + + internal static string WirePhase(ProgressPhase phase) => phase switch + { + ProgressPhase.Decompile => "decompile", + ProgressPhase.Patch => "patch", + ProgressPhase.Verify => "verify", + ProgressPhase.Assemble => "assemble", + _ => "assemble", + }; +} diff --git a/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj new file mode 100644 index 0000000..5b77889 --- /dev/null +++ b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj @@ -0,0 +1,22 @@ + + + net10.0 + Optimum.Bootstrap.Core + Optimum.Bootstrap.Core + enable + enable + false + Reusable engine logic for the Optimum installer: prerequisite detection, acquisition, the build driver, the transactional installer, path guards, and the NDJSON protocol. No UI, no argument parsing. + $(OptimumVersion) + + + + + + + + + + + + diff --git a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs new file mode 100644 index 0000000..e9f1a2d --- /dev/null +++ b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs @@ -0,0 +1,205 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Paths; + +public sealed record InstallPathRequest( + string InstallDirectory, + string? DataPath = null, + string? VintageStoryDirectory = null, + string? WorkspaceRoot = null, + string? BuildRoot = null); + +public sealed record InstallPathVerdict(bool Ok, string? Rejection) +{ + public static readonly InstallPathVerdict Allowed = new(true, null); + + public static InstallPathVerdict Reject(string reason) => new(false, reason); +} + +/// +/// Consolidates guard_install_dir from scripts/install-linux.sh and +/// Assert-SafeInstallerPaths from scripts/install-windows.ps1, plus +/// a symlink-component walk. Every current installer refuses a different subset +/// of these; the new one refuses all of them on every platform. +/// +public static partial class InstallPathGuard +{ + public static InstallPathVerdict Check(ISystemProbe probe, InstallPathRequest request) + { + string raw = request.InstallDirectory; + if (string.IsNullOrWhiteSpace(raw)) + return InstallPathVerdict.Reject("The install directory is empty."); + + if (IsFilesystemRoot(probe, raw)) + return InstallPathVerdict.Reject($"The install directory cannot be a filesystem or drive root: {raw.Trim()}"); + + string install = Canonical(probe, raw); + + if (PathEquals(probe, install, Canonical(probe, probe.HomeDirectory))) + return InstallPathVerdict.Reject("The install directory cannot be your home directory."); + + foreach (string reserved in ReservedDirectories(probe)) + { + if (PathEquals(probe, install, reserved)) + return InstallPathVerdict.Reject($"The install directory cannot be {reserved}."); + } + + // Leaf only: a symlinked home or a symlinked parent (a second drive + // mounted at ~/Games) is normal and the OS resolves it consistently. A + // symlinked install directory itself is the risk, because the + // transactional install and uninstall would then operate on the link's + // target rather than the directory the user named. + if (probe.PathExists(install) && probe.IsSymbolicLink(install)) + return InstallPathVerdict.Reject($"The install directory is a symbolic link: {install}. Choose a real directory."); + + foreach (string vsDir in KnownVintageStoryDirectories(probe)) + { + if (IsWithinOrEqual(probe, install, Canonical(probe, vsDir))) + return InstallPathVerdict.Reject( + $"The install directory cannot be inside a Vintage Story installation ({vsDir}). Optimum installs to a separate location."); + } + + if (LooksLikeVanillaGame(probe, install)) + return InstallPathVerdict.Reject( + "The install directory already holds a vanilla Vintage Story installation. Optimum installs to a separate location."); + + foreach ((string? other, string name) in NamedNeighbours(request)) + { + if (other is null) + continue; + string canonicalOther = Canonical(probe, other); + if (IsWithinOrEqual(probe, install, canonicalOther) || IsWithinOrEqual(probe, canonicalOther, install)) + return InstallPathVerdict.Reject($"The install directory cannot overlap {name}."); + } + + if (request.DataPath is { } dataRaw && !string.IsNullOrWhiteSpace(dataRaw)) + { + string data = Canonical(probe, dataRaw); + if (probe.PathExists(data) && probe.IsSymbolicLink(data)) + return InstallPathVerdict.Reject($"The data path is a symbolic link: {data}. Choose a real directory."); + if (IsWithinOrEqual(probe, data, install)) + return InstallPathVerdict.Reject("The data path cannot be inside the install directory."); + foreach (string vsDir in KnownVintageStoryDirectories(probe)) + { + if (IsWithinOrEqual(probe, data, Canonical(probe, vsDir))) + return InstallPathVerdict.Reject("The data path cannot be inside a Vintage Story installation."); + } + foreach ((string? other, string name) in NamedNeighbours(request)) + { + if (other is not null && IsWithinOrEqual(probe, data, Canonical(probe, other))) + return InstallPathVerdict.Reject($"The data path cannot be inside {name}."); + } + } + + return InstallPathVerdict.Allowed; + } + + private static IEnumerable<(string? Path, string Name)> NamedNeighbours(InstallPathRequest request) + { + yield return (request.VintageStoryDirectory, "the Vintage Story directory"); + yield return (request.WorkspaceRoot, "the Optimum workspace"); + yield return (request.BuildRoot, "the temporary build directory"); + } + + private static bool IsFilesystemRoot(ISystemProbe probe, string raw) + { + string trimmed = raw.Trim(); + if (probe.Os == OsKind.Windows) + return WindowsDriveRoot().IsMatch(trimmed) || trimmed is "\\" or "/"; + return trimmed == "/"; + } + + private static IEnumerable ReservedDirectories(ISystemProbe probe) + { + if (probe.Os != OsKind.Windows) + { + string home = probe.HomeDirectory; + string xdg = probe.GetEnvironmentVariable("XDG_DATA_HOME") is { Length: > 0 } x + ? x + : Path.Combine(home, ".local", "share"); + yield return Canonical(probe, xdg); + yield return Canonical(probe, Path.Combine(home, ".local")); + } + } + + private static IEnumerable KnownVintageStoryDirectories(ISystemProbe probe) + { + string home = probe.HomeDirectory; + switch (probe.Os) + { + case OsKind.Windows: + foreach (string var in new[] { "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)" }) + { + if (probe.GetEnvironmentVariable(var) is { Length: > 0 } value) + yield return Path.Combine(value, "Vintagestory"); + } + break; + case OsKind.MacOs: + yield return Path.Combine(home, "Library", "Application Support", "vintagestory"); + yield return "/Applications/Vintagestory.app"; + break; + default: + yield return Path.Combine(home, ".local", "share", "vintagestory"); + yield return Path.Combine(home, "ApplicationData", "vintagestory"); + yield return "/opt/vintagestory"; + break; + } + } + + private static bool LooksLikeVanillaGame(ISystemProbe probe, string directory) + { + bool hasGame = probe.FileExists(Path.Combine(directory, "Vintagestory")) + || probe.FileExists(Path.Combine(directory, "Vintagestory.exe")); + bool hasOptimum = probe.FileExists(Path.Combine(directory, "Optimum")) + || probe.FileExists(Path.Combine(directory, "Optimum.exe")); + return hasGame && !hasOptimum; + } + + private static string Canonical(ISystemProbe probe, string path) + { + string trimmed = path.Trim(); + + if (ProbeMatchesHost(probe)) + { + try { trimmed = Path.GetFullPath(trimmed); } + catch (ArgumentException) { /* fall through to string normalization */ } + } + + if (probe.Os == OsKind.Windows) + { + trimmed = trimmed.Replace('/', '\\'); + if (WindowsDriveRoot().IsMatch(trimmed)) + return trimmed.Length == 2 ? trimmed + "\\" : trimmed; + return trimmed.TrimEnd('\\'); + } + + trimmed = trimmed.TrimEnd('/'); + return trimmed.Length == 0 ? "/" : trimmed; + } + + private static bool ProbeMatchesHost(ISystemProbe probe) => probe.Os switch + { + OsKind.Windows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + OsKind.MacOs => RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + _ => RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + }; + + private static StringComparison Comparison(ISystemProbe probe) => + probe.Os == OsKind.Windows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private static bool PathEquals(ISystemProbe probe, string a, string b) => + string.Equals(a, b, Comparison(probe)); + + private static bool IsWithinOrEqual(ISystemProbe probe, string child, string parent) + { + if (PathEquals(probe, child, parent)) + return true; + char sep = probe.Os == OsKind.Windows ? '\\' : '/'; + return child.StartsWith(parent + sep, Comparison(probe)); + } + + [GeneratedRegex(@"^[A-Za-z]:[\\/]?$")] + private static partial Regex WindowsDriveRoot(); +} diff --git a/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs new file mode 100644 index 0000000..dc755c1 --- /dev/null +++ b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs @@ -0,0 +1,48 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Paths; + +/// +/// Ports RiftLauncher's assertNoSymlinkComponents: walk every existing +/// component of a path up to the root and return the first that is a symbolic +/// link. Use this for a path that is expected to stay within a trusted base +/// directory, where a symlinked component is an escape vector. The install and +/// data path guards do not use it: an arbitrary user-chosen directory legitimately +/// sits under a symlinked home or mount point, so +/// only rejects a symlinked leaf. +/// +public static class SymlinkComponentCheck +{ + /// + /// Returns the first path component that is a symbolic link, or null when the + /// path is clean. Components that do not exist yet are skipped unless + /// is set. + /// + public static string? FirstSymlinkComponent(ISystemProbe probe, string path, bool requireExists = false) + { + string full = Path.GetFullPath(path); + string? current = full; + + while (!string.IsNullOrEmpty(current)) + { + if (probe.PathExists(current)) + { + if (probe.IsSymbolicLink(current)) + return current; + } + else if (requireExists) + { + throw new DirectoryNotFoundException($"Path component does not exist: {current}"); + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || parent == current) + break; + current = parent; + } + + return null; + } + + public static bool IsClean(ISystemProbe probe, string path) => FirstSymlinkComponent(probe, path) is null; +} diff --git a/Optimum.Bootstrap.Core/Platform/CommandSearch.cs b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs new file mode 100644 index 0000000..03345c4 --- /dev/null +++ b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs @@ -0,0 +1,31 @@ +namespace Optimum.Bootstrap.Core.Platform; + +/// +/// The C# equivalent of command -v: the first executable match on +/// the probe's PATH. A non-executable file of the right name is skipped and the +/// search continues, which is what the shell does and what a broken wrapper on an +/// early PATH entry would otherwise hide. +/// +public static class CommandSearch +{ + public static string? Which(ISystemProbe probe, string command) + { + string[] names = probe.Os == OsKind.Windows + ? [command, command + ".exe", command + ".cmd", command + ".bat"] + : [command]; + + foreach (string dir in probe.PathDirectories) + { + foreach (string name in names) + { + string candidate = Path.Combine(dir, name); + if (probe.IsExecutable(candidate)) + return candidate; + } + } + + return null; + } + + public static bool Exists(ISystemProbe probe, string command) => Which(probe, command) is not null; +} diff --git a/Optimum.Bootstrap.Core/Platform/PowerShellHost.cs b/Optimum.Bootstrap.Core/Platform/PowerShellHost.cs new file mode 100644 index 0000000..4e9342d --- /dev/null +++ b/Optimum.Bootstrap.Core/Platform/PowerShellHost.cs @@ -0,0 +1,31 @@ +namespace Optimum.Bootstrap.Core.Platform; + +/// +/// Locates a PowerShell interpreter for the packaging and bootstrap scripts. +/// PowerShell 7 (pwsh) is preferred everywhere; on Windows the built-in +/// Windows PowerShell 5.1 (powershell.exe) is an accepted fallback, which +/// is what scripts/install-windows.ps1 uses to run bootstrap.ps1. +/// The package.ps1 family needs 5.1 or newer, so either satisfies it. +/// +public static class PowerShellHost +{ + /// + /// The interpreter to spawn, or null when none is on PATH. Returns a bare + /// command name (pwsh / powershell) so the caller passes it + /// straight to a process launcher; use for an absolute + /// path when one is needed. + /// + public static string? Resolve(ISystemProbe probe) + { + if (CommandSearch.Exists(probe, "pwsh")) + return "pwsh"; + if (probe.Os == OsKind.Windows && CommandSearch.Exists(probe, "powershell")) + return "powershell"; + return null; + } + + /// The absolute path to the interpreter, or null when none is found. + public static string? Find(ISystemProbe probe) => + CommandSearch.Which(probe, "pwsh") + ?? (probe.Os == OsKind.Windows ? CommandSearch.Which(probe, "powershell") : null); +} diff --git a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs new file mode 100644 index 0000000..198d05c --- /dev/null +++ b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Optimum.Bootstrap.Core.Platform; + +public enum OsKind +{ + Windows, + Linux, + MacOs, +} + +/// The outcome of a short probe command such as dotnet --list-sdks. +public readonly record struct ProcessOutcome(bool Started, int ExitCode, string StandardOutput, string StandardError) +{ + public static readonly ProcessOutcome NotStarted = new(false, -1, string.Empty, string.Empty); +} + +/// +/// The seam between Core and the machine. Every detection path takes an +/// so tests supply a fake filesystem and fake command +/// output instead of touching the host. The real implementation is +/// . +/// +public interface ISystemProbe +{ + OsKind Os { get; } + Architecture Arch { get; } + string HomeDirectory { get; } + string? GetEnvironmentVariable(string name); + IReadOnlyList PathDirectories { get; } + + /// True for a regular file ([[ -f ]]). + bool FileExists(string path); + + /// + /// True when the file exists and carries an execute bit ([[ -x ]]). + /// On Windows a file whose name matches an executable extension counts. + /// + bool IsExecutable(string path); + + /// True for a directory ([[ -d ]]). + bool DirectoryExists(string path); + + /// True for anything at that path, including a broken symlink ([[ -e ]]). + bool PathExists(string path); + + /// True when the leaf at is a symbolic link. + bool IsSymbolicLink(string path); + + string? ReadText(string path); + + IEnumerable EnumerateFiles(string directory, string searchPattern); + + IEnumerable EnumerateDirectories(string directory, string searchPattern); + + /// + /// Runs a short-lived command and returns its output. Never throws: a spawn + /// failure comes back as . The caller + /// bounds the wait through . + /// + ProcessOutcome Run(string executable, IReadOnlyList arguments, TimeSpan timeout); +} + +public sealed class SystemProbe : ISystemProbe +{ + public static readonly SystemProbe Default = new(); + + public OsKind Os { get; } = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OsKind.Windows + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OsKind.MacOs + : OsKind.Linux; + + public Architecture Arch => RuntimeInformation.OSArchitecture; + + public string HomeDirectory { get; } = + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + public string? GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); + + public IReadOnlyList PathDirectories { get; } = + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + + public bool FileExists(string path) => File.Exists(path); + + public bool IsExecutable(string path) + { + if (!File.Exists(path)) + return false; + if (OperatingSystem.IsWindows()) + return true; + + try + { + UnixFileMode mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public bool PathExists(string path) => File.Exists(path) || Directory.Exists(path); + + public bool IsSymbolicLink(string path) + { + try + { + if (Directory.Exists(path)) + return new DirectoryInfo(path).LinkTarget is not null; + var info = new FileInfo(path); + return info.Exists && info.LinkTarget is not null; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + + public string? ReadText(string path) + { + try { return File.ReadAllText(path); } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + public IEnumerable EnumerateFiles(string directory, string searchPattern) + { + if (!Directory.Exists(directory)) + return []; + try { return Directory.EnumerateFiles(directory, searchPattern); } + catch (IOException) { return []; } + catch (UnauthorizedAccessException) { return []; } + } + + public IEnumerable EnumerateDirectories(string directory, string searchPattern) + { + if (!Directory.Exists(directory)) + return []; + try { return Directory.EnumerateDirectories(directory, searchPattern); } + catch (IOException) { return []; } + catch (UnauthorizedAccessException) { return []; } + } + + public ProcessOutcome Run(string executable, IReadOnlyList arguments, TimeSpan timeout) + { + var psi = new ProcessStartInfo(executable) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in arguments) + psi.ArgumentList.Add(arg); + + Process? process = null; + try + { + process = Process.Start(psi); + if (process is null) + return ProcessOutcome.NotStarted; + + // Drain both pipes concurrently so a child that fills one buffer + // while we block on the other cannot deadlock the probe. + Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + Task stderrTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)Math.Min(timeout.TotalMilliseconds, int.MaxValue))) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return ProcessOutcome.NotStarted; + } + + return new ProcessOutcome(true, process.ExitCode, stdoutTask.GetAwaiter().GetResult(), stderrTask.GetAwaiter().GetResult()); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + return ProcessOutcome.NotStarted; + } + finally + { + process?.Dispose(); + } + } +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/ConfigFiles.cs b/Optimum.Bootstrap.Core/Prerequisites/ConfigFiles.cs new file mode 100644 index 0000000..74b16ab --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/ConfigFiles.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Reads the two files that pin the decompiler: .config/dotnet-tools.json +/// (the exact ilspycmd version) and .config/ilspycmd-compat.json (the +/// accepted range). Both front ends read this once and share the result, the +/// same way Get-Pinned-ILSpyVersion and Get-Accepted-ILSpyVersionRange +/// do in scripts/install-windows.ps1. +/// +public static class ConfigFiles +{ + public static IlspycmdCompatibility ReadIlspycmdCompatibility(ISystemProbe probe, string repoRoot) + { + var fallback = IlspycmdCompatibility.Fallback; + + IlspycmdVersion min = fallback.Minimum; + IlspycmdVersion max = fallback.Maximum; + string pin = fallback.Pin; + + string compatText = probe.ReadText(Path.Combine(repoRoot, ".config", "ilspycmd-compat.json")) ?? string.Empty; + if (TryReadObject(compatText, out JsonElement compat)) + { + if (compat.TryGetProperty("minimumVersion", out var minEl) + && IlspycmdVersion.TryParse(minEl.GetString(), out var parsedMin)) + min = parsedMin; + if (compat.TryGetProperty("maximumVersion", out var maxEl) + && IlspycmdVersion.TryParse(maxEl.GetString(), out var parsedMax)) + max = parsedMax; + } + + string toolsText = probe.ReadText(Path.Combine(repoRoot, ".config", "dotnet-tools.json")) ?? string.Empty; + if (TryReadObject(toolsText, out JsonElement tools) + && tools.TryGetProperty("tools", out var toolsObj) + && toolsObj.TryGetProperty("ilspycmd", out var ilspy) + && ilspy.TryGetProperty("version", out var verEl) + && verEl.GetString() is { Length: > 0 } parsedPin) + { + pin = parsedPin; + } + + return new IlspycmdCompatibility(min, max, pin); + } + + private static bool TryReadObject(string json, out JsonElement element) + { + element = default; + if (string.IsNullOrWhiteSpace(json)) + return false; + try + { + using var doc = JsonDocument.Parse(json); + element = doc.RootElement.Clone(); + return element.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/DistroPackageHints.cs b/Optimum.Bootstrap.Core/Prerequisites/DistroPackageHints.cs new file mode 100644 index 0000000..0a6e9fb --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/DistroPackageHints.cs @@ -0,0 +1,24 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Ports system_install_command from scripts/install-linux.sh: a +/// copyable sudo command for the distro's package manager. Core never runs +/// these; it shows them. +/// +public static class DistroPackageHints +{ + public static string? InstallCommand(ISystemProbe probe, string package) + { + if (CommandSearch.Exists(probe, "apt-get")) + return $"sudo apt-get install -y {package}"; + if (CommandSearch.Exists(probe, "dnf")) + return $"sudo dnf install -y {package}"; + if (CommandSearch.Exists(probe, "pacman")) + return $"sudo pacman -S --needed --noconfirm {package}"; + if (CommandSearch.Exists(probe, "zypper")) + return $"sudo zypper --non-interactive install {package}"; + return null; + } +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs b/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs new file mode 100644 index 0000000..251c2fa --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs @@ -0,0 +1,89 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Finds a .NET 10 SDK. Ports check_dotnet10 from +/// scripts/install-linux.sh and folds in the extra probe locations from +/// Resolve-DotNetPath in scripts/install-windows.ps1: PATH first, +/// then a per-platform candidate list, then run --list-sdks on each and +/// accept the one that reports a 10. line. OPTIMUM_DOTNET_CANDIDATES +/// (separated by : on Unix, ; on Windows) replaces the default +/// list, which is how the shell tests point detection at a stub. +/// +public static class DotnetSdkProbe +{ + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + + public static string? Find(ISystemProbe probe) + { + foreach (string candidate in Candidates(probe)) + { + if (!probe.IsExecutable(candidate)) + continue; + ProcessOutcome outcome = probe.Run(candidate, ["--list-sdks"], ProbeTimeout); + if (outcome.Started && HasNet10Line(outcome.StandardOutput)) + return candidate; + } + + return null; + } + + private static bool HasNet10Line(string listSdksOutput) + { + // Matches the shell's `grep -q '^10\.'`: anchored at column 0. + foreach (string line in listSdksOutput.Split('\n')) + { + if (line.StartsWith("10.", StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static IEnumerable Candidates(ISystemProbe probe) + { + string? onPath = CommandSearch.Which(probe, "dotnet"); + if (onPath is not null) + yield return onPath; + + string? overrideList = probe.GetEnvironmentVariable("OPTIMUM_DOTNET_CANDIDATES"); + if (!string.IsNullOrEmpty(overrideList)) + { + // Split on the *simulated* platform's separator, not the host's, so a + // Windows-shaped probe parses `C:\a;C:\b` even when the test runs on + // Linux. In production probe.Os always matches the host. + char separator = probe.Os == OsKind.Windows ? ';' : ':'; + foreach (string entry in overrideList.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + yield return entry; + yield break; + } + + string home = probe.HomeDirectory; + if (probe.Os == OsKind.Windows) + { + string? programFiles = probe.GetEnvironmentVariable("ProgramFiles"); + string? programFilesX86 = probe.GetEnvironmentVariable("ProgramFiles(x86)"); + string? localAppData = probe.GetEnvironmentVariable("LOCALAPPDATA"); + if (programFiles is not null) + yield return Path.Combine(programFiles, "dotnet", "dotnet.exe"); + if (programFilesX86 is not null) + yield return Path.Combine(programFilesX86, "dotnet", "dotnet.exe"); + yield return Path.Combine(home, ".dotnet", "dotnet.exe"); + if (localAppData is not null) + { + yield return Path.Combine(localAppData, "Microsoft", "dotnet", "dotnet.exe"); + yield return Path.Combine(localAppData, "Programs", "dotnet", "dotnet.exe"); + } + yield break; + } + + yield return Path.Combine(home, ".dotnet", "dotnet"); + yield return Path.Combine(home, ".nix-profile", "bin", "dotnet"); + yield return "/usr/share/dotnet/dotnet"; + yield return "/usr/lib/dotnet/dotnet"; + yield return "/snap/dotnet-sdk/current/dotnet"; + if (probe.Os == OsKind.MacOs) + yield return "/usr/local/share/dotnet/dotnet"; + } +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs b/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs new file mode 100644 index 0000000..e95a4d9 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs @@ -0,0 +1,68 @@ +using System.Globalization; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// A four-part ilspycmd version and the range check that decides whether a +/// decompiler is close enough to the tested revision that the fixup passes in +/// scripts/fix-base-ctor-calls.py and scripts/fix-closure-class.pl +/// still apply. Ports ilspycmd_version_supported and its comparators from +/// scripts/install-linux.sh: a version with a prerelease suffix +/// (-preview3, -rc1) is rejected outright because it is not +/// major.minor.patch.build. +/// +public readonly record struct IlspycmdVersion(int Major, int Minor, int Patch, int Build) + : IComparable +{ + public static bool TryParse(string? text, out IlspycmdVersion version) + { + version = default; + if (string.IsNullOrWhiteSpace(text)) + return false; + + string[] parts = text.Split('.'); + if (parts.Length != 4) + return false; + + int[] numbers = new int[4]; + for (int i = 0; i < 4; i++) + { + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out numbers[i])) + return false; + } + + version = new IlspycmdVersion(numbers[0], numbers[1], numbers[2], numbers[3]); + return true; + } + + public int CompareTo(IlspycmdVersion other) + { + int c = Major.CompareTo(other.Major); + if (c != 0) return c; + c = Minor.CompareTo(other.Minor); + if (c != 0) return c; + c = Patch.CompareTo(other.Patch); + if (c != 0) return c; + return Build.CompareTo(other.Build); + } + + public static bool operator <(IlspycmdVersion a, IlspycmdVersion b) => a.CompareTo(b) < 0; + public static bool operator >(IlspycmdVersion a, IlspycmdVersion b) => a.CompareTo(b) > 0; + public static bool operator <=(IlspycmdVersion a, IlspycmdVersion b) => a.CompareTo(b) <= 0; + public static bool operator >=(IlspycmdVersion a, IlspycmdVersion b) => a.CompareTo(b) >= 0; + + public override string ToString() => $"{Major}.{Minor}.{Patch}.{Build}"; +} + +/// The accepted ilspycmd range plus the pinned version, both read from .config/. +public readonly record struct IlspycmdCompatibility(IlspycmdVersion Minimum, IlspycmdVersion Maximum, string Pin) +{ + /// The hard-coded fallback in scripts/install-linux.sh when the config files are missing. + public static readonly IlspycmdCompatibility Fallback = new( + new IlspycmdVersion(10, 1, 0, 8386), + new IlspycmdVersion(10, 1, 1, 8388), + "10.1.1.8388"); + + public bool Supports(string? version) => + IlspycmdVersion.TryParse(version, out var parsed) && parsed >= Minimum && parsed <= Maximum; +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/NixEnvironment.cs b/Optimum.Bootstrap.Core/Prerequisites/NixEnvironment.cs new file mode 100644 index 0000000..760cfc9 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/NixEnvironment.cs @@ -0,0 +1,58 @@ +using System.Runtime.InteropServices; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Ports the NixOS and non-FHS detection from scripts/install-linux.sh. +/// The dotnet-install script downloads a glibc SDK whose binaries hardcode +/// the system dynamic linker; on NixOS and other non-FHS systems that linker +/// lives in the Nix store, so the downloaded SDK cannot run. Core keeps the same +/// refusal and the same nix profile install substitute. +/// +/// The whole concept is Linux-only: the checks short-circuit on Windows and +/// macOS, where the dot.net installer ships a native runtime with no +/// glibc interpreter dependency. +/// +public static class NixEnvironment +{ + public const string DotnetSdkInstallCommand = "nix profile install nixpkgs#dotnet-sdk_10"; + + public static bool IsNixOs(ISystemProbe probe) => + probe.Os == OsKind.Linux + && (probe.PathExists("/etc/NIXOS") + || !string.IsNullOrEmpty(probe.GetEnvironmentVariable("NIX_STORE"))); + + /// + /// The dynamic linker path for the current architecture, or an empty string + /// on an architecture the script does not know how to check. The + /// OPTIMUM_GLIBC_INTERPRETER environment variable overrides it, which + /// is how the shell tests simulate a non-FHS host. + /// + public static string GlibcInterpreterPath(ISystemProbe probe) + { + if (probe.Os != OsKind.Linux) + return string.Empty; + + string? overridePath = probe.GetEnvironmentVariable("OPTIMUM_GLIBC_INTERPRETER"); + if (!string.IsNullOrEmpty(overridePath)) + return overridePath; + + return probe.Arch switch + { + Architecture.X64 => "/lib64/ld-linux-x86-64.so.2", + Architecture.Arm64 => "/lib/ld-linux-aarch64.so.1", + _ => string.Empty, + }; + } + + /// + /// True when a downloaded glibc SDK could run here: either the architecture + /// is unknown (so the check is skipped) or the interpreter exists. + /// + public static bool DownloadedSdkRunnable(ISystemProbe probe) + { + string interpreter = GlibcInterpreterPath(probe); + return interpreter.Length == 0 || probe.PathExists(interpreter); + } +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/Prerequisite.cs b/Optimum.Bootstrap.Core/Prerequisites/Prerequisite.cs new file mode 100644 index 0000000..a4f3fb4 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/Prerequisite.cs @@ -0,0 +1,87 @@ +namespace Optimum.Bootstrap.Core.Prerequisites; + +public enum PrerequisiteId +{ + Dotnet, + Git, + Perl, + Python3, + Curl, + Tar, + Chmod, + Pwsh, + Unzip, + Ilspycmd, + Make, + Cmake, + Mkisofs, + Innoextract, + Appimagetool, +} + +public enum RequirementLevel +{ + /// Bootstrap and build cannot run without it. + Required, + + /// + /// Only the packaging step needs it. scripts/check-prereqs.sh marks + /// pwsh as required outright, which tells a Linux user building a + /// tar.gz to install PowerShell for no reason. Core narrows it. + /// + RequiredForPackaging, + + /// A missing optional tool only skips a package target. + Optional, +} + +public enum PrerequisiteState +{ + Ok, + + /// Present but the wrong version (ilspycmd out of range, innoextract below 1.11). + Outdated, + + /// A required or packaging tool that is not installed. + Missing, + + /// An optional tool that is not installed. + OptionalMissing, +} + +/// How the installer can resolve a missing prerequisite. +public enum AcquisitionKind +{ + /// Nothing the installer can do; the user installs it and retries. + None, + + /// The installer runs it without the user leaving the app (SDK script, ilspycmd tool). + Automatic, + + /// The installer shows a copyable command (a distro package, the Nix profile command). + Manual, + + /// The installer opens a download page. + DownloadPage, +} + +public sealed record PrerequisiteDefinition( + PrerequisiteId Id, + string Command, + string DisplayName, + RequirementLevel Level, + string UsedBy); + +public sealed record PrerequisiteResult( + PrerequisiteDefinition Definition, + PrerequisiteState State, + string Label, + string? DetectedPath, + string? DetectedVersion, + AcquisitionKind Acquisition, + string? AcquisitionCommand, + string? DownloadUrl) +{ + public bool BlocksBuild => State is PrerequisiteState.Missing or PrerequisiteState.Outdated + && Definition.Level is RequirementLevel.Required; +} diff --git a/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs new file mode 100644 index 0000000..aeaeb01 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs @@ -0,0 +1,246 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Detects every tool the bootstrap and packaging scripts need. +/// +/// On Linux and macOS the tool list is the one in scripts/check-prereqs.sh +/// (a bash script describing what the shell pipeline needs). On Windows +/// that list does not apply: scripts/bootstrap.ps1 reimplements every +/// fixup natively in PowerShell with "no perl/python3 dependency", and +/// scripts/install-windows.ps1 requires only the .NET SDK, Git, and +/// PowerShell. The Windows list reflects that. The per-tool detection folds in +/// the richer probes from the two GUI installers. +/// +public sealed class PrerequisiteScanner(ISystemProbe probe, string repoRoot) +{ + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(5); + + internal const string GitForWindowsUrl = "https://git-scm.com/download/win"; + + private static readonly PrerequisiteDefinition[] UnixDefinitions = + [ + new(PrerequisiteId.Dotnet, "dotnet", ".NET SDK 10", RequirementLevel.Required, "bootstrap, build"), + new(PrerequisiteId.Git, "git", "Git", RequirementLevel.Required, "bootstrap, extract-patches"), + new(PrerequisiteId.Perl, "perl", "Perl", RequirementLevel.Required, "bootstrap, extract-patches"), + new(PrerequisiteId.Python3, "python3", "Python 3", RequirementLevel.Required, "bootstrap"), + new(PrerequisiteId.Curl, "curl", "curl", RequirementLevel.Required, "bootstrap, packaging"), + new(PrerequisiteId.Tar, "tar", "tar", RequirementLevel.Required, "bootstrap, packaging"), + new(PrerequisiteId.Chmod, "chmod", "chmod (coreutils)", RequirementLevel.Required, "packaging"), + new(PrerequisiteId.Pwsh, "pwsh", "PowerShell", RequirementLevel.RequiredForPackaging, "package-linux.ps1, package-macos.ps1, package.ps1"), + new(PrerequisiteId.Unzip, "unzip", "unzip", RequirementLevel.Optional, "bootstrap (zip archives; a python3 fallback exists)"), + new(PrerequisiteId.Ilspycmd, "ilspycmd", "ilspycmd (decompiler)", RequirementLevel.Optional, "bootstrap (auto-installs via dotnet tool)"), + new(PrerequisiteId.Make, "make", "make", RequirementLevel.Optional, "package-macos (.dmg on Linux via libdmg-hfsplus)"), + new(PrerequisiteId.Cmake, "cmake", "cmake", RequirementLevel.Optional, "package-macos (.dmg on Linux via libdmg-hfsplus)"), + new(PrerequisiteId.Mkisofs, "mkisofs", "mkisofs or genisoimage", RequirementLevel.Optional, "package-macos (.dmg on Linux)"), + new(PrerequisiteId.Innoextract, "innoextract", "innoextract 1.11 or newer", RequirementLevel.Optional, "package.ps1 (off-platform Windows package)"), + new(PrerequisiteId.Appimagetool, "appimagetool", "appimagetool", RequirementLevel.Optional, "package-linux.sh --format appimage (auto-downloads)"), + ]; + + private static readonly PrerequisiteDefinition[] WindowsDefinitions = + [ + new(PrerequisiteId.Dotnet, "dotnet", ".NET SDK 10", RequirementLevel.Required, "bootstrap, build"), + new(PrerequisiteId.Git, "git", "Git", RequirementLevel.Required, "bootstrap, extract-patches"), + new(PrerequisiteId.Pwsh, "pwsh", "PowerShell", RequirementLevel.Required, "bootstrap.ps1, package.ps1"), + new(PrerequisiteId.Ilspycmd, "ilspycmd", "ilspycmd (decompiler)", RequirementLevel.Optional, "bootstrap (auto-installs via dotnet tool)"), + ]; + + private PrerequisiteDefinition[] Definitions => + probe.Os == OsKind.Windows ? WindowsDefinitions : UnixDefinitions; + + public IReadOnlyList Scan() => Definitions.Select(Detect).ToArray(); + + public bool AllRequiredPresent() => Scan().All(r => !r.BlocksBuild); + + private PrerequisiteResult Detect(PrerequisiteDefinition def) => def.Id switch + { + PrerequisiteId.Dotnet => DetectDotnet(def), + PrerequisiteId.Ilspycmd => DetectIlspycmd(def), + PrerequisiteId.Innoextract => DetectInnoextract(def), + PrerequisiteId.Mkisofs => DetectEither(def, "mkisofs", "genisoimage"), + PrerequisiteId.Appimagetool => DetectAppimagetool(def), + PrerequisiteId.Pwsh => DetectPowerShell(def), + PrerequisiteId.Git when probe.Os == OsKind.Windows => DetectGitOnWindows(def), + _ => DetectPlain(def), + }; + + private PrerequisiteResult DetectPlain(PrerequisiteDefinition def) + { + string? path = CommandSearch.Which(probe, def.Command); + if (path is not null) + return Ok(def, path, null); + + return Missing(def, DistroAcquisition(def.Command)); + } + + /// + /// PowerShell 7 (pwsh) satisfies the row everywhere; on Windows the + /// built-in Windows PowerShell 5.1 is an accepted fallback, so the row is + /// almost always Ready there. + /// + private PrerequisiteResult DetectPowerShell(PrerequisiteDefinition def) + { + string? path = PowerShellHost.Find(probe); + if (path is not null) + return Ok(def, path, null); + + return probe.Os == OsKind.Windows + ? new PrerequisiteResult(def, PrerequisiteState.Missing, def.DisplayName, null, null, + AcquisitionKind.DownloadPage, null, "https://aka.ms/powershell") + : Missing(def, DistroAcquisition("pwsh")); + } + + /// Git for Windows is a signed installer, not a package; point the user at it. + private PrerequisiteResult DetectGitOnWindows(PrerequisiteDefinition def) + { + string? path = CommandSearch.Which(probe, def.Command); + if (path is not null) + return Ok(def, path, null); + + return new PrerequisiteResult(def, PrerequisiteState.Missing, def.DisplayName, null, null, + AcquisitionKind.DownloadPage, null, GitForWindowsUrl); + } + + private PrerequisiteResult DetectEither(PrerequisiteDefinition def, string first, string second) + { + string? path = CommandSearch.Which(probe, first) ?? CommandSearch.Which(probe, second); + return path is not null ? Ok(def, path, null) : Missing(def, DistroAcquisition(first)); + } + + private PrerequisiteResult DetectDotnet(PrerequisiteDefinition def) + { + string? sdk = DotnetSdkProbe.Find(probe); + if (sdk is not null) + { + ProcessOutcome outcome = probe.Run(sdk, ["--version"], ProbeTimeout); + string? version = outcome.Started ? outcome.StandardOutput.Trim() : null; + return Ok(def, sdk, version); + } + + if (NixEnvironment.IsNixOs(probe)) + { + return new PrerequisiteResult(def, PrerequisiteState.Missing, + $"{def.DisplayName} (install through nixpkgs)", null, null, + AcquisitionKind.Manual, NixEnvironment.DotnetSdkInstallCommand, null); + } + + if (!NixEnvironment.DownloadedSdkRunnable(probe)) + { + return new PrerequisiteResult(def, PrerequisiteState.Missing, + $"{def.DisplayName} (non-FHS system: the dot.net installer will not run here)", null, null, + AcquisitionKind.None, null, null); + } + + return new PrerequisiteResult(def, PrerequisiteState.Missing, def.DisplayName, null, null, + AcquisitionKind.Automatic, null, "https://dotnet.microsoft.com/download/dotnet/10.0"); + } + + private PrerequisiteResult DetectIlspycmd(PrerequisiteDefinition def) + { + IlspycmdCompatibility compat = ConfigFiles.ReadIlspycmdCompatibility(probe, repoRoot); + string? path = CommandSearch.Which(probe, "ilspycmd") + ?? ExistingOrNull(Path.Combine(probe.HomeDirectory, ".dotnet", "tools", "ilspycmd")); + + if (path is null) + { + return new PrerequisiteResult(def, PrerequisiteState.OptionalMissing, + $"{def.DisplayName} {compat.Pin}", null, null, + AcquisitionKind.Automatic, IlspycmdVersionCommand(compat.Pin), null); + } + + string? version = ReadIlspycmdVersion(path); + if (compat.Supports(version)) + return Ok(def, path, version); + + return new PrerequisiteResult(def, PrerequisiteState.Outdated, + $"{def.DisplayName} {version ?? "unknown"} (needs {compat.Minimum} to {compat.Maximum})", + path, version, AcquisitionKind.Automatic, IlspycmdVersionCommand(compat.Pin), null); + } + + private PrerequisiteResult DetectInnoextract(PrerequisiteDefinition def) + { + string? path = CommandSearch.Which(probe, "innoextract"); + if (path is null) + return Missing(def, AcquisitionKind.DownloadPage, null, + "https://github.com/crazy-max/innoextract/releases"); + + ProcessOutcome outcome = probe.Run(path, ["--version"], ProbeTimeout); + (int major, int minor)? parsed = ParseInnoextractVersion(outcome.StandardOutput); + if (parsed is { } v && (v.major > 1 || (v.major == 1 && v.minor >= 11))) + return Ok(def, path, $"{v.major}.{v.minor}"); + + return new PrerequisiteResult(def, PrerequisiteState.Outdated, + $"{def.DisplayName} (found {parsed?.major}.{parsed?.minor}, need 1.11 or newer)", + path, parsed is { } p ? $"{p.major}.{p.minor}" : null, + AcquisitionKind.DownloadPage, null, "https://github.com/crazy-max/innoextract/releases"); + } + + private PrerequisiteResult DetectAppimagetool(PrerequisiteDefinition def) + { + string? path = CommandSearch.Which(probe, "appimagetool") + ?? ExecutableOrNull(Path.Combine(repoRoot, ".tools", "appimagetool")) + ?? ExecutableOrNull(Path.Combine(probe.HomeDirectory, ".tools", "appimagetool")); + return path is not null + ? Ok(def, path, null) + : new PrerequisiteResult(def, PrerequisiteState.OptionalMissing, def.DisplayName, null, null, + AcquisitionKind.Automatic, null, null); + } + + private static string IlspycmdVersionCommand(string pin) => + $"dotnet tool update -g ilspycmd --version {pin} --allow-downgrade"; + + private AcquisitionKind DistroAcquisition(string package) => + DistroPackageHints.InstallCommand(probe, package) is not null + ? AcquisitionKind.Manual + : AcquisitionKind.None; + + private PrerequisiteResult Ok(PrerequisiteDefinition def, string path, string? version) => + new(def, PrerequisiteState.Ok, + version is null ? def.DisplayName : $"{def.DisplayName} ({version})", + path, version, AcquisitionKind.None, null, null); + + private PrerequisiteResult Missing(PrerequisiteDefinition def, AcquisitionKind acquisition) => + Missing(def, acquisition, DistroPackageHints.InstallCommand(probe, def.Command), null); + + private PrerequisiteResult Missing(PrerequisiteDefinition def, AcquisitionKind acquisition, string? command, string? url) + { + PrerequisiteState state = def.Level == RequirementLevel.Optional + ? PrerequisiteState.OptionalMissing + : PrerequisiteState.Missing; + return new PrerequisiteResult(def, state, def.DisplayName, null, null, acquisition, command, url); + } + + private string? ExistingOrNull(string path) => probe.FileExists(path) ? path : null; + + private string? ExecutableOrNull(string path) => probe.IsExecutable(path) ? path : null; + + private string? ReadIlspycmdVersion(string path) + { + ProcessOutcome outcome = probe.Run(path, ["--version"], ProbeTimeout); + if (!outcome.Started) + return null; + string firstLine = outcome.StandardOutput.Split('\n').FirstOrDefault() ?? string.Empty; + string[] tokens = firstLine.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return tokens.Length >= 2 ? tokens[1].Trim() : null; + } + + public static (int major, int minor)? ParseInnoextractVersion(string output) + { + foreach (string line in output.Split('\n')) + { + string trimmed = line.Trim(); + const string prefix = "innoextract "; + if (!trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + string rest = trimmed[prefix.Length..]; + string[] parts = rest.Split('.', '-', ' '); + if (parts.Length >= 2 + && int.TryParse(parts[0], out int major) + && int.TryParse(parts[1], out int minor)) + return (major, minor); + } + + return null; + } +} diff --git a/Optimum.Cli.Tests/CliRunnerTests.cs b/Optimum.Cli.Tests/CliRunnerTests.cs new file mode 100644 index 0000000..f064e89 --- /dev/null +++ b/Optimum.Cli.Tests/CliRunnerTests.cs @@ -0,0 +1,305 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Tests; +using Optimum.Cli; +using Xunit; + +namespace Optimum.Cli.Tests; + +public class CliRunnerTests +{ + private static async Task<(int Code, string Stdout, string Stderr)> Run( + string[] args, ISystemProbe? probe = null, IBuildDriver? driver = null, CancellationToken cancel = default, + ISourceProvider? sourceProvider = null) + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + int code = await CliRunner.RunAsync( + args, stdout, stderr, + probe ?? new FakeSystemProbe(), + driver ?? new FakeBuildDriver(), + cancel, + sourceProvider); + return (code, stdout.ToString(), stderr.ToString()); + } + + private sealed class StubSourceProvider(SourceAcquisitionResult result) : ISourceProvider + { + public int Calls { get; private set; } + + public Task EnsureAsync( + SourceRequest request, IBuildObserver observer, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(result); + } + } + + private sealed class ThrowingSourceProvider : ISourceProvider + { + public Task EnsureAsync( + SourceRequest request, IBuildObserver observer, CancellationToken cancellationToken) => + throw new IOException("clone process could not start"); + } + + [Fact] + public async Task VersionPrintsOnePlainLine() + { + var (code, stdout, stderr) = await Run(["--version"]); + Assert.Equal(CliRunner.ExitOk, code); + Assert.Equal(CoreInfo.Version, stdout.Trim()); + Assert.Equal(string.Empty, stderr); + } + + [Fact] + public async Task UnknownVerbExitsWithUsage() + { + var (code, stdout, stderr) = await Run(["frobnicate"]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Equal(string.Empty, stdout); + Assert.Contains("unknown verb", stderr); + } + + [Fact] + public async Task BuildWithoutTheAcknowledgeFlagDoesNoWork() + { + var driver = new FakeBuildDriver(); + var (code, _, stderr) = await Run(["build", "--output", "/tmp/out"], driver: driver); + + Assert.Equal(CliRunner.ExitUsage, code); + Assert.False(driver.WasRun); + Assert.Contains("acknowledge-decompile", stderr); + } + + [Fact] + public async Task BuildRejectsARelativeOutputPath() + { + var (code, _, stderr) = await Run(["build", "--acknowledge-decompile", "--output", "relative/out"]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("absolute", stderr); + } + + [Fact] + public async Task BuildRejectsAMissingClientArchive() + { + var probe = RepoProbe(); + var (code, _, stderr) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--client-archive", "/no/such/archive.tar.gz", "--repo-root", "/repo"], + probe); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("does not exist", stderr); + } + + [Fact] + public async Task BuildJsonStreamMeetsTheContract() + { + var probe = RepoProbe(); + var (code, stdout, _) = await Run( + ["build", "--acknowledge-decompile", "--json", "--output", "/tmp/out", "--repo-root", "/repo"], + probe); + + Assert.Equal(CliRunner.ExitOk, code); + NdjsonStream stream = NdjsonStream.Parse(stdout); + stream.AssertContract(); + Assert.True(stream.Terminal.GetProperty("ok").GetBoolean()); + } + + [Fact] + public async Task BuildJsonFailurePropagatesTheKebabReasonAndExitsNonZero() + { + var probe = RepoProbe(); + var driver = new FakeBuildDriver + { + Behaviour = (observer, _) => + { + observer.Phase(ProgressPhase.Patch, 40, "applying patches"); + return BuildResult.Failure(FailureReason.PatchConflict, "patches/vsapi/0007 did not apply"); + }, + }; + + var (code, stdout, _) = await Run( + ["build", "--acknowledge-decompile", "--json", "--output", "/tmp/out", "--repo-root", "/repo"], + probe, driver); + + Assert.Equal(CliRunner.ExitError, code); + NdjsonStream stream = NdjsonStream.Parse(stdout); + stream.AssertContract(); + Assert.False(stream.Terminal.GetProperty("ok").GetBoolean()); + Assert.Equal("patch-conflict", stream.Terminal.GetProperty("reason").GetString()); + } + + [Fact] + public async Task BuildMapsACancelledTokenToTheCancelledResult() + { + var probe = RepoProbe(); + // A driver that reports whatever the token says, the way CliWrap does + // when a signal trips mid-run. + var driver = new FakeBuildDriver + { + Behaviour = (_, token) => + { + token.ThrowIfCancellationRequested(); + return BuildResult.Success("/should/not/reach"); + }, + }; + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + var (code, stdout, _) = await Run( + ["build", "--acknowledge-decompile", "--json", "--output", "/tmp/out", "--repo-root", "/repo"], + probe, driver, cancelled.Token); + + Assert.Equal(CliRunner.ExitError, code); + NdjsonStream stream = NdjsonStream.Parse(stdout); + stream.AssertContract(); + Assert.Equal("cancelled", stream.Terminal.GetProperty("reason").GetString()); + } + + [Fact] + public async Task BuildJsonStreamHasNoProgressAnomaliesOnACleanRun() + { + var probe = RepoProbe(); + var (_, stdout, _) = await Run( + ["build", "--acknowledge-decompile", "--json", "--output", "/tmp/out", "--repo-root", "/repo"], + probe); + + bool anyClampWarning = NdjsonStream.Parse(stdout).Lines.Any(l => + l.GetProperty("type").GetString() == "log" + && l.GetProperty("level").GetString() == "warn" + && l.GetProperty("message").GetString()!.Contains("adjusted to")); + Assert.False(anyClampWarning); + } + + [Fact] + public async Task PreflightJsonIsAnArrayOfPrerequisites() + { + var probe = RepoProbe(); + var (_, stdout, _) = await Run(["preflight", "--json", "--repo-root", "/repo"], probe); + + using var doc = System.Text.Json.JsonDocument.Parse(stdout.Trim()); + Assert.Equal(System.Text.Json.JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Contains(doc.RootElement.EnumerateArray(), e => e.GetProperty("id").GetString() == "Dotnet"); + } + + [Fact] + public async Task CapabilitiesJsonNamesThePinnedVersion() + { + var probe = RepoProbe(); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + + var (code, stdout, _) = await Run(["capabilities", "--json", "--repo-root", "/repo"], probe); + + Assert.Equal(CliRunner.ExitOk, code); + using var doc = System.Text.Json.JsonDocument.Parse(stdout.Trim()); + Assert.Equal("1.22.7", doc.RootElement.GetProperty("pinnedVersion").GetString()); + } + + [Fact] + public async Task InstallRejectsARelativePackagePath() + { + var (code, _, stderr) = await Run(["install", "--package", "rel/pkg", "--install-dir", "/tmp/i"]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("absolute", stderr); + } + + [Fact] + public async Task UninstallOnADirectoryWithNoManifestIsBadInput() + { + string dir = Directory.CreateTempSubdirectory("optimum-cli-test").FullName; + try + { + var (code, _, stderr) = await Run(["uninstall", "--install-dir", dir]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("manifest", stderr); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task BuildWithoutACheckoutPointsAtAcquireSource() + { + var (code, _, stderr) = await Run(["build", "--acknowledge-decompile", "--output", "/tmp/out"]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("--acquire-source", stderr); + } + + [Fact] + public async Task BuildAcquiresTheSourceWhenAskedAndThereIsNoCheckout() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/downloaded/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + probe.AddFile("/downloaded/scripts/bootstrap.sh"); + var driver = new FakeBuildDriver(); + var provider = new StubSourceProvider(SourceAcquisitionResult.Success("/downloaded")); + + var (code, stdout, _) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--acquire-source"], + probe, driver, sourceProvider: provider); + + Assert.Equal(CliRunner.ExitOk, code); + Assert.Equal(1, provider.Calls); + Assert.True(driver.WasRun); + Assert.Contains("Optimum-v0.3.14-linux-x64", stdout); + } + + [Fact] + public async Task BuildSurfacesASourceDownloadFailure() + { + var provider = new StubSourceProvider( + SourceAcquisitionResult.Failure(FailureReason.SourceUnavailable, "git clone failed (exit 128)")); + + var (code, _, stderr) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--acquire-source"], + sourceProvider: provider); + + Assert.Equal(CliRunner.ExitError, code); + Assert.Contains("source-unavailable", stderr); + } + + [Fact] + public async Task BuildRejectsAMalformedSuccessfulSourceResult() + { + var provider = new StubSourceProvider(new SourceAcquisitionResult(true, null, null, null)); + + var (code, _, stderr) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--acquire-source"], + sourceProvider: provider); + + Assert.Equal(CliRunner.ExitError, code); + Assert.Contains("source-unavailable", stderr); + } + + [Fact] + public async Task BuildConvertsAnUnexpectedSourceProviderFaultToAResult() + { + var (code, _, stderr) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--acquire-source"], + sourceProvider: new ThrowingSourceProvider()); + + Assert.Equal(CliRunner.ExitError, code); + Assert.Contains("source-unavailable", stderr); + Assert.Contains("could not start", stderr); + } + + [Fact] + public async Task BuildRejectsARelativeSourceCachePath() + { + var (code, _, stderr) = await Run( + ["build", "--acknowledge-decompile", "--output", "/tmp/out", "--acquire-source", "--source-cache", "rel/cache"]); + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Contains("absolute", stderr); + } + + private static FakeSystemProbe RepoProbe() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + probe.AddFile("/repo/scripts/bootstrap.sh"); + return probe; + } +} diff --git a/Optimum.Cli.Tests/FakeBuildDriver.cs b/Optimum.Cli.Tests/FakeBuildDriver.cs new file mode 100644 index 0000000..0e9b39e --- /dev/null +++ b/Optimum.Cli.Tests/FakeBuildDriver.cs @@ -0,0 +1,29 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; + +namespace Optimum.Cli.Tests; + +/// A scripted so CLI tests never run a real build. +public sealed class FakeBuildDriver : IBuildDriver +{ + public bool WasRun { get; private set; } + + public Func Behaviour { get; set; } = + static (observer, _) => + { + observer.Phase(ProgressPhase.Decompile, 5, "extracting"); + observer.Phase(ProgressPhase.Decompile, 30, "ilspycmd"); + observer.Phase(ProgressPhase.Patch, 50, "applying patches"); + observer.Log(LogLevel.Warn, "innoextract not present; Windows package skipped"); + observer.Phase(ProgressPhase.Assemble, 80, "dotnet build"); + observer.Phase(ProgressPhase.Verify, 98, "package produced"); + return BuildResult.Success("/out/Optimum-v0.3.14-linux-x64"); + }; + + public Task RunAsync(BuildRequest request, IBuildObserver observer, CancellationToken forceful, CancellationToken graceful = default) + { + WasRun = true; + forceful.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(observer, forceful)); + } +} diff --git a/Optimum.Cli.Tests/NdjsonStream.cs b/Optimum.Cli.Tests/NdjsonStream.cs new file mode 100644 index 0000000..a4f74be --- /dev/null +++ b/Optimum.Cli.Tests/NdjsonStream.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Xunit; + +namespace Optimum.Cli.Tests; + +/// +/// Consumes an NDJSON stream the way RiftLauncher's runTrackedWorker does +/// and asserts the contract in INSTALLER-PLAN.md section 4. This is the reusable +/// conformance check the CI step also runs. +/// +public sealed class NdjsonStream +{ + private static readonly HashSet KnownTypes = ["progress", "log", "result"]; + private static readonly HashSet KnownPhases = ["decompile", "patch", "verify", "assemble"]; + private static readonly HashSet KnownReasons = + [ + "bad-input", "unsupported-version", "patch-conflict", "decompile-failed", + "assemble-failed", "verification-failed", "output-exists", "cancelled", "engine-internal", + ]; + + public required IReadOnlyList Lines { get; init; } + + public JsonElement Terminal => Lines[^1]; + + public static NdjsonStream Parse(string stdout) + { + var lines = new List(); + foreach (string raw in stdout.Split('\n')) + { + if (raw.Length == 0) + continue; + using var doc = JsonDocument.Parse(raw); + lines.Add(doc.RootElement.Clone()); + } + + Assert.NotEmpty(lines); + return new NdjsonStream { Lines = lines }; + } + + public void AssertContract() + { + int lastProgress = 0; + int resultCount = 0; + + for (int i = 0; i < Lines.Count; i++) + { + JsonElement line = Lines[i]; + Assert.Equal(JsonValueKind.Object, line.ValueKind); + string type = line.GetProperty("type").GetString()!; + Assert.True(KnownTypes.Contains(type), $"unknown line type: {type}"); + + switch (type) + { + case "progress": + Assert.True(KnownPhases.Contains(line.GetProperty("phase").GetString()!)); + int progress = line.GetProperty("progress").GetInt32(); + Assert.InRange(progress, lastProgress, 99); + lastProgress = progress; + break; + + case "log": + Assert.Contains(line.GetProperty("level").GetString(), new[] { "info", "warn", "error" }); + break; + + case "result": + resultCount++; + Assert.Equal(Lines.Count - 1, i); + if (!line.GetProperty("ok").GetBoolean()) + { + Assert.True(KnownReasons.Contains(line.GetProperty("reason").GetString()!)); + Assert.False(string.IsNullOrWhiteSpace(line.GetProperty("message").GetString())); + } + else + { + Assert.False(string.IsNullOrWhiteSpace(line.GetProperty("runtimePath").GetString())); + } + break; + } + } + + Assert.Equal(1, resultCount); + } +} diff --git a/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj new file mode 100644 index 0000000..69a20b2 --- /dev/null +++ b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/Optimum.Cli/CliArgs.cs b/Optimum.Cli/CliArgs.cs new file mode 100644 index 0000000..ab2b3f8 --- /dev/null +++ b/Optimum.Cli/CliArgs.cs @@ -0,0 +1,47 @@ +namespace Optimum.Cli; + +/// +/// A small flag parser: --flag value for flags named in +/// , --switch for the rest. Positional +/// arguments and unknown value-flag usage are collected as errors rather than +/// thrown, so a verb can turn them into one bad-input result. +/// +public sealed class CliArgs +{ + private readonly Dictionary _options = new(StringComparer.Ordinal); + private readonly HashSet _switches = new(StringComparer.Ordinal); + private readonly List _errors = []; + + public CliArgs(IReadOnlyList args, ISet valueFlags) + { + for (int i = 0; i < args.Count; i++) + { + string token = args[i]; + if (!token.StartsWith("--", StringComparison.Ordinal)) + { + _errors.Add($"unexpected argument: {token}"); + continue; + } + + if (valueFlags.Contains(token)) + { + if (i + 1 >= args.Count) + { + _errors.Add($"{token} needs a value"); + break; + } + _options[token] = args[++i]; + } + else + { + _switches.Add(token); + } + } + } + + public bool Has(string name) => _switches.Contains(name); + + public string? Get(string name) => _options.TryGetValue(name, out string? value) ? value : null; + + public IReadOnlyList Errors => _errors; +} diff --git a/Optimum.Cli/CliRunner.cs b/Optimum.Cli/CliRunner.cs new file mode 100644 index 0000000..1e52da5 --- /dev/null +++ b/Optimum.Cli/CliRunner.cs @@ -0,0 +1,336 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Licensing; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Cli; + +/// +/// Parses the verb and flags, dispatches to the engine, and writes the NDJSON or +/// plain output. The verbs and their contract are INSTALLER-PLAN.md section 4. +/// +public static class CliRunner +{ + public const int ExitOk = 0; + public const int ExitError = 1; + public const int ExitUsage = 2; + + private static readonly JsonSerializerOptions Json = new() { WriteIndented = false }; + + public static Task RunAsync(IReadOnlyList args, TextWriter stdout, TextWriter stderr) => + RunAsync(args, stdout, stderr, SystemProbe.Default, new ScriptBuildDriver(SystemProbe.Default)); + + public static async Task RunAsync( + IReadOnlyList args, + TextWriter stdout, + TextWriter stderr, + ISystemProbe probe, + IBuildDriver buildDriver, + CancellationToken externalCancellation = default, + ISourceProvider? sourceProvider = null) + { + if (args.Count == 1 && args[0] == "--version") + { + stdout.WriteLine(CoreInfo.Version); + return ExitOk; + } + + if (args.Count == 0) + { + WriteUsage(stderr); + return ExitUsage; + } + + string verb = args[0]; + var rest = args.Skip(1).ToArray(); + bool json = rest.Contains("--json"); + + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(externalCancellation); + using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal); + using PosixSignalRegistration intr = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal); + void OnSignal(PosixSignalContext context) + { + context.Cancel = true; + cancellation.Cancel(); + } + + var output = new EngineOutput(stdout, stderr, json); + + return verb switch + { + "preflight" => Preflight(rest, probe, output), + "capabilities" => Capabilities(rest, probe, stdout, stderr), + "build" => await Build(rest, probe, buildDriver, sourceProvider ?? new GitSourceProvider(probe), output, cancellation.Token), + "install" => Install(rest, probe, output), + "validate" => Validate(rest, probe, output), + "uninstall" => Uninstall(rest, probe, output), + _ => Unknown(verb, stderr), + }; + } + + private static int Preflight(IReadOnlyList args, ISystemProbe probe, EngineOutput output) + { + var parsed = new CliArgs(args, new HashSet { "--repo-root" }); + if (parsed.Errors.Count > 0) + return output.Failure(FailureReason.BadInput, string.Join("; ", parsed.Errors)); + + string? repoRoot = ResolveRepoRoot(probe, parsed.Get("--repo-root")); + if (repoRoot is null) + return output.Failure(FailureReason.BadInput, "run this from inside an Optimum checkout, or pass --repo-root"); + + IReadOnlyList results = new PrerequisiteScanner(probe, repoRoot).Scan(); + + var jsonArray = results.Select(r => new + { + id = r.Definition.Id.ToString(), + command = r.Definition.Command, + level = r.Definition.Level.ToString(), + state = r.State.ToString(), + label = r.Label, + blocksBuild = r.BlocksBuild, + acquisition = r.Acquisition.ToString(), + acquisitionCommand = r.AcquisitionCommand, + downloadUrl = r.DownloadUrl, + }); + + var human = new StringBuilder(); + foreach (PrerequisiteResult r in results) + human.AppendLine($"{r.State,-15} {r.Definition.Command,-14} {r.Label}"); + + output.Answer(JsonSerializer.Serialize(jsonArray, Json), human.ToString().TrimEnd()); + return results.Any(r => r.BlocksBuild) ? ExitError : ExitOk; + } + + private static int Capabilities(IReadOnlyList args, ISystemProbe probe, TextWriter stdout, TextWriter stderr) + { + var parsed = new CliArgs(args, new HashSet { "--repo-root" }); + if (parsed.Errors.Count > 0) + { + stderr.WriteLine(string.Join("; ", parsed.Errors)); + return ExitUsage; + } + + string? repoRoot = ResolveRepoRoot(probe, parsed.Get("--repo-root")); + if (repoRoot is null) + { + stderr.WriteLine("run this from inside an Optimum checkout, or pass --repo-root"); + return ExitUsage; + } + + EngineCapabilities caps = Bootstrap.Core.Build.Capabilities.Read(probe, repoRoot); + stdout.WriteLine(JsonSerializer.Serialize(new + { + optimumVersion = CoreInfo.Version, + pinnedVersion = caps.PinnedVersion, + supportedVersions = caps.SupportedVersions, + patchSets = caps.PatchSets, + }, Json)); + return ExitOk; + } + + private static async Task Build( + IReadOnlyList args, + ISystemProbe probe, + IBuildDriver driver, + ISourceProvider sourceProvider, + EngineOutput output, + CancellationToken cancellationToken) + { + var parsed = new CliArgs(args, new HashSet + { + "--output", "--client-archive", "--version", "--repo-root", "--source-cache", + }); + if (parsed.Errors.Count > 0) + return output.Failure(FailureReason.BadInput, string.Join("; ", parsed.Errors)); + + if (!parsed.Has(ConsentNotice.AcknowledgeFlag)) + { + return output.Failure(FailureReason.BadInput, + $"build decompiles Vintage Story on this machine. Pass {ConsentNotice.AcknowledgeFlag} to confirm you accept that and the terms in the consent notice."); + } + + string? outputDir = parsed.Get("--output"); + if (outputDir is null) + return output.Failure(FailureReason.BadInput, "--output is required"); + if (!Path.IsPathRooted(outputDir)) + return output.Failure(FailureReason.BadInput, $"--output must be an absolute path: {outputDir}"); + + string? clientArchive = parsed.Get("--client-archive"); + if (clientArchive is not null) + { + if (!Path.IsPathRooted(clientArchive)) + return output.Failure(FailureReason.BadInput, $"--client-archive must be an absolute path: {clientArchive}"); + if (!probe.FileExists(clientArchive)) + return output.Failure(FailureReason.BadInput, $"--client-archive does not exist: {clientArchive}"); + } + + string? sourceCache = parsed.Get("--source-cache"); + if (sourceCache is not null && !Path.IsPathRooted(sourceCache)) + return output.Failure(FailureReason.BadInput, $"--source-cache must be an absolute path: {sourceCache}"); + + string? repoRoot = ResolveRepoRoot(probe, parsed.Get("--repo-root")); + if (repoRoot is null) + { + if (!parsed.Has("--acquire-source")) + { + return output.Failure(FailureReason.BadInput, + "run this from inside an Optimum checkout, pass --repo-root, or pass --acquire-source to download it"); + } + + SourceAcquisitionResult acquired; + try + { + acquired = await sourceProvider.EnsureAsync( + new SourceRequest(CoreInfo.Version, sourceCache), output, cancellationToken); + } + catch (OperationCanceledException) + { + return output.Failure(FailureReason.Cancelled, "the source download was cancelled"); + } + catch (Exception ex) + { + return output.Failure(FailureReason.SourceUnavailable, + $"could not obtain the Optimum source: {ex.Message}"); + } + + if (!acquired.Ok || string.IsNullOrWhiteSpace(acquired.RepoRoot)) + { + return output.Failure( + acquired.Reason ?? FailureReason.SourceUnavailable, + acquired.Message ?? "could not obtain the Optimum source"); + } + repoRoot = acquired.RepoRoot; + } + + var request = new BuildRequest(repoRoot!, Path.GetFullPath(outputDir), clientArchive, parsed.Get("--version")); + + BuildResult result; + try + { + result = await driver.RunAsync(request, output, cancellationToken); // single token: SIGTERM is a straight stop for the CLI + } + catch (OperationCanceledException) + { + result = BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); + } + + return result.Ok + ? output.Success(result.RuntimePath!) + : output.Failure(result.Reason ?? FailureReason.EngineInternal, result.Message ?? "unknown failure"); + } + + private static int Install(IReadOnlyList args, ISystemProbe probe, EngineOutput output) + { + var parsed = new CliArgs(args, new HashSet + { + "--package", "--install-dir", "--data-path", "--shortcuts", + }); + if (parsed.Errors.Count > 0) + return output.Failure(FailureReason.BadInput, string.Join("; ", parsed.Errors)); + + string? package = RequireAbsolute(parsed.Get("--package"), "--package", output, out int packageError); + if (package is null) + return packageError; + string? installDir = RequireAbsolute(parsed.Get("--install-dir"), "--install-dir", output, out int installError); + if (installDir is null) + return installError; + + ShortcutKinds shortcuts = ParseShortcuts(parsed.Get("--shortcuts")); + + DeployResult result = new PackageDeployer(probe).Deploy( + new DeployRequest(package, installDir, parsed.Get("--data-path"), shortcuts), output); + + return result.Ok + ? output.Success(result.InstallDirectory!) + : output.Failure(result.Reason ?? FailureReason.EngineInternal, result.Message ?? "install failed"); + } + + private static int Validate(IReadOnlyList args, ISystemProbe probe, EngineOutput output) + { + var parsed = new CliArgs(args, new HashSet { "--package" }); + if (parsed.Errors.Count > 0) + return output.Failure(FailureReason.BadInput, string.Join("; ", parsed.Errors)); + + string? package = RequireAbsolute(parsed.Get("--package"), "--package", output, out int packageError); + if (package is null) + return packageError; + + RuntimeValidationResult result = new RuntimeValidator(probe).Validate(package); + return result.Ok + ? output.Success(package) + : output.Failure(FailureReason.VerificationFailed, result.Detail ?? "runtime validation failed"); + } + + private static int Uninstall(IReadOnlyList args, ISystemProbe probe, EngineOutput output) + { + var parsed = new CliArgs(args, new HashSet { "--install-dir" }); + if (parsed.Errors.Count > 0) + return output.Failure(FailureReason.BadInput, string.Join("; ", parsed.Errors)); + + string? installDir = RequireAbsolute(parsed.Get("--install-dir"), "--install-dir", output, out int installError); + if (installDir is null) + return installError; + + UninstallResult result = new Uninstaller(probe).Uninstall(installDir); + return result.Ok + ? output.Success(installDir) + : output.Failure(result.Reason ?? FailureReason.EngineInternal, result.Message ?? "uninstall failed"); + } + + private static string? RequireAbsolute(string? value, string name, EngineOutput output, out int errorCode) + { + if (value is null) + { + errorCode = output.Failure(FailureReason.BadInput, $"{name} is required"); + return null; + } + if (!Path.IsPathRooted(value)) + { + errorCode = output.Failure(FailureReason.BadInput, $"{name} must be an absolute path: {value}"); + return null; + } + errorCode = ExitOk; + return Path.GetFullPath(value); + } + + private static ShortcutKinds ParseShortcuts(string? value) + { + if (string.IsNullOrEmpty(value)) + return ShortcutKinds.None; + ShortcutKinds result = ShortcutKinds.None; + foreach (string part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (part.Equals("menu", StringComparison.OrdinalIgnoreCase)) result |= ShortcutKinds.Menu; + if (part.Equals("desktop", StringComparison.OrdinalIgnoreCase)) result |= ShortcutKinds.Desktop; + } + return result; + } + + internal static string? ResolveRepoRoot(ISystemProbe probe, string? explicitRoot) => + RepoRoot.Discover(probe, explicitRoot); + + private static int Unknown(string verb, TextWriter stderr) + { + stderr.WriteLine($"unknown verb: {verb}"); + WriteUsage(stderr); + return ExitUsage; + } + + private static void WriteUsage(TextWriter stderr) + { + stderr.WriteLine("usage: optimum [--json] [flags]"); + stderr.WriteLine("verbs:"); + stderr.WriteLine(" preflight [--repo-root ]"); + stderr.WriteLine($" build {ConsentNotice.AcknowledgeFlag} --output [--client-archive ] [--version ] [--acquire-source [--source-cache ]]"); + stderr.WriteLine(" install --package --install-dir [--data-path ] [--shortcuts menu,desktop]"); + stderr.WriteLine(" validate --package "); + stderr.WriteLine(" uninstall --install-dir "); + stderr.WriteLine(" capabilities [--repo-root ]"); + stderr.WriteLine(" --version"); + } +} diff --git a/Optimum.Cli/EngineOutput.cs b/Optimum.Cli/EngineOutput.cs new file mode 100644 index 0000000..a2585f0 --- /dev/null +++ b/Optimum.Cli/EngineOutput.cs @@ -0,0 +1,59 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Ndjson; + +namespace Optimum.Cli; + +/// +/// Bridges the engine to the two output modes. Under --json every +/// structured event goes through on stdout and raw +/// subprocess output goes to stderr. Without it, everything is plain text. +/// +public sealed class EngineOutput(TextWriter stdout, TextWriter stderr, bool json) : IBuildObserver +{ + private readonly NdjsonWriter? _ndjson = json ? new NdjsonWriter(stdout) : null; + + public int ProgressAnomalies => _ndjson?.AnomalyCount ?? 0; + + public void Phase(ProgressPhase phase, int percent, string detail) + { + if (_ndjson is not null) + _ndjson.Progress(phase, percent, detail); + else + stderr.WriteLine($"[{phase.ToString().ToLowerInvariant()} {percent}%] {detail}"); + } + + public void Log(LogLevel level, string message) + { + if (_ndjson is not null) + _ndjson.Log(level, message); + else + stderr.WriteLine($"[{level.ToString().ToLowerInvariant()}] {message}"); + } + + public void RawOutput(bool isError, string line) => stderr.WriteLine(line); + + public int Success(string runtimePath) + { + if (_ndjson is not null) + _ndjson.Success(runtimePath); + else + stdout.WriteLine(runtimePath); + return CliRunner.ExitOk; + } + + public int Failure(FailureReason reason, string message) + { + if (_ndjson is not null) + _ndjson.Failure(reason, message); + else + stderr.WriteLine($"error ({reason.Wire()}): {message}"); + return reason == FailureReason.BadInput ? CliRunner.ExitUsage : CliRunner.ExitError; + } + + /// Emit a query answer (preflight, capabilities): a single JSON object or plain text. + public void Answer(string jsonLine, string humanText) + { + stdout.WriteLine(json ? jsonLine : humanText); + } +} diff --git a/Optimum.Cli/Optimum.Cli.csproj b/Optimum.Cli/Optimum.Cli.csproj new file mode 100644 index 0000000..48f553a --- /dev/null +++ b/Optimum.Cli/Optimum.Cli.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + Optimum.Cli + optimum + enable + enable + false + true + Machine-readable front end for the Optimum installer engine. Emits the NDJSON protocol in INSTALLER-PLAN.md section 4. This is the binary RiftLauncher spawns. + $(OptimumVersion) + + + + + + + + diff --git a/Optimum.Cli/Program.cs b/Optimum.Cli/Program.cs new file mode 100644 index 0000000..fdc48e0 --- /dev/null +++ b/Optimum.Cli/Program.cs @@ -0,0 +1,3 @@ +using Optimum.Cli; + +return await CliRunner.RunAsync(args, Console.Out, Console.Error); diff --git a/Optimum.Installer.Tests/Fakes.cs b/Optimum.Installer.Tests/Fakes.cs new file mode 100644 index 0000000..bda7cf5 --- /dev/null +++ b/Optimum.Installer.Tests/Fakes.cs @@ -0,0 +1,201 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Acquisition; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Tests; +using Optimum.Installer.Services; + +namespace Optimum.Installer.Tests; + +public sealed class FakeBuildDriver : IBuildDriver +{ + public Func Behaviour { get; set; } = + static (observer, _) => + { + observer.Phase(ProgressPhase.Decompile, 10, "decompiling"); + observer.Phase(ProgressPhase.Assemble, 80, "compiling"); + return BuildResult.Success("/tmp/pkg/Optimum-v0.3.14-linux-x64"); + }; + + public int RunCount { get; private set; } + + public Task RunAsync(BuildRequest request, IBuildObserver observer, CancellationToken forceful, CancellationToken graceful = default) + { + RunCount++; + LastRepoRoot = request.RepoRoot; + LastOutputDirectory = request.OutputDirectory; + forceful.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(observer, forceful)); + } + + public string? LastRepoRoot { get; private set; } + public string? LastOutputDirectory { get; private set; } +} + +/// +/// A driver that reports some progress then blocks until is +/// called, so a test can inspect the Progress screen mid-run and exercise cancel. +/// +public sealed class GatedBuildDriver : IBuildDriver +{ + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool ObservedForcefulCancellation { get; private set; } + + public void Release() => _gate.TrySetResult(); + + public async Task RunAsync( + BuildRequest request, IBuildObserver observer, CancellationToken forceful, CancellationToken graceful = default) + { + observer.Phase(ProgressPhase.Decompile, 30, "decompiling"); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(forceful, graceful); + try + { + await _gate.Task.WaitAsync(stop.Token); + } + catch (OperationCanceledException) + { + ObservedForcefulCancellation = forceful.IsCancellationRequested; + return BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); + } + + return BuildResult.Success("/tmp/pkg/Optimum-linux-x64"); + } +} + +public sealed class FakePackageInstaller : IPackageInstaller +{ + public Func Behaviour { get; set; } = + static request => DeployResult.Success(request.InstallDirectory, request.InstallDirectory + "/optimum-launch.sh"); + + public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = null) => Behaviour(request); +} + +public sealed class FakeSourceProvider : ISourceProvider +{ + public Func Behaviour { get; set; } = + static _ => SourceAcquisitionResult.Success("/downloaded-repo"); + + public int Calls { get; private set; } + + public Task EnsureAsync( + SourceRequest request, IBuildObserver observer, CancellationToken cancellationToken) + { + Calls++; + observer.Phase(ProgressPhase.Decompile, 1, "cloning"); + return Task.FromResult(Behaviour(request)); + } +} + +public sealed class FakeAppimagetoolAcquisition : IAppimagetoolAcquisition +{ + public Func Behaviour { get; set; } = + static repoRoot => ToolAcquisitionResult.Success(AppimagetoolAcquisition.TargetPath(repoRoot)); + + public int Calls { get; private set; } + + public Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + Calls++; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(repoRoot)); + } +} + +public sealed class FakeSdkAcquisition : ISdkAcquisition +{ + public Func Behaviour { get; set; } = + static _ => ToolAcquisitionResult.Success("/opt/dotnet/dotnet"); + + public int Calls { get; private set; } + + public Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + Calls++; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(repoRoot)); + } +} + +public sealed class FakeIlspycmdAcquisition : IIlspycmdAcquisition +{ + public Func Behaviour { get; set; } = + static _ => ToolAcquisitionResult.Success("/home/tester/.dotnet/tools/ilspycmd"); + + public int Calls { get; private set; } + + public Task InstallAsync( + string repoRoot, IBuildObserver observer, CancellationToken cancellationToken) + { + Calls++; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(repoRoot)); + } +} + +public sealed class FakeUpdateService : IUpdateService +{ + public string? AvailableVersion { get; set; } + public bool Applied { get; private set; } + + public Task CheckAsync(CancellationToken cancellationToken = default) => + Task.FromResult(AvailableVersion); + + public Task ApplyAsync(Action? progress = null) + { + Applied = true; + progress?.Invoke(100); + return Task.CompletedTask; + } +} + +public static class TestServices +{ + public static InstallerServices Build( + string? repoRoot = "/repo", + FakeSystemProbe? probe = null, + IBuildDriver? driver = null, + IPackageInstaller? installer = null, + IUpdateService? updates = null, + ISourceProvider? sourceProvider = null, + IAppimagetoolAcquisition? appimagetool = null, + ISdkAcquisition? sdk = null, + IIlspycmdAcquisition? ilspycmd = null, + bool dotnetPresent = true) + { + probe ??= new FakeSystemProbe(); + probe.Path.Add("/usr/bin"); + foreach (string tool in new[] { "git", "perl", "python3", "curl", "tar", "chmod", "pwsh", "bash" }) + if (!probe.Files.Contains($"/usr/bin/{tool}")) + probe.AddFile($"/usr/bin/{tool}"); + probe.Environment["OPTIMUM_DOTNET_CANDIDATES"] = dotnetPresent ? "/opt/dotnet/dotnet" : "/absent/dotnet"; + if (dotnetPresent) + { + probe.AddFile("/opt/dotnet/dotnet"); + probe.OnCommand("/opt/dotnet/dotnet", "--list-sdks", "10.0.100 [/x]\n"); + probe.OnCommand("/opt/dotnet/dotnet", "--version", "10.0.100\n"); + } + probe.AddFile("/lib64/ld-linux-x86-64.so.2"); + if (repoRoot is not null) + { + probe.AddFile($"{repoRoot}/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + probe.AddFile($"{repoRoot}/scripts/bootstrap.sh"); + } + + return new InstallerServices( + probe, + repoRoot, + driver ?? new FakeBuildDriver(), + installer ?? new FakePackageInstaller()) + { + UiPost = action => action(), + Updates = updates, + SourceProvider = sourceProvider, + Appimagetool = appimagetool, + Sdk = sdk, + Ilspycmd = ilspycmd, + }; + } +} diff --git a/Optimum.Installer.Tests/MainWindowRenderTests.cs b/Optimum.Installer.Tests/MainWindowRenderTests.cs new file mode 100644 index 0000000..9cab55d --- /dev/null +++ b/Optimum.Installer.Tests/MainWindowRenderTests.cs @@ -0,0 +1,59 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Optimum.Bootstrap.Core.Tests; +using Optimum.Installer.ViewModels; +using Optimum.Installer.Views; +using Xunit; + +namespace Optimum.Installer.Tests; + +public class MainWindowRenderTests +{ + [AvaloniaFact] + public void TheContinueButtonStaysOnScreenWhenTheToolListOverflows() + { + // A bare machine: every prerequisite is missing, so the row list is far + // taller than the window. The list must scroll inside its own region and + // leave the Continue button visible. + var probe = new FakeSystemProbe(); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + var services = TestServices.Build(repoRoot: "/repo", probe: probe, dotnetPresent: false); + var window = new MainWindow { DataContext = new MainWindowViewModel(services) }; + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + Button continueButton = window.GetVisualDescendants().OfType