863 lines
30 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|