acdream/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs

863 lines
30 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// Load/save/CRUD owner for <c>launcher-profiles.json</c> (Campaign LA
/// spec §5) — the launcher's ONLY credential/profile store, and the
/// binding surface the Avalonia UI (slice LA4) mutates directly.
///
/// <para>
/// A store instance holds the current in-memory <see cref="Document"/>
/// after <see cref="Load"/>; every CRUD method mutates that document in
/// place so callers can chain <c>store.AddServer(...); store.Save();</c>
/// without re-threading a returned document through every call.
/// </para>
/// </summary>
public sealed class LauncherProfileStore
{
internal const int CurrentVersion = 1;
internal const UnixFileMode OwnerOnlyFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite;
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();
}
/// <summary>Resolve the store at the canonical location under
/// <see cref="ApplicationPathSet.ConfigDirectory"/>
/// (<c>%APPDATA%\acdream\launcher-profiles.json</c> /
/// <c>~/.config/acdream/launcher-profiles.json</c>).</summary>
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; }
/// <summary>
/// Loads <see cref="Document"/> from <see cref="FilePath"/>. 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.
/// </summary>
public bool Load()
{
// Opportunistic cleanup of a stale ".tmp" left behind by a Save()
// that crashed between creating the temp file and the atomic
// rename (Campaign LA plan §LA3 review finding F4) — a stray
// temp file carries the same plaintext credentials as the real
// store and should not linger.
DeleteStaleTempFile(FilePath + ".tmp");
if (!File.Exists(FilePath))
{
Document = new LauncherProfileDocument();
return false;
}
EnsureExistingCredentialFilePermissions();
LauncherProfileDocument? document;
using (FileStream stream = File.OpenRead(FilePath))
{
try
{
document = JsonSerializer.Deserialize<LauncherProfileDocument>(
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}.");
}
ValidateAndNormalizeDocument(document);
Document = document;
return true;
}
/// <summary>
/// Persists <see cref="Document"/> to <see cref="FilePath"/> via a
/// write-then-atomic-rename so a crash mid-write never leaves a
/// truncated credentials file. On Linux, the temp file is created
/// atomically with owner read/write (0600) as its requested creation
/// mode — before its path is observable and before any plaintext
/// credential is serialized into it. The final path retains that mode
/// through the rename (Campaign LA's plaintext-credential decision,
/// spec §5, decisions log).
/// A failure between temp-file creation and the rename deletes the
/// stale temp file rather than leaving it behind.
/// </summary>
public void Save()
{
string? directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
string tempPath = FilePath + ".tmp";
DeleteStaleTempFile(tempPath);
try
{
using (FileStream stream = CreateCredentialTempFile(tempPath))
{
if (OperatingSystem.IsLinux())
{
// UnixCreateMode is subject to the process umask. It
// guarantees the file is never created with group/other
// access; normalize the owner bits while the still-empty
// file is open so the persisted contract is exactly 0600.
File.SetUnixFileMode(tempPath, OwnerOnlyFileMode);
}
JsonSerializer.Serialize(stream, Document, SerializerOptions);
}
if (OperatingSystem.IsLinux()
&& File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode)
{
throw new IOException(
"The launcher credential temp file could not be secured to mode 0600.");
}
File.Move(tempPath, FilePath, overwrite: true);
}
catch
{
DeleteStaleTempFile(tempPath);
throw;
}
}
/// <summary>
/// Builds the exact options used for the plaintext-credential temp
/// file. <see cref="FileMode.CreateNew"/> makes creation atomic and
/// refuses to follow an existing stale or raced path. On Linux,
/// <see cref="FileStreamOptions.UnixCreateMode"/> supplies 0600 to
/// the OS create operation itself, eliminating the observable
/// create-then-chmod window. Windows leaves UnixCreateMode unset and
/// therefore retains its normal user-profile ACL behavior.
/// </summary>
internal static FileStreamOptions CreateCredentialTempFileOptions()
{
var options = new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
};
if (OperatingSystem.IsLinux())
{
options.UnixCreateMode = OwnerOnlyFileMode;
}
return options;
}
internal static FileStream CreateCredentialTempFile(string tempPath) =>
new(tempPath, CreateCredentialTempFileOptions());
private static void DeleteStaleTempFile(string tempPath)
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
// Best-effort cleanup only — the caller's own exception (a
// failed Save()) or the fresh Load() already in progress is
// what matters; a cleanup failure must not mask either.
}
}
// --- 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.");
}
}
if (newHost is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
}
if (newPort is not null)
{
RequireValidPort(newPort.Value);
}
server.Name = newName ?? server.Name;
server.Host = newHost ?? server.Host;
server.Port = newPort ?? server.Port;
}
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 ?? profile.Account;
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 CRUD / user-owned settings --------------------------
/// <summary>
/// Adds a manually configured character row. Normal operation discovers
/// characters through <see cref="MergeRoster"/>, but LA4's full in-UI
/// CRUD contract also lets a user create a cached row before a successful
/// probe (for example, to launch by a known character name while a server
/// is temporarily unavailable). A later roster merge remains
/// authoritative for the id/name pair and preserves these user settings.
/// </summary>
public CharacterProfile AddCharacter(
string serverName,
string account,
string characterName,
string? id = null,
LaunchMode launchMode = LaunchMode.GuiSelect,
IReadOnlyList<string>? plugins = null,
IReadOnlyList<string>? loginCommands = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
RequireValidLaunchMode(launchMode);
ValidateStringList(plugins, "plugin", requireUnique: true);
ValidateStringList(loginCommands, "login command", requireUnique: false);
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
if (FindCharacter(profile, characterName) is not null)
{
throw new LauncherProfileException(
$"Character '{characterName}' already exists on account '{account}'.");
}
string? normalizedId = NormalizeCharacterId(id);
if (normalizedId is not null
&& profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
{
throw new LauncherProfileException(
$"Character id '{normalizedId}' already exists on account '{account}'.");
}
var character = new CharacterProfile
{
Name = characterName,
Id = normalizedId,
LaunchMode = launchMode,
Plugins = plugins is null ? [] : [.. plugins],
LoginCommands = loginCommands is null ? [] : [.. loginCommands],
};
profile.Characters.Add(character);
return character;
}
/// <summary>
/// Edits the identity cache and/or user-owned settings of an existing
/// character row. Passing an empty <paramref name="newId"/> clears a
/// manually entered id so launches fall back to the character name.
/// </summary>
public void EditCharacter(
string serverName,
string account,
string characterName,
LaunchMode? launchMode = null,
IReadOnlyList<string>? plugins = null,
IReadOnlyList<string>? loginCommands = null,
string? newName = null,
string? newId = null)
{
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
string? normalizedId = null;
if (newName is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
&& FindCharacter(profile, newName) is not null)
{
throw new LauncherProfileException(
$"Character '{newName}' already exists on account '{account}'.");
}
}
if (newId is not null)
{
normalizedId = NormalizeCharacterId(newId);
if (normalizedId is not null
&& profile.Characters.Any(candidate =>
!ReferenceEquals(candidate, character)
&& CharacterIdsEqual(candidate.Id, normalizedId)))
{
throw new LauncherProfileException(
$"Character id '{normalizedId}' already exists on account '{account}'.");
}
}
if (launchMode is not null)
{
RequireValidLaunchMode(launchMode.Value);
}
ValidateStringList(plugins, "plugin", requireUnique: true);
ValidateStringList(loginCommands, "login command", requireUnique: false);
character.Name = newName ?? character.Name;
if (newId is not null)
{
character.Id = normalizedId;
}
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];
}
}
private void EnsureExistingCredentialFilePermissions()
{
if (!OperatingSystem.IsLinux())
{
return;
}
try
{
UnixFileMode mode = File.GetUnixFileMode(FilePath);
if (mode != OwnerOnlyFileMode)
{
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
mode = File.GetUnixFileMode(FilePath);
}
if (mode != OwnerOnlyFileMode)
{
throw new IOException($"Mode remained {mode} after normalization.");
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw new LauncherProfileException(
$"'{FilePath}' could not be secured to owner-only mode 0600.",
ex);
}
}
/// <summary>
/// Applies one profile mutation and its atomic file replacement as a
/// single in-memory/on-disk transaction. Any validation or I/O failure
/// restores the exact pre-mutation document, including credentials.
/// </summary>
public void ExecuteTransaction(Action mutation)
{
ArgumentNullException.ThrowIfNull(mutation);
LauncherProfileDocument before = CloneDocument(Document);
try
{
mutation();
ValidateAndNormalizeDocument(Document);
Save();
}
catch
{
Document = before;
throw;
}
}
public void RemoveCharacter(
string serverName,
string account,
string characterName)
{
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
profile.Characters.Remove(character);
}
/// <summary>
/// Folds a reported character roster into an account's
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
/// §6): every roster entry either updates the name of an existing
/// row (matched by <see cref="CharacterProfile.Id"/>) while
/// PRESERVING that row's user settings (<see cref="LaunchMode"/>,
/// <see cref="CharacterProfile.Plugins"/>,
/// <see cref="CharacterProfile.LoginCommands"/>), or is inserted as a
/// new row with default settings (<see cref="LaunchMode.GuiSelect"/>,
/// 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.
/// </summary>
public void MergeRoster(
string serverName,
string account,
IReadOnlyList<CharacterRosterEntry> roster)
{
ArgumentNullException.ThrowIfNull(roster);
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
var rosterIds = new HashSet<uint>();
var rosterNames = new HashSet<string>(StringComparer.Ordinal);
foreach (CharacterRosterEntry entry in roster)
{
if (entry.Id == 0)
{
throw new LauncherProfileException("A roster character id cannot be zero.");
}
ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name);
if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name))
{
throw new LauncherProfileException(
"The reported character roster contains a duplicate id or name.");
}
}
foreach (CharacterRosterEntry entry in roster)
{
string idText = CharacterIdFormat.ToHexString(entry.Id);
CharacterProfile[] matches = profile.Characters
.Where(character =>
(CharacterIdFormat.TryParse(character.Id, out uint existingId)
&& existingId == entry.Id)
|| string.Equals(character.Name, entry.Name, StringComparison.Ordinal))
.ToArray();
CharacterProfile? existing = matches.FirstOrDefault(character =>
CharacterIdFormat.TryParse(character.Id, out uint existingId)
&& existingId == entry.Id)
?? matches.FirstOrDefault();
if (existing is not null)
{
existing.Id = idText;
existing.Name = entry.Name;
foreach (CharacterProfile duplicate in matches)
{
if (!ReferenceEquals(duplicate, existing))
{
profile.Characters.Remove(duplicate);
}
}
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? FindCharacter(
AccountProfile profile,
string characterName) =>
profile.Characters.Find(
character => string.Equals(
character.Name,
characterName,
StringComparison.Ordinal));
private static CharacterProfile FindCharacterOrThrow(
AccountProfile profile,
string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
return FindCharacter(profile, characterName)
?? throw new LauncherProfileException(
$"No character '{characterName}' on account '{profile.Account}'.");
}
private static string? NormalizeCharacterId(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0)
{
throw new LauncherProfileException(
"Character id must be a non-zero hexadecimal value with a 0x prefix.");
}
return CharacterIdFormat.ToHexString(parsed);
}
private static bool CharacterIdsEqual(string? left, string? right) =>
CharacterIdFormat.TryParse(left, out uint leftId)
&& CharacterIdFormat.TryParse(right, out uint rightId)
&& leftId == rightId;
private static LauncherProfileDocument CloneDocument(
LauncherProfileDocument source) =>
new()
{
Version = source.Version,
Servers = source.Servers.Select(server => new ServerProfile
{
Name = server.Name,
Host = server.Host,
Port = server.Port,
Accounts = server.Accounts.Select(account => new AccountProfile
{
Account = account.Account,
Password = account.Password,
Characters = account.Characters.Select(character => new CharacterProfile
{
Name = character.Name,
Id = character.Id,
LaunchMode = character.LaunchMode,
Plugins = [.. character.Plugins],
LoginCommands = [.. character.LoginCommands],
}).ToList(),
}).ToList(),
}).ToList(),
};
private static void ValidateAndNormalizeDocument(LauncherProfileDocument document)
{
if (document.Servers is null)
{
throw new LauncherProfileException("The servers collection cannot be null.");
}
var serverNames = new HashSet<string>(StringComparer.Ordinal);
var normalizedIds = new List<(CharacterProfile Character, uint Id)>();
foreach (ServerProfile? server in document.Servers)
{
if (server is null)
{
throw new LauncherProfileException("A server entry cannot be null.");
}
RequireLoadedText(server.Name, "server name");
RequireLoadedText(server.Host, $"host for server '{server.Name}'");
RequireValidPort(server.Port);
if (!serverNames.Add(server.Name))
{
throw new LauncherProfileException(
$"A server named '{server.Name}' appears more than once.");
}
if (server.Accounts is null)
{
throw new LauncherProfileException(
$"The accounts collection for server '{server.Name}' cannot be null.");
}
var accountNames = new HashSet<string>(StringComparer.Ordinal);
foreach (AccountProfile? account in server.Accounts)
{
if (account is null)
{
throw new LauncherProfileException(
$"A null account appears under server '{server.Name}'.");
}
RequireLoadedText(account.Account, "account name");
if (account.Password is null)
{
throw new LauncherProfileException(
$"Password for account '{account.Account}' cannot be null.");
}
if (!accountNames.Add(account.Account))
{
throw new LauncherProfileException(
$"Account '{account.Account}' appears more than once on server '{server.Name}'.");
}
if (account.Characters is null)
{
throw new LauncherProfileException(
$"The characters collection for account '{account.Account}' cannot be null.");
}
var characterNames = new HashSet<string>(StringComparer.Ordinal);
var characterIds = new HashSet<uint>();
foreach (CharacterProfile? character in account.Characters)
{
if (character is null)
{
throw new LauncherProfileException(
$"A null character appears under account '{account.Account}'.");
}
RequireLoadedText(character.Name, "character name");
if (!characterNames.Add(character.Name))
{
throw new LauncherProfileException(
$"Character '{character.Name}' appears more than once on account '{account.Account}'.");
}
RequireValidLaunchMode(character.LaunchMode);
if (character.Id is not null)
{
if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0)
{
throw new LauncherProfileException(
$"Character '{character.Name}' has an invalid id '{character.Id}'.");
}
if (!characterIds.Add(id))
{
throw new LauncherProfileException(
$"Character id '{character.Id}' appears more than once on account '{account.Account}'.");
}
normalizedIds.Add((character, id));
}
if (character.Plugins is null || character.LoginCommands is null)
{
throw new LauncherProfileException(
$"Character '{character.Name}' has a null settings collection.");
}
ValidateStringList(character.Plugins, "plugin", requireUnique: true);
ValidateStringList(
character.LoginCommands,
"login command",
requireUnique: false);
}
}
}
foreach ((CharacterProfile character, uint id) in normalizedIds)
{
character.Id = CharacterIdFormat.ToHexString(id);
}
}
private static void ValidateStringList(
IReadOnlyList<string>? values,
string valueName,
bool requireUnique)
{
if (values is null)
{
return;
}
HashSet<string>? seen = requireUnique
? new HashSet<string>(StringComparer.Ordinal)
: null;
foreach (string? value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new LauncherProfileException(
$"A {valueName} cannot be null or whitespace.");
}
if (seen is not null && !seen.Add(value))
{
throw new LauncherProfileException(
$"The {valueName} '{value}' appears more than once.");
}
}
}
private static void RequireLoadedText(string? value, string field)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new LauncherProfileException($"The {field} cannot be null or whitespace.");
}
}
private static void RequireValidLaunchMode(LaunchMode mode)
{
if (!Enum.IsDefined(mode))
{
throw new LauncherProfileException($"Launch mode '{mode}' is not supported.");
}
}
private static void RequireValidPort(int port)
{
if (port is < 1 or > 65535)
{
throw new LauncherProfileException(
$"Port {port} is outside the valid 1-65535 range.");
}
}
}