From fc1175afcabb18d739096ddfe9933cb00eb565db Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:09:13 -0300 Subject: [PATCH 01/59] docs: add Avalonia installer implementation plan Plan to replace the three diverged installer scripts (install-linux.sh, install-windows.ps1, install-macos.sh) with one C# .NET 10 codebase: Optimum.Bootstrap.Core (library), Optimum.Cli (NDJSON front end for RiftLauncher), and Optimum.Installer (Avalonia GUI). Records the engine contract, the cross-OS gap resolutions, the phased rollout, and the open questions. No code yet. --- INSTALLER-PLAN.md | 909 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 909 insertions(+) create mode 100644 INSTALLER-PLAN.md diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md new file mode 100644 index 0000000..c5b2e1c --- /dev/null +++ b/INSTALLER-PLAN.md @@ -0,0 +1,909 @@ +# 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. + +## 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`), `Assert-SafeInstallerPaths` + (`scripts/install-windows.ps1:152`), and a symlink-component walk equivalent to + RiftLauncher's `assertNoSymlinkComponents`. +- 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. + +### Verbs + +| Verb | Arguments | Effect | +| --- | --- | --- | +| `preflight` | `[--json]` | Detect prerequisites. No side effects, no writes, no network. | +| `build` | `--output ` `[--client-archive ]` `[--version ]` `[--json]` | Bootstrap, build, and package into `--output`. | +| `install` | `--package ` `--install-dir ` `[--data-path ]` `[--shortcuts menu,desktop]` `[--json]` | Transactional deploy, shortcuts, uninstaller registration. | +| `validate` | `--package ` `[--json]` | Run the runtime validation described in section 7. | +| `uninstall` | `--install-dir ` `[--json]` | Remove an install using its manifest. | +| `capabilities` | `--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 + +With `--json`, stdout carries one JSON object per line and nothing else. Without +`--json`, stdout carries human-readable text and the schema does not apply. stderr +is always free-form human log 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. + +### 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. | +| `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 the engine stops the current phase, removes whatever it wrote under +`--output`, 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`. On Windows the equivalent is +`CancelKeyPress` plus a job-object kill from the caller. + +### 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. + +### 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`). + +**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. + +**EULA modal.** Mandatory, scrollable, with an acceptance checkbox that gates the +Continue button. Shown on every attempt to start an install, as the Windows +installer does at `scripts/install-windows.ps1:1953`. + +**Progress and log.** A phase label driven by the `BootstrapProgress` phase, a +determinate progress bar, an honest elapsed and estimated remaining time, and a +filtered log pane. 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. A Cancel +button 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 --Continue--> EULA +EULA --Accept--> Progress +EULA --Decline--> Options +Progress --success--> Completion(ok) +Progress --failure--> Completion(error) +Progress --Cancel--> Completion(cancelled) +Completion(error) --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 modal | `scripts/install-windows.ps1:1953-2027` | Core EULA resource, Installer modal, and see the open question in section 12 | +| 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. + +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, and Phase 4 must pick one: + +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 as an assembly-load and JIT probe over the staged + DLLs, without the launcher. This is smaller, gets most of the value, and leaves + the Linux and macOS packaging model as it is. + +Option 2 is the recommendation for Phase 4 and option 1 belongs in a separate +proposal, because changing what a shipped package contains is a release-facing +decision that should not ride along inside an installer rewrite. + +## 8. Packaging and distribution of the installer + +Velopack 1.2 or newer handles all three platforms with one toolchain and one +release feed, and it supports delta updates. On Windows it integrates signtool and +Azure Trusted Signing. On macOS it produces a `.pkg` or a `ditto` ZIP and handles +`codesign` and notarization. On Linux its only output format is AppImage. + +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 requires a paid Apple Developer Program membership and notarization +regardless of which tool builds the bundle. `README.md:254` already records the +consequence of skipping it: an unsigned `.dmg` makes Gatekeeper warn and the user +must right-click and Open. That is tolerable for a game package and not tolerable +for an installer, which is exactly the kind of binary a user should refuse to run +when the operating system warns about it. + +Open question, deliberately not decided here: Avalonia Parcel automates the `.app` +bundle, `Info.plist`, signing, notarization, and `.dmg` in one step, but the full +signing feature sits behind a paid Avalonia tier. Velopack does macOS signing and +notarization at no license cost but produces a `.pkg` or a ZIP rather than a +`.dmg`. The cost of choosing Parcel is a recurring Avalonia subscription on top of +the Apple Developer membership. The cost of choosing Velopack is that macOS users +get a `.pkg` where they may expect a `.dmg`, and that the project maintains the +`.dmg` path in `scripts/package-macos.sh` separately for the game package. The +team should decide with the subscription price in hand. + +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 path with a symlink component. 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, Windows, and macOS from a clean machine + that finishes and launches the game into a world, verified by a person, not by + a script. +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.** Each of the five jobs gains a step +after the existing build that runs `Optimum.Cli build --json --client-archive +` and pipes the stream into the conformance checker. The step asserts a +valid package directory and a conformant stream. 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. + +**A release workflow.** Runs `vpk pack` per RID and publishes the Velopack feed. +Signing credentials come from repository secrets. This workflow is the only one +that touches signing, and it should refuse to publish an unsigned macOS artifact +rather than warn about it. + +## 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.** Create `Optimum.Bootstrap.Core`, `Optimum.Cli`, +`Optimum.Installer`, and the three test projects. Add them to +`VintageStory.slnx` under a new `/Installer/` folder. Add the push and +pull-request workflow with a placeholder test in each project. +*Verification:* the new workflow is green on a pull request and the run takes under +five minutes. + +**Phase 1: Core fundamentals.** The prerequisite model and detection for all three +platforms, acquisition, the path guards, the NDJSON emitter, and the EULA +resource. No build driver yet. +*Verification:* `Optimum.Bootstrap.Core.Tests` covers every path-guard case listed +in section 9, and the ported `install-linux-nixos.sh` and +`install-linux-prerequisites.sh` behaviors have C# equivalents that fail when the +behavior regresses. + +**Phase 2: the CLI.** All seven verbs, wrapping the existing scripts through the +build driver. Contract tests. Extend the platform workflow. +*Verification:* `Optimum.Cli build --json` produces a package on all five platform +jobs and the conformance checker passes on each. A deliberately broken patch +fixture produces `patch-conflict` and exit non-zero. + +**Phase 3: the GUI.** All five screens, the state machine, headless tests. Drives +Core in-process. At the end of this phase the GUI can do a complete install on the +platform the developer is sitting at. +*Verification:* `Optimum.Installer.Tests` green on `ubuntu-latest` with no xvfb, +plus one manual install per platform. + +**Phase 4: unification.** The transactional installer on all three platforms, the +registered uninstaller and install manifest on all three, unified shortcuts, +session-aware data-path detection on all three, and the `validate` decision from +section 7. +*Verification:* an injected failure at each step of the transactional install +restores the previous install, tested on each platform. An install followed by an +uninstall leaves no Optimum files and no orphaned shortcuts, verified by a +filesystem diff. + +**Phase 5: distribution.** Velopack packaging per RID, the release workflow, +signing on Windows and macOS. +*Verification:* a signed installer downloads and runs on a clean machine on each +platform without an operating system warning, and a delta update from the previous +version applies. + +**Phase 6: documentation and deprecation.** Update `README.md`, add the three new +project paths to the MIT list in `LICENSE-SCOPE.md`, note the new build and test +targets in `CONTRIBUTING.md`, and turn `scripts/install-linux.sh`, +`scripts/install-windows.ps1`, and `scripts/install-macos.sh` into thin shims that +forward to `Optimum.Cli`. Fix or replace `scripts/uninstall.sh`, which today +cannot uninstall anything: `scripts/uninstall.sh:75` exits 0 unless +`$VS_DIR/Optimum.dll` exists, and the current Linux standalone package contains no +`Optimum.dll`, so the script reports "Optimum not installed" and returns without +even reaching the `.desktop` cleanup at `:122-123`. +*Verification:* a fresh clone documents one install path, the shims work for +anyone with the old commands in their shell history, and `scripts/uninstall.sh` +either removes a real install or is gone. + +**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. + +## 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.** No explicit release note confirming .NET 10 support was +found. Phase 0 should add a throwaway CI job that runs `vpk pack` against a hello +world .NET 10 self-contained app before Phase 5 commits to the toolchain. If it +fails, the fallback is per-platform packaging with no auto-update, which is what +the project has today. + +**macOS notarization.** Requires a paid Apple Developer Program membership. The +project needs an account, a certificate, and a place to store the credentials. This +is an administrative dependency with a lead time and it should be started during +Phase 0, not Phase 5. + +**Parcel versus Velopack for `.dmg`.** Open, with the cost stated in 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. + +**Is the EULA legally load-bearing?** Today it is shown only in the Windows GUI +path (`scripts/install-windows.ps1:1953`) and skipped in `-Silent`. If acceptance +matters, the CLI path needs an equivalent gate, which means a flag such as +`--accept-license` and a refusal to proceed without it, which in turn means +RiftLauncher has to surface the text and pass the flag. If it does not matter, the +modal is a courtesy and the CLI can omit it. Someone has to decide, and the +decision changes the CLI's argument surface, so it should be made before Phase 2. + +**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.Tests/` (xUnit) +- `Optimum.Cli/` (console application, MIT) +- `Optimum.Cli.Tests/` (xUnit, NDJSON conformance) +- `Optimum.Installer/` (Avalonia application, MIT) +- `Optimum.Installer.Tests/` (Avalonia.Headless.XUnit) +- `.github/workflows/ci-installer.yml` (push and pull request) +- `.github/workflows/release-installer.yml` (Velopack) +- `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`. + +### Deprecated in Phase 6 + +- `scripts/install-linux.sh` becomes a shim over `Optimum.Cli`. +- `scripts/install-windows.ps1` becomes a shim over `Optimum.Cli`. +- `scripts/install-macos.sh` is removed; the overlay model is retired. +- `scripts/uninstall.sh` is fixed or replaced. See the Phase 6 verification. +- `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` are + already legacy and can go at the same time. From f84341100f85a0685934e97f9b5c58be9af66c6c Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:27:56 -0300 Subject: [PATCH 02/59] docs: record decisions on the four installer open questions EULA text gets rewritten to match LICENSE-SCOPE.md, consent posture B (notice, no hard gate), one legal question still out. macOS distribution deferred: no Apple Developer account yet, so no signed macOS release; install-macos.sh stays. macOS packaging when it ships is a Velopack .pkg, not Parcel. Velopack on .NET 10 confirmed by a local spike for linux-x64 and win-x64 including a delta. --- INSTALLER-PLAN.md | 170 ++++++++++++++++++++++++++++++---------------- 1 file changed, 113 insertions(+), 57 deletions(-) diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index c5b2e1c..8751684 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -102,6 +102,38 @@ safe to retry. - 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. Consent posture is B: a notice on every + platform and in every path, no hard click-through gate. The CLI prints the + notice to stderr and proceeds; the GUI shows it as a panel with a Continue + button, not a checkbox; RiftLauncher surfaces the text once. One legal question + is still out (does local decompilation of a game the user owns, for an + interoperable patch, require click-through consent in the jurisdictions that + matter). If the answer is yes, posture moves to C: a checkbox in the GUI and an + `--acknowledge-decompile` flag on `Optimum.Cli`, refused if absent. Posture B is + the working assumption and the plan is written to it. +- **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 still adds a CI job that + exercises the runtime update path, because the spike packed but did not apply an + update. + ## 3. Architecture Three new projects join `VintageStory.slnx`, all MIT, all .NET 10. @@ -463,6 +495,10 @@ at `:68-74`, the numbered picker at `:117-131`, and the version-mismatch guard a 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 @@ -577,10 +613,11 @@ decision that should not ride along inside an installer rewrite. ## 8. Packaging and distribution of the installer -Velopack 1.2 or newer handles all three platforms with one toolchain and one +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. On macOS it produces a `.pkg` or a `ditto` ZIP and handles -`codesign` and notarization. On Linux its only output format is AppImage. +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 @@ -588,22 +625,23 @@ 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 requires a paid Apple Developer Program membership and notarization -regardless of which tool builds the bundle. `README.md:254` already records the -consequence of skipping it: an unsigned `.dmg` makes Gatekeeper warn and the user -must right-click and Open. That is tolerable for a game package and not tolerable -for an installer, which is exactly the kind of binary a user should refuse to run -when the operating system warns about it. - -Open question, deliberately not decided here: Avalonia Parcel automates the `.app` -bundle, `Info.plist`, signing, notarization, and `.dmg` in one step, but the full -signing feature sits behind a paid Avalonia tier. Velopack does macOS signing and -notarization at no license cost but produces a `.pkg` or a ZIP rather than a -`.dmg`. The cost of choosing Parcel is a recurring Avalonia subscription on top of -the Apple Developer membership. The cost of choosing Velopack is that macOS users -get a `.pkg` where they may expect a `.dmg`, and that the project maintains the -`.dmg` path in `scripts/package-macos.sh` separately for the game package. The -team should decide with the subscription price in hand. +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 @@ -681,9 +719,11 @@ of the gate scripts move to `Optimum.Bootstrap.Core.Tests`. ### Definition of done -1. A real end-to-end install on Linux, Windows, and macOS from a clean machine - that finishes and launches the game into a world, verified by a person, not by - a script. +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. @@ -719,10 +759,14 @@ valid package directory and a conformant stream. 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. -**A release workflow.** Runs `vpk pack` per RID and publishes the Velopack feed. -Signing credentials come from repository secrets. This workflow is the only one -that touches signing, and it should refuse to publish an unsigned macOS artifact -rather than warn about it. +**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. Phase 0 +adds a throwaway job that runs `vpk pack` on a net10.0 hello world and applies the +resulting update, to confirm the runtime path before Phase 5 commits to the +toolchain. ## 11. Rollout plan @@ -765,24 +809,26 @@ restores the previous install, tested on each platform. An install followed by a uninstall leaves no Optimum files and no orphaned shortcuts, verified by a filesystem diff. -**Phase 5: distribution.** Velopack packaging per RID, the release workflow, -signing on Windows and macOS. -*Verification:* a signed installer downloads and runs on a clean machine on each -platform without an operating system warning, and a delta update from the previous -version applies. +**Phase 5: distribution.** Velopack packaging for `win-x64` and `linux-x64`, the +release workflow, signing on Windows. macOS binaries build but do not publish. +*Verification:* a signed Windows installer and a Linux AppImage download and run +on a clean machine without an operating system warning, and a delta update from +the previous version applies. **Phase 6: documentation and deprecation.** Update `README.md`, add the three new project paths to the MIT list in `LICENSE-SCOPE.md`, note the new build and test -targets in `CONTRIBUTING.md`, and turn `scripts/install-linux.sh`, -`scripts/install-windows.ps1`, and `scripts/install-macos.sh` into thin shims that -forward to `Optimum.Cli`. Fix or replace `scripts/uninstall.sh`, which today -cannot uninstall anything: `scripts/uninstall.sh:75` exits 0 unless -`$VS_DIR/Optimum.dll` exists, and the current Linux standalone package contains no -`Optimum.dll`, so the script reports "Optimum not installed" and returns without -even reaching the `.desktop` cleanup at `:122-123`. -*Verification:* a fresh clone documents one install path, the shims work for -anyone with the old commands in their shell history, and `scripts/uninstall.sh` -either removes a real install or is gone. +targets in `CONTRIBUTING.md`, and turn `scripts/install-linux.sh` and +`scripts/install-windows.ps1` into thin shims that forward to `Optimum.Cli`. Leave +`scripts/install-macos.sh` alone: it stays the macOS path until a signed macOS +release exists, so its retirement waits for the Apple account and a later phase. +Fix or replace `scripts/uninstall.sh`, which today cannot uninstall anything: +`scripts/uninstall.sh:75` exits 0 unless `$VS_DIR/Optimum.dll` exists, and the +current Linux standalone package contains no `Optimum.dll`, so the script reports +"Optimum not installed" and returns without even reaching the `.desktop` cleanup +at `:122-123`. +*Verification:* a fresh clone documents one install path per shipped platform, the +shims work for anyone with the old commands in their shell history, and +`scripts/uninstall.sh` either removes a real install or is gone. **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 @@ -797,18 +843,23 @@ 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.** No explicit release note confirming .NET 10 support was -found. Phase 0 should add a throwaway CI job that runs `vpk pack` against a hello -world .NET 10 self-contained app before Phase 5 commits to the toolchain. If it +**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. The spike did not apply an update at runtime, so Phase +0 still adds a CI job that packs two versions and applies the delta. If that fails, the fallback is per-platform packaging with no auto-update, which is what the project has today. -**macOS notarization.** Requires a paid Apple Developer Program membership. The -project needs an account, a certificate, and a place to store the credentials. This -is an administrative dependency with a lead time and it should be started during -Phase 0, not Phase 5. +**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 `.dmg`.** Open, with the cost stated in section 8. +**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 @@ -837,13 +888,16 @@ writes to a `.partial` file and moves it into place on completion 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. -**Is the EULA legally load-bearing?** Today it is shown only in the Windows GUI -path (`scripts/install-windows.ps1:1953`) and skipped in `-Silent`. If acceptance -matters, the CLI path needs an equivalent gate, which means a flag such as -`--accept-license` and a refusal to proceed without it, which in turn means -RiftLauncher has to surface the text and pass the flag. If it does not matter, the -modal is a courtesy and the CLI can omit it. Someone has to decide, and the -decision changes the CLI's argument surface, so it should be made before Phase 2. +**Is the EULA legally load-bearing?** Working answer: no, posture B, a notice +everywhere and no hard gate. The reasoning: the Commons Clause is a redistribution +term and an end user running an installer is not redistributing; RiftLauncher +already downloads the same client from the same CDN without a gate. One question +is still out with a lawyer: does local decompilation of a game the user owns, for +an interoperable patch, need click-through consent in the jurisdictions that +matter. If the answer is yes, posture moves to C, which adds an +`--acknowledge-decompile` flag to `Optimum.Cli` that is refused if absent and +makes RiftLauncher surface the text and pass it. The plan is written to B and the +CLI argument surface in section 4 assumes B. Confirm before Phase 2. **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 @@ -900,7 +954,9 @@ fixup scripts `scripts/fix-base-ctor-calls.py`, `scripts/fix-closure-class.pl`, - `scripts/install-linux.sh` becomes a shim over `Optimum.Cli`. - `scripts/install-windows.ps1` becomes a shim over `Optimum.Cli`. -- `scripts/install-macos.sh` is removed; the overlay model is retired. +- `scripts/install-macos.sh` stays until a signed macOS release exists. Its + removal and the overlay-model retirement wait for the Apple Developer account + and a later phase, not Phase 6. - `scripts/uninstall.sh` is fixed or replaced. See the Phase 6 verification. - `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 From 98b6a91525370f0d5eea6fd31b7455e7a08c0f0b Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:39:03 -0300 Subject: [PATCH 03/59] docs: consent posture C, click-through required for decompilation The legal question resolved: local decompilation of the user's own game needs explicit consent. GUI gates on a checkbox, Optimum.Cli build requires --acknowledge-decompile (refused as bad-input if absent), RiftLauncher renders the text and passes the flag. Updates the engine contract, the CI steps, Phase 2, and Phase 7. --- INSTALLER-PLAN.md | 80 +++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 8751684..e915747 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -109,15 +109,18 @@ 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. Consent posture is B: a notice on every - platform and in every path, no hard click-through gate. The CLI prints the - notice to stderr and proceeds; the GUI shows it as a panel with a Continue - button, not a checkbox; RiftLauncher surfaces the text once. One legal question - is still out (does local decompilation of a game the user owns, for an - interoperable patch, require click-through consent in the jurisdictions that - matter). If the answer is yes, posture moves to C: a checkbox in the GUI and an - `--acknowledge-decompile` flag on `Optimum.Cli`, refused if absent. Posture B is - the working assumption and the plan is written to it. + 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 @@ -224,7 +227,7 @@ resolving it against an ambient working directory. | Verb | Arguments | Effect | | --- | --- | --- | | `preflight` | `[--json]` | Detect prerequisites. No side effects, no writes, no network. | -| `build` | `--output ` `[--client-archive ]` `[--version ]` `[--json]` | Bootstrap, build, and package into `--output`. | +| `build` | `--acknowledge-decompile` `--output ` `[--client-archive ]` `[--version ]` `[--json]` | Bootstrap, build, and package into `--output`. Refuses with `bad-input` if `--acknowledge-decompile` is absent. | | `install` | `--package ` `--install-dir ` `[--data-path ]` `[--shortcuts menu,desktop]` `[--json]` | Transactional deploy, shortcuts, uninstaller registration. | | `validate` | `--package ` `[--json]` | Run the runtime validation described in section 7. | | `uninstall` | `--install-dir ` `[--json]` | Remove an install using its manifest. | @@ -323,6 +326,17 @@ and never follows a symlink out of `--output`. The caller re-validates the outpu 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 @@ -446,7 +460,7 @@ Progress starts, because the build is already writing to disk. | `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 modal | `scripts/install-windows.ps1:1953-2027` | Core EULA resource, Installer modal, and see the open question in section 12 | +| EULA modal | `scripts/install-windows.ps1:1953-2027` | Core EULA resource, Installer modal 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 | @@ -753,11 +767,12 @@ to violate: the moment `Optimum.Bootstrap.Core.Tests` references pull-request gate. **An extension to the platform workflow.** Each of the five jobs gains a step -after the existing build that runs `Optimum.Cli build --json --client-archive -` and pipes the stream into the conformance checker. The step asserts a -valid package directory and a conformant stream. 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. +after the existing build that runs `Optimum.Cli build --json +--acknowledge-decompile --client-archive ` and pipes the stream into the +conformance checker. The step asserts a valid package directory and a conformant +stream. 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. **A release workflow.** Runs `vpk pack` for `win-x64` and `linux-x64` and publishes the Velopack feed. Signing credentials for Windows come from repository @@ -789,10 +804,12 @@ in section 9, and the ported `install-linux-nixos.sh` and behavior regresses. **Phase 2: the CLI.** All seven verbs, wrapping the existing scripts through the -build driver. Contract tests. Extend the platform workflow. -*Verification:* `Optimum.Cli build --json` produces a package on all five platform -jobs and the conformance checker passes on each. A deliberately broken patch -fixture produces `patch-conflict` and exit non-zero. +build driver. The `--acknowledge-decompile` gate on `build`. Contract tests. +Extend the platform workflow. +*Verification:* `Optimum.Cli build --json --acknowledge-decompile` produces a +package on all five platform jobs and the conformance checker passes on each. +`build` without the flag exits non-zero with `bad-input` and does no work. A +deliberately broken patch fixture produces `patch-conflict` and exit non-zero. **Phase 3: the GUI.** All five screens, the state machine, headless tests. Drives Core in-process. At the end of this phase the GUI can do a complete install on the @@ -834,7 +851,10 @@ shims work for anyone with the old commands in their shell history, and 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. +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 @@ -888,16 +908,14 @@ writes to a `.partial` file and moves it into place on completion 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. -**Is the EULA legally load-bearing?** Working answer: no, posture B, a notice -everywhere and no hard gate. The reasoning: the Commons Clause is a redistribution -term and an end user running an installer is not redistributing; RiftLauncher -already downloads the same client from the same CDN without a gate. One question -is still out with a lawyer: does local decompilation of a game the user owns, for -an interoperable patch, need click-through consent in the jurisdictions that -matter. If the answer is yes, posture moves to C, which adds an -`--acknowledge-decompile` flag to `Optimum.Cli` that is refused if absent and -makes RiftLauncher surface the text and pass it. The plan is written to B and the -CLI argument surface in section 4 assumes B. Confirm before Phase 2. +**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 From a7dbc3e8da140cae8a7e08784b7e0c201f0c23a2 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:01:39 -0300 Subject: [PATCH 04/59] build: scaffold the Avalonia installer projects (Phase 0) Six projects under a new /Installer/ folder in VintageStory.slnx, built and tested bootstrap-free via Optimum.Installer.slnf: - Optimum.Bootstrap.Core: ProgressPhase and FailureReason contract types, CoreInfo.Version. CliWrap referenced for the Phase 2 build driver. - Optimum.Cli (AssemblyName 'optimum'): answers --version, exits 2 with usage on anything else. CliRunner split out for testing. - Optimum.Installer: one-window Avalonia 12.1 app, CommunityToolkit.Mvvm, compiled bindings. - Three test projects, 11 tests, one a headless Avalonia render. Installer tests are on xUnit v3 (Avalonia.Headless.XUnit 12.x requires it); the other two stay on v2 like the existing test projects. ci-installer.yml runs the tests and a velopack-smoke job (packs two versions of Optimum.Cli, asserts the delta builds) on push and PR touching installer paths. INSTALLER-PLAN.md section 11 Phase 0 marked done. --- .github/workflows/ci-installer.yml | 83 +++++++++++++++++++ INSTALLER-PLAN.md | 58 +++++++------ .../EngineProtocolTests.cs | 40 +++++++++ .../Optimum.Bootstrap.Core.Tests.csproj | 17 ++++ Optimum.Bootstrap.Core/EngineProtocol.cs | 78 +++++++++++++++++ .../Optimum.Bootstrap.Core.csproj | 15 ++++ Optimum.Cli.Tests/CliRunnerTests.cs | 34 ++++++++ Optimum.Cli.Tests/Optimum.Cli.Tests.csproj | 18 ++++ Optimum.Cli/CliRunner.cs | 28 +++++++ Optimum.Cli/Optimum.Cli.csproj | 20 +++++ Optimum.Cli/Program.cs | 3 + Optimum.Installer.Tests/MainWindowTests.cs | 32 +++++++ .../Optimum.Installer.Tests.csproj | 23 +++++ Optimum.Installer.Tests/TestAppBuilder.cs | 15 ++++ Optimum.Installer.slnf | 13 +++ Optimum.Installer/App.axaml | 8 ++ Optimum.Installer/App.axaml.cs | 25 ++++++ Optimum.Installer/Optimum.Installer.csproj | 27 ++++++ Optimum.Installer/Program.cs | 17 ++++ .../ViewModels/MainWindowViewModel.cs | 14 ++++ Optimum.Installer/Views/MainWindow.axaml | 17 ++++ Optimum.Installer/Views/MainWindow.axaml.cs | 8 ++ Optimum.Installer/app.manifest | 10 +++ VintageStory.slnx | 11 +++ 24 files changed, 590 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci-installer.yml create mode 100644 Optimum.Bootstrap.Core.Tests/EngineProtocolTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj create mode 100644 Optimum.Bootstrap.Core/EngineProtocol.cs create mode 100644 Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj create mode 100644 Optimum.Cli.Tests/CliRunnerTests.cs create mode 100644 Optimum.Cli.Tests/Optimum.Cli.Tests.csproj create mode 100644 Optimum.Cli/CliRunner.cs create mode 100644 Optimum.Cli/Optimum.Cli.csproj create mode 100644 Optimum.Cli/Program.cs create mode 100644 Optimum.Installer.Tests/MainWindowTests.cs create mode 100644 Optimum.Installer.Tests/Optimum.Installer.Tests.csproj create mode 100644 Optimum.Installer.Tests/TestAppBuilder.cs create mode 100644 Optimum.Installer.slnf create mode 100644 Optimum.Installer/App.axaml create mode 100644 Optimum.Installer/App.axaml.cs create mode 100644 Optimum.Installer/Optimum.Installer.csproj create mode 100644 Optimum.Installer/Program.cs create mode 100644 Optimum.Installer/ViewModels/MainWindowViewModel.cs create mode 100644 Optimum.Installer/Views/MainWindow.axaml create mode 100644 Optimum.Installer/Views/MainWindow.axaml.cs create mode 100644 Optimum.Installer/app.manifest diff --git a/.github/workflows/ci-installer.yml b/.github/workflows/ci-installer.yml new file mode 100644 index 0000000..9bac3f0 --- /dev/null +++ b/.github/workflows/ci-installer.yml @@ -0,0 +1,83 @@ +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 + + 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: Install vpk + run: | + dotnet tool install -g vpk + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + - name: Pack 0.0.1, then a delta to 0.0.2 + run: | + set -euo pipefail + pub=$(mktemp -d) + rel=$(mktemp -d) + + dotnet publish Optimum.Cli/Optimum.Cli.csproj -c Release -r linux-x64 \ + --self-contained -o "$pub" --nologo + vpk '[linux]' pack -u OptimumCliSmoke -v 0.0.1 -p "$pub" -e optimum -o "$rel" + + # A no-op source change so the second pack produces a real delta. + date > "$pub/velopack-smoke-marker.txt" + vpk '[linux]' pack -u OptimumCliSmoke -v 0.0.2 -p "$pub" -e optimum -o "$rel" + + ls -la "$rel" + test -f "$rel"/OptimumCliSmoke-0.0.2-linux-delta.nupkg + test -f "$rel"/OptimumCliSmoke-0.0.2-linux-full.nupkg + echo "Velopack 1.x packs a .NET 10 self-contained app and builds a delta." diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index e915747..53c9faa 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -133,9 +133,10 @@ reflect them. - **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 still adds a CI job that - exercises the runtime update path, because the spike packed but did not apply an - update. + `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 @@ -778,22 +779,27 @@ download. 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. Phase 0 -adds a throwaway job that runs `vpk pack` on a net10.0 hello world and applies the -resulting update, to confirm the runtime path before Phase 5 commits to the -toolchain. +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.** Create `Optimum.Bootstrap.Core`, `Optimum.Cli`, -`Optimum.Installer`, and the three test projects. Add them to -`VintageStory.slnx` under a new `/Installer/` folder. Add the push and -pull-request workflow with a placeholder test in each project. -*Verification:* the new workflow is green on a pull request and the run takes under -five minutes. +**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.** The prerequisite model and detection for all three platforms, acquisition, the path guards, the NDJSON emitter, and the EULA @@ -865,10 +871,11 @@ 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. The spike did not apply an update at runtime, so Phase -0 still adds a CI job that packs two versions and applies the delta. If that -fails, the fallback is per-platform packaging with no auto-update, which is what -the project has today. +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 @@ -932,13 +939,16 @@ The new modal should either gate on scroll properly or drop the pretense. ### New - `Optimum.Bootstrap.Core/` (class library, MIT) -- `Optimum.Bootstrap.Core.Tests/` (xUnit) -- `Optimum.Cli/` (console application, MIT) -- `Optimum.Cli.Tests/` (xUnit, NDJSON conformance) -- `Optimum.Installer/` (Avalonia application, MIT) -- `Optimum.Installer.Tests/` (Avalonia.Headless.XUnit) -- `.github/workflows/ci-installer.yml` (push and pull request) -- `.github/workflows/release-installer.yml` (Velopack) +- `Optimum.Bootstrap.Core.Tests/` (xUnit v2) +- `Optimum.Cli/` (console application, MIT, `AssemblyName` `optimum`) +- `Optimum.Cli.Tests/` (xUnit v2, NDJSON conformance in Phase 2) +- `Optimum.Installer/` (Avalonia 12.1 application, MIT) +- `Optimum.Installer.Tests/` (Avalonia.Headless.XUnit, xUnit v3) +- `Optimum.Installer.slnf` (solution filter over the six projects, for a + bootstrap-free build) +- `.github/workflows/ci-installer.yml` (push and pull request: tests plus the + `velopack-smoke` job) +- `.github/workflows/release-installer.yml` (Velopack, Phase 5) - `INSTALLER-PLAN.md` (this file) ### Modified 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/Optimum.Bootstrap.Core.Tests.csproj b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj new file mode 100644 index 0000000..2d80d34 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + diff --git a/Optimum.Bootstrap.Core/EngineProtocol.cs b/Optimum.Bootstrap.Core/EngineProtocol.cs new file mode 100644 index 0000000..759a3d0 --- /dev/null +++ b/Optimum.Bootstrap.Core/EngineProtocol.cs @@ -0,0 +1,78 @@ +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, + 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.Cancelled => "cancelled", + FailureReason.EngineInternal => "engine-internal", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null), + }; +} + +/// 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/Optimum.Bootstrap.Core.csproj b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj new file mode 100644 index 0000000..d486716 --- /dev/null +++ b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj @@ -0,0 +1,15 @@ + + + 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.Cli.Tests/CliRunnerTests.cs b/Optimum.Cli.Tests/CliRunnerTests.cs new file mode 100644 index 0000000..7d1b55f --- /dev/null +++ b/Optimum.Cli.Tests/CliRunnerTests.cs @@ -0,0 +1,34 @@ +using Optimum.Bootstrap.Core; +using Optimum.Cli; +using Xunit; + +namespace Optimum.Cli.Tests; + +public class CliRunnerTests +{ + [Fact] + public void VersionPrintsOnePlainLineAndExitsZero() + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + int code = CliRunner.Run(["--version"], stdout, stderr); + + Assert.Equal(CliRunner.ExitOk, code); + Assert.Equal(CoreInfo.Version, stdout.ToString().Trim()); + Assert.Equal(string.Empty, stderr.ToString()); + } + + [Fact] + public void UnknownInvocationWritesUsageToStderrAndExitsTwo() + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + int code = CliRunner.Run(["frobnicate"], stdout, stderr); + + Assert.Equal(CliRunner.ExitUsage, code); + Assert.Equal(string.Empty, stdout.ToString()); + Assert.Contains("usage: optimum", stderr.ToString()); + } +} diff --git a/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj new file mode 100644 index 0000000..70c551a --- /dev/null +++ b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/Optimum.Cli/CliRunner.cs b/Optimum.Cli/CliRunner.cs new file mode 100644 index 0000000..2efa258 --- /dev/null +++ b/Optimum.Cli/CliRunner.cs @@ -0,0 +1,28 @@ +using Optimum.Bootstrap.Core; + +namespace Optimum.Cli; + +/// +/// The command dispatcher, split from Program so tests drive it with +/// injected writers and without a process boundary. Phase 0 implements only +/// --version; the verbs in INSTALLER-PLAN.md section 4 land in Phase 2. +/// +internal static class CliRunner +{ + public const int ExitOk = 0; + public const int ExitUsage = 2; + + public static int Run(string[] args, TextWriter stdout, TextWriter stderr) + { + if (args.Length == 1 && args[0] == "--version") + { + stdout.WriteLine(CoreInfo.Version); + return ExitOk; + } + + stderr.WriteLine("usage: optimum [--json] [flags]"); + stderr.WriteLine("verbs: preflight, build, install, validate, uninstall, capabilities"); + stderr.WriteLine("(only --version is implemented in this build)"); + return ExitUsage; + } +} 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..ee4e7e7 --- /dev/null +++ b/Optimum.Cli/Program.cs @@ -0,0 +1,3 @@ +using Optimum.Cli; + +return CliRunner.Run(args, Console.Out, Console.Error); diff --git a/Optimum.Installer.Tests/MainWindowTests.cs b/Optimum.Installer.Tests/MainWindowTests.cs new file mode 100644 index 0000000..c95d1b3 --- /dev/null +++ b/Optimum.Installer.Tests/MainWindowTests.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Optimum.Bootstrap.Core; +using Optimum.Installer.ViewModels; +using Optimum.Installer.Views; +using Xunit; + +namespace Optimum.Installer.Tests; + +public class MainWindowViewModelTests +{ + [Fact] + public void TitleCarriesTheCoreVersion() + { + var vm = new MainWindowViewModel(); + Assert.Contains(CoreInfo.Version, vm.Title); + } +} + +public class MainWindowRenderTests +{ + [AvaloniaFact] + public void WindowShowsAndBindsTheTitle() + { + var window = new MainWindow { DataContext = new MainWindowViewModel() }; + window.Show(); + + var title = Assert.IsType( + ((StackPanel)window.Content!).Children[0]); + Assert.Equal("Optimum installer " + CoreInfo.Version, title.Text); + } +} diff --git a/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj b/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj new file mode 100644 index 0000000..c647f19 --- /dev/null +++ b/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + + Exe + + + + + + + + + + + + + diff --git a/Optimum.Installer.Tests/TestAppBuilder.cs b/Optimum.Installer.Tests/TestAppBuilder.cs new file mode 100644 index 0000000..a744a9b --- /dev/null +++ b/Optimum.Installer.Tests/TestAppBuilder.cs @@ -0,0 +1,15 @@ +using Avalonia; +using Avalonia.Headless; +using Optimum.Installer; +using Optimum.Installer.Tests; + +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace Optimum.Installer.Tests; + +public static class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} diff --git a/Optimum.Installer.slnf b/Optimum.Installer.slnf new file mode 100644 index 0000000..869d1a4 --- /dev/null +++ b/Optimum.Installer.slnf @@ -0,0 +1,13 @@ +{ + "solution": { + "path": "VintageStory.slnx", + "projects": [ + "Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj", + "Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj", + "Optimum.Cli/Optimum.Cli.csproj", + "Optimum.Cli.Tests/Optimum.Cli.Tests.csproj", + "Optimum.Installer/Optimum.Installer.csproj", + "Optimum.Installer.Tests/Optimum.Installer.Tests.csproj" + ] + } +} diff --git a/Optimum.Installer/App.axaml b/Optimum.Installer/App.axaml new file mode 100644 index 0000000..d7de85c --- /dev/null +++ b/Optimum.Installer/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/Optimum.Installer/App.axaml.cs b/Optimum.Installer/App.axaml.cs new file mode 100644 index 0000000..ca55304 --- /dev/null +++ b/Optimum.Installer/App.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Optimum.Installer.ViewModels; +using Optimum.Installer.Views; + +namespace Optimum.Installer; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Optimum.Installer/Optimum.Installer.csproj b/Optimum.Installer/Optimum.Installer.csproj new file mode 100644 index 0000000..94158c3 --- /dev/null +++ b/Optimum.Installer/Optimum.Installer.csproj @@ -0,0 +1,27 @@ + + + WinExe + net10.0 + Optimum.Installer + Optimum.Installer + enable + enable + false + true + app.manifest + true + Avalonia GUI for the Optimum installer. Links Optimum.Bootstrap.Core in-process. + $(OptimumVersion) + + + + + + + + + + + + + diff --git a/Optimum.Installer/Program.cs b/Optimum.Installer/Program.cs new file mode 100644 index 0000000..639a784 --- /dev/null +++ b/Optimum.Installer/Program.cs @@ -0,0 +1,17 @@ +using Avalonia; + +namespace Optimum.Installer; + +internal static class Program +{ + // Avalonia configuration, don't remove; also used by the visual designer. + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + [STAThread] + public static int Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); +} diff --git a/Optimum.Installer/ViewModels/MainWindowViewModel.cs b/Optimum.Installer/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..86ffbb4 --- /dev/null +++ b/Optimum.Installer/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,14 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Optimum.Bootstrap.Core; + +namespace Optimum.Installer.ViewModels; + +/// +/// Placeholder shell view model. The five-screen flow in INSTALLER-PLAN.md +/// section 5 lands in Phase 3. +/// +public partial class MainWindowViewModel : ObservableObject +{ + [ObservableProperty] + private string _title = $"Optimum installer {CoreInfo.Version}"; +} diff --git a/Optimum.Installer/Views/MainWindow.axaml b/Optimum.Installer/Views/MainWindow.axaml new file mode 100644 index 0000000..23d7487 --- /dev/null +++ b/Optimum.Installer/Views/MainWindow.axaml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/Optimum.Installer/Views/MainWindow.axaml.cs b/Optimum.Installer/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..ee3df48 --- /dev/null +++ b/Optimum.Installer/Views/MainWindow.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace Optimum.Installer.Views; + +public partial class MainWindow : Window +{ + public MainWindow() => InitializeComponent(); +} diff --git a/Optimum.Installer/app.manifest b/Optimum.Installer/app.manifest new file mode 100644 index 0000000..4b5817c --- /dev/null +++ b/Optimum.Installer/app.manifest @@ -0,0 +1,10 @@ + + + + + + true/pm + permonitorv2, permonitor + + + diff --git a/VintageStory.slnx b/VintageStory.slnx index ca8714b..0867a3b 100644 --- a/VintageStory.slnx +++ b/VintageStory.slnx @@ -28,4 +28,15 @@ + + + + + + + + + From 06d92362428aeec4cb970f6ea8f9d7e4553ca8a7 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:23:05 -0300 Subject: [PATCH 05/59] feat(installer): Core prerequisites, path guards, NDJSON, consent (Phase 1) Optimum.Bootstrap.Core gains its detection and protocol layer behind an ISystemProbe seam so tests run against an in-memory host: - Prerequisites: PrerequisiteScanner over the check-prereqs.sh tool list, DotnetSdkProbe (ports check_dotnet10), ConfigFiles (reads the ilspycmd pin and range from .config/), IlspycmdVersion range check, NixEnvironment (NixOS and non-FHS detection and refusal). pwsh is modelled as required-for-packaging, not required, so a Linux tar.gz build is not blocked by it. - Acquisition: SdkAcquisition plans the dotnet-install invocation with --jsonfile global.json and refuses on NixOS or a non-FHS host; IlspycmdAcquisition emits the exact 'tool update' arguments the shell test asserts. - Paths: InstallPathGuard consolidates guard_install_dir and Assert-SafeInstallerPaths plus a symlink-component walk; every section 9 case is covered. - DataPathProbe: session-aware data-folder detection, ported from prompt_data_path and widened per platform. - NdjsonWriter: the section 4 stream, with monotonic 0-99 progress and a single terminal result enforced. - Licensing: consent-notice.md rewritten to match LICENSE-SCOPE.md; ConsentNotice.AcknowledgeFlag is the CLI's --acknowledge-decompile. 72 Core tests, including the ported install-linux-prerequisites.sh and install-linux-nixos.sh behaviors. Full suite 76 green. --- INSTALLER-PLAN.md | 21 +- .../AcquisitionTests.cs | 46 ++++ .../ConsentNoticeTests.cs | 36 ++++ .../DataPathProbeTests.cs | 42 ++++ .../DotnetSdkProbeTests.cs | 47 ++++ .../FakeSystemProbe.cs | 73 +++++++ .../IlspycmdVersionTests.cs | 64 ++++++ .../InstallPathGuardTests.cs | 105 +++++++++ .../NdjsonWriterTests.cs | 70 ++++++ .../NixEnvironmentTests.cs | 105 +++++++++ .../PrerequisiteScannerTests.cs | 108 ++++++++++ .../SymlinkComponentCheckTests.cs | 33 +++ .../Acquisition/IlspycmdAcquisition.cs | 16 ++ .../Acquisition/SdkAcquisition.cs | 58 +++++ .../DataPath/DataPathProbe.cs | 64 ++++++ .../Licensing/ConsentNotice.cs | 29 +++ .../Licensing/consent-notice.md | 39 ++++ Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs | 113 ++++++++++ .../Optimum.Bootstrap.Core.csproj | 3 + .../Paths/InstallPathGuard.cs | 200 ++++++++++++++++++ .../Paths/SymlinkComponentCheck.cs | 45 ++++ .../Platform/CommandSearch.cs | 29 +++ .../Platform/SystemProbe.cs | 150 +++++++++++++ .../Prerequisites/ConfigFiles.cs | 63 ++++++ .../Prerequisites/DistroPackageHints.cs | 24 +++ .../Prerequisites/DotnetSdkProbe.cs | 84 ++++++++ .../Prerequisites/IlspycmdVersion.cs | 68 ++++++ .../Prerequisites/NixEnvironment.cs | 50 +++++ .../Prerequisites/Prerequisite.cs | 87 ++++++++ .../Prerequisites/PrerequisiteScanner.cs | 195 +++++++++++++++++ 30 files changed, 2060 insertions(+), 7 deletions(-) create mode 100644 Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/ConsentNoticeTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/DataPathProbeTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs create mode 100644 Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/NixEnvironmentTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/SymlinkComponentCheckTests.cs create mode 100644 Optimum.Bootstrap.Core/Acquisition/IlspycmdAcquisition.cs create mode 100644 Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs create mode 100644 Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs create mode 100644 Optimum.Bootstrap.Core/Licensing/ConsentNotice.cs create mode 100644 Optimum.Bootstrap.Core/Licensing/consent-notice.md create mode 100644 Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs create mode 100644 Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs create mode 100644 Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs create mode 100644 Optimum.Bootstrap.Core/Platform/CommandSearch.cs create mode 100644 Optimum.Bootstrap.Core/Platform/SystemProbe.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/ConfigFiles.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/DistroPackageHints.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/NixEnvironment.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/Prerequisite.cs create mode 100644 Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 53c9faa..a311de2 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -801,13 +801,20 @@ on push and pull request. tests, one a headless Avalonia render) in about three seconds locally; the workflow is expected green under five minutes. -**Phase 1: Core fundamentals.** The prerequisite model and detection for all three -platforms, acquisition, the path guards, the NDJSON emitter, and the EULA -resource. No build driver yet. -*Verification:* `Optimum.Bootstrap.Core.Tests` covers every path-guard case listed -in section 9, and the ported `install-linux-nixos.sh` and -`install-linux-prerequisites.sh` behaviors have C# equivalents that fail when the -behavior regresses. +**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 72 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`. `dotnet test +Optimum.Installer.slnf -c Release` is green (76 tests, about five seconds). **Phase 2: the CLI.** All seven verbs, wrapping the existing scripts through the build driver. The `--acknowledge-decompile` gate on `build`. Contract tests. diff --git a/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs new file mode 100644 index 0000000..9aa081f --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs @@ -0,0 +1,46 @@ +using Optimum.Bootstrap.Core.Acquisition; +using Xunit; + +namespace Optimum.Bootstrap.Core.Tests; + +public class AcquisitionTests +{ + [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/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/DotnetSdkProbeTests.cs b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs new file mode 100644 index 0000000..fa7c62d --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs @@ -0,0 +1,47 @@ +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)); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs new file mode 100644 index 0000000..42b585e --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs @@ -0,0 +1,73 @@ +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(); + 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 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.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); + + 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.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..6ddea2b --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs @@ -0,0 +1,105 @@ +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 RejectsAPathThatPassesThroughASymlink() + { + FakeSystemProbe probe = Linux(); + probe.AddSymlink("/home/tester/link"); + AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/link/opt")), "symbolic link"); + } + + [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..562ba9e --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs @@ -0,0 +1,70 @@ +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"); + + JsonElement[] lines = Parse(sw.ToString()); + Assert.Equal(4, lines.Length); + Assert.Equal(10, lines[0].GetProperty("progress").GetInt32()); + Assert.Equal("decompile", lines[0].GetProperty("phase").GetString()); + Assert.Equal(10, lines[1].GetProperty("progress").GetInt32()); + Assert.Equal(99, lines[2].GetProperty("progress").GetInt32()); + Assert.Equal("assemble", lines[2].GetProperty("phase").GetString()); + } + + [Fact] + public void TheTerminalResultIsTheLastLineAndCarriesTheKebabReason() + { + var sw = new StringWriter(); + var writer = new NdjsonWriter(sw); + + writer.Log(NdjsonLevel.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(NdjsonLevel.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..e261c9c --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/NixEnvironmentTests.cs @@ -0,0 +1,105 @@ +using System.Runtime.InteropServices; +using Optimum.Bootstrap.Core.Acquisition; +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)); + } + + [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/PrerequisiteScannerTests.cs b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs new file mode 100644 index 0000000..b82bc06 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs @@ -0,0 +1,108 @@ +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 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)); + } +} 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/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/SdkAcquisition.cs b/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs new file mode 100644 index 0000000..dfa3085 --- /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 ? "pwsh" : "bash", + args, + installDir); + + return new Decision(true, null, plan); + } +} 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/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..f0e3673 --- /dev/null +++ b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs @@ -0,0 +1,113 @@ +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. +/// +public sealed class NdjsonWriter(TextWriter output) +{ + private static readonly JsonWriterOptions WriterOptions = new() { Indented = false }; + + private int _lastPercent; + private bool _resultWritten; + + public bool ResultWritten => _resultWritten; + + public void Progress(ProgressPhase phase, int percent, string detail) + { + GuardOpen(); + int clamped = Math.Clamp(percent, _lastPercent, BootstrapProgress.MaxEnginePercent); + _lastPercent = clamped; + Write(writer => + { + writer.WriteString("type", "progress"); + writer.WriteString("phase", WirePhase(phase)); + writer.WriteNumber("progress", clamped); + writer.WriteString("detail", detail); + }); + } + + public void Log(NdjsonLevel level, string message) + { + GuardOpen(); + Write(writer => + { + writer.WriteString("type", "log"); + writer.WriteString("level", level switch + { + NdjsonLevel.Info => "info", + NdjsonLevel.Warn => "warn", + NdjsonLevel.Error => "error", + _ => "info", + }); + writer.WriteString("message", 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 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", + }; +} + +public enum NdjsonLevel +{ + Info, + Warn, + Error, +} diff --git a/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj index d486716..b5a4115 100644 --- a/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj +++ b/Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj @@ -12,4 +12,7 @@ + + + diff --git a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs new file mode 100644 index 0000000..6dac264 --- /dev/null +++ b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs @@ -0,0 +1,200 @@ +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}."); + } + + if (SymlinkComponentCheck.FirstSymlinkComponent(probe, install) is { } link) + return InstallPathVerdict.Reject($"The install path passes through a symbolic link: {link}"); + + 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 (SymlinkComponentCheck.FirstSymlinkComponent(probe, data) is { } dataLink) + return InstallPathVerdict.Reject($"The data path passes through a symbolic link: {dataLink}"); + 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..670edd1 --- /dev/null +++ b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs @@ -0,0 +1,45 @@ +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 reject the path if any component is a +/// symbolic link. A symlink anywhere in an install or data path is a way for a +/// later step to write outside the directory the user chose. +/// +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..a6299e4 --- /dev/null +++ b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs @@ -0,0 +1,29 @@ +namespace Optimum.Bootstrap.Core.Platform; + +/// +/// The C# equivalent of command -v: look for an executable on the probe's +/// PATH. On Windows it also tries the usual executable extensions. +/// +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.FileExists(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/SystemProbe.cs b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs new file mode 100644 index 0000000..a53d5f1 --- /dev/null +++ b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs @@ -0,0 +1,150 @@ +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 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); + + /// + /// 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 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 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; + + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + if (!process.WaitForExit(timeout)) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return ProcessOutcome.NotStarted; + } + return new ProcessOutcome(true, process.ExitCode, stdout, stderr); + } + 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..f8bd5ed --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs @@ -0,0 +1,84 @@ +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 +/// (colon-separated) 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.FileExists(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) + { + foreach (string line in listSdksOutput.Split('\n')) + { + if (line.TrimStart().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)) + { + foreach (string entry in overrideList.Split(':', 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..a5925b7 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/NixEnvironment.cs @@ -0,0 +1,50 @@ +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. +/// +public static class NixEnvironment +{ + public const string DotnetSdkInstallCommand = "nix profile install nixpkgs#dotnet-sdk_10"; + + public static bool IsNixOs(ISystemProbe probe) => + 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) + { + 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..99f1cb4 --- /dev/null +++ b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs @@ -0,0 +1,195 @@ +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Prerequisites; + +/// +/// Detects every tool the bootstrap and packaging scripts need. The tool list is +/// the one in scripts/check-prereqs.sh; 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); + + private static readonly PrerequisiteDefinition[] Definitions = + [ + 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)"), + ]; + + 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), + _ => 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)); + } + + 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") + ?? ExistingOrNull(Path.Combine(repoRoot, ".tools", "appimagetool")) + ?? ExistingOrNull(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? 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] : 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; + } +} From 930c7fd82bfab8fdbf689999b3db52f36a9de8f8 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:24:13 -0300 Subject: [PATCH 06/59] fix(installer): drain probe stdout and stderr concurrently The synchronous ReadToEnd pair in SystemProbe.Run could deadlock a probe whose child filled one pipe buffer while the reader blocked on the other. --- Optimum.Bootstrap.Core/Platform/SystemProbe.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs index a53d5f1..341a751 100644 --- a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs +++ b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs @@ -129,14 +129,18 @@ public ProcessOutcome Run(string executable, IReadOnlyList arguments, Ti if (process is null) return ProcessOutcome.NotStarted; - string stdout = process.StandardOutput.ReadToEnd(); - string stderr = process.StandardError.ReadToEnd(); - if (!process.WaitForExit(timeout)) + // 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, stdout, stderr); + + 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) { From 0d6e7dcfb1cc683c8015a19ef231c887690f1fa7 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:35:33 -0300 Subject: [PATCH 07/59] fix(installer): tighten Core detection and path guard after review An adversarial pass against the shell sources found three divergences worth closing: - command -v equivalent now checks the execute bit and continues past a non-executable file of the right name, matching the shell. Added ISystemProbe.IsExecutable; DotnetSdkProbe gates on it. - InstallPathGuard rejects a symlinked install or data directory itself, not any symlinked ancestor. A home or mount point that is a symlink is normal and was being refused. The full assertNoSymlinkComponents walk stays in SymlinkComponentCheck for paths with a trusted base. - NdjsonWriter emits a warn log and counts it when it adjusts a caller's progress value, so an engine-side miscalculation is not silent. Also: HasNet10Line anchors at column 0 like the shell grep, and the ilspycmd version token is trimmed. 79 tests green. --- INSTALLER-PLAN.md | 31 ++++++++++---- .../DotnetSdkProbeTests.cs | 13 ++++++ .../FakeSystemProbe.cs | 12 ++++++ .../InstallPathGuardTests.cs | 14 +++++-- .../NdjsonWriterTests.cs | 29 +++++++++---- Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs | 41 +++++++++++++------ .../Paths/InstallPathGuard.cs | 13 ++++-- .../Paths/SymlinkComponentCheck.cs | 9 ++-- .../Platform/CommandSearch.cs | 8 ++-- .../Platform/SystemProbe.cs | 22 ++++++++++ .../Prerequisites/DotnetSdkProbe.cs | 5 ++- .../Prerequisites/PrerequisiteScanner.cs | 2 +- 12 files changed, 156 insertions(+), 43 deletions(-) diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index a311de2..8e8a350 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -187,9 +187,15 @@ against a build that takes twenty minutes and allocates gigabytes. - 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`), `Assert-SafeInstallerPaths` - (`scripts/install-windows.ps1:152`), and a symlink-component walk equivalent to - RiftLauncher's `assertNoSymlinkComponents`. + (`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 @@ -285,6 +291,12 @@ short enough that a stalled build is distinguishable from a slow one. A build th 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 @@ -675,7 +687,7 @@ 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 path with a symlink component. The transactional installer, +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 @@ -810,11 +822,16 @@ detection (`DataPathProbe`), the NDJSON emitter (`NdjsonWriter`), and the consen 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 72 tests covering every +*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`. `dotnet test -Optimum.Installer.slnf -c Release` is green (76 tests, about five seconds). +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.** All seven verbs, wrapping the existing scripts through the build driver. The `--acknowledge-decompile` gate on `build`. Contract tests. diff --git a/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs index fa7c62d..1a1afc4 100644 --- a/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs +++ b/Optimum.Bootstrap.Core.Tests/DotnetSdkProbeTests.cs @@ -44,4 +44,17 @@ public void PrefersDotnetOnPathBeforeTheCandidateList() 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/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs index 42b585e..2a253bd 100644 --- a/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs +++ b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs @@ -15,6 +15,9 @@ public sealed class FakeSystemProbe : ISystemProbe 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 . @@ -40,6 +43,13 @@ public FakeSystemProbe AddSymlink(string 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); @@ -53,6 +63,8 @@ public FakeSystemProbe OnCommand(string exe, string args, string stdout = "", in 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) => diff --git a/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs b/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs index 6ddea2b..d4f5b8d 100644 --- a/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs +++ b/Optimum.Bootstrap.Core.Tests/InstallPathGuardTests.cs @@ -64,11 +64,19 @@ public void RejectsADirectoryHoldingAVanillaGameWithNoOptimumMarker() } [Fact] - public void RejectsAPathThatPassesThroughASymlink() + public void RejectsAnInstallDirectoryThatIsItselfASymlink() { FakeSystemProbe probe = Linux(); - probe.AddSymlink("/home/tester/link"); - AssertRejected(InstallPathGuard.Check(probe, new InstallPathRequest("/home/tester/link/opt")), "symbolic link"); + 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] diff --git a/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs index 562ba9e..b9f82eb 100644 --- a/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs +++ b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs @@ -23,13 +23,28 @@ public void ProgressIsMonotonicAndCappedAt99() writer.Progress(ProgressPhase.Assemble, 250, "c"); // over the ceiling, held at 99 writer.Success("/out/Optimum-v0.3.14-linux-x64"); - JsonElement[] lines = Parse(sw.ToString()); - Assert.Equal(4, lines.Length); - Assert.Equal(10, lines[0].GetProperty("progress").GetInt32()); - Assert.Equal("decompile", lines[0].GetProperty("phase").GetString()); - Assert.Equal(10, lines[1].GetProperty("progress").GetInt32()); - Assert.Equal(99, lines[2].GetProperty("progress").GetInt32()); - Assert.Equal("assemble", lines[2].GetProperty("phase").GetString()); + 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] diff --git a/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs index f0e3673..8e59111 100644 --- a/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs +++ b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs @@ -6,7 +6,9 @@ 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. +/// 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) { @@ -17,10 +19,21 @@ public sealed class NdjsonWriter(TextWriter output) 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(NdjsonLevel.Warn, + $"progress {percent} for phase {WirePhase(phase)} adjusted to {clamped}: it must be monotonic and in 0 to 99"); + } + _lastPercent = clamped; Write(writer => { @@ -34,18 +47,7 @@ public void Progress(ProgressPhase phase, int percent, string detail) public void Log(NdjsonLevel level, string message) { GuardOpen(); - Write(writer => - { - writer.WriteString("type", "log"); - writer.WriteString("level", level switch - { - NdjsonLevel.Info => "info", - NdjsonLevel.Warn => "warn", - NdjsonLevel.Error => "error", - _ => "info", - }); - writer.WriteString("message", message); - }); + WriteLog(level, message); } public void Success(string runtimePath) @@ -73,6 +75,19 @@ public void Failure(FailureReason reason, string message) }); } + private void WriteLog(NdjsonLevel level, string message) => Write(writer => + { + writer.WriteString("type", "log"); + writer.WriteString("level", level switch + { + NdjsonLevel.Info => "info", + NdjsonLevel.Warn => "warn", + NdjsonLevel.Error => "error", + _ => "info", + }); + writer.WriteString("message", message); + }); + private void GuardOpen() { if (_resultWritten) diff --git a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs index 6dac264..e9f1a2d 100644 --- a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs +++ b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs @@ -46,8 +46,13 @@ public static InstallPathVerdict Check(ISystemProbe probe, InstallPathRequest re return InstallPathVerdict.Reject($"The install directory cannot be {reserved}."); } - if (SymlinkComponentCheck.FirstSymlinkComponent(probe, install) is { } link) - return InstallPathVerdict.Reject($"The install path passes through a symbolic link: {link}"); + // 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)) { @@ -72,8 +77,8 @@ public static InstallPathVerdict Check(ISystemProbe probe, InstallPathRequest re if (request.DataPath is { } dataRaw && !string.IsNullOrWhiteSpace(dataRaw)) { string data = Canonical(probe, dataRaw); - if (SymlinkComponentCheck.FirstSymlinkComponent(probe, data) is { } dataLink) - return InstallPathVerdict.Reject($"The data path passes through a symbolic link: {dataLink}"); + 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)) diff --git a/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs index 670edd1..dc755c1 100644 --- a/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs +++ b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs @@ -4,9 +4,12 @@ namespace Optimum.Bootstrap.Core.Paths; /// /// Ports RiftLauncher's assertNoSymlinkComponents: walk every existing -/// component of a path up to the root and reject the path if any component is a -/// symbolic link. A symlink anywhere in an install or data path is a way for a -/// later step to write outside the directory the user chose. +/// 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 { diff --git a/Optimum.Bootstrap.Core/Platform/CommandSearch.cs b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs index a6299e4..03345c4 100644 --- a/Optimum.Bootstrap.Core/Platform/CommandSearch.cs +++ b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs @@ -1,8 +1,10 @@ namespace Optimum.Bootstrap.Core.Platform; /// -/// The C# equivalent of command -v: look for an executable on the probe's -/// PATH. On Windows it also tries the usual executable extensions. +/// 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 { @@ -17,7 +19,7 @@ public static class CommandSearch foreach (string name in names) { string candidate = Path.Combine(dir, name); - if (probe.FileExists(candidate)) + if (probe.IsExecutable(candidate)) return candidate; } } diff --git a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs index 341a751..f118508 100644 --- a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs +++ b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs @@ -33,6 +33,12 @@ public interface ISystemProbe /// 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); @@ -77,6 +83,22 @@ public sealed class SystemProbe : ISystemProbe 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); diff --git a/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs b/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs index f8bd5ed..1976c95 100644 --- a/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs +++ b/Optimum.Bootstrap.Core/Prerequisites/DotnetSdkProbe.cs @@ -19,7 +19,7 @@ public static class DotnetSdkProbe { foreach (string candidate in Candidates(probe)) { - if (!probe.FileExists(candidate)) + if (!probe.IsExecutable(candidate)) continue; ProcessOutcome outcome = probe.Run(candidate, ["--list-sdks"], ProbeTimeout); if (outcome.Started && HasNet10Line(outcome.StandardOutput)) @@ -31,9 +31,10 @@ public static class DotnetSdkProbe 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.TrimStart().StartsWith("10.", StringComparison.Ordinal)) + if (line.StartsWith("10.", StringComparison.Ordinal)) return true; } diff --git a/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs index 99f1cb4..42e75fc 100644 --- a/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs +++ b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs @@ -171,7 +171,7 @@ private PrerequisiteResult Missing(PrerequisiteDefinition def, AcquisitionKind a 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] : null; + return tokens.Length >= 2 ? tokens[1].Trim() : null; } public static (int major, int minor)? ParseInnoextractVersion(string output) From 500b1a0383b965c7ba47779e5e744dd7b5d06238 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:52:56 -0300 Subject: [PATCH 08/59] feat(installer): the seven Optimum.Cli verbs and the build driver (Phase 2) Core build layer: - ScriptBuildDriver drives scripts/bootstrap.*, dotnet build VintageStory.slnx, and the platform packaging script through CliWrap, one ProgressPhase per step, with per-step FailureReason mapping. BootstrapFailureClassifier splits a failed bootstrap into patch-conflict vs decompile-failed. - Capabilities reads forks.json and the patches-*-bridge dirs; PackageLayout, InstallManifest, PackageDeployer (guard + copy + manifest, non-transactional; the stage/backup/rollback is Phase 4), Uninstaller (manifest-driven), and a header-only RuntimeValidator. Optimum.Cli: preflight, build, install, validate, uninstall, capabilities, --version. build requires --acknowledge-decompile. --json emits the section 4 NDJSON stream via NdjsonWriter; plain text otherwise. SIGTERM/SIGINT cancel a build to a cancelled result. EngineOutput bridges the two modes. Tests: 12 CLI tests including the NDJSON contract check (NdjsonStream, twin of scripts/check-ndjson-stream.py), patch-conflict and cancelled reasons, the consent gate, and path-absoluteness. 95 Core tests (deploy/uninstall round trips on real temp dirs). CI: ci-installer.yml gains a cli-contract job; ci-platform-bootstrap.yml's linux job runs Optimum.Cli build end to end plus validate, timeout raised to 60m. 109 tests green. --- .github/workflows/ci-installer.yml | 35 ++ .github/workflows/ci-platform-bootstrap.yml | 17 +- INSTALLER-PLAN.md | 47 ++- .../BuildLayerTests.cs | 133 ++++++++ .../DeployRoundTripTests.cs | 116 +++++++ .../FakeSystemProbe.cs | 14 +- .../NdjsonWriterTests.cs | 4 +- .../Build/BootstrapFailureClassifier.cs | 22 ++ Optimum.Bootstrap.Core/Build/BuildDriver.cs | 49 +++ Optimum.Bootstrap.Core/Build/Capabilities.cs | 59 ++++ .../Build/ScriptBuildDriver.cs | 192 +++++++++++ Optimum.Bootstrap.Core/EngineProtocol.cs | 8 + .../Install/InstallManifest.cs | 47 +++ .../Install/PackageDeployer.cs | 155 +++++++++ .../Install/PackageLayout.cs | 39 +++ .../Install/RuntimeValidator.cs | 54 ++++ Optimum.Bootstrap.Core/Install/Uninstaller.cs | 61 ++++ Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs | 19 +- .../Platform/SystemProbe.cs | 11 + Optimum.Cli.Tests/CliRunnerTests.cs | 176 ++++++++++- Optimum.Cli.Tests/FakeBuildDriver.cs | 29 ++ Optimum.Cli.Tests/NdjsonStream.cs | 83 +++++ Optimum.Cli.Tests/Optimum.Cli.Tests.csproj | 2 + Optimum.Cli/CliArgs.cs | 47 +++ Optimum.Cli/CliRunner.cs | 298 +++++++++++++++++- Optimum.Cli/EngineOutput.cs | 59 ++++ Optimum.Cli/Program.cs | 2 +- scripts/check-ndjson-stream.py | 74 +++++ 28 files changed, 1798 insertions(+), 54 deletions(-) create mode 100644 Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs create mode 100644 Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs create mode 100644 Optimum.Bootstrap.Core/Build/BootstrapFailureClassifier.cs create mode 100644 Optimum.Bootstrap.Core/Build/BuildDriver.cs create mode 100644 Optimum.Bootstrap.Core/Build/Capabilities.cs create mode 100644 Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs create mode 100644 Optimum.Bootstrap.Core/Install/InstallManifest.cs create mode 100644 Optimum.Bootstrap.Core/Install/PackageDeployer.cs create mode 100644 Optimum.Bootstrap.Core/Install/PackageLayout.cs create mode 100644 Optimum.Bootstrap.Core/Install/RuntimeValidator.cs create mode 100644 Optimum.Bootstrap.Core/Install/Uninstaller.cs create mode 100644 Optimum.Cli.Tests/FakeBuildDriver.cs create mode 100644 Optimum.Cli.Tests/NdjsonStream.cs create mode 100644 Optimum.Cli/CliArgs.cs create mode 100644 Optimum.Cli/EngineOutput.cs create mode 100755 scripts/check-ndjson-stream.py diff --git a/.github/workflows/ci-installer.yml b/.github/workflows/ci-installer.yml index 9bac3f0..b439afd 100644 --- a/.github/workflows/ci-installer.yml +++ b/.github/workflows/ci-installer.yml @@ -46,6 +46,41 @@ jobs: - 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 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/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 8e8a350..1326bf9 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -779,13 +779,16 @@ 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.** Each of the five jobs gains a step -after the existing build that runs `Optimum.Cli build --json ---acknowledge-decompile --client-archive ` and pipes the stream into the -conformance checker. The step asserts a valid package directory and a conformant -stream. 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. +**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 @@ -833,13 +836,24 @@ 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.** All seven verbs, wrapping the existing scripts through the -build driver. The `--acknowledge-decompile` gate on `build`. Contract tests. -Extend the platform workflow. -*Verification:* `Optimum.Cli build --json --acknowledge-decompile` produces a -package on all five platform jobs and the conformance checker passes on each. -`build` without the flag exits non-zero with `bad-input` and does no work. A -deliberately broken patch fixture produces `patch-conflict` and exit non-zero. +**Phase 2: the CLI.** Done. The seven verbs are in `Optimum.Cli` over a Core +build layer: `ScriptBuildDriver` drives `scripts/bootstrap.*`, `dotnet build +VintageStory.slnx`, and the platform packaging script through CliWrap, mapping +each step to a `ProgressPhase` and a `FailureReason` +(`BootstrapFailureClassifier` splits a failed bootstrap into `patch-conflict` and +`decompile-failed`). `build` requires `--acknowledge-decompile` and refuses +without it. `install` runs the Phase 1 path guard then a straight copy plus an +`InstallManifest`; `uninstall` reverses it by that manifest; `validate` reads the +staged assemblies' headers; `capabilities` and `preflight` answer as JSON. 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 12 tests including the NDJSON contract +against a scripted driver, the `patch-conflict` and `cancelled` reasons, and the +no-flag gate. `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 and pipes it through +`check-ndjson-stream.py`, then `Optimum.Cli validate` on the produced package. The +other four platform jobs get the same step incrementally. **Phase 3: the GUI.** All five screens, the state machine, headless tests. Drives Core in-process. At the end of this phase the GUI can do a complete install on the @@ -970,8 +984,9 @@ The new modal should either gate on scroll properly or drop the pretense. - `Optimum.Installer.Tests/` (Avalonia.Headless.XUnit, xUnit v3) - `Optimum.Installer.slnf` (solution filter over the six projects, for a bootstrap-free build) -- `.github/workflows/ci-installer.yml` (push and pull request: tests plus the - `velopack-smoke` job) +- `.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, Phase 5) - `INSTALLER-PLAN.md` (this file) diff --git a/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs new file mode 100644 index 0000000..6d42f62 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs @@ -0,0 +1,133 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +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 +{ + [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); + } +} diff --git a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs new file mode 100644 index 0000000..b34afe2 --- /dev/null +++ b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs @@ -0,0 +1,116 @@ +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 DeployReplacesAnExistingOptimumInstall() + { + 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-file"), "old"); + + Assert.True(new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)).Ok); + Assert.False(File.Exists(Path.Combine(installDir, "stale-file"))); + } + + [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 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/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs index 2a253bd..b7d3149 100644 --- a/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs +++ b/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs @@ -76,7 +76,19 @@ bool ISystemProbe.PathExists(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); + 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) diff --git a/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs index b9f82eb..16b1f52 100644 --- a/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs +++ b/Optimum.Bootstrap.Core.Tests/NdjsonWriterTests.cs @@ -53,7 +53,7 @@ public void TheTerminalResultIsTheLastLineAndCarriesTheKebabReason() var sw = new StringWriter(); var writer = new NdjsonWriter(sw); - writer.Log(NdjsonLevel.Warn, "innoextract not present"); + writer.Log(LogLevel.Warn, "innoextract not present"); writer.Failure(FailureReason.PatchConflict, "patches/vsapi/0007 did not apply"); JsonElement[] lines = Parse(sw.ToString()); @@ -69,7 +69,7 @@ public void WritingAfterTheResultThrows() { var writer = new NdjsonWriter(new StringWriter()); writer.Success("/out"); - Assert.Throws(() => writer.Log(NdjsonLevel.Info, "too late")); + Assert.Throws(() => writer.Log(LogLevel.Info, "too late")); Assert.Throws(() => writer.Progress(ProgressPhase.Verify, 50, "too late")); } 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..40ac3ac --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/BuildDriver.cs @@ -0,0 +1,49 @@ +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. +/// +public interface IBuildDriver +{ + Task RunAsync(BuildRequest request, IBuildObserver observer, CancellationToken cancellationToken); +} + +/// 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/ScriptBuildDriver.cs b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs new file mode 100644 index 0000000..8e15f32 --- /dev/null +++ b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs @@ -0,0 +1,192 @@ +using System.Text; +using CliWrap; +using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Bootstrap.Core.Build; + +/// +/// The real build pipeline: it drives scripts/bootstrap.*, +/// dotnet build VintageStory.slnx, 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 cancellationToken) + { + 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)); + + if (probe.DirectoryExists(request.OutputDirectory) + && probe.EnumerateFiles(request.OutputDirectory, "*").Any()) + { + return BuildResult.Failure(FailureReason.OutputExists, + $"The output directory is not empty: {request.OutputDirectory}"); + } + + Directory.CreateDirectory(request.OutputDirectory); + + try + { + StepOutcome bootstrap = await RunStep( + BootstrapCommand(request), request.RepoRoot, ProgressPhase.Decompile, 2, 50, observer, + clearPlatformEnv: false, cancellationToken); + if (!bootstrap.Ok) + { + return BuildResult.Failure( + BootstrapFailureClassifier.Classify(bootstrap.Output), + $"bootstrap exited {bootstrap.ExitCode}"); + } + + observer.Phase(ProgressPhase.Patch, 52, "patches applied"); + + StepOutcome build = await RunStep( + (DotnetExecutable(), ["build", "VintageStory.slnx", "-c", "Release", "--nologo"]), + request.RepoRoot, ProgressPhase.Assemble, 55, 85, observer, + clearPlatformEnv: true, cancellationToken); + if (!build.Ok) + return BuildResult.Failure(FailureReason.AssembleFailed, $"dotnet build exited {build.ExitCode}"); + + StepOutcome package = await RunStep( + PackageCommand(request), request.RepoRoot, ProgressPhase.Assemble, 85, 96, observer, + clearPlatformEnv: false, cancellationToken); + 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 directory under {request.OutputDirectory}"); + + observer.Phase(ProgressPhase.Verify, 98, "package produced"); + return BuildResult.Success(produced); + } + catch (OperationCanceledException) + { + TryClean(request.OutputDirectory); + return BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); + } + } + + private (string Exe, IReadOnlyList Args) BootstrapCommand(BuildRequest request) + { + var args = new List(); + if (probe.Os == OsKind.Windows) + { + args.AddRange(["-File", "scripts/bootstrap.ps1"]); + args.AddRange(["-ClientArchive", request.ClientArchive ?? "__skip__"]); + if (request.Version is not null) + args.AddRange(["-Version", request.Version]); + return ("pwsh", args); + } + + args.Add("scripts/bootstrap.sh"); + if (request.ClientArchive is not null) + args.AddRange(["--client-archive", request.ClientArchive]); + if (request.Version is not null) + args.AddRange(["--version", request.Version]); + return ("bash", args); + } + + private (string Exe, IReadOnlyList Args) PackageCommand(BuildRequest request) + { + string output = request.OutputDirectory; + switch (probe.Os) + { + case OsKind.Windows: + return ("pwsh", ["-File", "scripts/package.ps1", "-OutputDir", output]); + 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.Version is not null) mac.AddRange(["--version", request.Version]); + return ("bash", mac); + default: + List linux = ["scripts/package-linux.sh", "--output", output]; + if (request.Version is not null) linux.AddRange(["--version", request.Version]); + return ("bash", linux); + } + } + + private string DotnetExecutable() => DotnetSdkProbe.Find(probe) ?? "dotnet"; + + private static string? LocatePackage(string outputDirectory) + { + if (!Directory.Exists(outputDirectory)) + return 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 cancellationToken) + { + 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)); + + await foreach (CommandEvent commandEvent in cmd.ListenAsync(cancellationToken)) + { + 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; + } + } + + 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]; + + private static void TryClean(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + 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/EngineProtocol.cs b/Optimum.Bootstrap.Core/EngineProtocol.cs index 759a3d0..c2e19c2 100644 --- a/Optimum.Bootstrap.Core/EngineProtocol.cs +++ b/Optimum.Bootstrap.Core/EngineProtocol.cs @@ -61,6 +61,14 @@ public static class FailureReasonExtensions }; } +/// 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 { diff --git a/Optimum.Bootstrap.Core/Install/InstallManifest.cs b/Optimum.Bootstrap.Core/Install/InstallManifest.cs new file mode 100644 index 0000000..ef33428 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/InstallManifest.cs @@ -0,0 +1,47 @@ +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; } + + 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..d684156 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs @@ -0,0 +1,155 @@ +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 to a chosen directory and records an +/// . Phase 2 does a straight copy after the path +/// guard clears; the stage, backup, and rollback dance from +/// Install-StagedPackage lands in Phase 4. +/// +public sealed class PackageDeployer(ISystemProbe probe) +{ + 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); + + if (Directory.Exists(installDir) && Directory.EnumerateFileSystemEntries(installDir).Any()) + { + string manifestPath = Path.Combine(installDir, InstallManifest.RelativePath); + if (!File.Exists(manifestPath)) + return DeployResult.Failure(FailureReason.OutputExists, + $"the install directory is not empty and carries no Optimum manifest: {installDir}"); + observer?.Log(LogLevel.Info, "replacing an existing Optimum install"); + Directory.Delete(installDir, recursive: true); + } + + Directory.CreateDirectory(installDir); + var entries = new List(); + foreach (string entry in Directory.EnumerateFileSystemEntries(request.PackageDirectory)) + { + string name = Path.GetFileName(entry); + entries.Add(name); + string target = Path.Combine(installDir, name); + if (Directory.Exists(entry)) + CopyDirectory(entry, target); + else + File.Copy(entry, target, overwrite: true); + } + + MakeExecutable(Path.Combine(installDir, "Optimum")); + MakeExecutable(Path.Combine(installDir, "run.sh")); + + string? launcher = WriteLauncher(installDir, request.DataPath); + if (launcher is not null && !entries.Contains(Path.GetFileName(launcher))) + entries.Add(Path.GetFileName(launcher)); + if (request.DataPath is not null) + entries.Add("datapath.cfg"); + + string version = probe.ReadText(Path.Combine(request.PackageDirectory, ".optimum", "version"))?.Trim() + ?? "dev"; + + var manifest = new InstallManifest + { + OptimumVersion = version, + InstalledAtUtc = DateTimeOffset.UtcNow, + InstallDirectory = installDir, + DataPath = request.DataPath, + Launcher = launcher, + Entries = entries.Distinct().OrderBy(e => e, StringComparer.Ordinal).ToArray(), + }; + Directory.CreateDirectory(Path.Combine(installDir, ".optimum")); + File.WriteAllText(Path.Combine(installDir, InstallManifest.RelativePath), manifest.Serialize()); + + return DeployResult.Success(installDir, launcher); + } + + private string? WriteLauncher(string installDir, string? dataPath) + { + if (probe.Os == OsKind.Windows) + { + string cmd = Path.Combine(installDir, "optimum-launch.cmd"); + string body = 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"; + File.WriteAllText(cmd, body); + if (dataPath is not null) + { + Directory.CreateDirectory(dataPath); + File.WriteAllText(Path.Combine(installDir, "datapath.cfg"), dataPath); + } + return cmd; + } + + string sh = Path.Combine(installDir, "optimum-launch.sh"); + string script = 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"; + File.WriteAllText(sh, script); + MakeExecutable(sh); + if (dataPath is not null) + { + Directory.CreateDirectory(dataPath); + File.WriteAllText(Path.Combine(installDir, "datapath.cfg"), dataPath); + } + return sh; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'"; + + 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(dir.Replace(source, destination, StringComparison.Ordinal)); + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + File.Copy(file, file.Replace(source, destination, StringComparison.Ordinal), overwrite: true); + } +} 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..4ee95b7 --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/RuntimeValidator.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Bootstrap.Core.Install; + +public sealed record RuntimeValidationResult(bool Ok, string? Detail); + +/// +/// A conservative check that a staged package is a complete runtime: the layout +/// holds, the patched engine assemblies exist and are non-empty, and each parses +/// as a managed assembly. It reads assembly headers without loading game code +/// into this process. The full JIT probe from Optimum.exe --validate-only +/// is a Phase 4 decision (see INSTALLER-PLAN.md section 7). +/// +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 new RuntimeValidationResult(true, null); + } +} diff --git a/Optimum.Bootstrap.Core/Install/Uninstaller.cs b/Optimum.Bootstrap.Core/Install/Uninstaller.cs new file mode 100644 index 0000000..90419ad --- /dev/null +++ b/Optimum.Bootstrap.Core/Install/Uninstaller.cs @@ -0,0 +1,61 @@ +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 never touches a +/// directory that has no manifest, so it cannot delete a directory it did not +/// create. +/// +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}"); + + InstallManifest? manifest = InstallManifest.Deserialize(json); + if (manifest is null) + return UninstallResult.Failure(FailureReason.BadInput, $"the install manifest is unreadable: {manifestPath}"); + + int removed = 0; + foreach (string entry in manifest.Entries) + { + string target = Path.Combine(installDir, entry); + if (Directory.Exists(target)) + { + Directory.Delete(target, recursive: true); + removed++; + } + else if (File.Exists(target)) + { + File.Delete(target); + removed++; + } + } + + string optimumDir = Path.Combine(installDir, ".optimum"); + if (Directory.Exists(optimumDir)) + { + Directory.Delete(optimumDir, recursive: true); + removed++; + } + + if (Directory.Exists(installDir) && !Directory.EnumerateFileSystemEntries(installDir).Any()) + Directory.Delete(installDir); + + return UninstallResult.Success(removed); + } +} diff --git a/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs index 8e59111..eda8c2d 100644 --- a/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs +++ b/Optimum.Bootstrap.Core/Ndjson/NdjsonWriter.cs @@ -30,7 +30,7 @@ public void Progress(ProgressPhase phase, int percent, string detail) if (clamped != percent) { AnomalyCount++; - WriteLog(NdjsonLevel.Warn, + WriteLog(LogLevel.Warn, $"progress {percent} for phase {WirePhase(phase)} adjusted to {clamped}: it must be monotonic and in 0 to 99"); } @@ -44,7 +44,7 @@ public void Progress(ProgressPhase phase, int percent, string detail) }); } - public void Log(NdjsonLevel level, string message) + public void Log(LogLevel level, string message) { GuardOpen(); WriteLog(level, message); @@ -75,14 +75,14 @@ public void Failure(FailureReason reason, string message) }); } - private void WriteLog(NdjsonLevel level, string message) => Write(writer => + private void WriteLog(LogLevel level, string message) => Write(writer => { writer.WriteString("type", "log"); writer.WriteString("level", level switch { - NdjsonLevel.Info => "info", - NdjsonLevel.Warn => "warn", - NdjsonLevel.Error => "error", + LogLevel.Info => "info", + LogLevel.Warn => "warn", + LogLevel.Error => "error", _ => "info", }); writer.WriteString("message", message); @@ -119,10 +119,3 @@ private void Write(Action body) _ => "assemble", }; } - -public enum NdjsonLevel -{ - Info, - Warn, - Error, -} diff --git a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs index f118508..198d05c 100644 --- a/Optimum.Bootstrap.Core/Platform/SystemProbe.cs +++ b/Optimum.Bootstrap.Core/Platform/SystemProbe.cs @@ -52,6 +52,8 @@ public interface ISystemProbe 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 @@ -132,6 +134,15 @@ public IEnumerable EnumerateFiles(string directory, string searchPattern 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) diff --git a/Optimum.Cli.Tests/CliRunnerTests.cs b/Optimum.Cli.Tests/CliRunnerTests.cs index 7d1b55f..17e917e 100644 --- a/Optimum.Cli.Tests/CliRunnerTests.cs +++ b/Optimum.Cli.Tests/CliRunnerTests.cs @@ -1,4 +1,7 @@ using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Tests; using Optimum.Cli; using Xunit; @@ -6,29 +9,180 @@ namespace Optimum.Cli.Tests; public class CliRunnerTests { - [Fact] - public void VersionPrintsOnePlainLineAndExitsZero() + private static async Task<(int Code, string Stdout, string Stderr)> Run( + string[] args, ISystemProbe? probe = null, IBuildDriver? driver = null) { var stdout = new StringWriter(); var stderr = new StringWriter(); + int code = await CliRunner.RunAsync( + args, stdout, stderr, + probe ?? new FakeSystemProbe(), + driver ?? new FakeBuildDriver()); + return (code, stdout.ToString(), stderr.ToString()); + } + + [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); + } - int code = CliRunner.Run(["--version"], stdout, 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); - Assert.Equal(CoreInfo.Version, stdout.ToString().Trim()); - Assert.Equal(string.Empty, stderr.ToString()); + NdjsonStream stream = NdjsonStream.Parse(stdout); + stream.AssertContract(); + Assert.True(stream.Terminal.GetProperty("ok").GetBoolean()); } [Fact] - public void UnknownInvocationWritesUsageToStderrAndExitsTwo() + public async Task BuildJsonFailurePropagatesTheKebabReasonAndExitsNonZero() { - var stdout = new StringWriter(); - var stderr = new StringWriter(); + 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 BuildCancellationYieldsCancelled() + { + var probe = RepoProbe(); + var driver = new FakeBuildDriver + { + Behaviour = (_, token) => + { + token.ThrowIfCancellationRequested(); + throw new OperationCanceledException(); + }, + }; + driver.Behaviour = (_, _) => throw new OperationCanceledException(); + + 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); + Assert.Equal("cancelled", stream.Terminal.GetProperty("reason").GetString()); + } + + [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"); + } - int code = CliRunner.Run(["frobnicate"], stdout, stderr); + [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.Equal(string.Empty, stdout.ToString()); - Assert.Contains("usage: optimum", stderr.ToString()); + 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); + } + } + + 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..7b2ac60 --- /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 cancellationToken) + { + WasRun = true; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(observer, cancellationToken)); + } +} 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 index 70c551a..bab5b20 100644 --- a/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj +++ b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj @@ -14,5 +14,7 @@ + + 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 index 2efa258..bc429ac 100644 --- a/Optimum.Cli/CliRunner.cs +++ b/Optimum.Cli/CliRunner.cs @@ -1,28 +1,308 @@ +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; /// -/// The command dispatcher, split from Program so tests drive it with -/// injected writers and without a process boundary. Phase 0 implements only -/// --version; the verbs in INSTALLER-PLAN.md section 4 land in Phase 2. +/// 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. /// -internal static class CliRunner +public static class CliRunner { public const int ExitOk = 0; + public const int ExitError = 1; public const int ExitUsage = 2; - public static int Run(string[] args, TextWriter stdout, TextWriter stderr) + 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) { - if (args.Length == 1 && args[0] == "--version") + if (args.Count == 1 && args[0] == "--version") { stdout.WriteLine(CoreInfo.Version); return ExitOk; } - stderr.WriteLine("usage: optimum [--json] [flags]"); - stderr.WriteLine("verbs: preflight, build, install, validate, uninstall, capabilities"); - stderr.WriteLine("(only --version is implemented in this build)"); + 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 = new CancellationTokenSource(); + 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, 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, + EngineOutput output, + CancellationToken cancellationToken) + { + var parsed = new CliArgs(args, new HashSet + { + "--output", "--client-archive", "--version", "--repo-root", + }); + 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? 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"); + + var request = new BuildRequest(repoRoot, Path.GetFullPath(outputDir), clientArchive, parsed.Get("--version")); + + BuildResult result; + try + { + result = await driver.RunAsync(request, output, cancellationToken); + } + 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) + { + 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; + } + + 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 ]"); + 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/Program.cs b/Optimum.Cli/Program.cs index ee4e7e7..fdc48e0 100644 --- a/Optimum.Cli/Program.cs +++ b/Optimum.Cli/Program.cs @@ -1,3 +1,3 @@ using Optimum.Cli; -return CliRunner.Run(args, Console.Out, Console.Error); +return await CliRunner.RunAsync(args, Console.Out, Console.Error); diff --git a/scripts/check-ndjson-stream.py b/scripts/check-ndjson-stream.py new file mode 100755 index 0000000..5cdf905 --- /dev/null +++ b/scripts/check-ndjson-stream.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Validate an Optimum engine NDJSON stream on stdin against INSTALLER-PLAN.md +section 4. Exits non-zero with a message on the first violation. The C# twin is +Optimum.Cli.Tests/NdjsonStream.cs; keep them in step.""" +import json +import sys + +KNOWN_TYPES = {"progress", "log", "result"} +KNOWN_PHASES = {"decompile", "patch", "verify", "assemble"} +KNOWN_LEVELS = {"info", "warn", "error"} +KNOWN_REASONS = { + "bad-input", "unsupported-version", "patch-conflict", "decompile-failed", + "assemble-failed", "verification-failed", "output-exists", "cancelled", + "engine-internal", +} + + +def fail(message): + print(f"NDJSON contract violation: {message}", file=sys.stderr) + sys.exit(1) + + +def main(): + lines = [line for line in sys.stdin.read().split("\n") if line] + if not lines: + fail("the stream is empty") + + last_progress = 0 + result_count = 0 + for index, raw in enumerate(lines): + try: + obj = json.loads(raw) + except json.JSONDecodeError as error: + fail(f"line {index + 1} is not JSON ({error}): {raw!r}") + if not isinstance(obj, dict): + fail(f"line {index + 1} is not an object") + + kind = obj.get("type") + if kind not in KNOWN_TYPES: + fail(f"line {index + 1} has unknown type {kind!r}") + + if kind == "progress": + if obj.get("phase") not in KNOWN_PHASES: + fail(f"line {index + 1} has unknown phase {obj.get('phase')!r}") + progress = obj.get("progress") + if not isinstance(progress, int) or not (last_progress <= progress <= 99): + fail(f"line {index + 1} progress {progress} is out of range or decreased from {last_progress}") + last_progress = progress + elif kind == "log": + if obj.get("level") not in KNOWN_LEVELS: + fail(f"line {index + 1} has unknown level {obj.get('level')!r}") + elif kind == "result": + result_count += 1 + if index != len(lines) - 1: + fail("the result line is not the last line") + if obj.get("ok") is True: + if not obj.get("runtimePath"): + fail("an ok result has no runtimePath") + elif obj.get("ok") is False: + if obj.get("reason") not in KNOWN_REASONS: + fail(f"a failed result has unknown reason {obj.get('reason')!r}") + if not obj.get("message"): + fail("a failed result has no message") + else: + fail("a result line has no boolean ok field") + + if result_count != 1: + fail(f"expected exactly one result line, saw {result_count}") + + print(f"NDJSON stream ok: {len(lines)} lines, terminal result present") + + +if __name__ == "__main__": + main() From 87620d02750ee395cce65ba9b799ef29e9642f12 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:08:35 -0300 Subject: [PATCH 09/59] fix(installer): close Phase 2 review findings Adversarial review of the CLI and build driver surfaced: - macOS build always failed: LocatePackage only looked for an Optimum-v* directory, but package-macos.sh produces Optimum.app. Now per-platform. - SIGTERM cleanup deleted the whole --output tree. The output guard now refuses any non-empty directory (files or subdirs), and cancellation removes only what the build wrote, keeping a pre-existing empty dir. - --client-archive was not forwarded to the packaging step, so it re-downloaded ~500 MB. Now forwarded to bootstrap and packaging both. - Windows build injected -ClientArchive __skip__ when no archive was given, which makes bootstrap.ps1 throw. Now the flag is omitted so bootstrap.ps1 downloads. - check-patches.sh --strict-unavailable now runs between build and package, matching CI; a header-level RuntimeValidator runs on the produced package. - Uninstaller confines manifest entries to the install directory; a rooted or ../ entry is refused, not followed. - install refuses a non-empty target rather than deleting it. In-place replace with rollback stays Phase 4. - Manifest version comes from the Optimum-v package name. - CopyDirectory uses Path.GetRelativePath, not string replace. - --repo-root documented in the contract; query verbs (preflight, capabilities) documented as single-document, not stream. - Cancellation test now drives a real cancelled token through CliRunner. 113 tests green. --- INSTALLER-PLAN.md | 83 ++++++++++----- .../BuildLayerTests.cs | 29 +++++ .../DeployRoundTripTests.cs | 48 ++++++++- .../Build/ScriptBuildDriver.cs | 100 +++++++++++++----- .../Install/PackageDeployer.cs | 47 +++++--- Optimum.Bootstrap.Core/Install/Uninstaller.cs | 27 ++++- Optimum.Cli.Tests/CliRunnerTests.cs | 33 ++++-- Optimum.Cli/CliRunner.cs | 5 +- 8 files changed, 290 insertions(+), 82 deletions(-) diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 1326bf9..9e38642 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -229,16 +229,23 @@ binary before spawning and refuse to run it if it is a symlink. All path argumen 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` need 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 +`. A caller that spawns the engine from outside a checkout must pass +`--repo-root`. This dependency goes away only when the scripts are ported into +Core, which is out of scope for the current plan (section 2). + ### Verbs | Verb | Arguments | Effect | | --- | --- | --- | -| `preflight` | `[--json]` | Detect prerequisites. No side effects, no writes, no network. | -| `build` | `--acknowledge-decompile` `--output ` `[--client-archive ]` `[--version ]` `[--json]` | Bootstrap, build, and package into `--output`. Refuses with `bad-input` if `--acknowledge-decompile` is absent. | -| `install` | `--package ` `--install-dir ` `[--data-path ]` `[--shortcuts menu,desktop]` `[--json]` | Transactional deploy, shortcuts, uninstaller registration. | +| `preflight` | `[--repo-root ]` `[--json]` | Detect prerequisites. No side effects, no writes, no network. | +| `build` | `--acknowledge-decompile` `--output ` `[--client-archive ]` `[--version ]` `[--repo-root ]` `[--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. | +| `install` | `--package ` `--install-dir ` `[--data-path ]` `[--shortcuts menu,desktop]` `[--json]` | Deploy into an empty or absent `--install-dir` and write an install manifest. Phase 2 refuses a non-empty directory; the in-place backup-and-rollback replace is Phase 4. | | `validate` | `--package ` `[--json]` | Run the runtime validation described in section 7. | -| `uninstall` | `--install-dir ` `[--json]` | Remove an install using its manifest. | -| `capabilities` | `--json` | Report supported game versions and patch set ids. | +| `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 @@ -246,9 +253,13 @@ scripting, and for CI. ### NDJSON schema -With `--json`, stdout carries one JSON object per line and nothing else. Without -`--json`, stdout carries human-readable text and the schema does not apply. stderr -is always free-form human log and callers must not parse it. +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: @@ -324,11 +335,14 @@ 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 the engine stops the current phase, removes whatever it wrote under -`--output`, 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`. On Windows the equivalent is -`CancelKeyPress` plus a job-object kill from the caller. +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 @@ -837,23 +851,34 @@ emits a `warn` and counts it when it has to adjust a caller's progress value. seconds). **Phase 2: the CLI.** Done. The seven verbs are in `Optimum.Cli` over a Core -build layer: `ScriptBuildDriver` drives `scripts/bootstrap.*`, `dotnet build -VintageStory.slnx`, and the platform packaging script through CliWrap, mapping -each step to a `ProgressPhase` and a `FailureReason` +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`). `build` requires `--acknowledge-decompile` and refuses -without it. `install` runs the Phase 1 path guard then a straight copy plus an -`InstallManifest`; `uninstall` reverses it by that manifest; `validate` reads the -staged assemblies' headers; `capabilities` and `preflight` answer as JSON. 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 12 tests including the NDJSON contract -against a scripted driver, the `patch-conflict` and `cancelled` reasons, and the -no-flag gate. `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 and pipes it through -`check-ndjson-stream.py`, then `Optimum.Cli validate` on the produced package. The -other four platform jobs get the same step incrementally. +`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.** All five screens, the state machine, headless tests. Drives Core in-process. At the end of this phase the GUI can do a complete install on the diff --git a/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs index 6d42f62..ffa49bf 100644 --- a/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs +++ b/Optimum.Bootstrap.Core.Tests/BuildLayerTests.cs @@ -116,6 +116,21 @@ public void EverythingElseClassifiesAsDecompileFailed(string 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() { @@ -130,4 +145,18 @@ public async Task RefusesWithBadInputWhenRequiredToolsAreMissing() 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); + } } diff --git a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs index b34afe2..52f0b0d 100644 --- a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs +++ b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs @@ -74,17 +74,59 @@ public void DeployRefusesANonEmptyDirectoryWithNoManifest() } [Fact] - public void DeployReplacesAnExistingOptimumInstall() + public void DeployRefusesToReplaceAnExistingOptimumInstall() { 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-file"), "old"); + + DeployResult second = new PackageDeployer(probe).Deploy(new DeployRequest(package, installDir)); + + Assert.False(second.Ok); + Assert.Equal(FailureReason.OutputExists, second.Reason); + Assert.Contains("uninstall", second.Message); + } + + [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); - Assert.False(File.Exists(Path.Combine(installDir, "stale-file"))); + + 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] diff --git a/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs index 8e15f32..3055d90 100644 --- a/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs +++ b/Optimum.Bootstrap.Core/Build/ScriptBuildDriver.cs @@ -1,6 +1,7 @@ using System.Text; using CliWrap; using CliWrap.EventStream; +using Optimum.Bootstrap.Core.Install; using Optimum.Bootstrap.Core.Platform; using Optimum.Bootstrap.Core.Prerequisites; @@ -8,9 +9,10 @@ namespace Optimum.Bootstrap.Core.Build; /// /// The real build pipeline: it drives scripts/bootstrap.*, -/// dotnet build VintageStory.slnx, and the platform packaging script -/// through CliWrap, the same sequence .github/workflows/ci-platform-bootstrap.yml -/// runs by hand. It never reimplements those scripts. +/// 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 { @@ -21,11 +23,13 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs if (missing.Length > 0) return BuildResult.Failure(FailureReason.BadInput, "Required tools missing: " + string.Join(", ", missing)); - if (probe.DirectoryExists(request.OutputDirectory) - && probe.EnumerateFiles(request.OutputDirectory, "*").Any()) + 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 is not empty: {request.OutputDirectory}"); + $"The output directory must be empty or absent: {request.OutputDirectory}"); } Directory.CreateDirectory(request.OutputDirectory); @@ -33,7 +37,7 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs try { StepOutcome bootstrap = await RunStep( - BootstrapCommand(request), request.RepoRoot, ProgressPhase.Decompile, 2, 50, observer, + BootstrapCommand(request), request.RepoRoot, ProgressPhase.Decompile, 2, 48, observer, clearPlatformEnv: false, cancellationToken); if (!bootstrap.Ok) { @@ -42,17 +46,25 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs $"bootstrap exited {bootstrap.ExitCode}"); } - observer.Phase(ProgressPhase.Patch, 52, "patches applied"); + observer.Phase(ProgressPhase.Patch, 50, "patches applied"); StepOutcome build = await RunStep( (DotnetExecutable(), ["build", "VintageStory.slnx", "-c", "Release", "--nologo"]), - request.RepoRoot, ProgressPhase.Assemble, 55, 85, observer, + request.RepoRoot, ProgressPhase.Assemble, 52, 82, observer, clearPlatformEnv: true, cancellationToken); if (!build.Ok) return BuildResult.Failure(FailureReason.AssembleFailed, $"dotnet build exited {build.ExitCode}"); + StepOutcome checkPatches = await RunStep( + ("bash", ["scripts/check-patches.sh", "--strict-unavailable"]), + request.RepoRoot, ProgressPhase.Patch, 82, 86, observer, + clearPlatformEnv: false, cancellationToken); + 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, 85, 96, observer, + PackageCommand(request), request.RepoRoot, ProgressPhase.Assemble, 86, 95, observer, clearPlatformEnv: false, cancellationToken); if (!package.Ok) return BuildResult.Failure(FailureReason.AssembleFailed, $"packaging exited {package.ExitCode}"); @@ -60,36 +72,41 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs string? produced = LocatePackage(request.OutputDirectory); if (produced is null) return BuildResult.Failure(FailureReason.AssembleFailed, - $"the packaging script produced no package directory under {request.OutputDirectory}"); + $"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) { - TryClean(request.OutputDirectory); + CleanOutput(request.OutputDirectory, outputPreexisted); return BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); } } private (string Exe, IReadOnlyList Args) BootstrapCommand(BuildRequest request) { - var args = new List(); if (probe.Os == OsKind.Windows) { - args.AddRange(["-File", "scripts/bootstrap.ps1"]); - args.AddRange(["-ClientArchive", request.ClientArchive ?? "__skip__"]); + List win = ["-File", "scripts/bootstrap.ps1"]; + if (request.ClientArchive is not null) + win.AddRange(["-ClientArchive", request.ClientArchive]); if (request.Version is not null) - args.AddRange(["-Version", request.Version]); - return ("pwsh", args); + win.AddRange(["-Version", request.Version]); + return ("pwsh", win); } - args.Add("scripts/bootstrap.sh"); + List unix = ["scripts/bootstrap.sh"]; if (request.ClientArchive is not null) - args.AddRange(["--client-archive", request.ClientArchive]); + unix.AddRange(["--client-archive", request.ClientArchive]); if (request.Version is not null) - args.AddRange(["--version", request.Version]); - return ("bash", args); + unix.AddRange(["--version", request.Version]); + return ("bash", unix); } private (string Exe, IReadOnlyList Args) PackageCommand(BuildRequest request) @@ -98,14 +115,18 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs switch (probe.Os) { case OsKind.Windows: - return ("pwsh", ["-File", "scripts/package.ps1", "-OutputDir", output]); + List win = ["-File", "scripts/package.ps1", "-OutputDir", output]; + if (request.ClientArchive is not null) win.AddRange(["-ClientArchive", request.ClientArchive]); + return ("pwsh", 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); } @@ -113,10 +134,22 @@ public async Task RunAsync(BuildRequest request, IBuildObserver obs private string DotnetExecutable() => DotnetSdkProbe.Find(probe) ?? "dotnet"; - private static string? LocatePackage(string outputDirectory) + /// + /// 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) @@ -177,12 +210,29 @@ private async Task RunStep( private static string Trim(string line) => line.Length <= 120 ? line : line[..120]; - private static void TryClean(string directory) + /// + /// 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)) + 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 */ } diff --git a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs index d684156..46ef489 100644 --- a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs +++ b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs @@ -27,10 +27,11 @@ public static DeployResult Success(string installDirectory, string? launcher) => } /// -/// Deploys a staged package to a chosen directory and records an +/// Deploys a staged package to an empty or absent directory and records an /// . Phase 2 does a straight copy after the path -/// guard clears; the stage, backup, and rollback dance from -/// Install-StagedPackage lands in Phase 4. +/// guard clears and refuses to touch a directory that already holds anything; +/// replacing an existing install in place, with a backup and rollback, is Phase +/// 4. To reinstall now, run uninstall first. /// public sealed class PackageDeployer(ISystemProbe probe) { @@ -50,14 +51,13 @@ public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = nul if (Directory.Exists(installDir) && Directory.EnumerateFileSystemEntries(installDir).Any()) { - string manifestPath = Path.Combine(installDir, InstallManifest.RelativePath); - if (!File.Exists(manifestPath)) - return DeployResult.Failure(FailureReason.OutputExists, - $"the install directory is not empty and carries no Optimum manifest: {installDir}"); - observer?.Log(LogLevel.Info, "replacing an existing Optimum install"); - Directory.Delete(installDir, recursive: true); + bool isOptimumInstall = File.Exists(Path.Combine(installDir, InstallManifest.RelativePath)); + return DeployResult.Failure(FailureReason.OutputExists, isOptimumInstall + ? $"an Optimum install already exists at {installDir}. Run `optimum uninstall --install-dir {installDir}` first." + : $"the install directory is not empty: {installDir}"); } + observer?.Log(LogLevel.Info, $"deploying {Path.GetFileName(request.PackageDirectory)} to {installDir}"); Directory.CreateDirectory(installDir); var entries = new List(); foreach (string entry in Directory.EnumerateFileSystemEntries(request.PackageDirectory)) @@ -80,8 +80,7 @@ public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = nul if (request.DataPath is not null) entries.Add("datapath.cfg"); - string version = probe.ReadText(Path.Combine(request.PackageDirectory, ".optimum", "version"))?.Trim() - ?? "dev"; + string version = ResolveVersion(probe, request.PackageDirectory); var manifest = new InstallManifest { @@ -131,6 +130,28 @@ public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = nul private static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'"; + /// + /// The package version, from the Optimum-v<version>-<rid> directory + /// name the packaging scripts produce, falling back to a .optimum/version + /// file and then to dev. + /// + 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)) @@ -148,8 +169,8 @@ private static void CopyDirectory(string source, string destination) { Directory.CreateDirectory(destination); foreach (string dir in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) - Directory.CreateDirectory(dir.Replace(source, destination, StringComparison.Ordinal)); + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, dir))); foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) - File.Copy(file, file.Replace(source, destination, StringComparison.Ordinal), overwrite: true); + File.Copy(file, Path.Combine(destination, Path.GetRelativePath(source, file)), overwrite: true); } } diff --git a/Optimum.Bootstrap.Core/Install/Uninstaller.cs b/Optimum.Bootstrap.Core/Install/Uninstaller.cs index 90419ad..ce57cc5 100644 --- a/Optimum.Bootstrap.Core/Install/Uninstaller.cs +++ b/Optimum.Bootstrap.Core/Install/Uninstaller.cs @@ -10,9 +10,10 @@ public sealed record UninstallResult(bool Ok, FailureReason? Reason, string? Mes } /// -/// Removes an install by its . It never touches a -/// directory that has no manifest, so it cannot delete a directory it did not -/// create. +/// Removes an install by its . It refuses a +/// directory with no manifest, and it removes only manifest entries that resolve +/// inside the install directory, so a tampered manifest cannot make it delete +/// something elsewhere. /// public sealed class Uninstaller(ISystemProbe probe) { @@ -30,10 +31,22 @@ public UninstallResult Uninstall(string installDirectory) if (manifest is null) return UninstallResult.Failure(FailureReason.BadInput, $"the install manifest is unreadable: {manifestPath}"); + string prefix = installDir + Path.DirectorySeparatorChar; int removed = 0; + var skipped = new List(); foreach (string entry in manifest.Entries) { - string target = Path.Combine(installDir, entry); + string target = Path.GetFullPath(Path.Combine(installDir, entry)); + if (target != installDir && !target.StartsWith(prefix, StringComparison.Ordinal)) + { + // A manifest entry that resolves outside the install directory + // (a rooted path, a `..` walk) is never removed. The deployer + // only ever writes leaf names, so this guards against a tampered + // or malformed manifest. + skipped.Add(entry); + continue; + } + if (Directory.Exists(target)) { Directory.Delete(target, recursive: true); @@ -46,6 +59,12 @@ public UninstallResult Uninstall(string installDirectory) } } + if (skipped.Count > 0) + { + return new UninstallResult(false, FailureReason.BadInput, + "the manifest names entries outside the install directory: " + string.Join(", ", skipped), removed); + } + string optimumDir = Path.Combine(installDir, ".optimum"); if (Directory.Exists(optimumDir)) { diff --git a/Optimum.Cli.Tests/CliRunnerTests.cs b/Optimum.Cli.Tests/CliRunnerTests.cs index 17e917e..ef47a91 100644 --- a/Optimum.Cli.Tests/CliRunnerTests.cs +++ b/Optimum.Cli.Tests/CliRunnerTests.cs @@ -10,14 +10,15 @@ 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) + string[] args, ISystemProbe? probe = null, IBuildDriver? driver = null, CancellationToken cancel = default) { var stdout = new StringWriter(); var stderr = new StringWriter(); int code = await CliRunner.RunAsync( args, stdout, stderr, probe ?? new FakeSystemProbe(), - driver ?? new FakeBuildDriver()); + driver ?? new FakeBuildDriver(), + cancel); return (code, stdout.ToString(), stderr.ToString()); } @@ -108,28 +109,48 @@ public async Task BuildJsonFailurePropagatesTheKebabReasonAndExitsNonZero() } [Fact] - public async Task BuildCancellationYieldsCancelled() + 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(); - throw new OperationCanceledException(); + return BuildResult.Success("/should/not/reach"); }, }; - driver.Behaviour = (_, _) => throw new OperationCanceledException(); + + 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); + 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() { diff --git a/Optimum.Cli/CliRunner.cs b/Optimum.Cli/CliRunner.cs index bc429ac..b994a8f 100644 --- a/Optimum.Cli/CliRunner.cs +++ b/Optimum.Cli/CliRunner.cs @@ -30,7 +30,8 @@ public static async Task RunAsync( TextWriter stdout, TextWriter stderr, ISystemProbe probe, - IBuildDriver buildDriver) + IBuildDriver buildDriver, + CancellationToken externalCancellation = default) { if (args.Count == 1 && args[0] == "--version") { @@ -48,7 +49,7 @@ public static async Task RunAsync( var rest = args.Skip(1).ToArray(); bool json = rest.Contains("--json"); - using var cancellation = new CancellationTokenSource(); + 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) From b8b1db98e3878f1948cd058970a3e4eb8eee7cf6 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:20:11 -0300 Subject: [PATCH 10/59] feat(installer): the Avalonia wizard GUI (Phase 3) Optimum.Installer is an Avalonia 12 MVVM app that drives Core in-process. - MainWindowViewModel: the wizard shell and state machine (Prerequisites, Options, a mandatory EULA modal, Progress, Completion). Backward nav only Options -> Prerequisites, blocked once Progress starts. Screen view models raise the transition they want; the shell decides. - PrerequisitesViewModel over PrerequisiteScanner, gates Continue on BlocksBuild; reports repo-root-missing when run outside a checkout. - OptionsViewModel: per-platform default install dir, InstallPathGuard on every change into an inline error, DataPathProbe pickup, a version selector only when Capabilities reports a bridge set. - EulaViewModel: accept gated on scroll-to-end plus a checkbox. - ProgressViewModel: its own IBuildObserver; runs ScriptBuildDriver then PackageDeployer under one bar with an honest time estimate; filters raw subprocess lines through InstallerLogFilter (ported from install-windows.ps1). - CompletionViewModel: Launch, Try again, View log. - ViewLocator resolves view models to views; five screen views plus the EULA view, minimal Fluent styling. FakeSystemProbe moved to a plain Optimum.Bootstrap.Core.TestSupport project so the xUnit v2 and v3 test projects can share it without the v2/v3 FactAttribute collision. Optimum.Installer.Tests: 27 tests (25 plain xUnit v3 view-model tests, two Avalonia.Headless render tests), no xvfb. 138 tests green overall. --- INSTALLER-PLAN.md | 35 +++- .../FakeSystemProbe.cs | 0 .../Optimum.Bootstrap.Core.TestSupport.csproj | 13 ++ .../Optimum.Bootstrap.Core.Tests.csproj | 1 + Optimum.Bootstrap.Core/Build/RepoRoot.cs | 28 +++ .../Install/PackageDeployer.cs | 2 +- .../Install/PackageInstaller.cs | 9 + Optimum.Cli.Tests/Optimum.Cli.Tests.csproj | 3 +- Optimum.Cli/CliRunner.cs | 13 +- Optimum.Installer.Tests/Fakes.cs | 71 +++++++ .../MainWindowRenderTests.cs | 35 ++++ Optimum.Installer.Tests/MainWindowTests.cs | 32 --- .../Optimum.Installer.Tests.csproj | 1 + .../ScreenViewModelTests.cs | 185 ++++++++++++++++++ .../WizardStateMachineTests.cs | 120 ++++++++++++ Optimum.Installer.slnf | 1 + Optimum.Installer/App.axaml | 4 + Optimum.Installer/App.axaml.cs | 3 +- Optimum.Installer/Optimum.Installer.csproj | 3 + .../Services/InstallerLogFilter.cs | 24 +++ .../Services/InstallerServices.cs | 33 ++++ Optimum.Installer/ViewLocator.cs | 26 +++ .../ViewModels/CompletionViewModel.cs | 45 +++++ Optimum.Installer/ViewModels/EulaViewModel.cs | 43 ++++ .../ViewModels/InstallSession.cs | 19 ++ .../ViewModels/MainWindowViewModel.cs | 118 ++++++++++- .../ViewModels/OptionsViewModel.cs | 121 ++++++++++++ .../ViewModels/PrerequisitesViewModel.cs | 88 +++++++++ .../ViewModels/ProgressViewModel.cs | 164 ++++++++++++++++ Optimum.Installer/ViewModels/ViewModelBase.cs | 5 + Optimum.Installer/Views/CompletionView.axaml | 25 +++ .../Views/CompletionView.axaml.cs | 9 + Optimum.Installer/Views/EulaView.axaml | 26 +++ Optimum.Installer/Views/EulaView.axaml.cs | 23 +++ Optimum.Installer/Views/MainWindow.axaml | 29 ++- Optimum.Installer/Views/OptionsView.axaml | 42 ++++ Optimum.Installer/Views/OptionsView.axaml.cs | 9 + .../Views/PrerequisitesView.axaml | 36 ++++ .../Views/PrerequisitesView.axaml.cs | 9 + Optimum.Installer/Views/ProgressView.axaml | 38 ++++ Optimum.Installer/Views/ProgressView.axaml.cs | 9 + VintageStory.slnx | 1 + 42 files changed, 1431 insertions(+), 70 deletions(-) rename {Optimum.Bootstrap.Core.Tests => Optimum.Bootstrap.Core.TestSupport}/FakeSystemProbe.cs (100%) create mode 100644 Optimum.Bootstrap.Core.TestSupport/Optimum.Bootstrap.Core.TestSupport.csproj create mode 100644 Optimum.Bootstrap.Core/Build/RepoRoot.cs create mode 100644 Optimum.Bootstrap.Core/Install/PackageInstaller.cs create mode 100644 Optimum.Installer.Tests/Fakes.cs create mode 100644 Optimum.Installer.Tests/MainWindowRenderTests.cs delete mode 100644 Optimum.Installer.Tests/MainWindowTests.cs create mode 100644 Optimum.Installer.Tests/ScreenViewModelTests.cs create mode 100644 Optimum.Installer.Tests/WizardStateMachineTests.cs create mode 100644 Optimum.Installer/Services/InstallerLogFilter.cs create mode 100644 Optimum.Installer/Services/InstallerServices.cs create mode 100644 Optimum.Installer/ViewLocator.cs create mode 100644 Optimum.Installer/ViewModels/CompletionViewModel.cs create mode 100644 Optimum.Installer/ViewModels/EulaViewModel.cs create mode 100644 Optimum.Installer/ViewModels/InstallSession.cs create mode 100644 Optimum.Installer/ViewModels/OptionsViewModel.cs create mode 100644 Optimum.Installer/ViewModels/PrerequisitesViewModel.cs create mode 100644 Optimum.Installer/ViewModels/ProgressViewModel.cs create mode 100644 Optimum.Installer/ViewModels/ViewModelBase.cs create mode 100644 Optimum.Installer/Views/CompletionView.axaml create mode 100644 Optimum.Installer/Views/CompletionView.axaml.cs create mode 100644 Optimum.Installer/Views/EulaView.axaml create mode 100644 Optimum.Installer/Views/EulaView.axaml.cs create mode 100644 Optimum.Installer/Views/OptionsView.axaml create mode 100644 Optimum.Installer/Views/OptionsView.axaml.cs create mode 100644 Optimum.Installer/Views/PrerequisitesView.axaml create mode 100644 Optimum.Installer/Views/PrerequisitesView.axaml.cs create mode 100644 Optimum.Installer/Views/ProgressView.axaml create mode 100644 Optimum.Installer/Views/ProgressView.axaml.cs diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 9e38642..3e080c3 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -880,11 +880,29 @@ manifest-entry containment in `uninstall`, and `install` refusing to overwrite. `check-ndjson-stream.py`, then `Optimum.Cli validate`. The other four platform jobs get the same step incrementally. -**Phase 3: the GUI.** All five screens, the state machine, headless tests. Drives -Core in-process. At the end of this phase the GUI can do a complete install on the -platform the developer is sitting at. -*Verification:* `Optimum.Installer.Tests` green on `ubuntu-latest` with no xvfb, -plus one manual install per platform. +**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, a mandatory EULA modal over Options, +Progress, Completion, with backward navigation only from Options to Prerequisites +and blocked once Progress starts. `PrerequisitesViewModel` renders +`PrerequisiteScanner` rows and gates Continue on `BlocksBuild`. +`OptionsViewModel` defaults the install directory per platform, runs +`InstallPathGuard` on every keystroke into an inline error, picks up a detected +data folder from `DataPathProbe`, 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` (ported from the Windows installer's filter). +`CompletionViewModel` offers Launch, Try again, or View 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. +*Verification:* `Optimum.Installer.Tests` has 27 tests (25 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, and the +build-then-deploy flow against fakes. A real install per platform is still a +manual check. **Phase 4: unification.** The transactional installer on all three platforms, the registered uninstaller and install manifest on all three, unified shortcuts, @@ -1002,12 +1020,13 @@ The new modal should either gate on scroll properly or drop the pretense. ### 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 in Phase 2) -- `Optimum.Installer/` (Avalonia 12.1 application, MIT) +- `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 six projects, for a +- `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) diff --git a/Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs similarity index 100% rename from Optimum.Bootstrap.Core.Tests/FakeSystemProbe.cs rename to Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs 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/Optimum.Bootstrap.Core.Tests.csproj b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj index 2d80d34..a5e385c 100644 --- a/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj +++ b/Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj @@ -13,5 +13,6 @@ + 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/Install/PackageDeployer.cs b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs index 46ef489..80c907d 100644 --- a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs +++ b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs @@ -33,7 +33,7 @@ public static DeployResult Success(string installDirectory, string? launcher) => /// replacing an existing install in place, with a backup and rollback, is Phase /// 4. To reinstall now, run uninstall first. /// -public sealed class PackageDeployer(ISystemProbe probe) +public sealed class PackageDeployer(ISystemProbe probe) : IPackageInstaller { public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = null) { 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.Cli.Tests/Optimum.Cli.Tests.csproj b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj index bab5b20..69a20b2 100644 --- a/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj +++ b/Optimum.Cli.Tests/Optimum.Cli.Tests.csproj @@ -14,7 +14,6 @@ - - + diff --git a/Optimum.Cli/CliRunner.cs b/Optimum.Cli/CliRunner.cs index b994a8f..ef5eefa 100644 --- a/Optimum.Cli/CliRunner.cs +++ b/Optimum.Cli/CliRunner.cs @@ -275,17 +275,8 @@ private static ShortcutKinds ParseShortcuts(string? value) return result; } - internal static string? ResolveRepoRoot(ISystemProbe probe, string? explicitRoot) - { - 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; - } + internal static string? ResolveRepoRoot(ISystemProbe probe, string? explicitRoot) => + RepoRoot.Discover(probe, explicitRoot); private static int Unknown(string verb, TextWriter stderr) { diff --git a/Optimum.Installer.Tests/Fakes.cs b/Optimum.Installer.Tests/Fakes.cs new file mode 100644 index 0000000..6ac9740 --- /dev/null +++ b/Optimum.Installer.Tests/Fakes.cs @@ -0,0 +1,71 @@ +using Optimum.Bootstrap.Core; +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 Task RunAsync(BuildRequest request, IBuildObserver observer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Behaviour(observer, cancellationToken)); + } +} + +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 static class TestServices +{ + public static InstallerServices Build( + string? repoRoot = "/repo", + FakeSystemProbe? probe = null, + IBuildDriver? driver = null, + IPackageInstaller? installer = 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(), + }; + } +} diff --git a/Optimum.Installer.Tests/MainWindowRenderTests.cs b/Optimum.Installer.Tests/MainWindowRenderTests.cs new file mode 100644 index 0000000..5aef6e5 --- /dev/null +++ b/Optimum.Installer.Tests/MainWindowRenderTests.cs @@ -0,0 +1,35 @@ +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Optimum.Installer.ViewModels; +using Optimum.Installer.Views; +using Xunit; + +namespace Optimum.Installer.Tests; + +public class MainWindowRenderTests +{ + [AvaloniaFact] + public void TheWindowShowsAndRendersThePrerequisitesView() + { + var window = new MainWindow { DataContext = new MainWindowViewModel(TestServices.Build()) }; + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + Assert.NotEmpty(window.GetVisualDescendants().OfType()); + } + + [AvaloniaFact] + public void ContinuingToOptionsSwapsTheRenderedView() + { + var vm = new MainWindowViewModel(TestServices.Build()); + var window = new MainWindow { DataContext = vm }; + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + vm.Prerequisites.ContinueCommand.Execute(null); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + Assert.Empty(window.GetVisualDescendants().OfType()); + Assert.NotEmpty(window.GetVisualDescendants().OfType()); + } +} diff --git a/Optimum.Installer.Tests/MainWindowTests.cs b/Optimum.Installer.Tests/MainWindowTests.cs deleted file mode 100644 index c95d1b3..0000000 --- a/Optimum.Installer.Tests/MainWindowTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Optimum.Bootstrap.Core; -using Optimum.Installer.ViewModels; -using Optimum.Installer.Views; -using Xunit; - -namespace Optimum.Installer.Tests; - -public class MainWindowViewModelTests -{ - [Fact] - public void TitleCarriesTheCoreVersion() - { - var vm = new MainWindowViewModel(); - Assert.Contains(CoreInfo.Version, vm.Title); - } -} - -public class MainWindowRenderTests -{ - [AvaloniaFact] - public void WindowShowsAndBindsTheTitle() - { - var window = new MainWindow { DataContext = new MainWindowViewModel() }; - window.Show(); - - var title = Assert.IsType( - ((StackPanel)window.Content!).Children[0]); - Assert.Equal("Optimum installer " + CoreInfo.Version, title.Text); - } -} diff --git a/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj b/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj index c647f19..f128601 100644 --- a/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj +++ b/Optimum.Installer.Tests/Optimum.Installer.Tests.csproj @@ -19,5 +19,6 @@ + diff --git a/Optimum.Installer.Tests/ScreenViewModelTests.cs b/Optimum.Installer.Tests/ScreenViewModelTests.cs new file mode 100644 index 0000000..d21089d --- /dev/null +++ b/Optimum.Installer.Tests/ScreenViewModelTests.cs @@ -0,0 +1,185 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Tests; +using Optimum.Installer.Services; +using Optimum.Installer.ViewModels; +using Xunit; + +namespace Optimum.Installer.Tests; + +public class EulaViewModelTests +{ + [Fact] + public void TheNoticeTextIsTheCoreConsentNotice() + { + var vm = new EulaViewModel(); + Assert.Contains("decompil", vm.ConsentText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AcceptNeedsBothScrollAndTheCheckbox() + { + var vm = new EulaViewModel(); + bool accepted = false; + vm.AcceptRequested += () => accepted = true; + + vm.AcceptCommand.Execute(null); + Assert.False(accepted); + + vm.Accepted = true; + vm.ScrolledToEnd = true; + vm.AcceptCommand.Execute(null); + Assert.True(accepted); + } + + [Fact] + public void ResetClearsBothGates() + { + var vm = new EulaViewModel { Accepted = true, ScrolledToEnd = true }; + vm.Reset(); + Assert.False(vm.CanAccept); + } +} + +public class OptionsViewModelTests +{ + private static FakeSystemProbe RepoProbe() + { + var probe = new FakeSystemProbe(); + probe.AddFile("/repo/forks.json", """{ "vintageStoryVersion": "1.22.7" }"""); + return probe; + } + + [Fact] + public void DefaultsToAPlatformInstallDirectoryAndValidates() + { + var vm = new OptionsViewModel(RepoProbe(), "/repo"); + Assert.NotEqual(string.Empty, vm.InstallDirectory); + Assert.Null(vm.ValidationError); + Assert.True(vm.CanContinue); + } + + [Fact] + public void AnUnsafeInstallDirectoryProducesAnInlineError() + { + var probe = RepoProbe(); + var vm = new OptionsViewModel(probe, "/repo") { InstallDirectory = probe.HomeDirectory }; + + Assert.NotNull(vm.ValidationError); + Assert.False(vm.CanContinue); + } + + [Fact] + public void PicksUpADetectedDataFolderWithASession() + { + var probe = RepoProbe(); + probe.AddDirectory("/home/tester/.config/VintagestoryData"); + probe.AddFile("/home/tester/.config/VintagestoryData/clientsettings.json", """{ "playeruid": "x" }"""); + + var vm = new OptionsViewModel(probe, "/repo"); + + Assert.True(vm.UseSeparateDataFolder); + Assert.Equal("/home/tester/.config/VintagestoryData", vm.DataPath); + Assert.Contains("session", vm.DataPathHint!); + } + + [Fact] + public void ShowsAVersionChoiceOnlyWhenABridgeSetExists() + { + var probe = RepoProbe(); + Assert.False(new OptionsViewModel(probe, "/repo").ShowVersionChoice); + + probe.AddDirectory("/repo/patches-1.22.6-bridge"); + Assert.True(new OptionsViewModel(probe, "/repo").ShowVersionChoice); + } +} + +public class PrerequisitesViewModelTests +{ + [Fact] + public void ReportsRepoRootMissingWhenThereIsNoCheckout() + { + var vm = new PrerequisitesViewModel(new FakeSystemProbe(), repoRoot: null); + Assert.True(vm.RepoRootMissing); + Assert.False(vm.CanContinue); + } +} + +public class InstallerLogFilterTests +{ + [Theory] + [InlineData("error: patch failed", true)] + [InlineData("[Optimum] Applying patches", true)] + [InlineData("Restored /home/x", true)] + [InlineData(" at System.String.Format (Exception)", true)] + [InlineData("Determining projects to restore...", false)] + [InlineData(" copying 1834 files", false)] + public void KeepsAlarmingAndWhitelistedLinesOnly(string line, bool kept) + { + Assert.Equal(kept, InstallerLogFilter.IsInteresting(line)); + } +} + +public class ProgressViewModelTests +{ + [Fact] + public async Task ACleanRunReportsSuccessAndAHundredPercent() + { + var services = TestServices.Build(); + var session = new InstallSession("/repo", "/home/tester/games/optimum", null, null, + Bootstrap.Core.Install.ShortcutKinds.None); + var vm = new ProgressViewModel(services, session, action => action()); + + InstallOutcome? outcome = null; + vm.Finished += o => outcome = o; + await vm.RunAsync(); + + Assert.NotNull(outcome); + Assert.True(outcome!.Succeeded); + Assert.Equal(100, vm.Percent); + } + + [Fact] + public async Task AFailedDeployReportsTheMessage() + { + var installer = new FakePackageInstaller + { + Behaviour = _ => Bootstrap.Core.Install.DeployResult.Failure(FailureReason.OutputExists, "already there"), + }; + var services = TestServices.Build(installer: installer); + var vm = new ProgressViewModel(services, + new InstallSession("/repo", "/home/tester/games/optimum", null, null, Bootstrap.Core.Install.ShortcutKinds.None), + action => action()); + + InstallOutcome? outcome = null; + vm.Finished += o => outcome = o; + await vm.RunAsync(); + + Assert.False(outcome!.Succeeded); + Assert.Contains("already there", outcome.Message); + } + + [Fact] + public async Task InterestingRawOutputReachesTheLogPaneButNoiseDoesNot() + { + var driver = new FakeBuildDriver + { + Behaviour = (observer, _) => + { + observer.RawOutput(false, "Determining projects to restore..."); + observer.RawOutput(false, "[Optimum] Applying patches: vsapi"); + observer.RawOutput(true, "error: something broke"); + return BuildResult.Success("/tmp/pkg/Optimum-v0.3.14-linux-x64"); + }, + }; + var services = TestServices.Build(driver: driver); + var vm = new ProgressViewModel(services, + new InstallSession("/repo", "/home/tester/games/optimum", null, null, Bootstrap.Core.Install.ShortcutKinds.None), + action => action()); + await vm.RunAsync(); + + Assert.Contains(vm.Log, l => l.Text.Contains("Applying patches")); + Assert.Contains(vm.Log, l => l.Text.Contains("something broke")); + Assert.DoesNotContain(vm.Log, l => l.Text.Contains("Determining projects")); + } +} diff --git a/Optimum.Installer.Tests/WizardStateMachineTests.cs b/Optimum.Installer.Tests/WizardStateMachineTests.cs new file mode 100644 index 0000000..611eeb3 --- /dev/null +++ b/Optimum.Installer.Tests/WizardStateMachineTests.cs @@ -0,0 +1,120 @@ +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Installer.ViewModels; +using Xunit; + +namespace Optimum.Installer.Tests; + +public class WizardStateMachineTests +{ + private static MainWindowViewModel Wizard(FakeBuildDriver? driver = null, FakePackageInstaller? installer = null) => + new(TestServices.Build(driver: driver, installer: installer)); + + [Fact] + public void StartsOnPrerequisites() + { + Assert.Equal(WizardScreen.Prerequisites, Wizard().CurrentScreen); + } + + [Fact] + public void PrerequisitesContinueIsBlockedWhenARequiredToolIsMissing() + { + var vm = new MainWindowViewModel(TestServices.Build(dotnetPresent: false)); + + Assert.False(vm.Prerequisites.CanContinue); + vm.Prerequisites.ContinueCommand.Execute(null); + Assert.Equal(WizardScreen.Prerequisites, vm.CurrentScreen); + } + + [Fact] + public void PrerequisitesToOptionsToEula() + { + var vm = Wizard(); + + Assert.True(vm.Prerequisites.CanContinue); + vm.Prerequisites.ContinueCommand.Execute(null); + Assert.Equal(WizardScreen.Options, vm.CurrentScreen); + Assert.True(vm.CanGoBack); + + vm.Options.ContinueCommand.Execute(null); + Assert.True(vm.IsEulaOpen); + Assert.Equal(WizardScreen.Options, vm.CurrentScreen); + } + + [Fact] + public void BackFromOptionsReturnsToPrerequisites() + { + var vm = Wizard(); + vm.Prerequisites.ContinueCommand.Execute(null); + vm.Options.BackCommand.Execute(null); + Assert.Equal(WizardScreen.Prerequisites, vm.CurrentScreen); + Assert.False(vm.CanGoBack); + } + + [Fact] + public void DecliningTheEulaKeepsTheUserOnOptions() + { + var vm = Wizard(); + vm.Prerequisites.ContinueCommand.Execute(null); + vm.Options.ContinueCommand.Execute(null); + + vm.Eula.DeclineCommand.Execute(null); + + Assert.False(vm.IsEulaOpen); + Assert.Equal(WizardScreen.Options, vm.CurrentScreen); + } + + [Fact] + public void AcceptingTheEulaIsBlockedUntilScrolledAndTicked() + { + var vm = Wizard(); + vm.Prerequisites.ContinueCommand.Execute(null); + vm.Options.ContinueCommand.Execute(null); + + Assert.False(vm.Eula.CanAccept); + vm.Eula.Accepted = true; + Assert.False(vm.Eula.CanAccept); + vm.Eula.ScrolledToEnd = true; + Assert.True(vm.Eula.CanAccept); + } + + [Fact] + public async Task AcceptingTheEulaRunsTheBuildAndLandsOnCompletionOk() + { + var vm = Wizard(); + vm.Prerequisites.ContinueCommand.Execute(null); + vm.Options.ContinueCommand.Execute(null); + vm.Eula.ScrolledToEnd = true; + vm.Eula.Accepted = true; + + vm.Eula.AcceptCommand.Execute(null); + await vm.InstallCompletion; + + Assert.Equal(WizardScreen.Completion, vm.CurrentScreen); + Assert.NotNull(vm.Completion); + Assert.True(vm.Completion!.Succeeded); + Assert.False(vm.IsEulaOpen); + } + + [Fact] + public async Task AFailedBuildLandsOnCompletionWithRetry() + { + var driver = new FakeBuildDriver + { + Behaviour = (_, _) => BuildResult.Failure(FailureReason.PatchConflict, "a patch did not apply"), + }; + var vm = Wizard(driver); + vm.Prerequisites.ContinueCommand.Execute(null); + vm.Options.ContinueCommand.Execute(null); + vm.Eula.ScrolledToEnd = true; + vm.Eula.Accepted = true; + vm.Eula.AcceptCommand.Execute(null); + await vm.InstallCompletion; + + Assert.Equal(WizardScreen.Completion, vm.CurrentScreen); + Assert.True(vm.Completion!.Failed); + + vm.Completion.RetryCommand.Execute(null); + Assert.Equal(WizardScreen.Prerequisites, vm.CurrentScreen); + } +} diff --git a/Optimum.Installer.slnf b/Optimum.Installer.slnf index 869d1a4..15a84a6 100644 --- a/Optimum.Installer.slnf +++ b/Optimum.Installer.slnf @@ -3,6 +3,7 @@ "path": "VintageStory.slnx", "projects": [ "Optimum.Bootstrap.Core/Optimum.Bootstrap.Core.csproj", + "Optimum.Bootstrap.Core.TestSupport/Optimum.Bootstrap.Core.TestSupport.csproj", "Optimum.Bootstrap.Core.Tests/Optimum.Bootstrap.Core.Tests.csproj", "Optimum.Cli/Optimum.Cli.csproj", "Optimum.Cli.Tests/Optimum.Cli.Tests.csproj", diff --git a/Optimum.Installer/App.axaml b/Optimum.Installer/App.axaml index d7de85c..1810c0c 100644 --- a/Optimum.Installer/App.axaml +++ b/Optimum.Installer/App.axaml @@ -1,7 +1,11 @@ + + + diff --git a/Optimum.Installer/App.axaml.cs b/Optimum.Installer/App.axaml.cs index ca55304..936d530 100644 --- a/Optimum.Installer/App.axaml.cs +++ b/Optimum.Installer/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Optimum.Installer.Services; using Optimum.Installer.ViewModels; using Optimum.Installer.Views; @@ -16,7 +17,7 @@ public override void OnFrameworkInitializationCompleted() { desktop.MainWindow = new MainWindow { - DataContext = new MainWindowViewModel(), + DataContext = new MainWindowViewModel(InstallerServices.CreateReal()), }; } diff --git a/Optimum.Installer/Optimum.Installer.csproj b/Optimum.Installer/Optimum.Installer.csproj index 94158c3..d428853 100644 --- a/Optimum.Installer/Optimum.Installer.csproj +++ b/Optimum.Installer/Optimum.Installer.csproj @@ -24,4 +24,7 @@ + + + diff --git a/Optimum.Installer/Services/InstallerLogFilter.cs b/Optimum.Installer/Services/InstallerLogFilter.cs new file mode 100644 index 0000000..44977fd --- /dev/null +++ b/Optimum.Installer/Services/InstallerLogFilter.cs @@ -0,0 +1,24 @@ +using System.Text.RegularExpressions; + +namespace Optimum.Installer.Services; + +/// +/// Decides which raw subprocess lines reach the visible log pane. Ports the +/// filter in scripts/install-windows.ps1:1281-1301: anything that looks +/// like an error is always shown, a short whitelist of progress prefixes is +/// shown verbatim, and the rest is kept only in the saved raw log. +/// +public static partial class InstallerLogFilter +{ + public static bool IsInteresting(string line) + { + string trimmed = line.TrimStart(); + return Alarming().IsMatch(line) || Whitelisted().IsMatch(trimmed); + } + + [GeneratedRegex(@"\berror\b|FAILED|ERROR|throw|Exception|fatal:|does not apply", RegexOptions.IgnoreCase)] + private static partial Regex Alarming(); + + [GeneratedRegex(@"^(\[Optimum\]|==PHASE==|==>|✓|✗|Bootstrap complete|Decompiling|Cloning|Applying|Building|Packaging|Restored|Compil)", RegexOptions.IgnoreCase)] + private static partial Regex Whitelisted(); +} diff --git a/Optimum.Installer/Services/InstallerServices.cs b/Optimum.Installer/Services/InstallerServices.cs new file mode 100644 index 0000000..b735da6 --- /dev/null +++ b/Optimum.Installer/Services/InstallerServices.cs @@ -0,0 +1,33 @@ +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Installer.Services; + +/// +/// The Core services the GUI drives in-process. The real app builds this from +/// ; a headless test builds it from fakes. +/// +public sealed record InstallerServices( + ISystemProbe Probe, + string? RepoRoot, + IBuildDriver BuildDriver, + IPackageInstaller Installer) +{ + /// + /// Marshals a callback to the UI thread. Null means the default + /// Dispatcher.UIThread.Post; a headless test injects a synchronous one. + /// + public Action? UiPost { get; init; } + + + public static InstallerServices CreateReal() + { + var probe = SystemProbe.Default; + return new InstallerServices( + probe, + Optimum.Bootstrap.Core.Build.RepoRoot.Discover(probe), + new ScriptBuildDriver(probe), + new PackageDeployer(probe)); + } +} diff --git a/Optimum.Installer/ViewLocator.cs b/Optimum.Installer/ViewLocator.cs new file mode 100644 index 0000000..7204662 --- /dev/null +++ b/Optimum.Installer/ViewLocator.cs @@ -0,0 +1,26 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Optimum.Installer.ViewModels; + +namespace Optimum.Installer; + +/// Maps a FooViewModel to a Optimum.Installer.Views.FooView. +public sealed class ViewLocator : IDataTemplate +{ + public Control Build(object? data) + { + if (data is null) + return new TextBlock { Text = "(no content)" }; + + string name = data.GetType().FullName! + .Replace("ViewModels", "Views", StringComparison.Ordinal) + .Replace("ViewModel", "View", StringComparison.Ordinal); + Type? type = Type.GetType(name); + return type is not null + ? (Control)Activator.CreateInstance(type)! + : new TextBlock { Text = "View not found: " + name }; + } + + public bool Match(object? data) => data is ViewModelBase; +} diff --git a/Optimum.Installer/ViewModels/CompletionViewModel.cs b/Optimum.Installer/ViewModels/CompletionViewModel.cs new file mode 100644 index 0000000..8368edd --- /dev/null +++ b/Optimum.Installer/ViewModels/CompletionViewModel.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Optimum.Installer.ViewModels; + +public sealed partial class CompletionViewModel(InstallOutcome outcome) : ViewModelBase +{ + public InstallOutcome Outcome { get; } = outcome; + + public bool Succeeded => Outcome.Succeeded; + public bool Failed => !Outcome.Succeeded && !Outcome.Cancelled; + public bool Cancelled => Outcome.Cancelled; + + public string Headline => Outcome.Succeeded + ? "Optimum is installed." + : Outcome.Cancelled + ? "The install was cancelled." + : "The install did not finish."; + + public string Message => Outcome.Message; + public string? InstallDirectory => Outcome.InstallDirectory; + + public bool CanLaunch => Outcome.Launcher is not null && File.Exists(Outcome.Launcher); + + public event Action? RetryRequested; + + [RelayCommand] + private void Retry() => RetryRequested?.Invoke(); + + [RelayCommand(CanExecute = nameof(CanLaunch))] + private void Launch() + { + if (Outcome.Launcher is null) + return; + Process.Start(new ProcessStartInfo(Outcome.Launcher) { UseShellExecute = true }); + } + + [RelayCommand] + private void ViewLog() + { + if (File.Exists(Outcome.RawLogPath)) + Process.Start(new ProcessStartInfo(Outcome.RawLogPath) { UseShellExecute = true }); + } +} diff --git a/Optimum.Installer/ViewModels/EulaViewModel.cs b/Optimum.Installer/ViewModels/EulaViewModel.cs new file mode 100644 index 0000000..03173c5 --- /dev/null +++ b/Optimum.Installer/ViewModels/EulaViewModel.cs @@ -0,0 +1,43 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Optimum.Bootstrap.Core.Licensing; + +namespace Optimum.Installer.ViewModels; + +/// +/// The mandatory consent modal. Posture C in INSTALLER-PLAN.md: the user must +/// scroll to the end and tick the box before the build can start. +/// +public sealed partial class EulaViewModel : ViewModelBase +{ + public string ConsentText => ConsentNotice.Text; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanAccept))] + private bool _scrolledToEnd; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanAccept))] + private bool _accepted; + + public bool CanAccept => ScrolledToEnd && Accepted; + + public event Action? AcceptRequested; + public event Action? DeclineRequested; + + [RelayCommand] + private void Accept() + { + if (CanAccept) + AcceptRequested?.Invoke(); + } + + [RelayCommand] + private void Decline() => DeclineRequested?.Invoke(); + + public void Reset() + { + ScrolledToEnd = false; + Accepted = false; + } +} diff --git a/Optimum.Installer/ViewModels/InstallSession.cs b/Optimum.Installer/ViewModels/InstallSession.cs new file mode 100644 index 0000000..1de2036 --- /dev/null +++ b/Optimum.Installer/ViewModels/InstallSession.cs @@ -0,0 +1,19 @@ +using Optimum.Bootstrap.Core.Install; + +namespace Optimum.Installer.ViewModels; + +/// What the Options screen collected, handed to the Progress screen. +public sealed record InstallSession( + string RepoRoot, + string InstallDirectory, + string? DataPath, + string? Version, + ShortcutKinds Shortcuts); + +public sealed record InstallOutcome( + bool Succeeded, + bool Cancelled, + string Message, + string? InstallDirectory, + string? Launcher, + string RawLogPath); diff --git a/Optimum.Installer/ViewModels/MainWindowViewModel.cs b/Optimum.Installer/ViewModels/MainWindowViewModel.cs index 86ffbb4..e429cfc 100644 --- a/Optimum.Installer/ViewModels/MainWindowViewModel.cs +++ b/Optimum.Installer/ViewModels/MainWindowViewModel.cs @@ -1,14 +1,122 @@ using CommunityToolkit.Mvvm.ComponentModel; -using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Install; +using Optimum.Installer.Services; namespace Optimum.Installer.ViewModels; +public enum WizardScreen +{ + Prerequisites, + Options, + Progress, + Completion, +} + /// -/// Placeholder shell view model. The five-screen flow in INSTALLER-PLAN.md -/// section 5 lands in Phase 3. +/// The wizard shell and its state machine (INSTALLER-PLAN.md section 5). The EULA +/// is a modal over Options, not a screen. Backward navigation is allowed from +/// Options to Prerequisites and blocked once Progress starts. Each screen view +/// model raises the transition it wants; the shell decides whether to honour it. /// -public partial class MainWindowViewModel : ObservableObject +public sealed partial class MainWindowViewModel : ViewModelBase { + private readonly InstallerServices _services; + + public MainWindowViewModel(InstallerServices services) + { + _services = services; + + Prerequisites = new PrerequisitesViewModel(services.Probe, services.RepoRoot); + Prerequisites.ContinueRequested += () => CurrentScreen = WizardScreen.Options; + + Options = new OptionsViewModel(services.Probe, services.RepoRoot); + Options.BackRequested += () => CurrentScreen = WizardScreen.Prerequisites; + Options.ContinueRequested += OpenEula; + + Eula = new EulaViewModel(); + Eula.DeclineRequested += () => IsEulaOpen = false; + Eula.AcceptRequested += () => InstallCompletion = StartInstallAsync(); + } + + /// The running (or finished) install, so a test can await it. + internal Task InstallCompletion { get; private set; } = Task.CompletedTask; + + public string Title { get; } = "Optimum installer"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentViewModel))] + [NotifyPropertyChangedFor(nameof(CanGoBack))] + private WizardScreen _currentScreen = WizardScreen.Prerequisites; + [ObservableProperty] - private string _title = $"Optimum installer {CoreInfo.Version}"; + private bool _isEulaOpen; + + public PrerequisitesViewModel Prerequisites { get; } + + public OptionsViewModel Options { get; } + + public EulaViewModel Eula { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentViewModel))] + private ProgressViewModel? _progress; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentViewModel))] + private CompletionViewModel? _completion; + + public bool CanGoBack => CurrentScreen == WizardScreen.Options; + + public ViewModelBase CurrentViewModel => CurrentScreen switch + { + WizardScreen.Options => Options, + WizardScreen.Progress => Progress ?? (ViewModelBase)Prerequisites, + WizardScreen.Completion => Completion ?? (ViewModelBase)Prerequisites, + _ => Prerequisites, + }; + + private void OpenEula() + { + Eula.Reset(); + IsEulaOpen = true; + } + + private async Task StartInstallAsync() + { + if (!Eula.CanAccept || _services.RepoRoot is null) + return; + + IsEulaOpen = false; + + var session = new InstallSession( + _services.RepoRoot, + Options.InstallDirectory, + Options.ResolvedDataPath, + Options.SelectedVersion, + (Options.CreateMenuEntry ? ShortcutKinds.Menu : ShortcutKinds.None) + | (Options.CreateDesktopShortcut ? ShortcutKinds.Desktop : ShortcutKinds.None)); + + var progress = new ProgressViewModel(_services, session, _services.UiPost); + progress.Finished += OnBuildFinished; + Progress = progress; + CurrentScreen = WizardScreen.Progress; + + await progress.RunAsync(); + } + + private void OnBuildFinished(InstallOutcome outcome) + { + var completion = new CompletionViewModel(outcome); + completion.RetryRequested += RestartFromPrerequisites; + Completion = completion; + CurrentScreen = WizardScreen.Completion; + } + + private void RestartFromPrerequisites() + { + Prerequisites.Rescan(); + Progress = null; + Completion = null; + CurrentScreen = WizardScreen.Prerequisites; + } } diff --git a/Optimum.Installer/ViewModels/OptionsViewModel.cs b/Optimum.Installer/ViewModels/OptionsViewModel.cs new file mode 100644 index 0000000..5a84b3a --- /dev/null +++ b/Optimum.Installer/ViewModels/OptionsViewModel.cs @@ -0,0 +1,121 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.DataPath; +using Optimum.Bootstrap.Core.Paths; +using Optimum.Bootstrap.Core.Platform; + +namespace Optimum.Installer.ViewModels; + +public sealed partial class OptionsViewModel : ViewModelBase +{ + private readonly ISystemProbe _probe; + + public OptionsViewModel(ISystemProbe probe, string? repoRoot) + { + _probe = probe; + RepoRoot = repoRoot; + + InstallDirectory = DefaultInstallDirectory(probe); + + DataPathDetection detected = DataPathProbe.Detect(probe); + if (detected.Path is not null) + { + UseSeparateDataFolder = true; + DataPath = detected.Path; + DataPathHint = detected.HasActiveSession + ? "Detected a Vintage Story data folder with a signed-in session." + : "Detected an existing Vintage Story data folder."; + } + + if (repoRoot is not null) + { + foreach (string version in Capabilities.Read(probe, repoRoot).SupportedVersions) + Versions.Add(version); + } + SelectedVersion = Versions.FirstOrDefault(); + + Validate(); + } + + public string? RepoRoot { get; } + + public ObservableCollection Versions { get; } = []; + + public bool ShowVersionChoice => Versions.Count > 1; + + [ObservableProperty] + private string? _selectedVersion; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanContinue))] + private string _installDirectory = string.Empty; + + [ObservableProperty] + private bool _useSeparateDataFolder; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanContinue))] + private string _dataPath = string.Empty; + + [ObservableProperty] + private string? _dataPathHint; + + [ObservableProperty] + private bool _createMenuEntry = true; + + [ObservableProperty] + private bool _createDesktopShortcut; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanContinue))] + private string? _validationError; + + public bool CanContinue => ValidationError is null && InstallDirectory.Length > 0; + + public event Action? ContinueRequested; + public event Action? BackRequested; + + [RelayCommand] + private void Continue() + { + Validate(); + if (CanContinue) + ContinueRequested?.Invoke(); + } + + [RelayCommand] + private void Back() => BackRequested?.Invoke(); + + partial void OnInstallDirectoryChanged(string value) => Validate(); + + partial void OnUseSeparateDataFolderChanged(bool value) => Validate(); + + partial void OnDataPathChanged(string value) => Validate(); + + public void Validate() + { + if (string.IsNullOrWhiteSpace(InstallDirectory)) + { + ValidationError = "Choose an install directory."; + return; + } + + string? data = UseSeparateDataFolder && DataPath.Length > 0 ? DataPath : null; + InstallPathVerdict verdict = InstallPathGuard.Check(_probe, new InstallPathRequest(InstallDirectory, data)); + ValidationError = verdict.Ok ? null : verdict.Rejection; + } + + public string? ResolvedDataPath => UseSeparateDataFolder && DataPath.Length > 0 ? DataPath : null; + + private static string DefaultInstallDirectory(ISystemProbe probe) => probe.Os switch + { + OsKind.Windows => Path.Combine( + probe.GetEnvironmentVariable("LOCALAPPDATA") ?? probe.HomeDirectory, "Programs", "Optimum"), + OsKind.MacOs => Path.Combine(probe.HomeDirectory, "Applications", "Optimum"), + _ => Path.Combine( + probe.GetEnvironmentVariable("XDG_DATA_HOME") ?? Path.Combine(probe.HomeDirectory, ".local", "share"), + "optimum"), + }; +} diff --git a/Optimum.Installer/ViewModels/PrerequisitesViewModel.cs b/Optimum.Installer/ViewModels/PrerequisitesViewModel.cs new file mode 100644 index 0000000..31b118c --- /dev/null +++ b/Optimum.Installer/ViewModels/PrerequisitesViewModel.cs @@ -0,0 +1,88 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Optimum.Bootstrap.Core.Platform; +using Optimum.Bootstrap.Core.Prerequisites; + +namespace Optimum.Installer.ViewModels; + +public sealed partial class PrerequisiteRowViewModel(PrerequisiteResult result) : ViewModelBase +{ + public PrerequisiteResult Result { get; } = result; + + public string Name => Result.Definition.DisplayName; + public string Status => Result.State.ToString(); + public string Detail => Result.Label; + + /// The action button label, or null when there is nothing to do. + public string? ActionLabel => Result.Acquisition switch + { + AcquisitionKind.Automatic => "Install", + AcquisitionKind.Manual => "Copy command", + AcquisitionKind.DownloadPage => "Download", + _ => null, + }; + + public string? ActionDetail => Result.AcquisitionCommand ?? Result.DownloadUrl; +} + +public sealed partial class PrerequisitesViewModel : ViewModelBase +{ + private readonly ISystemProbe _probe; + private readonly string? _repoRoot; + + public PrerequisitesViewModel(ISystemProbe probe, string? repoRoot) + { + _probe = probe; + _repoRoot = repoRoot; + Rescan(); + } + + public ObservableCollection Rows { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanContinue))] + private bool _repoRootMissing; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanContinue))] + private int _blockingCount; + + public bool CanContinue => !RepoRootMissing && BlockingCount == 0; + + public event Action? ContinueRequested; + + [RelayCommand] + private void Continue() + { + if (CanContinue) + ContinueRequested?.Invoke(); + } + + public string Summary => RepoRootMissing + ? "Run the installer from inside an Optimum checkout." + : BlockingCount == 0 + ? "All required tools are present." + : $"{BlockingCount} required tool(s) still missing."; + + [RelayCommand] + public void Rescan() + { + Rows.Clear(); + + if (_repoRoot is null) + { + RepoRootMissing = true; + BlockingCount = 0; + OnPropertyChanged(nameof(Summary)); + return; + } + + RepoRootMissing = false; + var results = new PrerequisiteScanner(_probe, _repoRoot).Scan(); + foreach (var result in results) + Rows.Add(new PrerequisiteRowViewModel(result)); + BlockingCount = results.Count(r => r.BlocksBuild); + OnPropertyChanged(nameof(Summary)); + } +} diff --git a/Optimum.Installer/ViewModels/ProgressViewModel.cs b/Optimum.Installer/ViewModels/ProgressViewModel.cs new file mode 100644 index 0000000..93afde6 --- /dev/null +++ b/Optimum.Installer/ViewModels/ProgressViewModel.cs @@ -0,0 +1,164 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Optimum.Bootstrap.Core; +using Optimum.Bootstrap.Core.Build; +using Optimum.Bootstrap.Core.Install; +using Optimum.Installer.Services; + +namespace Optimum.Installer.ViewModels; + +public sealed record LogLine(string Level, string Text); + +/// +/// Runs the build then the deploy under one progress bar, feeding the phase +/// label, the bar, an honest time estimate, and a filtered log pane. It is its +/// own and marshals every callback to the UI thread. +/// +public sealed partial class ProgressViewModel : ViewModelBase, IBuildObserver +{ + private readonly InstallerServices _services; + private readonly InstallSession _session; + private readonly Action _post; + private readonly CancellationTokenSource _cancellation = new(); + private readonly Stopwatch _stopwatch = new(); + private readonly StringBuilder _rawLog = new(); + + public ProgressViewModel(InstallerServices services, InstallSession session, Action? post = null) + { + _services = services; + _session = session; + _post = post ?? (action => Dispatcher.UIThread.Post(action)); + } + + public ObservableCollection Log { get; } = []; + + public event Action? Finished; + + [ObservableProperty] + private string _phaseLabel = "Starting"; + + [ObservableProperty] + private string _statusDetail = string.Empty; + + [ObservableProperty] + private double _percent; + + [ObservableProperty] + private string _elapsed = "0:00"; + + [ObservableProperty] + private string? _estimatedRemaining; + + [ObservableProperty] + private bool _cancelRequested; + + public async Task RunAsync() + { + _stopwatch.Start(); + string outputDirectory = Path.Combine(Path.GetTempPath(), "optimum-build-" + Guid.NewGuid().ToString("N")); + + BuildResult build; + try + { + build = await _services.BuildDriver.RunAsync( + new BuildRequest(_session.RepoRoot, outputDirectory, ClientArchive: null, _session.Version), + this, + _cancellation.Token); + } + catch (OperationCanceledException) + { + build = BuildResult.Failure(FailureReason.Cancelled, "the build was cancelled"); + } + + if (!build.Ok) + { + Finish(build.Reason == FailureReason.Cancelled, build.Message ?? "the build failed"); + return; + } + + Phase(ProgressPhase.Verify, 96, "installing"); + DeployResult deploy = _services.Installer.Deploy( + new DeployRequest(build.RuntimePath!, _session.InstallDirectory, _session.DataPath, _session.Shortcuts), + this); + + if (!deploy.Ok) + { + Finish(cancelled: false, deploy.Message ?? "the install failed"); + return; + } + + Phase(ProgressPhase.Verify, 99, "done"); + Finish(cancelled: false, "Optimum is installed.", deploy.InstallDirectory, deploy.Launcher); + } + + [RelayCommand] + private void Cancel() + { + CancelRequested = true; + StatusDetail = "cancelling"; + _cancellation.Cancel(); + } + + void IBuildObserver.Phase(ProgressPhase phase, int percent, string detail) => Phase(phase, percent, detail); + + void IBuildObserver.Log(LogLevel level, string message) => + _post(() => Log.Add(new LogLine(level.ToString().ToLowerInvariant(), message))); + + void IBuildObserver.RawOutput(bool isError, string line) + { + _rawLog.AppendLine(line); + if (isError || InstallerLogFilter.IsInteresting(line)) + _post(() => Log.Add(new LogLine(isError ? "error" : "info", line))); + } + + private void Phase(ProgressPhase phase, int percent, string detail) => _post(() => + { + PhaseLabel = Humanize(phase); + StatusDetail = detail; + Percent = Math.Max(Percent, percent); + Elapsed = FormatDuration(_stopwatch.Elapsed); + EstimatedRemaining = Percent is > 5 and < 99 + ? "about " + FormatDuration(TimeSpan.FromSeconds( + _stopwatch.Elapsed.TotalSeconds * (100 - Percent) / Percent)) + " left" + : null; + }); + + private void Finish(bool cancelled, string message, string? installDir = null, string? launcher = null) + { + _stopwatch.Stop(); + string rawLogPath = Path.Combine(Path.GetTempPath(), + $"optimum-install-{DateTime.Now:yyyy-MM-ddTHHmmss}.log"); + try { File.WriteAllText(rawLogPath, _rawLog.ToString()); } + catch (IOException) { rawLogPath = "(log not written)"; } + + _post(() => + { + Percent = cancelled ? Percent : 100; + Finished?.Invoke(new InstallOutcome( + Succeeded: installDir is not null, + Cancelled: cancelled, + Message: message, + InstallDirectory: installDir, + Launcher: launcher, + RawLogPath: rawLogPath)); + }); + } + + private static string Humanize(ProgressPhase phase) => phase switch + { + ProgressPhase.Decompile => "Downloading and decompiling Vintage Story", + ProgressPhase.Patch => "Applying Optimum patches", + ProgressPhase.Assemble => "Compiling and packaging", + ProgressPhase.Verify => "Verifying and installing", + _ => "Working", + }; + + private static string FormatDuration(TimeSpan span) => + span.TotalHours >= 1 + ? $"{(int)span.TotalHours}:{span.Minutes:D2}:{span.Seconds:D2}" + : $"{span.Minutes}:{span.Seconds:D2}"; +} diff --git a/Optimum.Installer/ViewModels/ViewModelBase.cs b/Optimum.Installer/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..b6f900e --- /dev/null +++ b/Optimum.Installer/ViewModels/ViewModelBase.cs @@ -0,0 +1,5 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Optimum.Installer.ViewModels; + +public abstract class ViewModelBase : ObservableObject; diff --git a/Optimum.Installer/Views/CompletionView.axaml b/Optimum.Installer/Views/CompletionView.axaml new file mode 100644 index 0000000..e69488b --- /dev/null +++ b/Optimum.Installer/Views/CompletionView.axaml @@ -0,0 +1,25 @@ + + + + + + + +