mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-02 04:39:14 +01:00
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:
@@ -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()
|
||||
{
|
||||
|
||||
15
StarterNG/Application/Abstractions/IClock.cs
Normal file
15
StarterNG/Application/Abstractions/IClock.cs
Normal 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; }
|
||||
}
|
||||
25
StarterNG/Application/Abstractions/IDiagnosticsLog.cs
Normal file
25
StarterNG/Application/Abstractions/IDiagnosticsLog.cs
Normal 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();
|
||||
}
|
||||
55
StarterNG/Application/Abstractions/IFileSystem.cs
Normal file
55
StarterNG/Application/Abstractions/IFileSystem.cs
Normal 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);
|
||||
}
|
||||
34
StarterNG/Application/Abstractions/IGamePaths.cs
Normal file
34
StarterNG/Application/Abstractions/IGamePaths.cs
Normal 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/<maker>/<vehicle>/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);
|
||||
}
|
||||
22
StarterNG/Application/Abstractions/ILocalizedStrings.cs
Normal file
22
StarterNG/Application/Abstractions/ILocalizedStrings.cs
Normal 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; }
|
||||
}
|
||||
42
StarterNG/Application/Abstractions/IProcessLauncher.cs
Normal file
42
StarterNG/Application/Abstractions/IProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
75
StarterNG/Application/AppServices.cs
Normal file
75
StarterNG/Application/AppServices.cs
Normal 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; }
|
||||
}
|
||||
150
StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
Normal file
150
StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
41
StarterNG/Infrastructure/Adapters/GamePaths.cs
Normal file
41
StarterNG/Infrastructure/Adapters/GamePaths.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
50
StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
Normal file
50
StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
Normal 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);
|
||||
}
|
||||
12
StarterNG/Infrastructure/Adapters/SystemClock.cs
Normal file
12
StarterNG/Infrastructure/Adapters/SystemClock.cs
Normal 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;
|
||||
}
|
||||
106
StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
Normal file
106
StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
56
StarterNG/Infrastructure/InstallationCheck.cs
Normal file
56
StarterNG/Infrastructure/InstallationCheck.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user