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

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)