using System.Text; using Tomlyn; using Tomlyn.Parsing; using Tomlyn.Syntax; namespace WiiCompiled.Setup.Common; public sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents); /// /// Reads and writes the runtime's Config.toml. Every entry point takes the file it operates on /// since its location depends on the installation (portable UserData vs. per-user app data); /// callers obtain it once via . Shared by both installers - Linux /// never creates a marker file, so /// /'s portable-root lookups always miss /// there and this degrades to the same plain per-user-app-data, always-absolute-path behavior a /// non-portable Windows install already gets. /// public static class RuntimeConfiguration { public const string ConfigFileName = "Config.toml"; /// The per-user configuration an ordinary (non-portable) installation shares. public static string ApplicationDataConfigPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", ConfigFileName); /// /// The configuration file that governs . This mirrors /// RuntimeConfigFile::ResolveConfigPath in the runtime: portable roots win, everything /// else is per-user application data. /// public static string ResolveConfigPath(string? installDirectory) { if (!string.IsNullOrWhiteSpace(installDirectory) && PortableRoot.TryFind(installDirectory) is { } portableRoot) return Path.Combine(PortableRoot.UserDataDirectory(portableRoot), ConfigFileName); return ApplicationDataConfigPath; } public static RuntimeConfigSnapshot Capture(string configPath) => File.Exists(configPath) ? new RuntimeConfigSnapshot(true, File.ReadAllBytes(configPath)) : new RuntimeConfigSnapshot(false, []); public static void Restore(string configPath, RuntimeConfigSnapshot snapshot) { if (snapshot.Existed) FileSystemUtilities.WriteAtomic(configPath, snapshot.Contents); else File.Delete(configPath); } public static void SetDvdRoot(string configPath, string dvdRoot) => SetPath(configPath, "dvd_root", dvdRoot); /// /// Records the one canonical Retro Rewind installation Wheel Wizard owns. The runtime builds its /// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update /// needs no backend work: the next launch simply reads the new files. /// public static void SetRetroRewindRoot(string configPath, string retroRewindRoot) => SetPath(configPath, "retro_rewind_root", retroRewindRoot); /// The canonical Retro Rewind root, or null when no installation has recorded one. public static string? GetRetroRewindRoot(string configPath) => GetResolvedPath(configPath, "retro_rewind_root"); public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) => RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot); public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) => RemovePathIfOwned(configPath, "dvd_root", dvdRoot); /// The stored string of a [paths] key, exactly as the file holds it. public static string? GetPath(string configPath, string key) => TryLoad(configPath) is { } document && Find(document, ["paths"], key)?.Item.Value is StringValueSyntax value ? value.Value! : null; /// /// A [paths] value as the runtime will actually use it. The runtime resolves every relative /// [paths] value against the directory holding Config.toml (never the working /// directory), so the host must resolve it the same way before comparing or reading it. /// public static string? GetResolvedPath(string configPath, string key) { var stored = GetPath(configPath, key); return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored); } public static string ConfigDirectory(string configPath) => Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? throw new InvalidOperationException($"{configPath} has no containing directory."); public static string ResolveAgainstConfig(string configPath, string value) => Path.GetFullPath(value, ConfigDirectory(configPath)); private static void SetPath(string configPath, string key, string value) { var document = Parse(File.Exists(configPath) ? File.ReadAllText(configPath) : DefaultConfig); var section = new[] { "paths" }; var newValue = new StringValueSyntax(FormatPathValue(configPath, value)); if (Find(document, section, key) is { } existing) { LastToken(newValue).TrailingTrivia = LastToken(existing.Item.Value!).TrailingTrivia; existing.Item.Value = newValue; } else { Append(document, section, new KeyValueSyntax(key, newValue)); } Save(configPath, document); } /// /// Paths inside the portable root are stored relative to the config directory (forward slashes, /// needing no TOML escaping) so the tree keeps working if the user moves or renames the root. /// Everything else, including all non-portable paths, is stored absolute. /// private static string FormatPathValue(string configPath, string value) { var full = Path.GetFullPath(value); var configDirectory = ConfigDirectory(configPath); if (PortableRoot.TryFind(configDirectory) is not { } root || !PortableRoot.Contains(root, full)) return full; var relative = Path.GetRelativePath(configDirectory, full); return Path.IsPathRooted(relative) ? full : relative.Replace(Path.DirectorySeparatorChar, '/'); } private static void RemovePathIfOwned(string configPath, string key, string expectedValue) { if (TryLoad(configPath) is not { } document || Find(document, ["paths"], key) is not { } entry || entry.Item.Value is not StringValueSyntax configured) return; // A portable installation stores this relative, so ownership is decided on the path the // runtime would actually resolve, not on the stored text. if (!ResolveAgainstConfig(configPath, configured.Value!) .Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase)) return; var previous = entry.Previous?.EndOfLineToken ?? entry.Table?.EndOfLineToken; if (previous is not null && entry.Item.EndOfLineToken?.TrailingTrivia is { } trivia) (previous.TrailingTrivia ??= []).AddRange(trivia); entry.Items.RemoveChild(entry.Item); Save(configPath, document); } private const string DefaultConfig = "# WiiCompiled user configuration\n\n"; private sealed record Entry( KeyValueSyntax Item, SyntaxList Items, TableSyntax? Table, KeyValueSyntax? Previous); private static DocumentSyntax Parse(string text) => SyntaxParser.ParseStrict(text); private static DocumentSyntax? TryLoad(string configPath) { if (!File.Exists(configPath)) return null; try { return Parse(File.ReadAllText(configPath)); } catch (TomlException) { return null; } } /// Re-parses the edited tree before replacing the file, so an invalid edit never reaches disk. private static void Save(string configPath, DocumentSyntax document) { var text = document.ToString(); Parse(text); FileSystemUtilities.WriteAtomic(configPath, new UTF8Encoding(false).GetBytes(text)); } private static IEnumerable Tables(DocumentSyntax document) => document.Tables.OfType(); private static string[] Parts(KeySyntax key) => [Part(key.Key!), .. key.DotKeys.Select(dotted => Part(dotted.Key!))]; private static string Part(BareKeyOrStringValueSyntax part) => part is StringValueSyntax quoted ? quoted.Value! : ((BareKeySyntax)part).Key!.Text!; /// Matches by TOML meaning, so "dvd_root" and dvd_root are the same key. private static Entry? Find(DocumentSyntax document, string[] section, string key) { string[] wanted = [.. section, key]; var scopes = Tables(document) .Select(table => (Table: (TableSyntax?)table, Prefix: Parts(table.Name!), Items: table.Items)) .Prepend((Table: null, Prefix: [], Items: document.KeyValues)); foreach (var (table, prefix, items) in scopes) { KeyValueSyntax? previous = null; foreach (var item in items) { if (prefix.Concat(Parts(item.Key!)).SequenceEqual(wanted)) return new Entry(item, items, table, previous); previous = item; } } return null; } private static void Append(DocumentSyntax document, string[] section, KeyValueSyntax item) { var text = document.ToString(); var newline = text.Contains("\r\n") ? "\r\n" : "\n"; var table = Tables(document).FirstOrDefault(t => Parts(t.Name!).SequenceEqual(section)); if (table is null) { var name = new KeySyntax(section[0]); foreach (var part in section.Skip(1)) name.DotKeys.Add(new DottedKeyItemSyntax(part)); table = new TableSyntax(name) { EndOfLineToken = NewLine(newline) }; var blankLine = text.EndsWith("\n\n") || text.EndsWith("\n\r\n"); var gap = text.Length == 0 ? 0 : !text.EndsWith('\n') ? 2 : blankLine ? 0 : 1; for (var i = 0; i < gap; i++) (table.LeadingTrivia ??= []).Add(new SyntaxTrivia(TokenKind.NewLine, newline)); document.Tables.Add(table); } // Blank lines and comments after the last entry stay after the new one. var last = table.Items.LastOrDefault(); var before = last is null ? table.EndOfLineToken ??= NewLine(newline) : last.EndOfLineToken ??= NewLine(newline); item.EndOfLineToken = NewLine(newline); item.EndOfLineToken.TrailingTrivia = before.TrailingTrivia; before.TrailingTrivia = null; table.Items.Add(item); } private static SyntaxToken NewLine(string text) => new(TokenKind.NewLine, text); private static SyntaxToken LastToken(SyntaxNode node) { while (node is not SyntaxToken) node = node.GetChild(node.ChildrenCount - 1)!; return (SyntaxToken)node; } }