From d0444c98535989105d1ffddfc969406da3e31be6 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:06:43 -0300 Subject: [PATCH] Bump ilspycmd to 11.0.0.9375, migrate installer to FluentAvalonia, fix bootstrap SIGPIPE Pin ilspycmd to 11.0.0.9375 across the config, bootstrap and install scripts, the C# compatibility fallback, and the tests. The range is now a single accepted version. Extend the base constructor fixup to rewrite the ILSpy 11 cast form ((BaseType)this)._002Ector(args) into : base(args), which ILSpy 11 emits for GameWindowNative and eight other files. Regenerate the four VintagestoryLib patches whose context shifted under the new decompiler output (EventManager, ClientEventManager, InventoryItemRenderer, ShaderRegistry). Migrate the Avalonia installer from SukiUI to FluentAvalonia for the native WinUI look: FluentAvaloniaTheme with the system accent and light/dark variant, standard Fluent controls, a numbered step rail, and no custom palette. Replace the auto-launch flow with a Finish button. Make the bootstrap patch detection immune to SIGPIPE by wrapping the find | head | grep probe in a subshell with pipefail off, so head closing the pipe does not surface exit 141 to the if condition and all 118 patches are detected and applied. Portable across Linux and macOS. Verified on Windows: bootstrap -Refresh reports 118 patches applied and 0 failed, the solution builds with 0 errors, and the installer and bootstrap test suites pass. --- .config/dotnet-tools.json | 2 +- .config/ilspycmd-compat.json | 4 +- INSTALLER-PLAN.md | 6 +- .../FakeSystemProbe.cs | 46 ++++--- .../AcquisitionTests.cs | 4 +- .../DeployRoundTripTests.cs | 16 +++ .../GamePatcherTests.cs | 6 +- .../IlspycmdVersionTests.cs | 32 +++-- .../PrerequisiteScannerTests.cs | 6 +- .../ShortcutWriterTests.cs | 17 ++- .../SourceAcquisitionTests.cs | 10 +- .../Acquisition/SdkAcquisition.cs | 5 +- Optimum.Bootstrap.Core/Build/RepoRoot.cs | 24 +++- .../Build/SourceAcquisition.cs | 20 ++- .../DataPath/DataPathProbe.cs | 23 ++-- .../Install/PackageDeployer.cs | 15 ++- .../Install/ShortcutWriter.cs | 16 ++- Optimum.Bootstrap.Core/Patch/GamePatcher.cs | 42 ++++++- .../Paths/InstallPathGuard.cs | 17 +-- .../Paths/SymlinkComponentCheck.cs | 18 ++- .../Platform/CommandSearch.cs | 8 +- .../Prerequisites/IlspycmdVersion.cs | 6 +- Optimum.Cli/CliRunner.cs | 8 +- .../ScreenViewModelTests.cs | 45 +++---- Optimum.Installer/App.axaml | 95 +++----------- Optimum.Installer/App.axaml.cs | 13 +- Optimum.Installer/Optimum.Installer.csproj | 13 +- Optimum.Installer/Program.cs | 2 +- .../ViewModels/CompletionViewModel.cs | 92 +++----------- .../ViewModels/InstallSession.cs | 3 +- .../ViewModels/MainWindowViewModel.cs | 9 +- .../ViewModels/OptionsViewModel.cs | 8 +- .../ViewModels/ProgressViewModel.cs | 2 +- Optimum.Installer/Views/CompletionView.axaml | 25 ++-- Optimum.Installer/Views/EulaView.axaml | 42 +++---- .../Views/InstallerConverters.cs | 41 +++--- Optimum.Installer/Views/MainWindow.axaml | 117 +++++++++--------- Optimum.Installer/Views/MainWindow.axaml.cs | 5 +- Optimum.Installer/Views/OptionsView.axaml | 45 ++++--- .../Views/PrerequisitesView.axaml | 41 +++--- Optimum.Installer/Views/ProgressView.axaml | 78 ++++++------ Optimum.Installer/app.manifest | 22 ++++ .../adaptive-radius-controller-tests.cs | 28 +++++ .../installer-release-coverage-tests.cs | 4 +- .../VintagestoryAPI.csproj.patch | 26 ---- .../ClientEventManager.cs.patch | 4 +- .../InventoryItemRenderer.cs.patch | 10 +- .../ShaderRegistry.cs.patch | 4 +- .../Vintagestory.Common/EventManager.cs.patch | 28 ++--- scripts/bootstrap.ps1 | 24 +++- scripts/bootstrap.sh | 4 +- scripts/fix-base-ctor-calls.py | 16 ++- scripts/install-linux-legacy.sh | 4 +- scripts/install-linux.sh | 4 +- scripts/install-windows.ps1 | 16 +-- scripts/package.ps1 | 8 +- scripts/regen-one-patch.ps1 | 57 +++++++++ scripts/tests/install-linux-prerequisites.sh | 26 ++-- .../Config/OptimumAdaptiveRadiusController.cs | 2 +- .../VintagestoryApi/Config/OptimumConfig.cs | 2 +- .../VintagestoryApi/VintagestoryAPI.csproj | 5 + 61 files changed, 729 insertions(+), 592 deletions(-) delete mode 100644 patches/VintagestoryApi/VintagestoryAPI.csproj.patch create mode 100644 scripts/regen-one-patch.ps1 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 136ed3d1..4df8553f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "ilspycmd": { - "version": "10.1.1.8388", + "version": "11.0.0.9375", "commands": [ "ilspycmd" ] diff --git a/.config/ilspycmd-compat.json b/.config/ilspycmd-compat.json index 40ad53b6..19a6c1eb 100644 --- a/.config/ilspycmd-compat.json +++ b/.config/ilspycmd-compat.json @@ -1,4 +1,4 @@ { - "minimumVersion": "10.1.0.8386", - "maximumVersion": "10.1.1.8388" + "minimumVersion": "11.0.0.9375", + "maximumVersion": "11.0.0.9375" } diff --git a/INSTALLER-PLAN.md b/INSTALLER-PLAN.md index 493e6ace..8e6b7a19 100644 --- a/INSTALLER-PLAN.md +++ b/INSTALLER-PLAN.md @@ -175,8 +175,8 @@ against a build that takes twenty minutes and allocates gigabytes. 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 + (`11.0.0.9375`) and `.config/ilspycmd-compat.json` (`11.0.0.9375` through + `11.0.0.9375`). 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 @@ -275,7 +275,7 @@ integer. `detail` is a human string and carries no contract. Log: ```json -{"type":"log","level":"info","message":"ilspycmd 10.1.1.8388 accepted"} +{"type":"log","level":"info","message":"ilspycmd 11.0.0.9375 accepted"} ``` `level` is one of `info`, `warn`, `error`. diff --git a/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs b/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs index b7d31497..b0012c87 100644 --- a/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs +++ b/Optimum.Bootstrap.Core.TestSupport/FakeSystemProbe.cs @@ -25,34 +25,34 @@ public sealed class FakeSystemProbe : ISystemProbe public FakeSystemProbe AddFile(string path, string? content = null) { - Files.Add(path); + Files.Add(Norm(path)); if (content is not null) - FileContents[path] = content; + FileContents[Norm(path)] = content; return this; } public FakeSystemProbe AddDirectory(string path) { - Directories.Add(path); + Directories.Add(Norm(path)); return this; } public FakeSystemProbe AddSymlink(string path) { - Symlinks.Add(path); + Symlinks.Add(Norm(path)); return this; } public FakeSystemProbe AddNonExecutableFile(string path) { - Files.Add(path); - NonExecutable.Add(path); + Files.Add(Norm(path)); + NonExecutable.Add(Norm(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); + Commands[$"{Norm(exe)}|{args}"] = new ProcessOutcome(true, exitCode, stdout, string.Empty); return this; } @@ -61,25 +61,39 @@ public FakeSystemProbe OnCommand(string exe, string args, string stdout = "", in IReadOnlyList ISystemProbe.PathDirectories => Path; - bool ISystemProbe.FileExists(string path) => Files.Contains(path); + bool ISystemProbe.FileExists(string path) => Files.Contains(Norm(path)); - bool ISystemProbe.IsExecutable(string path) => Files.Contains(path) && !NonExecutable.Contains(path); + bool ISystemProbe.IsExecutable(string path) => Files.Contains(Norm(path)) && !NonExecutable.Contains(Norm(path)); - bool ISystemProbe.DirectoryExists(string path) => Directories.Contains(path); + bool ISystemProbe.DirectoryExists(string path) => Directories.Contains(Norm(path)); bool ISystemProbe.PathExists(string path) => - Files.Contains(path) || Directories.Contains(path) || Symlinks.Contains(path); + Files.Contains(Norm(path)) || Directories.Contains(Norm(path)) || Symlinks.Contains(Norm(path)); - bool ISystemProbe.IsSymbolicLink(string path) => Symlinks.Contains(path); + bool ISystemProbe.IsSymbolicLink(string path) => Symlinks.Contains(Norm(path)); string? ISystemProbe.ReadText(string path) => - FileContents.TryGetValue(path, out string? content) ? content : null; + FileContents.TryGetValue(Norm(path), out string? content) ? content : null; IEnumerable ISystemProbe.EnumerateFiles(string directory, string searchPattern) => - Files.Where(f => System.IO.Path.GetDirectoryName(f) == directory && Matches(f, searchPattern)); + Files.Where(f => DirOf(f) == Norm(directory) && Matches(f, searchPattern)); IEnumerable ISystemProbe.EnumerateDirectories(string directory, string searchPattern) => - Directories.Where(d => System.IO.Path.GetDirectoryName(d) == directory && Matches(d, searchPattern)); + Directories.Where(d => DirOf(d) == Norm(directory) && Matches(d, searchPattern)); + + /// + /// The fake models a POSIX filesystem with '/' separators. Production code + /// builds candidate paths with , which on + /// Windows inserts '\'. Normalise both the stored keys and every lookup to '/' + /// so the tests behave identically on Linux CI and a Windows developer box. + /// + private static string Norm(string path) => path.Replace('\\', '/'); + + private static string? DirOf(string path) + { + int slash = path.LastIndexOf('/'); + return slash <= 0 ? (slash == 0 ? "/" : null) : path[..slash]; + } private static bool Matches(string path, string searchPattern) { @@ -91,7 +105,7 @@ private static bool Matches(string path, string searchPattern) } ProcessOutcome ISystemProbe.Run(string executable, IReadOnlyList arguments, TimeSpan timeout) => - Commands.TryGetValue($"{executable}|{string.Join(' ', arguments)}", out ProcessOutcome outcome) + Commands.TryGetValue($"{Norm(executable)}|{string.Join(' ', arguments)}", out ProcessOutcome outcome) ? outcome : ProcessOutcome.NotStarted; } diff --git a/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs index af1ef809..81540023 100644 --- a/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs +++ b/Optimum.Bootstrap.Core.Tests/AcquisitionTests.cs @@ -57,8 +57,8 @@ 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"))); + "tool update -g ilspycmd --version 11.0.0.9375 --allow-downgrade", + string.Join(' ', IlspycmdAcquisition.ToolArguments("11.0.0.9375"))); } [Fact] diff --git a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs index 6d5b6cd8..5215d2bd 100644 --- a/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs +++ b/Optimum.Bootstrap.Core.Tests/DeployRoundTripTests.cs @@ -73,6 +73,22 @@ public void DeployRefusesANonEmptyDirectoryWithNoManifest() Assert.True(File.Exists(Path.Combine(occupied, "someone-elses-file"))); } + [Fact] + public void DeployCleansANonEmptyDirectoryWhenCleanDestinationIsTrue() + { + var probe = SystemProbe.Default; + string package = StagePackage(); + string occupied = Path.Combine(_root, "occupied-clean"); + Directory.CreateDirectory(occupied); + File.WriteAllText(Path.Combine(occupied, "someone-elses-file"), "x"); + + DeployResult result = new PackageDeployer(probe).Deploy(new DeployRequest(package, occupied, CleanDestination: true)); + + Assert.True(result.Ok); + Assert.False(File.Exists(Path.Combine(occupied, "someone-elses-file"))); + Assert.True(File.Exists(Path.Combine(occupied, "run.sh"))); + } + [Fact] public void DeployReplacesAnExistingOptimumInstallInPlace() { diff --git a/Optimum.Bootstrap.Core.Tests/GamePatcherTests.cs b/Optimum.Bootstrap.Core.Tests/GamePatcherTests.cs index 9ddce806..114ffd1c 100644 --- a/Optimum.Bootstrap.Core.Tests/GamePatcherTests.cs +++ b/Optimum.Bootstrap.Core.Tests/GamePatcherTests.cs @@ -185,7 +185,11 @@ public async Task RealFilesystemRoundTripPatchAndRollback() string patcherExe = Path.Combine(overlayDir, OperatingSystem.IsWindows() ? "Optimum.Patcher.bat" : "Optimum.Patcher"); if (OperatingSystem.IsWindows()) { - File.WriteAllText(patcherExe, "@echo off\r\necho patched > %~dpnx3\r\nexit /b 0\r\n"); + // Mirror the Unix stub: write "patched" to the LAST argument, + // whichever position it is (the lib call passes 3 args, the --api + // call passes 4). A for-loop keeps the final token in %%t. + File.WriteAllText(patcherExe, + "@echo off\r\nset \"target=\"\r\nfor %%t in (%*) do set \"target=%%~t\"\r\necho patched > \"%target%\"\r\nexit /b 0\r\n"); } else { diff --git a/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs b/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs index 0e71fecd..969e3106 100644 --- a/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs +++ b/Optimum.Bootstrap.Core.Tests/IlspycmdVersionTests.cs @@ -13,27 +13,25 @@ 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")] + [InlineData("11.0.0.9375")] 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("11.0.0.9374")] + [InlineData("11.0.0.9376")] + [InlineData("10.1.1.8388")] + [InlineData("10.1.0.8386")] + [InlineData("11.0.1.0")] + [InlineData("11.1.0.0")] + [InlineData("12.0.0.0")] [InlineData("10.2.0.1")] - [InlineData("10.0.0.8323-preview3")] - [InlineData("10.1.1.8388-rc1")] + [InlineData("11.0.0.9375-rc1")] [InlineData("")] [InlineData("not-a-version")] - [InlineData("10.1.1")] + [InlineData("11.0.0")] public void RejectsEverythingElse(string version) { Assert.False(Range.Supports(version)); @@ -44,15 +42,15 @@ public void ReadsTheRangeAndPinFromConfigFiles() { var probe = new FakeSystemProbe(); probe.AddFile("/repo/.config/ilspycmd-compat.json", - """{ "minimumVersion": "10.1.0.8386", "maximumVersion": "10.1.1.8388" }"""); + """{ "minimumVersion": "11.0.0.9375", "maximumVersion": "11.0.0.9375" }"""); probe.AddFile("/repo/.config/dotnet-tools.json", - """{ "version": 1, "tools": { "ilspycmd": { "version": "10.1.1.8388" } } }"""); + """{ "version": 1, "tools": { "ilspycmd": { "version": "11.0.0.9375" } } }"""); 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); + Assert.Equal("11.0.0.9375", compat.Pin); + Assert.Equal(new IlspycmdVersion(11, 0, 0, 9375), compat.Minimum); + Assert.Equal(new IlspycmdVersion(11, 0, 0, 9375), compat.Maximum); } [Fact] diff --git a/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs index 8d134b8d..0f323784 100644 --- a/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs +++ b/Optimum.Bootstrap.Core.Tests/PrerequisiteScannerTests.cs @@ -70,14 +70,14 @@ public void AllRequiredPresentWhenTheSdkAndAnInRangeDecompilerAreThere() 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"); + probe.OnCommand("/home/tester/.dotnet/tools/ilspycmd", "--version", "ilspycmd: 11.0.0.9375\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); + Assert.Equal("11.0.0.9375", ilspy.DetectedVersion); } [Fact] @@ -93,7 +93,7 @@ public void AnOutOfRangeDecompilerIsReportedOutdatedWithTheUpdateCommand() Assert.Equal(PrerequisiteState.Outdated, ilspy.State); Assert.Equal( - "dotnet tool update -g ilspycmd --version 10.1.1.8388 --allow-downgrade", + "dotnet tool update -g ilspycmd --version 11.0.0.9375 --allow-downgrade", ilspy.AcquisitionCommand); } diff --git a/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs b/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs index 80ddb92f..27acf93f 100644 --- a/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs +++ b/Optimum.Bootstrap.Core.Tests/ShortcutWriterTests.cs @@ -12,8 +12,8 @@ public sealed class ShortcutWriterTests : IDisposable private FakeSystemProbe LinuxProbe() { - var probe = new FakeSystemProbe { Os = OsKind.Linux, HomeDirectory = _home }; - probe.Environment["XDG_DATA_HOME"] = Path.Combine(_home, ".local", "share"); + var probe = new FakeSystemProbe { Os = OsKind.Linux, HomeDirectory = _home.Replace('\\', '/') }; + probe.Environment["XDG_DATA_HOME"] = _home.Replace('\\', '/') + "/.local/share"; return probe; } @@ -21,15 +21,20 @@ private FakeSystemProbe LinuxProbe() public void WritesAndRemovesTheLinuxMenuAndDesktopEntries() { FakeSystemProbe probe = LinuxProbe(); - string installDir = Path.Combine(_home, "games", "optimum"); - string launcher = Path.Combine(installDir, "optimum-launch.sh"); + // A Linux install uses '/' paths. Build them with '/' so the generated + // .desktop Exec matches regardless of the host running the test (on + // Windows Path.Combine would inject '\', which the Desktop Entry writer + // then backslash-escapes, diverging from this assertion). + string home = _home.Replace('\\', '/'); + string installDir = $"{home}/games/optimum"; + string launcher = $"{installDir}/optimum-launch.sh"; Directory.CreateDirectory(installDir); var writer = new ShortcutWriter(probe); IReadOnlyList created = writer.Create(installDir, launcher, ShortcutKinds.Menu | ShortcutKinds.Desktop); - string menuEntry = Path.Combine(_home, ".local", "share", "applications", "optimum.desktop"); - string desktopEntry = Path.Combine(_home, "Desktop", "Optimum.desktop"); + string menuEntry = $"{home}/.local/share/applications/optimum.desktop"; + string desktopEntry = $"{home}/Desktop/Optimum.desktop"; Assert.Contains(menuEntry, created); Assert.Contains(desktopEntry, created); Assert.True(File.Exists(menuEntry)); diff --git a/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs b/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs index 6bcf935e..9ca84278 100644 --- a/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs +++ b/Optimum.Bootstrap.Core.Tests/SourceAcquisitionTests.cs @@ -213,11 +213,11 @@ public async Task DoesNotReuseAnIncompleteWindowsCheckout() public async Task ReusesACompleteWindowsCheckoutFromTheSourceCache() { var probe = new FakeSystemProbe { Os = OsKind.Windows }; - probe.Environment["LOCALAPPDATA"] = "/cache"; - string cached = "/cache/optimum/src-v0.3.14"; - probe.AddFile($"{cached}/forks.json"); - probe.AddFile($"{cached}/scripts/bootstrap.ps1"); - probe.AddFile($"{cached}/scripts/package.ps1"); + probe.Environment["LOCALAPPDATA"] = @"C:\cache"; + string cached = @"C:\cache\optimum\src-v0.3.14"; + probe.AddFile($@"{cached}\forks.json"); + probe.AddFile($@"{cached}\scripts\bootstrap.ps1"); + probe.AddFile($@"{cached}\scripts\package.ps1"); // No Git on PATH: proves the complete cached source is the one used. var result = await new GitSourceProvider(probe) diff --git a/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs b/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs index 6329ba29..6cd3150b 100644 --- a/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs +++ b/Optimum.Bootstrap.Core/Acquisition/SdkAcquisition.cs @@ -34,9 +34,10 @@ public static Decision Evaluate(ISystemProbe probe, string repoRoot) "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; + char sep = windows ? '\\' : '/'; + string installDir = probe.HomeDirectory.TrimEnd('/', '\\') + sep + ".dotnet"; + string globalJson = repoRoot.TrimEnd('/', '\\') + sep + "global.json"; var args = windows ? new List { "-InstallDir", installDir, "-NoPath" } diff --git a/Optimum.Bootstrap.Core/Build/RepoRoot.cs b/Optimum.Bootstrap.Core/Build/RepoRoot.cs index fc94e6d2..354800ac 100644 --- a/Optimum.Bootstrap.Core/Build/RepoRoot.cs +++ b/Optimum.Bootstrap.Core/Build/RepoRoot.cs @@ -12,11 +12,14 @@ public static class RepoRoot { public static string? Discover(ISystemProbe probe, string? explicitRoot = null) { - string start = explicitRoot is not null - ? Path.GetFullPath(explicitRoot) - : Directory.GetCurrentDirectory(); + // An explicit root is already an absolute path in the probe's world, so + // walk it as given. Only the implicit case needs the real working + // directory. Resolving an explicit path through Path.GetFullPath would + // rewrite a POSIX path against the host drive on Windows, which is wrong + // whenever the probe models a different platform (and in tests). + string start = explicitRoot ?? Directory.GetCurrentDirectory(); - for (string? dir = start; dir is not null; dir = Path.GetDirectoryName(dir)) + for (string? dir = start; dir is not null; dir = ParentOf(dir)) { if (SourceCache.IsUsableCheckout(probe, dir)) return dir; @@ -24,4 +27,17 @@ public static class RepoRoot return null; } + + /// + /// The parent of a path, honouring both separators so a POSIX path resolves + /// the same way regardless of the host the binary runs on. Returns null at + /// the root so the walk terminates. + /// + private static string? ParentOf(string dir) + { + int cut = dir.TrimEnd('/', '\\').LastIndexOfAny(['/', '\\']); + if (cut < 0) + return null; + return cut == 0 ? dir[..1] : dir[..cut]; + } } diff --git a/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs b/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs index 6b134217..70a7c5cb 100644 --- a/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs +++ b/Optimum.Bootstrap.Core/Build/SourceAcquisition.cs @@ -51,18 +51,30 @@ public static class SourceCache public static string Directory(ISystemProbe probe, string version, string? overrideRoot = null) { string root = overrideRoot ?? DefaultRoot(probe); - return Path.Combine(root, "optimum", "src-" + SanitizeVersion(version)); + return Join(probe, root, "optimum", "src-" + SanitizeVersion(version)); } private static string DefaultRoot(ISystemProbe probe) => probe.Os switch { OsKind.Windows => probe.GetEnvironmentVariable("LOCALAPPDATA") - ?? Path.Combine(probe.HomeDirectory, "AppData", "Local"), - OsKind.MacOs => Path.Combine(probe.HomeDirectory, "Library", "Caches"), + ?? Join(probe, probe.HomeDirectory, "AppData", "Local"), + OsKind.MacOs => Join(probe, probe.HomeDirectory, "Library", "Caches"), _ => probe.GetEnvironmentVariable("XDG_CACHE_HOME") - ?? Path.Combine(probe.HomeDirectory, ".cache"), + ?? Join(probe, probe.HomeDirectory, ".cache"), }; + /// + /// Joins path parts with the separator of the probed platform. In + /// production probe.Os matches the host, so a Windows install gets native + /// backslash paths and a Linux/macOS install gets '/'. The FakeSystemProbe + /// normalises separators, so cross-platform tests match either form. + /// + private static string Join(ISystemProbe probe, string root, params string[] parts) + { + char sep = probe.Os == OsKind.Windows ? '\\' : '/'; + return root.TrimEnd('/', '\\') + sep + string.Join(sep, parts); + } + /// /// A filesystem-safe token for the version, prefixed v when it starts /// with a digit so it matches the release tag naming (v0.3.14). diff --git a/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs b/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs index 464688ff..42d45a10 100644 --- a/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs +++ b/Optimum.Bootstrap.Core/DataPath/DataPathProbe.cs @@ -41,24 +41,29 @@ private static string[] Candidates(ISystemProbe probe) { OsKind.Windows => [ - Combine(probe.GetEnvironmentVariable("APPDATA"), "VintagestoryData"), - Combine(probe.GetEnvironmentVariable("APPDATA"), "OptimumData"), + WinCombine(probe.GetEnvironmentVariable("APPDATA"), "VintagestoryData"), + WinCombine(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"), + Posix(home, "Library", "Application Support", "VintagestoryData"), + Posix(home, "Library", "Application Support", "OptimumVintagestoryData"), + Posix(home, ".config", "VintagestoryData"), ], _ => [ - System.IO.Path.Combine(home, ".config", "VintagestoryData"), - System.IO.Path.Combine(home, ".config", "OptimumVintagestoryData"), - System.IO.Path.Combine(home, "ApplicationData", "vintagestorydata"), + Posix(home, ".config", "VintagestoryData"), + Posix(home, ".config", "OptimumVintagestoryData"), + Posix(home, "ApplicationData", "vintagestorydata"), ], }; - static string Combine(string? root, string child) => + // The macOS and Linux data folders follow POSIX '/' convention regardless + // of the host the installer binary happens to run on, so join with '/' + // rather than System.IO.Path.Combine (which would emit '\' on Windows). + static string Posix(params string[] parts) => string.Join('/', parts); + + static string WinCombine(string? root, string child) => root is { Length: > 0 } ? System.IO.Path.Combine(root, child) : child; } } diff --git a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs index 106b3bb2..d300500c 100644 --- a/Optimum.Bootstrap.Core/Install/PackageDeployer.cs +++ b/Optimum.Bootstrap.Core/Install/PackageDeployer.cs @@ -16,7 +16,8 @@ public sealed record DeployRequest( string PackageDirectory, string InstallDirectory, string? DataPath = null, - ShortcutKinds Shortcuts = ShortcutKinds.None); + ShortcutKinds Shortcuts = ShortcutKinds.None, + bool CleanDestination = false); public sealed record DeployResult(bool Ok, FailureReason? Reason, string? Message, string? InstallDirectory, string? Launcher) { @@ -42,7 +43,7 @@ public sealed class PackageDeployer(ISystemProbe probe) : IPackageInstaller public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = null) { InstallPathVerdict guard = InstallPathGuard.Check(probe, new InstallPathRequest( - request.InstallDirectory, request.DataPath)); + request.InstallDirectory, request.DataPath, CleanDestination: request.CleanDestination)); if (!guard.Ok) return DeployResult.Failure(FailureReason.BadInput, guard.Rejection!); @@ -57,7 +58,7 @@ public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = nul return DeployResult.Failure(FailureReason.BadInput, $"the install directory has no parent: {installDir}"); bool hasExisting = Directory.Exists(installDir) && Directory.EnumerateFileSystemEntries(installDir).Any(); - if (hasExisting && !File.Exists(Path.Combine(installDir, InstallManifest.RelativePath))) + if (hasExisting && !request.CleanDestination && !File.Exists(Path.Combine(installDir, InstallManifest.RelativePath))) return DeployResult.Failure(FailureReason.OutputExists, $"the install directory is not empty: {installDir}"); Directory.CreateDirectory(parent); @@ -115,7 +116,7 @@ public DeployResult Deploy(DeployRequest request, IBuildObserver? observer = nul try { if (backedUp && Directory.Exists(backupDir)) - Directory.Delete(backupDir, recursive: true); + TryDelete(backupDir); RegisterInstall(installDir, request, finalLauncher, observer); } catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) @@ -333,7 +334,13 @@ private static void TryDelete(string directory) try { if (Directory.Exists(directory)) + { + foreach (string file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + try { File.SetAttributes(file, FileAttributes.Normal); } catch { } + } Directory.Delete(directory, recursive: true); + } } catch (IOException) { /* best effort */ } catch (UnauthorizedAccessException) { /* best effort */ } diff --git a/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs b/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs index f71b202a..8f4904c2 100644 --- a/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs +++ b/Optimum.Bootstrap.Core/Install/ShortcutWriter.cs @@ -45,19 +45,27 @@ private List CreateLinux(string installDirectory, string launcherPath, S string home = probe.HomeDirectory; string dataHome = probe.GetEnvironmentVariable("XDG_DATA_HOME") is { Length: > 0 } x ? x - : Path.Combine(home, ".local", "share"); + : Posix(home, ".local", "share"); - string? icon = InstallIcon(installDirectory, Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps", "optimum.png")); + string? icon = InstallIcon(installDirectory, Posix(dataHome, "icons", "hicolor", "256x256", "apps", "optimum.png")); string entry = DesktopEntry(launcherPath, installDirectory, icon); if (kinds.HasFlag(ShortcutKinds.Menu)) - written.AddRange(WriteText(Path.Combine(dataHome, "applications", "optimum.desktop"), entry, executable: true)); + written.AddRange(WriteText(Posix(dataHome, "applications", "optimum.desktop"), entry, executable: true)); if (kinds.HasFlag(ShortcutKinds.Desktop)) - written.AddRange(WriteText(Path.Combine(home, "Desktop", "Optimum.desktop"), entry, executable: true)); + written.AddRange(WriteText(Posix(home, "Desktop", "Optimum.desktop"), entry, executable: true)); return written; } + /// + /// Joins Linux paths with '/', the separator the target platform uses, rather + /// than System.IO.Path.Combine (which emits '\' on a Windows build host and + /// would produce mixed separators in the generated entries). + /// + private static string Posix(string root, params string[] parts) => + root.TrimEnd('/', '\\') + "/" + string.Join('/', parts); + private List CreateMac(string installDirectory, ShortcutKinds kinds) { var written = new List(); diff --git a/Optimum.Bootstrap.Core/Patch/GamePatcher.cs b/Optimum.Bootstrap.Core/Patch/GamePatcher.cs index 026d3f22..14519b6c 100644 --- a/Optimum.Bootstrap.Core/Patch/GamePatcher.cs +++ b/Optimum.Bootstrap.Core/Patch/GamePatcher.cs @@ -57,7 +57,7 @@ public Task PatchAsync( if (!Path.IsPathRooted(request.GameDirectory)) return Task.FromResult(PatchResult.Failure(FailureReason.BadInput, $"--game-dir must be an absolute path: {request.GameDirectory}")); - string gameDir = Path.GetFullPath(request.GameDirectory); + string gameDir = NormalizeAbsolute(request.GameDirectory); if (!probe.DirectoryExists(gameDir)) return Task.FromResult(PatchResult.Failure(FailureReason.BadInput, $"the game directory does not exist: {gameDir}")); @@ -90,7 +90,7 @@ public Task PatchAsync( if (!Path.IsPathRooted(request.OverlayDirectory)) return Task.FromResult(PatchResult.Failure(FailureReason.BadInput, $"--overlay must be an absolute path: {request.OverlayDirectory}")); - overlayDir = Path.GetFullPath(request.OverlayDirectory); + overlayDir = NormalizeAbsolute(request.OverlayDirectory); if (!probe.DirectoryExists(overlayDir)) return Task.FromResult(PatchResult.Failure(FailureReason.BadInput, $"the overlay directory does not exist: {overlayDir}")); } @@ -343,6 +343,17 @@ public Task PatchAsync( return Task.FromResult(PatchResult.Success(gameDir, patchedTargets)); } + /// + /// Normalises an already-absolute path without rewriting it against the host + /// drive. would turn a POSIX path like + /// /game into C:\game on Windows, which is wrong whenever the + /// probe models another platform (and in cross-platform tests). Relative + /// paths are rejected by the caller before this runs, so the input is always + /// rooted; collapse only redundant separators. + /// + private static string NormalizeAbsolute(string path) => + path.Length > 1 ? path.TrimEnd('/', '\\') : path; + private PatchResult Rollback(string gameDir, IBuildObserver? observer) { string vanillaBackupDir = Path.Combine(gameDir, ".optimum", "vanilla"); @@ -494,10 +505,18 @@ private void MergeJsonFile(string srcFile, string dstFile) foreach (string dir in dirs) { - string exeName = probe.Os == OsKind.Windows ? "Optimum.Patcher.exe" : "Optimum.Patcher"; - string exePath = Path.Combine(dir, exeName); - if (probe.FileExists(exePath) && (probe.Os == OsKind.Windows || probe.IsExecutable(exePath))) - return (exePath, false); + // On Windows the patcher may ship as a native .exe or, in dev and test + // setups, a .bat/.cmd wrapper; accept either. On Unix it is the + // extensionless executable. + string[] exeNames = probe.Os == OsKind.Windows + ? ["Optimum.Patcher.exe", "Optimum.Patcher.bat", "Optimum.Patcher.cmd"] + : ["Optimum.Patcher"]; + foreach (string exeName in exeNames) + { + string exePath = Path.Combine(dir, exeName); + if (probe.FileExists(exePath) && (probe.Os == OsKind.Windows || probe.IsExecutable(exePath))) + return (exePath, false); + } string dllPath = Path.Combine(dir, "Optimum.Patcher.dll"); if (probe.FileExists(dllPath)) @@ -586,6 +605,17 @@ private ProcessOutcome RunPatcher(string patcherPath, bool isDll, IReadOnlyList< return probe.Run("dotnet", commandArgs, TimeSpan.FromMinutes(5)); } + // A .bat/.cmd cannot be launched directly when UseShellExecute is false; + // route it through the command interpreter. Native executables run as-is. + if (probe.Os == OsKind.Windows + && (patcherPath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase) + || patcherPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase))) + { + var commandArgs = new List { "/c", patcherPath }; + commandArgs.AddRange(args); + return probe.Run("cmd.exe", commandArgs, TimeSpan.FromMinutes(5)); + } + return probe.Run(patcherPath, args, TimeSpan.FromMinutes(5)); } diff --git a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs index e9f1a2d4..2d05321d 100644 --- a/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs +++ b/Optimum.Bootstrap.Core/Paths/InstallPathGuard.cs @@ -9,7 +9,8 @@ public sealed record InstallPathRequest( string? DataPath = null, string? VintageStoryDirectory = null, string? WorkspaceRoot = null, - string? BuildRoot = null); + string? BuildRoot = null, + bool CleanDestination = false); public sealed record InstallPathVerdict(bool Ok, string? Rejection) { @@ -61,7 +62,7 @@ public static InstallPathVerdict Check(ISystemProbe probe, InstallPathRequest re $"The install directory cannot be inside a Vintage Story installation ({vsDir}). Optimum installs to a separate location."); } - if (LooksLikeVanillaGame(probe, install)) + if (!request.CleanDestination && LooksLikeVanillaGame(probe, install)) return InstallPathVerdict.Reject( "The install directory already holds a vanilla Vintage Story installation. Optimum installs to a separate location."); @@ -150,10 +151,12 @@ private static IEnumerable KnownVintageStoryDirectories(ISystemProbe pro 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")); + char sep = probe.Os == OsKind.Windows ? '\\' : '/'; + string d = directory.TrimEnd('\\', '/'); + bool hasGame = probe.FileExists(d + sep + "Vintagestory") + || probe.FileExists(d + sep + "Vintagestory.exe"); + bool hasOptimum = probe.FileExists(d + sep + "Optimum") + || probe.FileExists(d + sep + "Optimum.exe"); return hasGame && !hasOptimum; } @@ -175,7 +178,7 @@ private static string Canonical(ISystemProbe probe, string path) return trimmed.TrimEnd('\\'); } - trimmed = trimmed.TrimEnd('/'); + trimmed = trimmed.Replace('\\', '/').TrimEnd('/'); return trimmed.Length == 0 ? "/" : trimmed; } diff --git a/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs index dc755c15..5041563e 100644 --- a/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs +++ b/Optimum.Bootstrap.Core/Paths/SymlinkComponentCheck.cs @@ -20,8 +20,11 @@ public static class SymlinkComponentCheck /// public static string? FirstSymlinkComponent(ISystemProbe probe, string path, bool requireExists = false) { - string full = Path.GetFullPath(path); - string? current = full; + // Walk the components as given rather than through Path.GetFullPath / + // Path.GetDirectoryName, which on Windows rewrite a POSIX path against the + // host drive and split on '\'. The probe already holds absolute paths; in + // production probe.Os matches the host so behaviour is unchanged. + string? current = path; while (!string.IsNullOrEmpty(current)) { @@ -35,7 +38,7 @@ public static class SymlinkComponentCheck throw new DirectoryNotFoundException($"Path component does not exist: {current}"); } - string? parent = Path.GetDirectoryName(current); + string? parent = ParentOf(current); if (parent is null || parent == current) break; current = parent; @@ -44,5 +47,14 @@ public static class SymlinkComponentCheck return null; } + /// Parent of a path, honouring both separators; null at the root. + private static string? ParentOf(string dir) + { + int cut = dir.TrimEnd('/', '\\').LastIndexOfAny(['/', '\\']); + if (cut < 0) + return null; + return cut == 0 ? dir[..1] : dir[..cut]; + } + 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 index 03345c4a..6c481990 100644 --- a/Optimum.Bootstrap.Core/Platform/CommandSearch.cs +++ b/Optimum.Bootstrap.Core/Platform/CommandSearch.cs @@ -14,11 +14,17 @@ public static class CommandSearch ? [command, command + ".exe", command + ".cmd", command + ".bat"] : [command]; + // Join with the probed platform's separator rather than the host's, so a + // POSIX PATH entry yields a POSIX candidate even when the binary runs on + // Windows. In production probe.Os matches the host, so this is a no-op + // there; it only matters for cross-platform tests. + char sep = probe.Os == OsKind.Windows ? '\\' : '/'; + foreach (string dir in probe.PathDirectories) { foreach (string name in names) { - string candidate = Path.Combine(dir, name); + string candidate = dir.TrimEnd('/', '\\') + sep + name; if (probe.IsExecutable(candidate)) return candidate; } diff --git a/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs b/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs index e95a4d91..1d41e480 100644 --- a/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs +++ b/Optimum.Bootstrap.Core/Prerequisites/IlspycmdVersion.cs @@ -59,9 +59,9 @@ public readonly record struct IlspycmdCompatibility(IlspycmdVersion Minimum, Ils { /// 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"); + new IlspycmdVersion(11, 0, 0, 9375), + new IlspycmdVersion(11, 0, 0, 9375), + "11.0.0.9375"); public bool Supports(string? version) => IlspycmdVersion.TryParse(version, out var parsed) && parsed >= Minimum && parsed <= Maximum; diff --git a/Optimum.Cli/CliRunner.cs b/Optimum.Cli/CliRunner.cs index 1f9da3ac..00ff57d2 100644 --- a/Optimum.Cli/CliRunner.cs +++ b/Optimum.Cli/CliRunner.cs @@ -244,9 +244,10 @@ private static int Install(IReadOnlyList args, ISystemProbe probe, Engin return installError; ShortcutKinds shortcuts = ParseShortcuts(parsed.Get("--shortcuts")); + bool clean = parsed.Has("--clean") || parsed.Has("--force"); DeployResult result = new PackageDeployer(probe).Deploy( - new DeployRequest(package, installDir, parsed.Get("--data-path"), shortcuts), output); + new DeployRequest(package, installDir, parsed.Get("--data-path"), shortcuts, clean), output); return result.Ok ? output.Success(result.InstallDirectory!) @@ -346,7 +347,10 @@ private static int Uninstall(IReadOnlyList args, ISystemProbe probe, Eng return null; } errorCode = ExitOk; - return Path.GetFullPath(value); + // The value is already validated as absolute; trim a trailing separator + // but do not run it through Path.GetFullPath, which on Windows rewrites a + // POSIX path against the host drive (e.g. /abs/game -> C:\abs\game). + return value.Length > 1 ? value.TrimEnd('/', '\\') : value; } private static ShortcutKinds ParseShortcuts(string? value) diff --git a/Optimum.Installer.Tests/ScreenViewModelTests.cs b/Optimum.Installer.Tests/ScreenViewModelTests.cs index 1d1d51b5..4fffb142 100644 --- a/Optimum.Installer.Tests/ScreenViewModelTests.cs +++ b/Optimum.Installer.Tests/ScreenViewModelTests.cs @@ -511,38 +511,41 @@ public async Task InterestingRawOutputReachesTheLogPaneButNoiseDoesNot() public class CompletionViewModelTests { - private static InstallOutcome Success(string launcher) => + private static InstallOutcome Success(string? installDir = "/opt/optimum") => new(Succeeded: true, Cancelled: false, Message: "ok", - InstallDirectory: "/opt/optimum", Launcher: launcher, RawLogPath: "/does/not/exist.log"); + InstallDirectory: installDir, Launcher: null, RawLogPath: "/does/not/exist.log"); [Fact] - public async Task LaunchDisablesTheButtonAndThenAsksTheShellToExit() + public void FinishAsksTheShellToExit() { - string launcher = Path.Combine(Path.GetTempPath(), "optimum-launch-" + Guid.NewGuid().ToString("N") + ".sh"); - await File.WriteAllTextAsync(launcher, "#!/bin/sh\nexit 0\n", TestContext.Current.CancellationToken); - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(launcher, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var vm = new CompletionViewModel(Success()); + bool exitAsked = false; + vm.ExitRequested += () => exitAsked = true; - try - { - var vm = new CompletionViewModel(Success(launcher)); - bool exitAsked = false; - vm.ExitRequested += () => exitAsked = true; + Assert.True(vm.FinishCommand.CanExecute(null)); + vm.FinishCommand.Execute(null); - Assert.True(vm.LaunchCommand.CanExecute(null)); + Assert.True(exitAsked); + } - System.Threading.Tasks.Task run = vm.LaunchCommand.ExecuteAsync(null); - Assert.True(vm.Launching); - Assert.False(vm.LaunchCommand.CanExecute(null)); - Assert.Equal("Launching Optimum...", vm.LaunchLabel); + [Fact] + public void OpenFolderIsEnabledOnlyWhenTheInstallDirectoryExists() + { + string dir = Path.Combine(Path.GetTempPath(), "optimum-install-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var present = new CompletionViewModel(Success(dir)); + Assert.True(present.CanOpenFolder); + Assert.True(present.OpenFolderCommand.CanExecute(null)); - await run.WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); - Assert.True(exitAsked); + var missing = new CompletionViewModel(Success("/does/not/exist")); + Assert.False(missing.CanOpenFolder); + Assert.False(missing.OpenFolderCommand.CanExecute(null)); } finally { - File.Delete(launcher); + Directory.Delete(dir, recursive: true); } } } diff --git a/Optimum.Installer/App.axaml b/Optimum.Installer/App.axaml index 9c961c09..c97c4c5d 100644 --- a/Optimum.Installer/App.axaml +++ b/Optimum.Installer/App.axaml @@ -1,96 +1,29 @@ - - - - avares://Optimum.Installer/Assets/Fonts/Lexend/Lexend-VF.ttf#Lexend - avares://Optimum.Installer/Assets/Fonts/Lexend/Lexend-VF.ttf#Lexend - avares://Optimum.Installer/Assets/Fonts/JetBrainsMono/JetBrainsMono-VF.ttf#JetBrains Mono - - - - - - - - - - - #F6FAFB - - - - - - #193B44 - - - - - - - - - - - - - - - + + - - diff --git a/Optimum.Installer/App.axaml.cs b/Optimum.Installer/App.axaml.cs index c3182c02..03a13beb 100644 --- a/Optimum.Installer/App.axaml.cs +++ b/Optimum.Installer/App.axaml.cs @@ -1,12 +1,9 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Avalonia.Media; using Optimum.Installer.Services; using Optimum.Installer.ViewModels; using Optimum.Installer.Views; -using SukiUI; -using SukiUI.Models; namespace Optimum.Installer; @@ -16,14 +13,8 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - // Cyan throughout: a deep cyan primary (buttons, step markers, links, - // progress) and a brighter cyan accent. The window surfaces are set to - // matching cyan-slate tones in App.axaml so nothing reads green or grey. - SukiTheme.GetInstance().ChangeColorTheme(new SukiColorTheme( - "Optimum", - primary: Color.Parse("#0B7C97"), - accent: Color.Parse("#22A5C2"))); - + // Native Fluent theme; the OS supplies the accent colour and the + // light/dark variant. No runtime colour setup is needed. if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var shell = new MainWindowViewModel(InstallerServices.CreateReal()); diff --git a/Optimum.Installer/Optimum.Installer.csproj b/Optimum.Installer/Optimum.Installer.csproj index 7ea5f598..ef69be69 100644 --- a/Optimum.Installer/Optimum.Installer.csproj +++ b/Optimum.Installer/Optimum.Installer.csproj @@ -12,6 +12,7 @@ true Avalonia GUI for the Optimum installer. Links Optimum.Bootstrap.Core in-process. $(OptimumVersion) + ..\Optimum.Launcher\optimum.ico - - + + diff --git a/Optimum.Installer/Program.cs b/Optimum.Installer/Program.cs index a90d8e30..adad7c38 100644 --- a/Optimum.Installer/Program.cs +++ b/Optimum.Installer/Program.cs @@ -23,7 +23,7 @@ public static AppBuilder BuildAvaloniaApp() => .With(new FontManagerOptions { // Lexend is also the app-wide default so any text outside a - // SukiUI template (tooltips, flyouts) matches. + // control template (tooltips, flyouts) matches. DefaultFamilyName = "avares://Optimum.Installer/Assets/Fonts/Lexend/Lexend-VF.ttf#Lexend", FontFallbacks = [new FontFallback { FontFamily = new FontFamily("Segoe UI") }], }) diff --git a/Optimum.Installer/ViewModels/CompletionViewModel.cs b/Optimum.Installer/ViewModels/CompletionViewModel.cs index 923677d7..548393c6 100644 --- a/Optimum.Installer/ViewModels/CompletionViewModel.cs +++ b/Optimum.Installer/ViewModels/CompletionViewModel.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace Optimum.Installer.ViewModels; @@ -22,13 +21,14 @@ public sealed partial class CompletionViewModel(InstallOutcome outcome) : ViewMo /// A line that adds to the headline rather than repeating it. public string Subtext => Outcome.Succeeded - ? "Launch it from the button below, or from your application menu." + ? "Launch Optimum from your application menu or the install folder." : Outcome.Message; public string Message => Outcome.Message; public string? InstallDirectory => Outcome.InstallDirectory; - public bool CanLaunch => Outcome.Launcher is not null && File.Exists(Outcome.Launcher); + /// Offer to open the install folder only when there is one to open. + public bool CanOpenFolder => Outcome.InstallDirectory is not null && Directory.Exists(Outcome.InstallDirectory); public bool HasLog => File.Exists(Outcome.RawLogPath); @@ -37,96 +37,34 @@ public sealed partial class CompletionViewModel(InstallOutcome outcome) : ViewMo public event Action? RetryRequested; - /// Raised once the launched game has a window; the shell then exits. + /// Raised when the user finishes the wizard; the shell then exits. public event Action? ExitRequested; - /// True from the moment Launch is clicked until the shell closes. - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(LaunchCommand))] - private bool _launching; - - public string LaunchLabel => Launching ? "Launching Optimum..." : "Launch Optimum"; - - partial void OnLaunchingChanged(bool value) => OnPropertyChanged(nameof(LaunchLabel)); - [RelayCommand(CanExecute = nameof(CanRetry))] private void Retry() => RetryRequested?.Invoke(); - private bool CanLaunchNow => CanLaunch && !Launching; + /// Close the installer. Standard "Finish" button on the final screen. + [RelayCommand] + private void Finish() => ExitRequested?.Invoke(); - [RelayCommand(CanExecute = nameof(CanLaunchNow))] - private async Task Launch() + [RelayCommand(CanExecute = nameof(CanOpenFolder))] + private void OpenFolder() { - if (Outcome.Launcher is not { } launcher || Launching) + if (Outcome.InstallDirectory is not { } dir) return; - - Launching = true; try { - // Run the launcher script directly. UseShellExecute would route a .sh - // through xdg-open on Linux, which opens it in an editor rather than - // running it. + // Open the install directory in the platform file manager. ProcessStartInfo start = OperatingSystem.IsWindows() - ? new ProcessStartInfo("cmd.exe", $"/c \"{launcher}\"") { UseShellExecute = false, CreateNoWindow = true } - : new ProcessStartInfo(launcher) { UseShellExecute = false }; - start.WorkingDirectory = Path.GetDirectoryName(launcher) ?? Environment.CurrentDirectory; + ? new ProcessStartInfo("explorer.exe", $"\"{dir}\"") + : new ProcessStartInfo(dir) { UseShellExecute = true }; Process.Start(start); - - await WaitForGameWindowAsync(); } catch (Exception) { - // Let the user try again rather than leaving a dead spinner. - Launching = false; - return; - } - - ExitRequested?.Invoke(); - } - - /// - /// Holds the spinner until the launched game has put a window up. On Windows - /// this polls the Optimum process for a main window handle; elsewhere there - /// is no cheap window probe, so it waits a short fixed moment. Either way it - /// gives up after 45s and closes the installer anyway. - /// - private static async Task WaitForGameWindowAsync() - { - if (!OperatingSystem.IsWindows()) - { - await Task.Delay(TimeSpan.FromSeconds(3)); - return; - } - - DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(45); - while (DateTime.UtcNow < deadline) - { - if (await Task.Run(() => HasVisibleWindow("Optimum") || HasVisibleWindow("Vintagestory"))) - return; - await Task.Delay(400); - } - } - - private static bool HasVisibleWindow(string processName) - { - foreach (Process process in Process.GetProcessesByName(processName)) - { - try - { - process.Refresh(); - if (process.MainWindowHandle != IntPtr.Zero) - return true; - } - catch (Exception) - { - // Access denied / exited between the enumerate and the read. - } - finally - { - process.Dispose(); - } + // Opening the folder is a convenience; ignore a failure rather than + // breaking the finish screen. } - return false; } [RelayCommand(CanExecute = nameof(HasLog))] diff --git a/Optimum.Installer/ViewModels/InstallSession.cs b/Optimum.Installer/ViewModels/InstallSession.cs index 1de2036b..b6c74aad 100644 --- a/Optimum.Installer/ViewModels/InstallSession.cs +++ b/Optimum.Installer/ViewModels/InstallSession.cs @@ -8,7 +8,8 @@ public sealed record InstallSession( string InstallDirectory, string? DataPath, string? Version, - ShortcutKinds Shortcuts); + ShortcutKinds Shortcuts, + bool CleanDestination = false); public sealed record InstallOutcome( bool Succeeded, diff --git a/Optimum.Installer/ViewModels/MainWindowViewModel.cs b/Optimum.Installer/ViewModels/MainWindowViewModel.cs index 9693e06e..a1678cb7 100644 --- a/Optimum.Installer/ViewModels/MainWindowViewModel.cs +++ b/Optimum.Installer/ViewModels/MainWindowViewModel.cs @@ -137,10 +137,10 @@ private async Task CheckForUpdateAsync(IUpdateService updates) private static readonly string[] StepNames = ["System", "Options", "Review", "Install"]; - /// The rail labels for suki:VerticalStepper. + /// The rail labels for the step navigation. public IReadOnlyList StepLabels => StepNames; - /// Zero-based current step for suki:VerticalStepper.Index. + /// Zero-based current step for the step rail. /// On a successful completion it points past the last step so the rail /// shows every step done rather than the last one still "current". public int StepIndex => @@ -201,7 +201,7 @@ public IReadOnlyList Steps WizardScreen.Options => "Where Optimum goes, the game data it uses, and the shortcuts to add.", WizardScreen.Review => "Confirm the summary, then read and accept the build notice.", WizardScreen.Progress => "This runs on your computer and can take a few minutes the first time.", - WizardScreen.Completion when Completion?.Succeeded == true => "Optimum is ready to launch.", + WizardScreen.Completion when Completion?.Succeeded == true => "Optimum is installed and ready to play.", WizardScreen.Completion => "Nothing on your system was changed. Fix the issue below, then try again.", _ => string.Empty, }; @@ -277,7 +277,8 @@ private async Task StartInstallAsync() Options.ResolvedDataPath, Options.SelectedVersion, (Options.CreateMenuEntry ? ShortcutKinds.Menu : ShortcutKinds.None) - | (Options.CreateDesktopShortcut ? ShortcutKinds.Desktop : ShortcutKinds.None)); + | (Options.CreateDesktopShortcut ? ShortcutKinds.Desktop : ShortcutKinds.None), + Options.CleanInstallDirectory); var progress = new ProgressViewModel(_services, session, _services.UiPost); progress.Finished += OnBuildFinished; diff --git a/Optimum.Installer/ViewModels/OptionsViewModel.cs b/Optimum.Installer/ViewModels/OptionsViewModel.cs index 29220f10..c9195c7c 100644 --- a/Optimum.Installer/ViewModels/OptionsViewModel.cs +++ b/Optimum.Installer/ViewModels/OptionsViewModel.cs @@ -70,6 +70,9 @@ public OptionsViewModel(ISystemProbe probe, string? repoRoot) [ObservableProperty] private bool _createDesktopShortcut; + [ObservableProperty] + private bool _cleanInstallDirectory = true; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanContinue))] private string? _validationError; @@ -92,6 +95,8 @@ private void Continue() partial void OnInstallDirectoryChanged(string value) => Validate(); + partial void OnCleanInstallDirectoryChanged(bool value) => Validate(); + partial void OnUseSeparateDataFolderChanged(bool value) => Validate(); partial void OnDataPathChanged(string value) => Validate(); @@ -105,7 +110,8 @@ public void Validate() } string? data = UseSeparateDataFolder && DataPath.Length > 0 ? DataPath : null; - InstallPathVerdict verdict = InstallPathGuard.Check(_probe, new InstallPathRequest(InstallDirectory, data)); + InstallPathVerdict verdict = InstallPathGuard.Check(_probe, new InstallPathRequest( + InstallDirectory, data, CleanDestination: CleanInstallDirectory)); ValidationError = verdict.Ok ? null : verdict.Rejection; } diff --git a/Optimum.Installer/ViewModels/ProgressViewModel.cs b/Optimum.Installer/ViewModels/ProgressViewModel.cs index 8a6b3328..ab2c237b 100644 --- a/Optimum.Installer/ViewModels/ProgressViewModel.cs +++ b/Optimum.Installer/ViewModels/ProgressViewModel.cs @@ -143,7 +143,7 @@ private async Task RunBuildAndDeployAsync(string outputDirectory Phase(ProgressPhase.Verify, 96, "installing"); DeployResult deploy = _services.Installer.Deploy( - new DeployRequest(build.RuntimePath!, _session.InstallDirectory, _session.DataPath, _session.Shortcuts), + new DeployRequest(build.RuntimePath!, _session.InstallDirectory, _session.DataPath, _session.Shortcuts, _session.CleanDestination), this); if (!deploy.Ok) diff --git a/Optimum.Installer/Views/CompletionView.axaml b/Optimum.Installer/Views/CompletionView.axaml index 66b132b9..92af8d80 100644 --- a/Optimum.Installer/Views/CompletionView.axaml +++ b/Optimum.Installer/Views/CompletionView.axaml @@ -1,6 +1,5 @@ - + - + - - + + - + -