using System.Text.Json; using System.Text.Json.Serialization; using AcDream.Platform; namespace AcDream.Launcher.Core.Profiles; /// /// Load/save/CRUD owner for launcher-profiles.json (Campaign LA /// spec §5) — the launcher's ONLY credential/profile store, and the /// binding surface the Avalonia UI (slice LA4) mutates directly. /// /// /// A store instance holds the current in-memory /// after ; every CRUD method mutates that document in /// place so callers can chain store.AddServer(...); store.Save(); /// without re-threading a returned document through every call. /// /// public sealed class LauncherProfileStore { internal const int CurrentVersion = 1; private static readonly JsonSerializerOptions SerializerOptions = new() { AllowTrailingCommas = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = false, ReadCommentHandling = JsonCommentHandling.Disallow, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, WriteIndented = true, Converters = { new JsonStringEnumConverter( JsonNamingPolicy.CamelCase, allowIntegerValues: false), }, }; public LauncherProfileStore(string filePath) { ArgumentException.ThrowIfNullOrWhiteSpace(filePath); FilePath = Path.GetFullPath(filePath); Document = new LauncherProfileDocument(); } /// Resolve the store at the canonical location under /// /// (%APPDATA%\acdream\launcher-profiles.json / /// ~/.config/acdream/launcher-profiles.json). public static LauncherProfileStore ForApplicationPaths(ApplicationPathSet paths) { ArgumentNullException.ThrowIfNull(paths); return new LauncherProfileStore( Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")); } public string FilePath { get; } public LauncherProfileDocument Document { get; private set; } /// /// Loads from . A /// missing file is not an error — it resolves to a fresh empty /// document (version 1, no servers), matching a never-launched /// installation. Returns true when a file was actually read. /// public bool Load() { if (!File.Exists(FilePath)) { Document = new LauncherProfileDocument(); return false; } LauncherProfileDocument? document; using (FileStream stream = File.OpenRead(FilePath)) { try { document = JsonSerializer.Deserialize( stream, SerializerOptions); } catch (JsonException ex) { throw new LauncherProfileException( $"'{FilePath}' is not a valid launcher profile document.", ex); } } if (document is null) { throw new LauncherProfileException($"'{FilePath}' is empty."); } if (document.Version != CurrentVersion) { throw new LauncherProfileException( $"Unsupported launcher-profiles version {document.Version}; " + $"expected {CurrentVersion}."); } Document = document; return true; } /// /// Persists to via a /// write-then-atomic-rename so a crash mid-write never leaves a /// truncated credentials file. On Linux, restricts the final file to /// owner read/write (0600) per Campaign LA's plaintext-credential /// decision (spec §5, decisions log). /// public void Save() { string? directory = Path.GetDirectoryName(FilePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } string tempPath = FilePath + ".tmp"; using (FileStream stream = File.Create(tempPath)) { JsonSerializer.Serialize(stream, Document, SerializerOptions); } File.Move(tempPath, FilePath, overwrite: true); if (OperatingSystem.IsLinux()) { File.SetUnixFileMode( FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } } // --- Server CRUD ----------------------------------------------- public ServerProfile AddServer(string name, string host, int port) { ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(host); RequireValidPort(port); if (FindServer(name) is not null) { throw new LauncherProfileException( $"A server named '{name}' already exists."); } var server = new ServerProfile { Name = name, Host = host, Port = port }; Document.Servers.Add(server); return server; } public void EditServer( string name, string? newName = null, string? newHost = null, int? newPort = null) { ServerProfile server = FindServerOrThrow(name); if (newName is not null) { ArgumentException.ThrowIfNullOrWhiteSpace(newName); if (!string.Equals(newName, server.Name, StringComparison.Ordinal) && FindServer(newName) is not null) { throw new LauncherProfileException( $"A server named '{newName}' already exists."); } server.Name = newName; } if (newHost is not null) { ArgumentException.ThrowIfNullOrWhiteSpace(newHost); server.Host = newHost; } if (newPort is not null) { RequireValidPort(newPort.Value); server.Port = newPort.Value; } } public void RemoveServer(string name) { ServerProfile server = FindServerOrThrow(name); Document.Servers.Remove(server); } // --- Account CRUD ------------------------------------------------ public AccountProfile AddAccount(string serverName, string account, string password) { ArgumentException.ThrowIfNullOrWhiteSpace(account); ArgumentNullException.ThrowIfNull(password); ServerProfile server = FindServerOrThrow(serverName); if (FindAccount(server, account) is not null) { throw new LauncherProfileException( $"Account '{account}' already exists on server '{serverName}'."); } var profile = new AccountProfile { Account = account, Password = password }; server.Accounts.Add(profile); return profile; } public void EditAccount( string serverName, string account, string? newAccount = null, string? newPassword = null) { ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); if (newAccount is not null) { ArgumentException.ThrowIfNullOrWhiteSpace(newAccount); if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal) && FindAccount(server, newAccount) is not null) { throw new LauncherProfileException( $"Account '{newAccount}' already exists on server '{serverName}'."); } profile.Account = newAccount; } if (newPassword is not null) { profile.Password = newPassword; } } public void RemoveAccount(string serverName, string account) { ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); server.Accounts.Remove(profile); } // --- Character settings (roster-driven add/remove; user-edited settings) --- /// /// Edits the user-owned settings of an existing character row. There /// is no manual add/remove for characters — the roster ( /// ) is the only source of new rows, per /// spec §5/§6. /// public void EditCharacter( string serverName, string account, string characterName, LaunchMode? launchMode = null, IReadOnlyList? plugins = null, IReadOnlyList? loginCommands = null) { ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); CharacterProfile character = FindCharacterOrThrow(profile, characterName); if (launchMode is not null) { character.LaunchMode = launchMode.Value; } if (plugins is not null) { character.Plugins = [.. plugins]; } if (loginCommands is not null) { character.LoginCommands = [.. loginCommands]; } } /// /// Folds a reported character roster into an account's /// (Campaign LA spec §3/§5/ /// §6): every roster entry either updates the name of an existing /// row (matched by ) while /// PRESERVING that row's user settings (, /// , /// ), or is inserted as a /// new row with default settings (, /// no plugins, no login commands). Existing rows absent from the /// roster are RETAINED unchanged — they may simply be pending-delete /// (ACE keeps deleted characters queryable during the grace window) /// or the roster snapshot may be partial; this store never deletes a /// character row on the caller's behalf. /// public void MergeRoster( string serverName, string account, IReadOnlyList roster) { ArgumentNullException.ThrowIfNull(roster); ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); foreach (CharacterRosterEntry entry in roster) { string idText = CharacterIdFormat.ToHexString(entry.Id); CharacterProfile? existing = profile.Characters.Find( character => string.Equals( character.Id, idText, StringComparison.OrdinalIgnoreCase)); // Defensive fallback for a hand-edited file where a character // row was added with a name but no id yet. existing ??= profile.Characters.Find( character => character.Id is null && string.Equals( character.Name, entry.Name, StringComparison.Ordinal)); if (existing is not null) { existing.Id = idText; existing.Name = entry.Name; continue; } profile.Characters.Add(new CharacterProfile { Id = idText, Name = entry.Name, LaunchMode = LaunchMode.GuiSelect, Plugins = [], LoginCommands = [], }); } } // --- Lookups ------------------------------------------------------- private ServerProfile? FindServer(string name) => Document.Servers.Find( server => string.Equals(server.Name, name, StringComparison.Ordinal)); private ServerProfile FindServerOrThrow(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); return FindServer(name) ?? throw new LauncherProfileException($"No server named '{name}'."); } private static AccountProfile? FindAccount(ServerProfile server, string account) => server.Accounts.Find( candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal)); private static AccountProfile FindAccountOrThrow(ServerProfile server, string account) { ArgumentException.ThrowIfNullOrWhiteSpace(account); return FindAccount(server, account) ?? throw new LauncherProfileException( $"No account '{account}' on server '{server.Name}'."); } private static CharacterProfile FindCharacterOrThrow( AccountProfile profile, string characterName) { ArgumentException.ThrowIfNullOrWhiteSpace(characterName); return profile.Characters.Find( character => string.Equals( character.Name, characterName, StringComparison.Ordinal)) ?? throw new LauncherProfileException( $"No character '{characterName}' on account '{profile.Account}'."); } private static void RequireValidPort(int port) { if (port is < 1 or > 65535) { throw new LauncherProfileException( $"Port {port} is outside the valid 1-65535 range."); } } }