mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02: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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
/.idea
|
/.idea
|
||||||
/.vs
|
/.vs
|
||||||
starter/
|
starter/
|
||||||
|
|
||||||
|
# Build output of any project in the solution
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
|||||||
26
StarterNG.Tests/Fakes/FakeStrings.cs
Normal file
26
StarterNG.Tests/Fakes/FakeStrings.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
StarterNG.Tests/Fakes/FixedClock.cs
Normal file
16
StarterNG.Tests/Fakes/FixedClock.cs
Normal 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();
|
||||||
|
}
|
||||||
131
StarterNG.Tests/Fakes/InMemoryFileSystem.cs
Normal file
131
StarterNG.Tests/Fakes/InMemoryFileSystem.cs
Normal 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);
|
||||||
|
}
|
||||||
45
StarterNG.Tests/FileDiagnosticsLogTests.cs
Normal file
45
StarterNG.Tests/FileDiagnosticsLogTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
StarterNG.Tests/GamePathsTests.cs
Normal file
26
StarterNG.Tests/GamePathsTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
StarterNG.Tests/InstallationCheckTests.cs
Normal file
39
StarterNG.Tests/InstallationCheckTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
StarterNG.Tests/StarterNG.Tests.csproj
Normal file
25
StarterNG.Tests/StarterNG.Tests.csproj
Normal 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>
|
||||||
@@ -5,6 +5,8 @@ VisualStudioVersion = 18.1.11312.151 d18.0
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|Any CPU.Build.0 = Release|Any CPU
|
||||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
|
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
|
||||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using StarterNG.Application;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Infrastructure;
|
using StarterNG.Infrastructure;
|
||||||
using StarterNG.Services;
|
using StarterNG.Services;
|
||||||
|
|
||||||
namespace StarterNG;
|
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()
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using StarterNG.Application;
|
||||||
using StarterNG.Views;
|
using StarterNG.Views;
|
||||||
|
|
||||||
namespace StarterNG.Infrastructure;
|
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
|
public static class Diagnostics
|
||||||
{
|
{
|
||||||
private static readonly object Gate = new();
|
private static Application.Abstractions.IDiagnosticsLog Log_ => AppServices.Current.Log;
|
||||||
private static readonly List<string> Lines = new();
|
|
||||||
|
|
||||||
private static StreamWriter? _writer;
|
public static string LogPath => AppServices.Current.Paths.DiagnosticsLog;
|
||||||
private static bool _writerFailed;
|
|
||||||
|
|
||||||
public static string LogPath =>
|
public static string Text => Log_.Text;
|
||||||
Path.Combine(Directory.GetCurrentDirectory(), "starter", "bledy.txt");
|
|
||||||
|
|
||||||
public static string Text
|
public static void Clear() => Log_.Clear();
|
||||||
{
|
|
||||||
get { lock (Gate) return string.Join(Environment.NewLine, Lines); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Clear()
|
public static void Log(string message) => Log_.Log(message);
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
Lines.Clear();
|
|
||||||
CloseWriter();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(LogPath))
|
|
||||||
File.Delete(LogPath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Log(string message)
|
public static void Log(IEnumerable<string> messages) => Log_.Log(messages);
|
||||||
{
|
|
||||||
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)
|
public static void Log(string context, Exception ex) => Log_.Log(context, ex);
|
||||||
{
|
|
||||||
foreach (string message in messages)
|
|
||||||
Log(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Log(string context, Exception ex) =>
|
public static void Flush() => Log_.Flush();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Window? Owner =>
|
private static Window? Owner =>
|
||||||
(Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
|
(Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
|
||||||
@@ -132,7 +43,7 @@ public static class Diagnostics
|
|||||||
|
|
||||||
public static Task ReportAsync(string message, string? title = null)
|
public static Task ReportAsync(string message, string? title = null)
|
||||||
{
|
{
|
||||||
Log(message.Replace(Environment.NewLine, " | "));
|
Log_.Log(message.Replace(Environment.NewLine, " | "));
|
||||||
|
|
||||||
var owner = Owner;
|
var owner = Owner;
|
||||||
if (owner is null)
|
if (owner is null)
|
||||||
@@ -152,31 +63,13 @@ public static class Diagnostics
|
|||||||
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
|
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<string> CheckInstallation()
|
/// <summary>
|
||||||
{
|
/// Sanity check of the installation the starter was launched from.
|
||||||
var faults = new List<string>();
|
/// </summary>
|
||||||
string root = Directory.GetCurrentDirectory();
|
public static List<string> CheckInstallation() =>
|
||||||
|
new InstallationCheck(
|
||||||
foreach (string folder in new[] { "dynamic", "sounds", "models", "scenery", "textures" })
|
AppServices.Current.Paths,
|
||||||
if (!Directory.Exists(Path.Combine(root, folder)))
|
AppServices.Current.Files,
|
||||||
faults.Add(string.Format(App.Loc["FaultNoDir"], "/" + folder));
|
AppServices.Current.Localization)
|
||||||
|
.Run();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 Avalonia;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using StarterNG.Application;
|
||||||
using StarterNG.Infrastructure;
|
using StarterNG.Infrastructure;
|
||||||
|
|
||||||
namespace StarterNG;
|
namespace StarterNG;
|
||||||
@@ -11,6 +12,10 @@ static class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
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) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.ExceptionObject is Exception ex)
|
if (e.ExceptionObject is Exception ex)
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ using System.Collections.Generic;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
|
||||||
namespace StarterNG.Services;
|
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 =>
|
public static string LangDirectory =>
|
||||||
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
|
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
|
||||||
|
|
||||||
|
private readonly IDiagnosticsLog _log;
|
||||||
|
|
||||||
private Dictionary<string, string> _strings = new();
|
private Dictionary<string, string> _strings = new();
|
||||||
|
|
||||||
|
public LocalizationService(IDiagnosticsLog log)
|
||||||
|
{
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
public event Action? LanguageChanged;
|
public event Action? LanguageChanged;
|
||||||
@@ -43,7 +56,7 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
|
|
||||||
lock (_reportedMisses)
|
lock (_reportedMisses)
|
||||||
if (_reportedMisses.Add(key))
|
if (_reportedMisses.Add(key))
|
||||||
Infrastructure.Diagnostics.Log($"lang/{CurrentLangCode}: missing key \"{key}\"");
|
_log.Log($"lang/{CurrentLangCode}: missing key \"{key}\"");
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,7 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
|
|
||||||
public readonly record struct LanguageInfo(string Code, string Name, string Path);
|
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>();
|
var list = new List<LanguageInfo>();
|
||||||
if (!Directory.Exists(LangDirectory))
|
if (!Directory.Exists(LangDirectory))
|
||||||
@@ -74,7 +87,7 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StarterNG.Infrastructure.Diagnostics.Log($"lang/{Path.GetFileName(path)}", ex);
|
log?.Log($"lang/{Path.GetFileName(path)}", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
@@ -82,7 +95,7 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string Load(string codeOrName)
|
public string Load(string codeOrName)
|
||||||
{
|
{
|
||||||
var languages = AvailableLanguages();
|
var languages = AvailableLanguages(_log);
|
||||||
|
|
||||||
LanguageInfo? match = null;
|
LanguageInfo? match = null;
|
||||||
foreach (var lang in languages)
|
foreach (var lang in languages)
|
||||||
|
|||||||
Reference in New Issue
Block a user