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

@@ -25,6 +25,16 @@ public interface IFileSystem
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 WriteAllBytes(string path, byte[] bytes);
@@ -46,4 +56,32 @@ public interface IFileSystem
long GetFileSize(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 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)
{
_files = files;
@@ -52,6 +55,7 @@ public sealed class FavoriteSceneries
catch (Exception ex)
{
_log.Log($"reading {_path}", ex);
_unread = true;
}
}
@@ -62,7 +66,12 @@ public sealed class FavoriteSceneries
if (Path.GetDirectoryName(_path) is { Length: > 0 } 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)
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
namespace StarterNG.Classes;
@@ -12,6 +13,12 @@ public sealed class ConfigFile
private readonly Dictionary<string, int> _index =
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)
{
var cfg = new ConfigFile();
@@ -24,14 +31,44 @@ public sealed class ConfigFile
private void AddLine(string raw)
{
int idx = _lines.Count;
_lines.Add(raw);
if (TryParseKey(raw, out string key))
_index[key] = idx;
IndexLine(_lines.Count - 1);
}
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);
/// <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)
{
if (!_index.TryGetValue(key, out int i))
@@ -83,35 +120,61 @@ public sealed class ConfigFile
if (_index.TryGetValue(key, out int i))
{
SplitLine(_lines[i], out string indent, out _, out _, out string comment);
var sb = new StringBuilder();
sb.Append(indent).Append(key);
if (value.Length > 0)
sb.Append(' ').Append(value);
if (comment.Length > 0)
sb.Append('\t').Append(comment);
_lines[i] = sb.ToString();
string line = Line(indent, key, value, comment);
if (i > _end && _end >= 0)
{
_lines.RemoveAt(i);
InsertBeforeEnd(line);
}
else
{
_lines[i] = line;
}
}
else if (_end >= 0)
{
InsertBeforeEnd(Line("", key, value, ""));
}
else
{
int idx = _lines.Count;
_lines.Add(value.Length > 0 ? $"{key} {value}" : key);
_lines.Add(Line("", key, value, ""));
_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();
sb.Append(indent).Append("// ").Append(key);
sb.Append(indent).Append(key);
if (value.Length > 0)
sb.Append(' ').Append(value);
if (comment.Length > 0)
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);
}
@@ -133,7 +196,7 @@ public sealed class ConfigFile
private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment)
{
indent = key = value = comment = string.Empty;
key = value = comment = string.Empty;
int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start]))

View File

@@ -1,13 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes;
@@ -54,18 +54,14 @@ public static class PresetStore
public static PresetSort SortMode { get; set; } = PresetSort.Name;
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
TypeInfoResolver = PresetJsonContext.Default,
};
private static PresetFile? _disk;
private static JsonTypeInfo<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
private static PresetFile Disk =>
_disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
public static IReadOnlyList<TrainsetPreset> All()
{
var list = Load().Presets.ToList();
var list = Disk.Presets();
if (SortMode == PresetSort.Track)
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
else
@@ -73,25 +69,9 @@ public static class PresetStore
return list;
}
public static void Save(string? name, string? entry)
{
name = name?.Trim() ?? "";
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
return;
public static void Save(string? name, string? entry) => Disk.Save(name, entry);
var col = Load();
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 void Delete(string name) => Disk.Delete(name);
public static int ImportMagazyn(string? path = null)
{
@@ -162,35 +142,95 @@ public static class PresetStore
private static string Sanitize(string name) =>
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
{
if (File.Exists(FilePath))
{
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo);
if (col?.Presets != null)
return col;
}
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
}
return new PresetCollection();
WriteIndented = true,
TypeInfoResolver = PresetJsonContext.Default,
};
private static JsonTypeInfo<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
private readonly string _path;
public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
{
_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
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
if (!_files.FileExists(_path))
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)
{
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 bool _writerFailed;
private bool _previousKept;
public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths)
{
@@ -91,7 +92,8 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
try
{
_files.CreateDirectory(Path.GetDirectoryName(_logPath)!);
_files.WriteAllLines(_logPath, _lines);
if (KeepPreviousSession())
_files.WriteAllLines(_logPath, _lines);
}
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)
{
if (_writerFailed)
@@ -109,7 +131,7 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog
if (_writer is null)
{
_files.CreateDirectory(Path.GetDirectoryName(_logPath)!);
_writer = new StreamWriter(_logPath, append: false) { AutoFlush = true };
_writer = new StreamWriter(_logPath, append: !KeepPreviousSession()) { AutoFlush = true };
}
_writer.WriteLine(line);
}

View File

@@ -10,7 +10,24 @@ public sealed class GamePaths : IGamePaths
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; }

View File

@@ -32,14 +32,26 @@ public static class LegacyText
/// </summary>
public static string Decode(byte[] bytes)
{
ReadOnlySpan<byte> text = bytes;
if (text.StartsWith(Utf8Bom))
text = text[Utf8Bom.Length..];
return Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : CodePage1250.GetString(text);
var (encoding, bom) = Detect(bytes);
return encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
}
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()
{

View File

@@ -8,6 +8,8 @@ namespace StarterNG.Infrastructure.Adapters;
public sealed class PhysicalFileSystem : IFileSystem
{
public const string TempSuffix = ".starter.tmp";
public bool FileExists(string path) => File.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 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, Encoding 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 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 = "*") =>
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) =>
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>();
public IReadOnlyList<string> GetDirectories(string path) =>
@@ -62,4 +119,11 @@ public sealed class PhysicalFileSystem : IFileSystem
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
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.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -53,6 +55,25 @@ public static class Diagnostics
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() =>
new(AppServices.Current.Paths,
AppServices.Current.Files,