Write settings, presets and favourites whole or not at all

A failed write leaves the old file in place. Settings that could not be read
are not saved over. UI exceptions reach the log. Started from a shortcut set
up elsewhere, the starter moves to its own folder when only that one holds a
game.
This commit is contained in:
maj00r
2026-11-01 16:06:15 +01:00
parent 6948b13503
commit 07987a60a9
16 changed files with 1017 additions and 90 deletions

View File

@@ -0,0 +1,161 @@
using StarterNG.Classes;
namespace StarterNG.Tests;
public class ConfigFileTests
{
private static string[] Lines(ConfigFile config) =>
config.ToText().Split(Environment.NewLine);
[Fact]
public void A_file_is_written_with_the_hosts_line_endings_whichever_it_came_with()
{
// parser.cpp breaks words on \r as on \n, so the simulator takes either.
string nl = Environment.NewLine;
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\nb 2\n").ToText());
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\r\nb 2\r\n").ToText());
}
[Fact]
public void Removing_a_key_comments_every_line_that_sets_it()
{
var config = ConfigFile.Parse("width 800\nheight 600\nwidth 1024\nWIDTH 1920\n");
config.Remove("width");
Assert.False(config.Has("width"));
Assert.Equal(new[] { "// width 800", "height 600", "// width 1024", "// WIDTH 1920", "" }, Lines(config));
// Parsed again, nothing of it comes back.
Assert.False(ConfigFile.Parse(config.ToText()).Has("width"));
}
[Fact]
public void A_removed_key_keeps_its_comment_and_indent()
{
var config = ConfigFile.Parse(" width 800\t// mine\n");
config.Remove("width");
Assert.Equal(" // width 800\t// mine", Lines(config)[0]);
}
[Fact]
public void Removing_a_key_leaves_what_only_mentions_it()
{
var config = ConfigFile.Parse("// width 800\nwidth.max 900\nwidth 1024\n");
config.Remove("width");
Assert.Equal(new[] { "// width 800", "width.max 900", "// width 1024", "" }, Lines(config));
}
[Fact]
public void A_new_key_goes_before_endconfig()
{
// Globals.cpp stops reading at endconfig.
var config = ConfigFile.Parse("width 800\nendconfig\n// notes\n");
config.Set("height", "600");
config.Set("fullscreen", "no");
Assert.Equal(new[] { "width 800", "height 600", "fullscreen no", "endconfig", "// notes", "" }, Lines(config));
}
[Theory]
[InlineData("endconfig")]
[InlineData("ENDCONFIG")]
[InlineData(" EndConfig // the end")]
public void Endconfig_is_known_as_the_simulator_knows_it(string line)
{
// Its parser lower-cases every word.
var config = ConfigFile.Parse(line + "\n");
config.Set("width", "800");
Assert.Equal("width 800", Lines(config)[0]);
}
[Fact]
public void Endconfig_in_a_comment_ends_nothing()
{
var config = ConfigFile.Parse("// endconfig\n");
config.Set("width", "800");
Assert.Equal(new[] { "// endconfig", "", "width 800" }, Lines(config));
}
[Fact]
public void Keys_found_after_an_insertion_are_still_the_right_lines()
{
var config = ConfigFile.Parse("width 800\nendconfig\nlate 1\n");
config.Set("height", "600");
config.Set("width", "1024");
config.Set("height", "720");
Assert.Equal(new[] { "width 1024", "height 720", "endconfig", "late 1", "" }, Lines(config));
Assert.Equal("1024", config.GetRaw("width"));
Assert.Equal("720", config.GetRaw("height"));
Assert.Equal("1", config.GetRaw("late"));
}
[Fact]
public void A_key_past_endconfig_is_read_but_known_to_be_past_it()
{
var config = ConfigFile.Parse("width 800\nendconfig\nheight 600\n");
Assert.Equal("600", config.GetRaw("height"));
Assert.True(config.IsPastEnd("height"));
Assert.False(config.IsPastEnd("width"));
Assert.False(config.IsPastEnd("endconfig"));
Assert.False(config.IsPastEnd("absent"));
}
[Fact]
public void A_key_set_past_endconfig_moves_before_it_with_its_comment()
{
var config = ConfigFile.Parse("endconfig\n height 600\t// mine\nlate 1\n");
config.Set("height", "720");
Assert.Equal(new[] { " height 720\t// mine", "endconfig", "late 1", "" }, Lines(config));
Assert.False(config.IsPastEnd("height"));
Assert.True(config.IsPastEnd("late"));
}
[Fact]
public void Of_a_key_on_both_sides_of_endconfig_the_line_the_simulator_reads_counts()
{
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\n");
Assert.Equal("800", config.GetRaw("width"));
Assert.False(config.IsPastEnd("width"));
config.Set("width", "1024");
Assert.Equal(new[] { "width 1024", "endconfig", "width 1920", "" }, Lines(config));
}
[Fact]
public void A_key_past_endconfig_is_removed_like_any_other()
{
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\nheight 600\n");
config.Remove("width");
config.Remove("height");
Assert.Equal(new[] { "// width 800", "endconfig", "// width 1920", "// height 600", "" }, Lines(config));
}
[Fact]
public void Without_endconfig_a_new_key_goes_at_the_end_as_it_did()
{
var config = ConfigFile.Parse("width 800");
config.Set("height", "600");
Assert.Equal(new[] { "width 800", "height 600" }, Lines(config));
}
}

View File

@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Paths are normalised to forward slashes and compared case-insensitively, /// Paths are normalised to forward slashes and compared case-insensitively,
/// matching how the parsers treat installation paths on both platforms. /// matching how the parsers treat installation paths on both platforms - or,
/// asked for, case-sensitively, as a Linux disk holds them.
/// </remarks> /// </remarks>
public sealed class InMemoryFileSystem : IFileSystem public sealed class InMemoryFileSystem : IFileSystem
{ {
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase); private readonly StringComparison _comparison;
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, byte[]> _files;
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _directories;
private readonly HashSet<string> _executable;
private readonly Dictionary<string, string> _links;
private readonly HashSet<string> _unreadable;
private readonly HashSet<string> _unlistable;
private readonly Dictionary<string, DateTime> _written;
public InMemoryFileSystem(bool caseSensitive = false)
{
_comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
var comparer = StringComparer.FromComparison(_comparison);
_files = new Dictionary<string, byte[]>(comparer);
_directories = new HashSet<string>(comparer);
_executable = new HashSet<string>(comparer);
_links = new Dictionary<string, string>(comparer);
_unreadable = new HashSet<string>(comparer);
_unlistable = new HashSet<string>(comparer);
_written = new Dictionary<string, DateTime>(comparer);
}
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Making a link throws, as on a disk that cannot hold one.</summary>
public bool LinksFail { get; set; }
/// <summary>A folder moved to a destination this says yes to, its path with forward slashes, throws.</summary>
public Func<string, bool>? MoveFails { get; set; }
/// <summary>The file stays, but reading or copying it throws, as a locked one does; writing it still goes through.</summary>
public InMemoryFileSystem FailReading(string path)
{
_unreadable.Add(Normalize(path));
return this;
}
public InMemoryFileSystem AllowReading(string path)
{
_unreadable.Remove(Normalize(path));
return this;
}
/// <summary>The folder is there, but listing it throws, as one without the permission does.</summary>
public InMemoryFileSystem DenyListing(string path)
{
_unlistable.Add(Normalize(path));
return this;
}
static InMemoryFileSystem() static InMemoryFileSystem()
{ {
// The game's own files are code page 1250; a test that writes Polish text // The game's own files are code page 1250; a test that writes Polish text
@@ -54,7 +99,10 @@ public sealed class InMemoryFileSystem : IFileSystem
public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); /// <summary>A link counts as the directory it points at, as the disk has it.</summary>
public bool DirectoryExists(string path) =>
_directories.Contains(Normalize(path)) ||
(_links.TryGetValue(Normalize(path), out string? target) && _directories.Contains(Normalize(target)));
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path)); public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
@@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
AddParents(key); AddParents(key);
} }
public void WriteAllTextAtomic(string path, string contents) => WithFile(path, contents);
public void WriteAllBytesAtomic(string path, byte[] bytes) => WriteAllBytes(path, bytes);
public void WriteAllLines(string path, IEnumerable<string> lines) => public void WriteAllLines(string path, IEnumerable<string> lines) =>
WithFile(path, string.Join("\n", lines)); WithFile(path, string.Join("\n", lines));
@@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern); var pattern = ToRegex(searchPattern);
return _files.Keys return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/')) .Where(key => !key[prefix.Length..].Contains('/'))
.Where(key => pattern.IsMatch(key[prefix.Length..])) .Where(key => pattern.IsMatch(key[prefix.Length..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
@@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern); var pattern = ToRegex(searchPattern);
return _files.Keys return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !_unlistable.Any(folder => key.StartsWith(folder + "/", _comparison)))
.Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..])) .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
@@ -123,15 +176,26 @@ public sealed class InMemoryFileSystem : IFileSystem
public IReadOnlyList<string> GetDirectories(string path) public IReadOnlyList<string> GetDirectories(string path)
{ {
if (_unlistable.Contains(Normalize(path)))
throw new UnauthorizedAccessException($"Access to the path '{path}' is denied.");
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
return _directories return _directories
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/')) .Where(key => !key[prefix.Length..].Contains('/'))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
} }
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc; public DateTime GetLastWriteTimeUtc(string path) =>
_written.TryGetValue(Normalize(path), out var written) ? written : LastWriteTimeUtc;
/// <summary>One file's own time, in place of <see cref="LastWriteTimeUtc"/>.</summary>
public InMemoryFileSystem WrittenAt(string path, DateTime utc)
{
_written[Normalize(path)] = utc;
return this;
}
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary> /// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
public long GetFileSize(string path) => Bytes(path).LongLength; public long GetFileSize(string path) => Bytes(path).LongLength;
@@ -145,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem
return this; return this;
} }
private byte[] Bytes(string path) => public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
_files.TryGetValue(Normalize(path), out var bytes)
public void CreateDirectoryLink(string path, string target)
{
if (LinksFail)
throw new IOException($"cannot link {path}");
string key = Normalize(path);
if (_links.ContainsKey(key) || _directories.Contains(key) || _files.ContainsKey(key))
throw new IOException($"{path} already exists");
_links[key] = target;
AddParents(key);
}
public void MoveDirectory(string sourcePath, string destinationPath)
{
if (MoveFails?.Invoke(Normalize(destinationPath)) == true)
throw new IOException($"cannot move to {destinationPath}");
string from = Normalize(sourcePath), to = Normalize(destinationPath);
if (!_directories.Contains(from) && !_links.ContainsKey(from))
throw new DirectoryNotFoundException(sourcePath);
if (_directories.Contains(to) || _links.ContainsKey(to) || _files.ContainsKey(to))
throw new IOException($"{destinationPath} already exists");
string? Moved(string key) =>
string.Equals(key, from, _comparison) ? to
: key.StartsWith(from + "/", _comparison) ? to + key[from.Length..]
: null;
foreach (var (key, bytes) in _files.ToList())
if (Moved(key) is { } moved) { _files.Remove(key); _files[moved] = bytes; }
foreach (string key in _executable.ToList())
if (Moved(key) is { } moved) { _executable.Remove(key); _executable.Add(moved); }
foreach (var (key, target) in _links.ToList())
if (Moved(key) is { } moved) { _links.Remove(key); _links[moved] = target; }
foreach (string key in _directories.ToList())
if (Moved(key) is { } moved) { _directories.Remove(key); _directories.Add(moved); }
AddParents(to);
}
private byte[] Bytes(string path)
{
if (_unreadable.Contains(Normalize(path)))
throw new IOException($"The process cannot access the file '{path}'.");
return _files.TryGetValue(Normalize(path), out var bytes)
? bytes ? bytes
: throw new FileNotFoundException(path); : throw new FileNotFoundException(path);
}
private void AddParents(string key) private void AddParents(string key)
{ {

View File

@@ -0,0 +1,12 @@
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Tests.Fakes;
/// <summary>The diagnostics log for a test that only wants one to write to or read back.</summary>
public static class TestLog
{
/// <param name="files">Where the log file goes; a disk of its own when left out.</param>
public static FileDiagnosticsLog Over(IFileSystem? files = null) =>
new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game"));
}

View File

@@ -1,5 +1,4 @@
using StarterNG.Application; using StarterNG.Application;
using StarterNG.Domain.Sceneries;
using StarterNG.Tests.Fakes; using StarterNG.Tests.Fakes;
namespace StarterNG.Tests; namespace StarterNG.Tests;
@@ -35,4 +34,43 @@ public class FavoriteSceneriesTests
Assert.False(favorites.Contains(scenery)); Assert.False(favorites.Contains(scenery));
} }
[Fact]
public void A_file_that_could_not_be_read_goes_aside_before_a_star_replaces_it()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
.FailReading(FavoritesPath));
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
installation.Files.AllowReading(FavoritesPath);
favorites.Toggle(scenery);
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath + ".unreadable"));
Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath));
Assert.Contains(FavoritesPath + ".unreadable", installation.Log.Text);
// Set aside once: the file is the starter's own again.
favorites.Toggle(scenery);
Assert.False(installation.Files.FileExists(FavoritesPath + ".unreadable-2"));
}
[Fact]
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
.FailReading(FavoritesPath));
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
favorites.Toggle(scenery);
installation.Files.AllowReading(FavoritesPath);
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath));
Assert.Contains("writing", installation.Log.Text);
}
} }

View File

@@ -42,4 +42,128 @@ public class FileDiagnosticsLogTests
Assert.Equal(string.Empty, log.Text); Assert.Equal(string.Empty, log.Text);
} }
private static string Written(string path)
{
// The writer holds it open for as long as the session lasts.
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
[Fact]
public void The_first_entry_of_a_session_keeps_the_log_of_the_one_before()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("after the restart");
log.Log("and on");
Assert.Equal("the crash\n", Written(paths.DiagnosticsLog + ".1"));
string now = Written(paths.DiagnosticsLog);
Assert.DoesNotContain("the crash", now);
Assert.Contains("after the restart", now);
Assert.Contains("and on", now);
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Only_the_session_before_is_kept()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the last one\n");
File.WriteAllText(paths.DiagnosticsLog + ".1", "the one before it\n");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("now");
Assert.Equal("the last one\n", Written(paths.DiagnosticsLog + ".1"));
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Clearing_the_log_does_not_touch_the_session_kept()
{
var log = Build(out var files);
string path = new GamePaths("game").DiagnosticsLog;
files.WithFile(path, "the crash\n");
log.Log("after the restart");
log.Clear();
log.Log("cleared");
Assert.Equal("the crash\n", files.ReadAllText(path + ".1"));
}
[Fact]
public void A_log_that_cannot_be_copied_is_added_to_and_not_wiped()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
// A folder where the copy would go: nothing can be written in its place.
Directory.CreateDirectory(paths.DiagnosticsLog + ".1");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("after the restart");
string now = Written(paths.DiagnosticsLog);
Assert.Contains("the crash", now);
Assert.Contains("after the restart", now);
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void A_folder_it_cannot_write_in_does_not_stop_the_program()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
// A file where the log's folder should be, as unwritable as read-only gets
// on every platform and for every user.
File.WriteAllText(paths.StarterConfig, "");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("first");
log.Log("second");
log.Flush();
log.Clear();
log.Log("third");
Assert.Contains("third", log.Text);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
} }

View File

@@ -0,0 +1,36 @@
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Tests;
public sealed class PhysicalFileSystemTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("starterng-disk-").FullName;
private readonly PhysicalFileSystem _files = new();
public void Dispose() => Directory.Delete(_root, recursive: true);
[Fact]
public void An_atomic_write_makes_or_replaces_the_file_and_leaves_nothing_beside_it()
{
string path = Path.Combine(_root, "file.json");
_files.WriteAllTextAtomic(path, "first");
_files.WriteAllTextAtomic(path, "zażółć");
Assert.Equal("zażółć", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
}
[Fact]
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
{
string path = Path.Combine(_root, "file.json");
File.WriteAllText(path, "what stood there");
// Nowhere to put the new text on its way in.
Directory.CreateDirectory(path + PhysicalFileSystem.TempSuffix);
Assert.ThrowsAny<Exception>(() => _files.WriteAllTextAtomic(path, "half"));
Assert.Equal("what stood there", File.ReadAllText(path));
}
}

View File

@@ -0,0 +1,160 @@
using StarterNG.Classes;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
// Over the fake disk throughout: PresetStore itself is the player's real file.
public class PresetFileTests
{
private const string PresetsPath = "/home/user/.config/MaSzyna/starter/userpresets.json";
private const string ThreePresets = """
{ "Presets": [
{ "Name": "a", "Entry": "trainset a" },
{ "Name": "b", "Entry": "trainset b" },
{ "Name": "c", "Entry": "trainset c" }
] }
""";
private static (PresetFile Presets, InMemoryFileSystem Files, FileDiagnosticsLog Log) Rig(string? held = null)
{
var files = new InMemoryFileSystem();
if (held is not null)
files.WithFile(PresetsPath, held);
var log = TestLog.Over(files);
return (new PresetFile(files, log, PresetsPath), files, log);
}
[Fact]
public void A_saved_preset_joins_the_ones_in_the_file()
{
var (presets, files, _) = Rig(ThreePresets);
presets.Save("d", "trainset d");
Assert.Equal(new[] { "a", "b", "c", "d" }, presets.Presets().Select(p => p.Name));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
}
[Fact]
public void Saving_under_a_name_there_replaces_that_one()
{
var (presets, _, _) = Rig(ThreePresets);
presets.Save("B", "trainset other");
Assert.Equal(new[] { "a", "B", "c" }, presets.Presets().Select(p => p.Name));
}
[Fact]
public void A_first_preset_needs_no_file_to_be_there()
{
var (presets, files, log) = Rig();
presets.Save("a", "trainset a");
Assert.Equal("a", Assert.Single(presets.Presets()).Name);
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
Assert.Equal(string.Empty, log.Text);
}
[Theory]
[InlineData("""{ "Presets": [ { "Name": "a", "Entry": "trainset a" }, { "Na""")]
[InlineData("")]
[InlineData("null")]
[InlineData("""{ "Presets": null }""")]
public void A_file_cut_short_goes_aside_before_a_save_replaces_it(string held)
{
var (presets, files, log) = Rig(held);
presets.Save("new", "trainset new");
Assert.Equal(held, files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
Assert.Contains(PresetsPath + ".unreadable", log.Text);
}
[Fact]
public void An_earlier_copy_set_aside_is_not_overwritten()
{
var (presets, files, _) = Rig("{ cut");
files.WithFile(PresetsPath + ".unreadable", "older");
presets.Save("new", "trainset new");
Assert.Equal("older", files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("{ cut", files.ReadAllText(PresetsPath + ".unreadable-2"));
}
[Fact]
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
{
var (presets, files, log) = Rig(ThreePresets);
files.FailReading(PresetsPath);
presets.Save("new", "trainset new");
files.AllowReading(PresetsPath);
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
Assert.Contains("saving", log.Text);
}
[Fact]
public void A_file_unlocked_between_the_read_and_the_write_is_still_kept()
{
// The list in hand is the empty one from the read that failed: the file has
// to be kept whatever it looks like by the time it would be replaced.
var (presets, files, _) = Rig(ThreePresets);
var unlocking = new UnlockingLog(files, PresetsPath);
var racing = new PresetFile(files, unlocking, PresetsPath);
files.FailReading(PresetsPath);
racing.Save("new", "trainset new");
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
}
[Fact]
public void Deleting_from_an_unreadable_file_leaves_it_as_it_is()
{
var (presets, files, _) = Rig("{ cut");
presets.Delete("a");
Assert.Equal("{ cut", files.ReadAllText(PresetsPath));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
}
[Fact]
public void An_unreadable_file_shows_as_no_presets_and_is_logged()
{
var (presets, _, log) = Rig("{ cut");
Assert.Empty(presets.Presets());
Assert.Contains("reading", log.Text);
}
/// <summary>Lets the file go the moment its read has failed and been logged.</summary>
private sealed class UnlockingLog : StarterNG.Application.Abstractions.IDiagnosticsLog
{
private readonly InMemoryFileSystem _files;
private readonly string _path;
public UnlockingLog(InMemoryFileSystem files, string path)
{
_files = files;
_path = path;
}
public string LogPath => "";
public string Text => "";
public void Log(string message) => _files.AllowReading(_path);
public void Log(string context, Exception exception) => _files.AllowReading(_path);
public void Log(IEnumerable<string> messages) => _files.AllowReading(_path);
public void Clear() { }
public void Flush() { }
}
}

View File

@@ -25,6 +25,16 @@ public interface IFileSystem
void WriteAllText(string path, string contents, Encoding encoding); void WriteAllText(string path, string contents, Encoding encoding);
/// <summary>
/// Writes the file whole or not at all: a crash or a full disk half way leaves
/// what stood there, not the start of what was on its way. A link stays a link,
/// its file gets the contents; a file keeps its mode.
/// </summary>
void WriteAllBytesAtomic(string path, byte[] bytes);
/// <summary>As UTF-8 without a mark, by <see cref="WriteAllBytesAtomic"/>.</summary>
void WriteAllTextAtomic(string path, string contents);
void WriteAllLines(string path, IEnumerable<string> lines); void WriteAllLines(string path, IEnumerable<string> lines);
void WriteAllBytes(string path, byte[] bytes); void WriteAllBytes(string path, byte[] bytes);
@@ -46,4 +56,32 @@ public interface IFileSystem
long GetFileSize(string path); long GetFileSize(string path);
bool IsExecutable(string path); bool IsExecutable(string path);
/// <summary>Where the symbolic link at <paramref name="path"/> points, or null for anything else.</summary>
string? LinkTarget(string path);
/// <summary>Makes <paramref name="path"/> a symbolic link to the directory <paramref name="target"/>.</summary>
void CreateDirectoryLink(string path, string target);
void MoveDirectory(string sourcePath, string destinationPath);
}
public static class FileSystemExtensions
{
/// <summary>
/// Copies a file that could not be read beside itself, before something replaces
/// it: it may hold what was not read. Throws when it cannot; then it must stay.
/// </summary>
public static void KeepAside(this IFileSystem files, string path, IDiagnosticsLog log)
{
if (!files.FileExists(path))
return;
string aside = path + ".unreadable";
for (int n = 2; files.FileExists(aside); n++)
aside = $"{path}.unreadable-{n}";
files.CopyFile(path, aside, overwrite: false);
log.Log($"{path} could not be read; a copy is kept as {aside}");
}
} }

View File

@@ -18,6 +18,9 @@ public sealed class FavoriteSceneries
private readonly string _path; private readonly string _path;
private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase);
// The file holds more than _names does: it goes aside before anything replaces it.
private bool _unread;
public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path) public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path)
{ {
_files = files; _files = files;
@@ -52,6 +55,7 @@ public sealed class FavoriteSceneries
catch (Exception ex) catch (Exception ex)
{ {
_log.Log($"reading {_path}", ex); _log.Log($"reading {_path}", ex);
_unread = true;
} }
} }
@@ -62,7 +66,12 @@ public sealed class FavoriteSceneries
if (Path.GetDirectoryName(_path) is { Length: > 0 } directory) if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
_files.CreateDirectory(directory); _files.CreateDirectory(directory);
_files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase))); if (_unread)
{
_files.KeepAside(_path, _log);
_unread = false;
}
_files.WriteAllTextAtomic(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Text; using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -12,6 +13,12 @@ public sealed class ConfigFile
private readonly Dictionary<string, int> _index = private readonly Dictionary<string, int> _index =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
// The simulator stops reading at this word (Globals.cpp), whatever its case.
private const string EndKey = "endconfig";
// Its first line, or -1 when the file runs to its end.
private int _end = -1;
public static ConfigFile Parse(string text) public static ConfigFile Parse(string text)
{ {
var cfg = new ConfigFile(); var cfg = new ConfigFile();
@@ -24,14 +31,44 @@ public sealed class ConfigFile
private void AddLine(string raw) private void AddLine(string raw)
{ {
int idx = _lines.Count;
_lines.Add(raw); _lines.Add(raw);
if (TryParseKey(raw, out string key)) IndexLine(_lines.Count - 1);
_index[key] = idx; }
private void IndexLine(int line)
{
if (!TryParseKey(_lines[line], out string key))
return;
if (_end < 0 && key.Equals(EndKey, StringComparison.OrdinalIgnoreCase))
_end = line;
// Of a key on both sides of the end, the line the simulator reads counts.
if (_end < 0 || line == _end || !_index.TryGetValue(key, out int seen) || seen > _end)
_index[key] = line;
}
private void Reindex()
{
_index.Clear();
_end = -1;
for (int line = 0; line < _lines.Count; line++)
IndexLine(line);
} }
public bool Has(string key) => _index.ContainsKey(key); public bool Has(string key) => _index.ContainsKey(key);
/// <summary>Whether the line setting the key is one the simulator never gets to.</summary>
public bool IsPastEnd(string key) =>
_end >= 0 && _index.TryGetValue(key, out int line) && line > _end;
/// <summary>Every key the file sets, in the order of its lines, with its value.</summary>
public IEnumerable<KeyValuePair<string, string>> Entries()
{
foreach (var (key, _) in _index.OrderBy(pair => pair.Value))
yield return new KeyValuePair<string, string>(key, GetRaw(key) ?? string.Empty);
}
public string? GetRaw(string key) public string? GetRaw(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -83,35 +120,61 @@ public sealed class ConfigFile
if (_index.TryGetValue(key, out int i)) if (_index.TryGetValue(key, out int i))
{ {
SplitLine(_lines[i], out string indent, out _, out _, out string comment); SplitLine(_lines[i], out string indent, out _, out _, out string comment);
var sb = new StringBuilder(); string line = Line(indent, key, value, comment);
sb.Append(indent).Append(key);
if (value.Length > 0) if (i > _end && _end >= 0)
sb.Append(' ').Append(value); {
if (comment.Length > 0) _lines.RemoveAt(i);
sb.Append('\t').Append(comment); InsertBeforeEnd(line);
_lines[i] = sb.ToString(); }
else
{
_lines[i] = line;
}
}
else if (_end >= 0)
{
InsertBeforeEnd(Line("", key, value, ""));
} }
else else
{ {
int idx = _lines.Count; int idx = _lines.Count;
_lines.Add(value.Length > 0 ? $"{key} {value}" : key); _lines.Add(Line("", key, value, ""));
_index[key] = idx; _index[key] = idx;
} }
} }
public void Remove(string key) private static string Line(string indent, string key, string value, string comment)
{ {
if (!_index.TryGetValue(key, out int i))
return;
SplitLine(_lines[i], out string indent, out _, out string value, out string comment);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append(indent).Append("// ").Append(key); sb.Append(indent).Append(key);
if (value.Length > 0) if (value.Length > 0)
sb.Append(' ').Append(value); sb.Append(' ').Append(value);
if (comment.Length > 0) if (comment.Length > 0)
sb.Append('\t').Append(comment); sb.Append('\t').Append(comment);
_lines[i] = sb.ToString(); return sb.ToString();
}
private void InsertBeforeEnd(string line)
{
_lines.Insert(_end, line);
Reindex();
}
/// <summary>Comments out every line setting the key: the last one alone would bring the one before it back.</summary>
public void Remove(string key)
{
if (!_index.ContainsKey(key))
return;
for (int i = 0; i < _lines.Count; i++)
{
SplitLine(_lines[i], out string indent, out string found, out string value, out string comment);
if (!found.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
_lines[i] = Line(indent + "// ", found, value, comment);
}
_index.Remove(key); _index.Remove(key);
} }
@@ -133,7 +196,7 @@ public sealed class ConfigFile
private static void SplitLine(string raw, out string indent, out string key, private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment) out string value, out string comment)
{ {
indent = key = value = comment = string.Empty; key = value = comment = string.Empty;
int start = 0; int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start])) while (start < raw.Length && char.IsWhiteSpace(raw[start]))

View File

@@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -54,18 +54,14 @@ public static class PresetStore
public static PresetSort SortMode { get; set; } = PresetSort.Name; public static PresetSort SortMode { get; set; } = PresetSort.Name;
private static readonly JsonSerializerOptions Options = new() private static PresetFile? _disk;
{
WriteIndented = true,
TypeInfoResolver = PresetJsonContext.Default,
};
private static JsonTypeInfo<PresetCollection> TypeInfo => private static PresetFile Disk =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection)); _disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
public static IReadOnlyList<TrainsetPreset> All() public static IReadOnlyList<TrainsetPreset> All()
{ {
var list = Load().Presets.ToList(); var list = Disk.Presets();
if (SortMode == PresetSort.Track) if (SortMode == PresetSort.Track)
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase)); list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
else else
@@ -73,25 +69,9 @@ public static class PresetStore
return list; return list;
} }
public static void Save(string? name, string? entry) public static void Save(string? name, string? entry) => Disk.Save(name, entry);
{
name = name?.Trim() ?? "";
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
return;
var col = Load(); public static void Delete(string name) => Disk.Delete(name);
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
Persist(col);
}
public static void Delete(string name)
{
var col = Load();
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
Persist(col);
}
public static int ImportMagazyn(string? path = null) public static int ImportMagazyn(string? path = null)
{ {
@@ -162,35 +142,95 @@ public static class PresetStore
private static string Sanitize(string name) => private static string Sanitize(string name) =>
string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_'); string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
}
private static PresetCollection Load() /// <summary>The presets as one file holds them; <see cref="PresetStore"/> is the player's own.</summary>
public sealed class PresetFile
{
private static readonly JsonSerializerOptions Options = new()
{ {
try WriteIndented = true,
{ TypeInfoResolver = PresetJsonContext.Default,
if (File.Exists(FilePath)) };
{
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo); private static JsonTypeInfo<PresetCollection> TypeInfo =>
if (col?.Presets != null) (JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
return col;
} private readonly IFileSystem _files;
} private readonly IDiagnosticsLog _log;
catch (Exception ex) private readonly string _path;
{
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex); public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
} {
return new PresetCollection(); _files = files;
_log = log;
_path = path;
} }
private static void Persist(PresetCollection col) public List<TrainsetPreset> Presets()
{ {
TryRead(out var col);
return col.Presets;
}
public void Save(string? name, string? entry)
{
name = name?.Trim() ?? "";
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
return;
bool read = TryRead(out var col);
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
Persist(col, read);
}
public void Delete(string name)
{
bool read = TryRead(out var col);
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
Persist(col, read);
}
/// <summary>The file's presets; false, with none, when there is a file but they cannot be had from it.</summary>
private bool TryRead(out PresetCollection col)
{
col = new PresetCollection();
try try
{ {
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); if (!_files.FileExists(_path))
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo)); return true;
var read = JsonSerializer.Deserialize(_files.ReadAllText(_path), TypeInfo);
if (read?.Presets != null)
{
col = read;
return true;
}
_log.Log($"reading {_path}: no presets in it");
} }
catch (Exception ex) catch (Exception ex)
{ {
StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex); _log.Log($"reading {_path}", ex);
}
return false;
}
// What was not read is not in the list: written over, those presets are gone, so
// a copy goes first and without one the file stays - the new preset waits.
private void Persist(PresetCollection col, bool read)
{
try
{
_files.CreateDirectory(Path.GetDirectoryName(_path)!);
if (!read)
_files.KeepAside(_path, _log);
_files.WriteAllTextAtomic(_path, JsonSerializer.Serialize(col, TypeInfo));
}
catch (Exception ex)
{
_log.Log($"saving {_path}", ex);
} }
} }
} }

View File

@@ -17,6 +17,7 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
private StreamWriter? _writer; private StreamWriter? _writer;
private bool _writerFailed; private bool _writerFailed;
private bool _previousKept;
public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths) public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths)
{ {
@@ -91,7 +92,8 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
try try
{ {
_files.CreateDirectory(Path.GetDirectoryName(_logPath)!); _files.CreateDirectory(Path.GetDirectoryName(_logPath)!);
_files.WriteAllLines(_logPath, _lines); if (KeepPreviousSession())
_files.WriteAllLines(_logPath, _lines);
} }
catch catch
{ {
@@ -99,6 +101,26 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
} }
} }
// What the last session left, a crash's traces above all, is what this log is
// read for: one copy of it goes aside before the first entry of this one. False
// when it could not, and the log is then added to instead of started over.
private bool KeepPreviousSession()
{
if (_previousKept)
return true;
try
{
if (_files.FileExists(_logPath))
_files.CopyFile(_logPath, _logPath + ".1", overwrite: true);
return _previousKept = true;
}
catch
{
return false;
}
}
private void Append(string line) private void Append(string line)
{ {
if (_writerFailed) if (_writerFailed)
@@ -109,7 +131,7 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
if (_writer is null) if (_writer is null)
{ {
_files.CreateDirectory(Path.GetDirectoryName(_logPath)!); _files.CreateDirectory(Path.GetDirectoryName(_logPath)!);
_writer = new StreamWriter(_logPath, append: false) { AutoFlush = true }; _writer = new StreamWriter(_logPath, append: !KeepPreviousSession()) { AutoFlush = true };
} }
_writer.WriteLine(line); _writer.WriteLine(line);
} }

View File

@@ -10,7 +10,24 @@ public sealed class GamePaths : IGamePaths
Root = root; Root = root;
} }
public static GamePaths ForCurrentDirectory() => new(Directory.GetCurrentDirectory()); /// <summary>
/// The working directory, which is how the game's own shortcuts point at it - or
/// the starter's folder where only that one holds a game, as when started from a
/// shortcut set up elsewhere. Moved there too: more than these paths go by it.
/// </summary>
public static GamePaths ForCurrentDirectory()
{
string current = Directory.GetCurrentDirectory();
string own = System.AppContext.BaseDirectory;
if (HoldsGame(current) || !HoldsGame(own))
return new GamePaths(current);
Directory.SetCurrentDirectory(own);
return new GamePaths(Directory.GetCurrentDirectory());
}
private static bool HoldsGame(string folder) =>
Directory.Exists(Path.Combine(folder, "scenery")) && Directory.Exists(Path.Combine(folder, "dynamic"));
public string Root { get; } public string Root { get; }

View File

@@ -32,14 +32,26 @@ public static class LegacyText
/// </summary> /// </summary>
public static string Decode(byte[] bytes) public static string Decode(byte[] bytes)
{ {
ReadOnlySpan<byte> text = bytes; var (encoding, bom) = Detect(bytes);
if (text.StartsWith(Utf8Bom)) return encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
text = text[Utf8Bom.Length..];
return Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : CodePage1250.GetString(text);
} }
private static ReadOnlySpan<byte> Utf8Bom => [0xEF, 0xBB, 0xBF]; /// <summary>
/// What a game file is written in, so it is written back the way it was read:
/// UTF-8 only for a file that is UTF-8 and says so with its mark or a letter
/// outside ASCII, code page 1250 - the game's own - else. That one gives every
/// byte back as it was, also behind a mark that lied; UTF-8 would turn what is
/// not UTF-8 into replacement characters.
/// </summary>
public static (Encoding Encoding, byte[] Bom) Detect(byte[] bytes)
{
byte[] bom = bytes.AsSpan().StartsWith(Utf8Bom) ? Utf8Bom : Array.Empty<byte>();
var body = bytes.AsSpan(bom.Length);
bool utf8 = Utf8.IsValid(body) && (bom.Length > 0 || body.ContainsAnyInRange((byte)0x80, (byte)0xFF));
return (utf8 ? new UTF8Encoding(false) : CodePage1250, bom);
}
private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF };
private static Encoding Resolve() private static Encoding Resolve()
{ {

View File

@@ -8,6 +8,8 @@ namespace StarterNG.Infrastructure.Adapters;
public sealed class PhysicalFileSystem : IFileSystem public sealed class PhysicalFileSystem : IFileSystem
{ {
public const string TempSuffix = ".starter.tmp";
public bool FileExists(string path) => File.Exists(path); public bool FileExists(string path) => File.Exists(path);
public bool DirectoryExists(string path) => Directory.Exists(path); public bool DirectoryExists(string path) => Directory.Exists(path);
@@ -20,13 +22,61 @@ public sealed class PhysicalFileSystem : IFileSystem
public byte[] ReadAllBytes(string path) => File.ReadAllBytes(path); public byte[] ReadAllBytes(string path) => File.ReadAllBytes(path);
public Stream OpenRead(string path) => File.OpenRead(path); // Shared for writing too, so a file the simulator still holds open - a physics log of
// a running trip - can be read.
public Stream OpenRead(string path) =>
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents); public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents);
public void WriteAllText(string path, string contents, Encoding encoding) => public void WriteAllText(string path, string contents, Encoding encoding) =>
File.WriteAllText(path, contents, encoding); File.WriteAllText(path, contents, encoding);
public void WriteAllTextAtomic(string path, string contents) =>
WriteAllBytesAtomic(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(contents));
/// <summary>
/// Written beside the file and moved over it. A crash at any moment leaves the old
/// file or the new one whole - at worst with the temporary one beside it, which
/// the next save writes over, under a name nothing in the game reads.
/// </summary>
public void WriteAllBytesAtomic(string path, byte[] bytes)
{
// Through a link, to its file: moved over the link, the text would take its place.
if (File.Exists(path) && File.ResolveLinkTarget(path, returnFinalTarget: true) is { } linked)
path = linked.FullName;
string temporary = path + TempSuffix;
try
{
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(bytes);
// On the disk itself first, or a power cut could leave the new name with no text under it.
stream.Flush(flushToDisk: true);
}
// A new file gets the default mode; a write in place kept the old one.
if (!OperatingSystem.IsWindows() && File.Exists(path) &&
File.GetUnixFileMode(path) is var mode && mode != File.GetUnixFileMode(temporary))
File.SetUnixFileMode(temporary, mode);
File.Move(temporary, path, overwrite: true);
}
catch
{
try
{
File.Delete(temporary);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// The failure to tell is the one that brought us here.
}
throw;
}
}
public void WriteAllLines(string path, IEnumerable<string> lines) => File.WriteAllLines(path, lines); 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 WriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes);
@@ -41,9 +91,16 @@ public sealed class PhysicalFileSystem : IFileSystem
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") => public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>(); Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
// A folder it may not enter costs what is in it, not the whole listing.
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) => public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
Directory.Exists(path) Directory.Exists(path)
? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories) ? Directory.GetFiles(path, searchPattern, new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = 0,
MatchType = MatchType.Win32
})
: Array.Empty<string>(); : Array.Empty<string>();
public IReadOnlyList<string> GetDirectories(string path) => public IReadOnlyList<string> GetDirectories(string path) =>
@@ -62,4 +119,11 @@ public sealed class PhysicalFileSystem : IFileSystem
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
return (File.GetUnixFileMode(path) & anyExecute) != 0; return (File.GetUnixFileMode(path) & anyExecute) != 0;
} }
public string? LinkTarget(string path) => new FileInfo(path).LinkTarget;
public void CreateDirectoryLink(string path, string target) => Directory.CreateSymbolicLink(path, target);
public void MoveDirectory(string sourcePath, string destinationPath) =>
Directory.Move(sourcePath, destinationPath);
} }

View File

@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
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;
@@ -53,6 +55,25 @@ public static class Diagnostics
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
} }
/// <summary>Opens a file or folder the way the system does; a dialog says if it is not there or will not open.</summary>
public static void OpenOrReport(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
{
ReportOnUiThread(string.Format(App.Loc["FaultFileNotFound"], path));
return;
}
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
_ = ReportAsync(path, ex);
}
}
public static InstallationCheck InstallationCheck() => public static InstallationCheck InstallationCheck() =>
new(AppServices.Current.Paths, new(AppServices.Current.Paths,
AppServices.Current.Files, AppServices.Current.Files,