diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs index bcaed3f..84a4fb9 100644 --- a/StarterNG.Tests/ExecutableLocatorTests.cs +++ b/StarterNG.Tests/ExecutableLocatorTests.cs @@ -79,4 +79,79 @@ public class ExecutableLocatorTests Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates()); } + + private static readonly string Wine = Path.Combine("/usr/bin", "wine"); + + private static TestInstallation WithWine(InMemoryFileSystem files) + { + var installation = new TestInstallation(files.WithExecutable(Wine)); + installation.Environment.With("PATH", "/usr/bin"); + return installation; + } + + [Fact] + public void Without_wine_a_windows_build_on_linux_is_not_a_candidate() + { + var installation = new TestInstallation( + new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)); + + installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.Equal(ExeProblem.NotFound, problem); + Assert.Null(installation.Executables.Wine()); + } + + [Fact] + public void With_wine_a_windows_build_on_linux_is_found_even_without_the_execute_bit() + { + var installation = WithWine( + new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.EndsWith("eu07.exe", resolved); + Assert.Equal(ExeProblem.WrongPlatform, problem); + Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates()); + } + + [Fact] + public void A_native_build_wins_over_the_windows_one() + { + var installation = WithWine(new InMemoryFileSystem() + .WithFile(TestInstallation.At("eu07.exe"), PortableExecutable) + .WithExecutable(TestInstallation.At("eu07-dev"), Elf)); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.EndsWith("eu07-dev", resolved); + Assert.Equal(ExeProblem.None, problem); + } + + [Fact] + public void Wine_starts_the_windows_build_and_a_native_one_starts_itself() + { + string exe = TestInstallation.At("eu07.exe"); + string native = TestInstallation.At("eu07"); + var installation = WithWine(new InMemoryFileSystem() + .WithFile(exe, PortableExecutable) + .WithExecutable(native, Elf)); + string[] arguments = { "-s", "$td.scn" }; + + var (wine, wineArguments) = installation.Executables.LaunchCommand(exe, arguments); + var (program, programArguments) = installation.Executables.LaunchCommand(native, arguments); + + Assert.Equal(Wine, wine); + Assert.Equal(new[] { exe, "-s", "$td.scn" }, wineArguments); + Assert.Equal(native, program); + Assert.Equal(arguments, programArguments); + } + + [Fact] + public void Wine_is_never_looked_for_on_windows() + { + var installation = WithWine(new InMemoryFileSystem()); + installation.Environment.AsWindows(); + + Assert.Null(installation.Executables.Wine()); + } } diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs index 0a489f6..08219ff 100644 --- a/StarterNG.Tests/StartSimulationTests.cs +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -33,6 +33,27 @@ public class StartSimulationTests Assert.NotNull(launcher.Executable); } + [Fact] + public void A_windows_build_on_linux_is_started_through_wine() + { + string wine = Path.Combine("/usr/bin", "wine"); + string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe"); + var (start, launcher, installation) = Rig(files => files + .WithExecutable(wine) + .WithFile(exe, "MZ\u0090\u0000")); + installation.Environment.With("PATH", "/usr/bin"); + installation.SettingsStore.Settings.ExecutablePath = exe; + + var result = start.Execute(freeFly: true, saveSettings: false); + + // Unpacked from the Windows zip, it has no execute bit - Wine does not need one. + Assert.Equal(SimulationStartOutcome.Started, result.Outcome); + Assert.Equal(wine, launcher.Executable); + Assert.Equal(exe, launcher.Arguments![0]); + Assert.Equal("-s", launcher.Arguments[1]); + Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory); + } + [Fact] public void A_file_that_cannot_be_run_is_not() { @@ -68,8 +89,8 @@ public class StartSimulationTests var launcher = new RecordingLauncher(); var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles, - installation.Files, installation.Paths, launcher, installation.Random, - installation.Log); + installation.Executables, installation.Files, installation.Paths, launcher, + installation.Random, installation.Log); return (start, launcher, installation); } diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 0ac4e72..82fb036 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -59,8 +59,8 @@ public sealed class AppServices assembly.GetCustomAttribute()?.InformationalVersion); Updates = new UpdateCheck(SettingsStore.Settings, new GitHubReleaseFeed(log, version), version, commit, files, clock, log, settingsPaths.UpdateCheckPath()); - StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, files, paths, Processes, - Random, log); + StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths, + Processes, Random, log); } public static AppServices Current => diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index d7c8e2f..d208922 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -8,6 +8,7 @@ using StarterNG.Domain; using StarterNG.Domain.Settings; using StarterNG.Domain.Vehicles; using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Settings; namespace StarterNG.Application; @@ -39,18 +40,21 @@ public sealed class StartSimulation private readonly AppState _state; private readonly SettingsStore _settings; private readonly VehicleCatalog _vehicles; + private readonly ExecutableLocator _executables; private readonly IFileSystem _files; private readonly IGamePaths _paths; private readonly IProcessLauncher _processes; private readonly IRandomSource _random; private readonly IDiagnosticsLog _log; - public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles, IFileSystem files, - IGamePaths paths, IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log) + public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles, + ExecutableLocator executables, IFileSystem files, IGamePaths paths, + IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log) { _state = state; _settings = settings; _vehicles = vehicles; + _executables = executables; _files = files; _paths = paths; _processes = processes; @@ -123,7 +127,7 @@ public sealed class StartSimulation string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem)); // A binary for the other system is let through: on Linux a Windows build - // runs under Wine or Proton, and only the launch can tell. + // runs under Wine, started below, or Proton, and only the launch can tell. if (problem is not (ExeProblem.None or ExeProblem.WrongPlatform)) return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable, Problem: problem); @@ -134,7 +138,8 @@ public sealed class StartSimulation // The installation, not the folder of the binary: a simulator picked from a // build tree lives beside the data, and the data is what it opens. - var process = _processes.Start(executable, arguments, _paths.Root, out string? error); + var (program, programArguments) = _executables.LaunchCommand(executable, arguments); + var process = _processes.Start(program, programArguments, _paths.Root, out string? error); if (process is null) return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, Detail: error); diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs index 2283eee..ddfdb96 100644 --- a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs +++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs @@ -19,6 +19,9 @@ public sealed class ExecutableLocator private readonly IEnvironment _environment; private readonly IDiagnosticsLog _log; + private string? _wine; + private bool _wineLookedUp; + public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log) { _files = files; @@ -42,8 +45,9 @@ public sealed class ExecutableLocator { var candidates = _files.GetFiles(_paths.Root, "eu07*") .Where(IsCandidate) - .OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName, - StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .OrderBy(path => IsExeOnLinux(path) ? 1 : 0) + .ThenBy(path => string.Equals(Path.GetFileName(path), CanonicalName, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ThenBy(path => Path.GetFileName(path).Length); foreach (string path in candidates) @@ -94,10 +98,16 @@ public sealed class ExecutableLocator if (!_files.FileExists(path)) return ExeProblem.NotFound; + bool foreign = !MatchesPlatform(path); + + // Started through Wine, a Windows build needs no execute bit of its own. + if (foreign && RunsThroughWine(path)) + return ExeProblem.WrongPlatform; + if (!_environment.IsWindows && !_files.IsExecutable(path)) return ExeProblem.NotExecutable; - return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform; + return foreign ? ExeProblem.WrongPlatform : ExeProblem.None; } catch (Exception) { @@ -105,24 +115,92 @@ public sealed class ExecutableLocator } } - private bool MatchesPlatform(string path) + /// + /// What to start for a simulator binary: the binary itself, or Wine with the + /// binary as its first argument when it is a Windows build on Linux. + /// + public (string FileName, IReadOnlyList Arguments) LaunchCommand(string executable, + IReadOnlyList arguments) + { + if (!RunsThroughWine(executable)) + return (executable, arguments); + + return (Wine()!, new[] { executable }.Concat(arguments).ToList()); + } + + /// Wine from the PATH, or null where there is none - and always on Windows. + public string? Wine() + { + if (_environment.IsWindows) + return null; + + if (!_wineLookedUp) + { + _wine = FindOnPath("wine") ?? FindOnPath("wine64"); + _wineLookedUp = true; + } + + return _wine; + } + + private string? FindOnPath(string name) + { + // The PATH of Linux and macOS, whatever the host the tests run on. + string[] directories = (_environment.GetVariable("PATH") ?? "").Split(':', StringSplitOptions.RemoveEmptyEntries); + foreach (string directory in directories) + { + string candidate = Path.Combine(directory, name); + if (_files.FileExists(candidate) && _files.IsExecutable(candidate)) + return candidate; + } + + return null; + } + + private bool RunsThroughWine(string path) + { + try + { + return !_environment.IsWindows && Wine() is not null && Format(path) == BinaryFormat.PortableExecutable; + } + catch (Exception) + { + return false; + } + } + + private bool MatchesPlatform(string path) => Format(path) switch + { + BinaryFormat.Elf => !_environment.IsWindows, + BinaryFormat.PortableExecutable => !_environment.IsLinux, + _ => true + }; + + private enum BinaryFormat + { + Unknown, + Elf, + PortableExecutable + } + + private BinaryFormat Format(string path) { byte[] head = new byte[4]; using var stream = _files.OpenRead(path); if (stream.Read(head, 0, 4) != 4) - return true; + return BinaryFormat.Unknown; - bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A; - bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46; + if (head[0] == 0x4D && head[1] == 0x5A) + return BinaryFormat.PortableExecutable; + if (head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46) + return BinaryFormat.Elf; - if (_environment.IsWindows && elf) - return false; - if (_environment.IsLinux && portableExecutable) - return false; - - return true; + return BinaryFormat.Unknown; } + private bool IsExeOnLinux(string path) => + _environment.IsLinux && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase); + private bool IsCandidate(string path) { string extension = Path.GetExtension(path).ToLowerInvariant(); @@ -133,8 +211,11 @@ public sealed class ExecutableLocator if (_environment.IsWindows) return extension == ".exe"; + // A Windows build is offered only where Wine can start it, and after any + // native one - see the ordering in Resolve. if (_environment.IsLinux) - return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage"; + return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage" || + (IsExeOnLinux(path) && Wine() is not null); return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage"; }