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 @@ - + - + - - + + - + -