From a3137a27c4cb6a83a06eb6d99f9fc3fd764a56c3 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 4 Sep 2026 20:46:51 +0200 Subject: [PATCH] Introduce layers, ports and a composition root Ports in Application/Abstractions, adapters in Infrastructure, hand-wired AppServices (no reflection, as PublishAot requires). Adds a test project. --- .gitignore | 4 + StarterNG.Tests/Fakes/FakeStrings.cs | 26 +++ StarterNG.Tests/Fakes/FixedClock.cs | 16 ++ StarterNG.Tests/Fakes/InMemoryFileSystem.cs | 131 ++++++++++++++ StarterNG.Tests/FileDiagnosticsLogTests.cs | 45 +++++ StarterNG.Tests/GamePathsTests.cs | 26 +++ StarterNG.Tests/InstallationCheckTests.cs | 39 +++++ StarterNG.Tests/StarterNG.Tests.csproj | 25 +++ StarterNG.sln | 10 ++ StarterNG/App.axaml.cs | 10 +- StarterNG/Application/Abstractions/IClock.cs | 15 ++ .../Abstractions/IDiagnosticsLog.cs | 25 +++ .../Application/Abstractions/IFileSystem.cs | 55 ++++++ .../Application/Abstractions/IGamePaths.cs | 34 ++++ .../Abstractions/ILocalizedStrings.cs | 22 +++ .../Abstractions/IProcessLauncher.cs | 42 +++++ StarterNG/Application/AppServices.cs | 75 ++++++++ .../Adapters/FileDiagnosticsLog.cs | 150 ++++++++++++++++ .../Infrastructure/Adapters/GamePaths.cs | 41 +++++ .../Adapters/PhysicalFileSystem.cs | 50 ++++++ .../Infrastructure/Adapters/SystemClock.cs | 12 ++ .../Adapters/SystemProcessLauncher.cs | 106 +++++++++++ StarterNG/Infrastructure/Diagnostics.cs | 165 +++--------------- StarterNG/Infrastructure/InstallationCheck.cs | 56 ++++++ StarterNG/Program.cs | 5 + StarterNG/Services/LocalizationService.cs | 23 ++- 26 files changed, 1065 insertions(+), 143 deletions(-) create mode 100644 StarterNG.Tests/Fakes/FakeStrings.cs create mode 100644 StarterNG.Tests/Fakes/FixedClock.cs create mode 100644 StarterNG.Tests/Fakes/InMemoryFileSystem.cs create mode 100644 StarterNG.Tests/FileDiagnosticsLogTests.cs create mode 100644 StarterNG.Tests/GamePathsTests.cs create mode 100644 StarterNG.Tests/InstallationCheckTests.cs create mode 100644 StarterNG.Tests/StarterNG.Tests.csproj create mode 100644 StarterNG/Application/Abstractions/IClock.cs create mode 100644 StarterNG/Application/Abstractions/IDiagnosticsLog.cs create mode 100644 StarterNG/Application/Abstractions/IFileSystem.cs create mode 100644 StarterNG/Application/Abstractions/IGamePaths.cs create mode 100644 StarterNG/Application/Abstractions/ILocalizedStrings.cs create mode 100644 StarterNG/Application/Abstractions/IProcessLauncher.cs create mode 100644 StarterNG/Application/AppServices.cs create mode 100644 StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs create mode 100644 StarterNG/Infrastructure/Adapters/GamePaths.cs create mode 100644 StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs create mode 100644 StarterNG/Infrastructure/Adapters/SystemClock.cs create mode 100644 StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs create mode 100644 StarterNG/Infrastructure/InstallationCheck.cs diff --git a/.gitignore b/.gitignore index 1774014..e5e78b9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ /.idea /.vs starter/ + +# Build output of any project in the solution +[Bb]in/ +[Oo]bj/ diff --git a/StarterNG.Tests/Fakes/FakeStrings.cs b/StarterNG.Tests/Fakes/FakeStrings.cs new file mode 100644 index 0000000..3097b94 --- /dev/null +++ b/StarterNG.Tests/Fakes/FakeStrings.cs @@ -0,0 +1,26 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// Translation table that echoes keys back, so assertions can name the key that +/// a screen or check would have shown. +/// +public sealed class FakeStrings : ILocalizedStrings +{ + private readonly Dictionary _overrides = new(StringComparer.Ordinal); + + public string this[string key] => _overrides.TryGetValue(key, out var value) ? value : key; + + public string Opt(string key) => _overrides.TryGetValue(key, out var value) ? value : string.Empty; + + public string CurrentLanguage => "English"; + + public string CurrentLangCode => "en"; + + public FakeStrings With(string key, string value) + { + _overrides[key] = value; + return this; + } +} diff --git a/StarterNG.Tests/Fakes/FixedClock.cs b/StarterNG.Tests/Fakes/FixedClock.cs new file mode 100644 index 0000000..e4a2a0e --- /dev/null +++ b/StarterNG.Tests/Fakes/FixedClock.cs @@ -0,0 +1,16 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// A clock frozen at a chosen instant. +public sealed class FixedClock : IClock +{ + public FixedClock(DateTime now) + { + Now = now; + } + + public DateTime Now { get; set; } + + public DateTime UtcNow => Now.ToUniversalTime(); +} diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs new file mode 100644 index 0000000..84c67ce --- /dev/null +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -0,0 +1,131 @@ +using System.Text; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// An over a dictionary, so a whole MaSzyna +/// installation can be described inline in a test. +/// +/// +/// Paths are normalised to forward slashes and compared case-insensitively, +/// matching how the parsers treat installation paths on both platforms. +/// +public sealed class InMemoryFileSystem : IFileSystem +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); + + public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// Adds a text file, creating its parent directories. + public InMemoryFileSystem WithFile(string path, string contents) + { + string key = Normalize(path); + _files[key] = Encoding.UTF8.GetBytes(contents); + AddParents(key); + return this; + } + + /// Adds an empty directory. + public InMemoryFileSystem WithDirectory(string path) + { + string key = Normalize(path); + _directories.Add(key); + AddParents(key); + return this; + } + + public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); + + public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); + + public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path)); + + public string ReadAllText(string path, Encoding encoding) => encoding.GetString(Bytes(path)); + + public string[] ReadAllLines(string path) => + ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + + public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false); + + public void WriteAllText(string path, string contents) => WithFile(path, contents); + + public void WriteAllText(string path, string contents, Encoding encoding) + { + string key = Normalize(path); + _files[key] = encoding.GetBytes(contents); + AddParents(key); + } + + public void WriteAllLines(string path, IEnumerable lines) => + WithFile(path, string.Join("\n", lines)); + + public void WriteAllBytes(string path, byte[] bytes) + { + string key = Normalize(path); + _files[key] = bytes; + AddParents(key); + } + + public void CreateDirectory(string path) => WithDirectory(path); + + public void DeleteFile(string path) => _files.Remove(Normalize(path)); + + public void CopyFile(string sourcePath, string destinationPath, bool overwrite) + { + string destination = Normalize(destinationPath); + if (!overwrite && _files.ContainsKey(destination)) + throw new IOException($"{destinationPath} already exists"); + WriteAllBytes(destinationPath, Bytes(sourcePath)); + } + + public IReadOnlyList GetFiles(string path, string searchPattern = "*") + { + string prefix = Normalize(path) + "/"; + var pattern = ToRegex(searchPattern); + return _files.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => !key[prefix.Length..].Contains('/')) + .Where(key => pattern.IsMatch(key[prefix.Length..])) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList GetDirectories(string path) + { + string prefix = Normalize(path) + "/"; + return _directories + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => !key[prefix.Length..].Contains('/')) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc; + + private byte[] Bytes(string path) => + _files.TryGetValue(Normalize(path), out var bytes) + ? bytes + : throw new FileNotFoundException(path); + + private void AddParents(string key) + { + int slash = key.LastIndexOf('/'); + while (slash > 0) + { + key = key[..slash]; + if (!_directories.Add(key)) + return; + slash = key.LastIndexOf('/'); + } + } + + private static string Normalize(string path) => + path.Replace('\\', '/').TrimEnd('/'); + + private static System.Text.RegularExpressions.Regex ToRegex(string searchPattern) => + new("^" + System.Text.RegularExpressions.Regex.Escape(searchPattern) + .Replace("\\*", ".*").Replace("\\?", ".") + "$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); +} diff --git a/StarterNG.Tests/FileDiagnosticsLogTests.cs b/StarterNG.Tests/FileDiagnosticsLogTests.cs new file mode 100644 index 0000000..aa3f629 --- /dev/null +++ b/StarterNG.Tests/FileDiagnosticsLogTests.cs @@ -0,0 +1,45 @@ +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class FileDiagnosticsLogTests +{ + private static FileDiagnosticsLog Build(out InMemoryFileSystem files) + { + files = new InMemoryFileSystem(); + return new FileDiagnosticsLog(files, new FixedClock(new DateTime(2026, 3, 7, 14, 5, 0)), + new GamePaths("game")); + } + + [Fact] + public void Log_timestamps_each_line_from_the_clock() + { + var log = Build(out _); + + log.Log("scenery loaded"); + + Assert.Equal("2026-03-07 14:05:00 scenery loaded", log.Text); + } + + [Fact] + public void Exceptions_are_logged_with_their_context_and_type() + { + var log = Build(out _); + + log.Log("scenery/foo.scn", new InvalidOperationException("bad node")); + + Assert.Contains("scenery/foo.scn: InvalidOperationException: bad node", log.Text); + } + + [Fact] + public void Clear_drops_everything_logged_so_far() + { + var log = Build(out _); + log.Log("first"); + + log.Clear(); + + Assert.Equal(string.Empty, log.Text); + } +} diff --git a/StarterNG.Tests/GamePathsTests.cs b/StarterNG.Tests/GamePathsTests.cs new file mode 100644 index 0000000..ca0930a --- /dev/null +++ b/StarterNG.Tests/GamePathsTests.cs @@ -0,0 +1,26 @@ +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests; + +public class GamePathsTests +{ + [Fact] + public void Wellknown_folders_hang_off_the_installation_root() + { + var paths = new GamePaths(Path.Combine("opt", "maszyna")); + + Assert.Equal(Path.Combine("opt", "maszyna", "scenery"), paths.Scenery); + Assert.Equal(Path.Combine("opt", "maszyna", "dynamic"), paths.Dynamic); + Assert.Equal(Path.Combine("opt", "maszyna", "textures", "mini"), paths.MiniTextures); + Assert.Equal(Path.Combine("opt", "maszyna", "starter", "bledy.txt"), paths.DiagnosticsLog); + } + + [Fact] + public void FromRoot_joins_segments_below_the_root() + { + var paths = new GamePaths("game"); + + Assert.Equal(Path.Combine("game", "data", "load_weights.txt"), + paths.FromRoot("data", "load_weights.txt")); + } +} diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs new file mode 100644 index 0000000..ee4c139 --- /dev/null +++ b/StarterNG.Tests/InstallationCheckTests.cs @@ -0,0 +1,39 @@ +using StarterNG.Infrastructure; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class InstallationCheckTests +{ + [Fact] + public void An_empty_folder_reports_every_missing_game_directory() + { + var strings = new FakeStrings().With("FaultNoDir", "missing {0}"); + var check = new InstallationCheck(new GamePaths("game"), new InMemoryFileSystem(), strings); + + var faults = check.Run(); + + Assert.Contains("missing /dynamic", faults); + Assert.Contains("missing /scenery", faults); + Assert.Contains("FaultNoWeights", faults); + } + + [Fact] + public void A_complete_installation_reports_no_missing_directories() + { + var files = new InMemoryFileSystem() + .WithDirectory(Path.Combine("game", "dynamic")) + .WithDirectory(Path.Combine("game", "sounds")) + .WithDirectory(Path.Combine("game", "models")) + .WithDirectory(Path.Combine("game", "scenery")) + .WithDirectory(Path.Combine("game", "textures")) + .WithFile(Path.Combine("game", "data", "load_weights.txt"), ""); + var strings = new FakeStrings().With("FaultNoDir", "missing {0}"); + + var faults = new InstallationCheck(new GamePaths("game"), files, strings).Run(); + + Assert.DoesNotContain(faults, fault => fault.StartsWith("missing ")); + Assert.DoesNotContain("FaultNoWeights", faults); + } +} diff --git a/StarterNG.Tests/StarterNG.Tests.csproj b/StarterNG.Tests/StarterNG.Tests.csproj new file mode 100644 index 0000000..165de2d --- /dev/null +++ b/StarterNG.Tests/StarterNG.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/StarterNG.sln b/StarterNG.sln index 7afd782..262289f 100644 --- a/StarterNG.sln +++ b/StarterNG.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,14 @@ Global {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64 {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64 + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 6053a20..1417935 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -8,16 +8,22 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using StarterNG.Application; using StarterNG.Classes; using StarterNG.Infrastructure; using StarterNG.Services; namespace StarterNG; -public partial class App : Application +public partial class App : Avalonia.Application { - public static LocalizationService Loc { get; } = new(); + /// + /// The translation table, as a static source for the XAML bindings. Code + /// should take + /// through its constructor rather than reading this. + /// + public static LocalizationService Loc => AppServices.Current.Localization; public override void Initialize() { diff --git a/StarterNG/Application/Abstractions/IClock.cs b/StarterNG/Application/Abstractions/IClock.cs new file mode 100644 index 0000000..b3fa40d --- /dev/null +++ b/StarterNG/Application/Abstractions/IClock.cs @@ -0,0 +1,15 @@ +namespace StarterNG.Application.Abstractions; + +using System; + +/// +/// Port over the system clock. Scenery weather defaults, timetable rendering and +/// log timestamps all depend on "now"; routing them through a clock keeps those +/// rules deterministic under test. +/// +public interface IClock +{ + DateTime Now { get; } + + DateTime UtcNow { get; } +} diff --git a/StarterNG/Application/Abstractions/IDiagnosticsLog.cs b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs new file mode 100644 index 0000000..aa049b4 --- /dev/null +++ b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Application.Abstractions; + +/// +/// Port over the starter's error log. Domain and application code depends on +/// this interface only; the file-backed implementation and the message box that +/// surfaces faults to the user live in Infrastructure and Presentation. +/// +public interface IDiagnosticsLog +{ + void Log(string message); + + void Log(string context, Exception exception); + + void Log(IEnumerable messages); + + /// Everything logged so far, newline separated. + string Text { get; } + + void Clear(); + + void Flush(); +} diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs new file mode 100644 index 0000000..2d04926 --- /dev/null +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace StarterNG.Application.Abstractions; + +/// +/// Port over the file system. Everything that reads or writes the MaSzyna +/// installation goes through this interface instead of and +/// , so parsers and use cases can be exercised against an +/// in-memory installation in tests. +/// +/// +/// Kept deliberately narrow: only the operations the application actually +/// performs are exposed. Paths are passed through verbatim to the adapter, which +/// resolves them relative to the process working directory just like the +/// framework methods it replaces. +/// +public interface IFileSystem +{ + bool FileExists(string path); + + bool DirectoryExists(string path); + + string ReadAllText(string path); + + string ReadAllText(string path, Encoding encoding); + + string[] ReadAllLines(string path); + + Stream OpenRead(string path); + + void WriteAllText(string path, string contents); + + void WriteAllText(string path, string contents, Encoding encoding); + + void WriteAllLines(string path, IEnumerable lines); + + void WriteAllBytes(string path, byte[] bytes); + + void CreateDirectory(string path); + + void DeleteFile(string path); + + void CopyFile(string sourcePath, string destinationPath, bool overwrite); + + /// Top-level files, empty when the directory does not exist. + IReadOnlyList GetFiles(string path, string searchPattern = "*"); + + /// Top-level directories, empty when the directory does not exist. + IReadOnlyList GetDirectories(string path); + + DateTime GetLastWriteTimeUtc(string path); +} diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs new file mode 100644 index 0000000..464cf37 --- /dev/null +++ b/StarterNG/Application/Abstractions/IGamePaths.cs @@ -0,0 +1,34 @@ +namespace StarterNG.Application.Abstractions; + +/// +/// The layout of a MaSzyna installation. Replaces the +/// Directory.GetCurrentDirectory() calls and hard-coded "scenery/", +/// "dynamic", "textures/mini/" literals that were previously spread across +/// parsers, views and the loader. +/// +public interface IGamePaths +{ + /// Installation root: the folder holding eu07.exe. + string Root { get; } + + /// Scenery definitions (*.scn). + string Scenery { get; } + + /// Rolling stock: dynamic/<maker>/<vehicle>/textures.txt. + string Dynamic { get; } + + /// Vehicle thumbnails used by the depot and the consist strip. + string MiniTextures { get; } + + /// Simulator data files (load_weights.txt and friends). + string Data { get; } + + /// Starter-owned configuration: presets, profiles, key bindings. + string StarterConfig { get; } + + /// The starter's own error log (starter/bledy.txt). + string DiagnosticsLog { get; } + + /// Resolves a path relative to . + string FromRoot(params string[] segments); +} diff --git a/StarterNG/Application/Abstractions/ILocalizedStrings.cs b/StarterNG/Application/Abstractions/ILocalizedStrings.cs new file mode 100644 index 0000000..f72ce5b --- /dev/null +++ b/StarterNG/Application/Abstractions/ILocalizedStrings.cs @@ -0,0 +1,22 @@ +namespace StarterNG.Application.Abstractions; + +/// +/// Port over the translation table. Anything that needs a user-facing string +/// takes this, rather than reaching for the concrete localization service or a +/// static on App. +/// +public interface ILocalizedStrings +{ + /// + /// Translation for a key. Missing keys return the key itself (and are logged + /// once), so an untranslated screen stays usable. + /// + string this[string key] { get; } + + /// Lookup for a key that may legitimately be absent; "" when missing. + string Opt(string key); + + string CurrentLanguage { get; } + + string CurrentLangCode { get; } +} diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs new file mode 100644 index 0000000..7029f36 --- /dev/null +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StarterNG.Application.Abstractions; + +/// +/// A process the starter has launched or adopted. Exposes only what the starter +/// needs — whether the simulator is still up, and a way to await its exit — so +/// the presentation layer never handles +/// directly. +/// +public interface IProcessHandle +{ + bool HasExited { get; } + + Task WaitForExitAsync(CancellationToken cancellationToken = default); +} + +/// +/// Port over process creation: starting the simulator, opening a file, folder or +/// URL in the desktop shell, and adopting an already running simulator. +/// +public interface IProcessLauncher +{ + /// + /// Starts an executable with the given arguments. Returns null when the + /// process could not be created; then carries the + /// reason for display. + /// + IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, + out string? error); + + /// + /// Hands a path or URL to the desktop shell (explorer, xdg-open, ...). + /// Returns false and logs when the shell refused it. + /// + bool OpenInShell(string pathOrUrl); + + /// The first running process with that name, or null. + IProcessHandle? FindRunning(string processName); +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs new file mode 100644 index 0000000..5540e6c --- /dev/null +++ b/StarterNG/Application/AppServices.cs @@ -0,0 +1,75 @@ +using System; +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Services; + +namespace StarterNG.Application; + +/// +/// The composition root: the single place where concrete adapters are chosen and +/// wired to the ports the rest of the application depends on. +/// +/// +/// Hand-written on purpose. The starter publishes with PublishAot and +/// PublishTrimmed, which rules out reflection-based containers; the object +/// graph is small enough that explicit construction is also the clearest +/// documentation of what depends on what. +/// +/// is a service locator and is used in exactly one kind of +/// place — the entry point and the view constructors Avalonia creates for us, +/// which cannot take arguments. Everything below that boundary receives its +/// dependencies through its constructor; new code must not reach for +/// . +/// +public sealed class AppServices +{ + private static AppServices? _current; + + private AppServices(IGamePaths paths, IFileSystem files, IClock clock) + { + Paths = paths; + Files = files; + Clock = clock; + + var log = new FileDiagnosticsLog(files, clock, paths); + Log = log; + Processes = new SystemProcessLauncher(log); + Localization = new LocalizationService(log); + } + + /// + /// The graph built by the entry point. Throws when read before + /// , which would mean a static initializer somewhere + /// ran ahead of the composition root. + /// + public static AppServices Current => + _current ?? throw new InvalidOperationException( + "AppServices.Current read before Initialize(); the composition root must be built first."); + + /// Builds the production graph. Called once, from the entry point. + public static AppServices Initialize(IGamePaths? paths = null, IFileSystem? files = null, IClock? clock = null) + { + _current = new AppServices( + paths ?? GamePaths.ForCurrentDirectory(), + files ?? new PhysicalFileSystem(), + clock ?? new SystemClock()); + return _current; + } + + public IGamePaths Paths { get; } + + public IFileSystem Files { get; } + + public IClock Clock { get; } + + public IDiagnosticsLog Log { get; } + + public IProcessLauncher Processes { get; } + + /// + /// Exposed as the concrete service rather than + /// because the XAML binds to it as a change-notifying source; code should take + /// the port. + /// + public LocalizationService Localization { get; } +} diff --git a/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs new file mode 100644 index 0000000..f8b1c18 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// +/// that mirrors every line into starter/bledy.txt. +/// Writing is best-effort: a read-only or full installation directory degrades to +/// an in-memory log rather than taking the starter down, and a single retry is +/// made at time. +/// +/// +/// Instance state replaces the previous static Diagnostics accumulator, so +/// tests can assert on what a use case logged without touching disk (pair it with +/// an in-memory ). +/// +public sealed class FileDiagnosticsLog : IDiagnosticsLog +{ + private readonly IFileSystem _files; + private readonly IClock _clock; + private readonly string _logPath; + + private readonly object _gate = new(); + private readonly List _lines = new(); + + private StreamWriter? _writer; + private bool _writerFailed; + + public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths) + { + _files = files; + _clock = clock; + _logPath = paths.DiagnosticsLog; + } + + public string LogPath => _logPath; + + public string Text + { + get { lock (_gate) return string.Join(Environment.NewLine, _lines); } + } + + public void Log(string message) + { + string line = $"{_clock.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} {message}"; + lock (_gate) + { + _lines.Add(line); + Append(line); + } + } + + public void Log(string context, Exception exception) => + Log($"{context}: {exception.GetType().Name}: {exception.Message}"); + + public void Log(IEnumerable messages) + { + foreach (string message in messages) + Log(message); + } + + public void Clear() + { + lock (_gate) + { + _lines.Clear(); + CloseWriter(); + try + { + if (_files.FileExists(_logPath)) + _files.DeleteFile(_logPath); + } + catch + { + // A log we cannot delete is not worth failing a user action over. + } + } + } + + public void Flush() + { + lock (_gate) + { + if (_lines.Count == 0) + return; + + try + { + _writer?.Flush(); + } + catch (Exception ex) + { + _writerFailed = true; + _lines.Add($"log flush failed: {ex.GetType().Name}: {ex.Message}"); + } + + if (!_writerFailed && _writer is not null) + return; + + // The streaming writer never came up (or died mid-session): fall back + // to one bulk write so the session is not lost entirely. + try + { + _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); + _files.WriteAllLines(_logPath, _lines); + } + catch + { + // Nothing left to try; the in-memory log still backs the UI. + } + } + } + + private void Append(string line) + { + if (_writerFailed) + return; + + try + { + if (_writer is null) + { + _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); + _writer = new StreamWriter(_logPath, append: false) { AutoFlush = true }; + } + _writer.WriteLine(line); + } + catch + { + _writerFailed = true; + CloseWriter(); + } + } + + private void CloseWriter() + { + try + { + _writer?.Dispose(); + } + catch + { + // Disposing a broken writer is allowed to fail. + } + _writer = null; + } +} diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs new file mode 100644 index 0000000..fe5d38e --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs @@ -0,0 +1,41 @@ +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// +/// The stock MaSzyna layout, rooted at the process working directory (the +/// starter ships inside the installation folder, next to eu07.exe). +/// +public sealed class GamePaths : IGamePaths +{ + public GamePaths(string root) + { + Root = root; + } + + /// The installation the starter was launched from. + public static GamePaths ForCurrentDirectory() => new(Directory.GetCurrentDirectory()); + + public string Root { get; } + + public string Scenery => Path.Combine(Root, "scenery"); + + public string Dynamic => Path.Combine(Root, "dynamic"); + + public string MiniTextures => Path.Combine(Root, "textures", "mini"); + + public string Data => Path.Combine(Root, "data"); + + public string StarterConfig => Path.Combine(Root, "starter"); + + public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt"); + + public string FromRoot(params string[] segments) + { + string result = Root; + foreach (string segment in segments) + result = Path.Combine(result, segment); + return result; + } +} diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs new file mode 100644 index 0000000..124475a --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// +/// backed by the real disk. The only place in the +/// application allowed to touch and . +/// +public sealed class PhysicalFileSystem : IFileSystem +{ + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public string ReadAllText(string path) => File.ReadAllText(path); + + public string ReadAllText(string path, Encoding encoding) => File.ReadAllText(path, encoding); + + public string[] ReadAllLines(string path) => File.ReadAllLines(path); + + public Stream OpenRead(string path) => File.OpenRead(path); + + public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents); + + public void WriteAllText(string path, string contents, Encoding encoding) => + File.WriteAllText(path, contents, encoding); + + public void WriteAllLines(string path, IEnumerable lines) => File.WriteAllLines(path, lines); + + public void WriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteFile(string path) => File.Delete(path); + + public void CopyFile(string sourcePath, string destinationPath, bool overwrite) => + File.Copy(sourcePath, destinationPath, overwrite); + + public IReadOnlyList GetFiles(string path, string searchPattern = "*") => + Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty(); + + public IReadOnlyList GetDirectories(string path) => + Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty(); + + public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path); +} diff --git a/StarterNG/Infrastructure/Adapters/SystemClock.cs b/StarterNG/Infrastructure/Adapters/SystemClock.cs new file mode 100644 index 0000000..6f3e514 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemClock.cs @@ -0,0 +1,12 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// backed by the machine clock. +public sealed class SystemClock : IClock +{ + public DateTime Now => DateTime.Now; + + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs new file mode 100644 index 0000000..ae99789 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// +/// backed by . The only place +/// in the application that starts an operating-system process. +/// +public sealed class SystemProcessLauncher : IProcessLauncher +{ + private readonly IDiagnosticsLog _log; + + public SystemProcessLauncher(IDiagnosticsLog log) + { + _log = log; + } + + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, + out string? error) + { + var info = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = workingDirectory ?? string.Empty, + UseShellExecute = false + }; + foreach (string argument in arguments) + info.ArgumentList.Add(argument); + + try + { + var process = Process.Start(info); + if (process is null) + { + error = executablePath; + _log.Log($"start {executablePath}: process not created"); + return null; + } + + error = null; + return new ProcessHandle(process); + } + catch (Exception ex) + { + error = ex.Message; + _log.Log($"start {executablePath}", ex); + return null; + } + } + + public bool OpenInShell(string pathOrUrl) + { + try + { + Process.Start(new ProcessStartInfo(pathOrUrl) { UseShellExecute = true }); + return true; + } + catch (Exception ex) + { + _log.Log($"open {pathOrUrl}", ex); + return false; + } + } + + public IProcessHandle? FindRunning(string processName) + { + try + { + var process = Process.GetProcessesByName(processName).FirstOrDefault(); + return process is null ? null : new ProcessHandle(process); + } + catch (Exception ex) + { + _log.Log($"lookup {processName}", ex); + return null; + } + } + + private sealed class ProcessHandle : IProcessHandle + { + private readonly Process _process; + + public ProcessHandle(Process process) + { + _process = process; + } + + public bool HasExited + { + get + { + try { return _process.HasExited; } + catch { return true; } + } + } + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _process.WaitForExitAsync(cancellationToken); + } +} diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs index 2a70e20..05c1ee0 100644 --- a/StarterNG/Infrastructure/Diagnostics.cs +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -1,130 +1,41 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; +using StarterNG.Application; using StarterNG.Views; namespace StarterNG.Infrastructure; +/// +/// Compatibility facade over +/// plus the fault dialog. +/// +/// +/// The log itself now lives in Infrastructure.Adapters.FileDiagnosticsLog +/// and is reached through the composition root. This static shim keeps the +/// existing call sites compiling while they are migrated to constructor-injected +/// logging; new code should take IDiagnosticsLog instead. +/// public static class Diagnostics { - private static readonly object Gate = new(); - private static readonly List Lines = new(); + private static Application.Abstractions.IDiagnosticsLog Log_ => AppServices.Current.Log; - private static StreamWriter? _writer; - private static bool _writerFailed; + public static string LogPath => AppServices.Current.Paths.DiagnosticsLog; - public static string LogPath => - Path.Combine(Directory.GetCurrentDirectory(), "starter", "bledy.txt"); + public static string Text => Log_.Text; - public static string Text - { - get { lock (Gate) return string.Join(Environment.NewLine, Lines); } - } + public static void Clear() => Log_.Clear(); - public static void Clear() - { - lock (Gate) - { - Lines.Clear(); - CloseWriter(); - try - { - if (File.Exists(LogPath)) - File.Delete(LogPath); - } - catch - { - } - } - } + public static void Log(string message) => Log_.Log(message); - public static void Log(string message) - { - string line = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} {message}"; - lock (Gate) - { - Lines.Add(line); - Append(line); - } - } + public static void Log(IEnumerable messages) => Log_.Log(messages); - public static void Log(IEnumerable messages) - { - foreach (string message in messages) - Log(message); - } + public static void Log(string context, Exception ex) => Log_.Log(context, ex); - public static void Log(string context, Exception ex) => - Log($"{context}: {ex.GetType().Name}: {ex.Message}"); - - public static void Flush() - { - lock (Gate) - { - if (Lines.Count == 0) - return; - - try - { - _writer?.Flush(); - } - catch (Exception ex) - { - _writerFailed = true; - Lines.Add($"log flush failed: {ex.GetType().Name}: {ex.Message}"); - } - - if (!_writerFailed && _writer is not null) - return; - - try - { - Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); - File.WriteAllLines(LogPath, Lines); - } - catch - { - } - } - } - - private static void Append(string line) - { - if (_writerFailed) - return; - - try - { - if (_writer is null) - { - Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); - _writer = new StreamWriter(LogPath, append: false) { AutoFlush = true }; - } - _writer.WriteLine(line); - } - catch - { - _writerFailed = true; - CloseWriter(); - } - } - - private static void CloseWriter() - { - try - { - _writer?.Dispose(); - } - catch - { - } - _writer = null; - } + public static void Flush() => Log_.Flush(); private static Window? Owner => (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) @@ -132,7 +43,7 @@ public static class Diagnostics public static Task ReportAsync(string message, string? title = null) { - Log(message.Replace(Environment.NewLine, " | ")); + Log_.Log(message.Replace(Environment.NewLine, " | ")); var owner = Owner; if (owner is null) @@ -152,31 +63,13 @@ public static class Diagnostics Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); } - public static List CheckInstallation() - { - var faults = new List(); - string root = Directory.GetCurrentDirectory(); - - foreach (string folder in new[] { "dynamic", "sounds", "models", "scenery", "textures" }) - if (!Directory.Exists(Path.Combine(root, folder))) - faults.Add(string.Format(App.Loc["FaultNoDir"], "/" + folder)); - - if (!File.Exists(Path.Combine(root, "data", "load_weights.txt"))) - faults.Add(App.Loc["FaultNoWeights"]); - - if (Classes.GameData.Instance.Sceneries.Count == 0) - faults.Add(App.Loc["FaultNoScenery"]); - - if (Classes.GameData.Instance.Vehicles.Textures.Count == 0) - faults.Add(App.Loc["FaultNoVehicles"]); - - if (Classes.Physics.IndexedCount == 0) - faults.Add(App.Loc["FaultNoPhysics"]); - - if (Classes.Settings.Instance.ResolveExecutable(out var problem) is var _ && - problem == Classes.ExeProblem.NotFound) - faults.Add(App.Loc["FaultNoExe"]); - - return faults; - } + /// + /// Sanity check of the installation the starter was launched from. + /// + public static List CheckInstallation() => + new InstallationCheck( + AppServices.Current.Paths, + AppServices.Current.Files, + AppServices.Current.Localization) + .Run(); } diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs new file mode 100644 index 0000000..61ed9c1 --- /dev/null +++ b/StarterNG/Infrastructure/InstallationCheck.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; + +namespace StarterNG.Infrastructure; + +/// +/// Verifies that the folder the starter runs from actually looks like a MaSzyna +/// installation, and reports what is missing in the user's language. +/// +/// +/// Depends only on ports, so a broken installation can be described in a test by +/// handing it an empty in-memory file system. +/// +public sealed class InstallationCheck +{ + private static readonly string[] RequiredFolders = { "dynamic", "sounds", "models", "scenery", "textures" }; + + private readonly IGamePaths _paths; + private readonly IFileSystem _files; + private readonly ILocalizedStrings _strings; + + public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings) + { + _paths = paths; + _files = files; + _strings = strings; + } + + public List Run() + { + var faults = new List(); + + foreach (string folder in RequiredFolders) + if (!_files.DirectoryExists(_paths.FromRoot(folder))) + faults.Add(string.Format(_strings["FaultNoDir"], "/" + folder)); + + if (!_files.FileExists(Path.Combine(_paths.Data, "load_weights.txt"))) + faults.Add(_strings["FaultNoWeights"]); + + if (GameData.Instance.Sceneries.Count == 0) + faults.Add(_strings["FaultNoScenery"]); + + if (GameData.Instance.Vehicles.Textures.Count == 0) + faults.Add(_strings["FaultNoVehicles"]); + + if (Physics.IndexedCount == 0) + faults.Add(_strings["FaultNoPhysics"]); + + if (Settings.Instance.ResolveExecutable(out var problem) is var _ && problem == ExeProblem.NotFound) + faults.Add(_strings["FaultNoExe"]); + + return faults; + } +} diff --git a/StarterNG/Program.cs b/StarterNG/Program.cs index f7d5eb7..635bec6 100644 --- a/StarterNG/Program.cs +++ b/StarterNG/Program.cs @@ -1,6 +1,7 @@ using Avalonia; using System; using System.Threading.Tasks; +using StarterNG.Application; using StarterNG.Infrastructure; namespace StarterNG; @@ -11,6 +12,10 @@ static class Program [STAThread] public static void Main(string[] args) { + // Composition root first: everything below, including the crash handlers, + // resolves its collaborators from this graph. + AppServices.Initialize(); + AppDomain.CurrentDomain.UnhandledException += (_, e) => { if (e.ExceptionObject is Exception ex) diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs index 5f43819..8357d95 100644 --- a/StarterNG/Services/LocalizationService.cs +++ b/StarterNG/Services/LocalizationService.cs @@ -3,16 +3,29 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Xml; +using StarterNG.Application.Abstractions; namespace StarterNG.Services; -public class LocalizationService : INotifyPropertyChanged +/// +/// Loads the translation table from startercfg/lang/*.xml and exposes it as +/// . Stays a change-notifying class because the +/// XAML binds to it directly as a static source. +/// +public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings { public static string LangDirectory => Path.Combine(AppContext.BaseDirectory, "startercfg", "lang"); + private readonly IDiagnosticsLog _log; + private Dictionary _strings = new(); + public LocalizationService(IDiagnosticsLog log) + { + _log = log; + } + public event PropertyChangedEventHandler? PropertyChanged; public event Action? LanguageChanged; @@ -43,7 +56,7 @@ public class LocalizationService : INotifyPropertyChanged lock (_reportedMisses) if (_reportedMisses.Add(key)) - Infrastructure.Diagnostics.Log($"lang/{CurrentLangCode}: missing key \"{key}\""); + _log.Log($"lang/{CurrentLangCode}: missing key \"{key}\""); return key; } @@ -58,7 +71,7 @@ public class LocalizationService : INotifyPropertyChanged public readonly record struct LanguageInfo(string Code, string Name, string Path); - public static IReadOnlyList AvailableLanguages() + public static IReadOnlyList AvailableLanguages(IDiagnosticsLog? log = null) { var list = new List(); if (!Directory.Exists(LangDirectory)) @@ -74,7 +87,7 @@ public class LocalizationService : INotifyPropertyChanged } catch (Exception ex) { - StarterNG.Infrastructure.Diagnostics.Log($"lang/{Path.GetFileName(path)}", ex); + log?.Log($"lang/{Path.GetFileName(path)}", ex); } } return list; @@ -82,7 +95,7 @@ public class LocalizationService : INotifyPropertyChanged public string Load(string codeOrName) { - var languages = AvailableLanguages(); + var languages = AvailableLanguages(_log); LanguageInfo? match = null; foreach (var lang in languages)