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.
This commit is contained in:
maj00r
2026-09-04 20:46:51 +02:00
parent aef086e9f0
commit a3137a27c4
26 changed files with 1065 additions and 143 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
/.idea
/.vs
starter/
# Build output of any project in the solution
[Bb]in/
[Oo]bj/

View File

@@ -0,0 +1,26 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Translation table that echoes keys back, so assertions can name the key that
/// a screen or check would have shown.
/// </summary>
public sealed class FakeStrings : ILocalizedStrings
{
private readonly Dictionary<string, string> _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;
}
}

View File

@@ -0,0 +1,16 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>A clock frozen at a chosen instant.</summary>
public sealed class FixedClock : IClock
{
public FixedClock(DateTime now)
{
Now = now;
}
public DateTime Now { get; set; }
public DateTime UtcNow => Now.ToUniversalTime();
}

View File

@@ -0,0 +1,131 @@
using System.Text;
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// An <see cref="IFileSystem"/> over a dictionary, so a whole MaSzyna
/// installation can be described inline in a test.
/// </summary>
/// <remarks>
/// Paths are normalised to forward slashes and compared case-insensitively,
/// matching how the parsers treat installation paths on both platforms.
/// </remarks>
public sealed class InMemoryFileSystem : IFileSystem
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Adds a text file, creating its parent directories.</summary>
public InMemoryFileSystem WithFile(string path, string contents)
{
string key = Normalize(path);
_files[key] = Encoding.UTF8.GetBytes(contents);
AddParents(key);
return this;
}
/// <summary>Adds an empty directory.</summary>
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<string> 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<string> 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<string> 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);
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -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();
/// <summary>
/// The translation table, as a static source for the XAML bindings. Code
/// should take <see cref="Application.Abstractions.ILocalizedStrings"/>
/// through its constructor rather than reading this.
/// </summary>
public static LocalizationService Loc => AppServices.Current.Localization;
public override void Initialize()
{

View File

@@ -0,0 +1,15 @@
namespace StarterNG.Application.Abstractions;
using System;
/// <summary>
/// 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.
/// </summary>
public interface IClock
{
DateTime Now { get; }
DateTime UtcNow { get; }
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// 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.
/// </summary>
public interface IDiagnosticsLog
{
void Log(string message);
void Log(string context, Exception exception);
void Log(IEnumerable<string> messages);
/// <summary>Everything logged so far, newline separated.</summary>
string Text { get; }
void Clear();
void Flush();
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Port over the file system. Everything that reads or writes the MaSzyna
/// installation goes through this interface instead of <see cref="File"/> and
/// <see cref="Directory"/>, so parsers and use cases can be exercised against an
/// in-memory installation in tests.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<string> lines);
void WriteAllBytes(string path, byte[] bytes);
void CreateDirectory(string path);
void DeleteFile(string path);
void CopyFile(string sourcePath, string destinationPath, bool overwrite);
/// <summary>Top-level files, empty when the directory does not exist.</summary>
IReadOnlyList<string> GetFiles(string path, string searchPattern = "*");
/// <summary>Top-level directories, empty when the directory does not exist.</summary>
IReadOnlyList<string> GetDirectories(string path);
DateTime GetLastWriteTimeUtc(string path);
}

View File

@@ -0,0 +1,34 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// The layout of a MaSzyna installation. Replaces the
/// <c>Directory.GetCurrentDirectory()</c> calls and hard-coded "scenery/",
/// "dynamic", "textures/mini/" literals that were previously spread across
/// parsers, views and the loader.
/// </summary>
public interface IGamePaths
{
/// <summary>Installation root: the folder holding eu07.exe.</summary>
string Root { get; }
/// <summary>Scenery definitions (*.scn).</summary>
string Scenery { get; }
/// <summary>Rolling stock: dynamic/&lt;maker&gt;/&lt;vehicle&gt;/textures.txt.</summary>
string Dynamic { get; }
/// <summary>Vehicle thumbnails used by the depot and the consist strip.</summary>
string MiniTextures { get; }
/// <summary>Simulator data files (load_weights.txt and friends).</summary>
string Data { get; }
/// <summary>Starter-owned configuration: presets, profiles, key bindings.</summary>
string StarterConfig { get; }
/// <summary>The starter's own error log (starter/bledy.txt).</summary>
string DiagnosticsLog { get; }
/// <summary>Resolves a path relative to <see cref="Root"/>.</summary>
string FromRoot(params string[] segments);
}

View File

@@ -0,0 +1,22 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// 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 <c>App</c>.
/// </summary>
public interface ILocalizedStrings
{
/// <summary>
/// Translation for a key. Missing keys return the key itself (and are logged
/// once), so an untranslated screen stays usable.
/// </summary>
string this[string key] { get; }
/// <summary>Lookup for a key that may legitimately be absent; "" when missing.</summary>
string Opt(string key);
string CurrentLanguage { get; }
string CurrentLangCode { get; }
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// 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 <see cref="System.Diagnostics.Process"/>
/// directly.
/// </summary>
public interface IProcessHandle
{
bool HasExited { get; }
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Port over process creation: starting the simulator, opening a file, folder or
/// URL in the desktop shell, and adopting an already running simulator.
/// </summary>
public interface IProcessLauncher
{
/// <summary>
/// Starts an executable with the given arguments. Returns null when the
/// process could not be created; <paramref name="error"/> then carries the
/// reason for display.
/// </summary>
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
out string? error);
/// <summary>
/// Hands a path or URL to the desktop shell (explorer, xdg-open, ...).
/// Returns false and logs when the shell refused it.
/// </summary>
bool OpenInShell(string pathOrUrl);
/// <summary>The first running process with that name, or null.</summary>
IProcessHandle? FindRunning(string processName);
}

View File

@@ -0,0 +1,75 @@
using System;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Services;
namespace StarterNG.Application;
/// <summary>
/// The composition root: the single place where concrete adapters are chosen and
/// wired to the ports the rest of the application depends on.
/// </summary>
/// <remarks>
/// Hand-written on purpose. The starter publishes with <c>PublishAot</c> and
/// <c>PublishTrimmed</c>, 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.
///
/// <see cref="Current"/> 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
/// <see cref="Current"/>.
/// </remarks>
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);
}
/// <summary>
/// The graph built by the entry point. Throws when read before
/// <see cref="Initialize"/>, which would mean a static initializer somewhere
/// ran ahead of the composition root.
/// </summary>
public static AppServices Current =>
_current ?? throw new InvalidOperationException(
"AppServices.Current read before Initialize(); the composition root must be built first.");
/// <summary>Builds the production graph. Called once, from the entry point.</summary>
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; }
/// <summary>
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
/// because the XAML binds to it as a change-notifying source; code should take
/// the port.
/// </summary>
public LocalizationService Localization { get; }
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Adapters;
/// <summary>
/// <see cref="IDiagnosticsLog"/> 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 <see cref="Flush"/> time.
/// </summary>
/// <remarks>
/// Instance state replaces the previous static <c>Diagnostics</c> accumulator, so
/// tests can assert on what a use case logged without touching disk (pair it with
/// an in-memory <see cref="IFileSystem"/>).
/// </remarks>
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<string> _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<string> 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;
}
}

View File

@@ -0,0 +1,41 @@
using System.IO;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Adapters;
/// <summary>
/// The stock MaSzyna layout, rooted at the process working directory (the
/// starter ships inside the installation folder, next to eu07.exe).
/// </summary>
public sealed class GamePaths : IGamePaths
{
public GamePaths(string root)
{
Root = root;
}
/// <summary>The installation the starter was launched from.</summary>
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;
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Adapters;
/// <summary>
/// <see cref="IFileSystem"/> backed by the real disk. The only place in the
/// application allowed to touch <see cref="File"/> and <see cref="Directory"/>.
/// </summary>
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<string> 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<string> GetFiles(string path, string searchPattern = "*") =>
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
public IReadOnlyList<string> GetDirectories(string path) =>
Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty<string>();
public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
}

View File

@@ -0,0 +1,12 @@
using System;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Adapters;
/// <summary><see cref="IClock"/> backed by the machine clock.</summary>
public sealed class SystemClock : IClock
{
public DateTime Now => DateTime.Now;
public DateTime UtcNow => DateTime.UtcNow;
}

View File

@@ -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;
/// <summary>
/// <see cref="IProcessLauncher"/> backed by <see cref="Process"/>. The only place
/// in the application that starts an operating-system process.
/// </summary>
public sealed class SystemProcessLauncher : IProcessLauncher
{
private readonly IDiagnosticsLog _log;
public SystemProcessLauncher(IDiagnosticsLog log)
{
_log = log;
}
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> 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);
}
}

View File

@@ -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;
/// <summary>
/// Compatibility facade over <see cref="Application.Abstractions.IDiagnosticsLog"/>
/// plus the fault dialog.
/// </summary>
/// <remarks>
/// The log itself now lives in <c>Infrastructure.Adapters.FileDiagnosticsLog</c>
/// 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 <c>IDiagnosticsLog</c> instead.
/// </remarks>
public static class Diagnostics
{
private static readonly object Gate = new();
private static readonly List<string> 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<string> messages) => Log_.Log(messages);
public static void Log(IEnumerable<string> 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<string> CheckInstallation()
{
var faults = new List<string>();
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;
}
/// <summary>
/// Sanity check of the installation the starter was launched from.
/// </summary>
public static List<string> CheckInstallation() =>
new InstallationCheck(
AppServices.Current.Paths,
AppServices.Current.Files,
AppServices.Current.Localization)
.Run();
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
namespace StarterNG.Infrastructure;
/// <summary>
/// Verifies that the folder the starter runs from actually looks like a MaSzyna
/// installation, and reports what is missing in the user's language.
/// </summary>
/// <remarks>
/// Depends only on ports, so a broken installation can be described in a test by
/// handing it an empty in-memory file system.
/// </remarks>
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<string> Run()
{
var faults = new List<string>();
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;
}
}

View File

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

View File

@@ -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
/// <summary>
/// Loads the translation table from startercfg/lang/*.xml and exposes it as
/// <see cref="ILocalizedStrings"/>. Stays a change-notifying class because the
/// XAML binds to it directly as a static source.
/// </summary>
public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings
{
public static string LangDirectory =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
private readonly IDiagnosticsLog _log;
private Dictionary<string, string> _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<LanguageInfo> AvailableLanguages()
public static IReadOnlyList<LanguageInfo> AvailableLanguages(IDiagnosticsLog? log = null)
{
var list = new List<LanguageInfo>();
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)