fix(launcher): close LA4 review findings

This commit is contained in:
Erik 2026-08-14 19:02:20 +02:00
parent d0a9c65d85
commit 10a712d66b
19 changed files with 1631 additions and 134 deletions

View file

@ -18,6 +18,11 @@ public interface ILauncherOrchestrator : IDisposable
LauncherCapability GetLaunchCapability(LaunchMode mode);
LauncherCapability GetAccountLaunchCapability(
string serverName,
string accountName,
LaunchMode mode);
LauncherCapability GetProbeCapability(string serverName, string accountName);
void SetInstallRecord(LauncherInstallRecord? installRecord);
@ -67,7 +72,7 @@ public interface ILauncherOrchestrator : IDisposable
Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string characterName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default);

View file

@ -4,20 +4,59 @@ using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// Host executable paths supplied by the current installation. LA10 will
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
/// mapping injectable and host-agnostic.
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
/// replace the directory lookup with its versioned-current resolver; until
/// then a missing host disables the corresponding action instead of deferring
/// failure until process creation.
/// </summary>
public sealed record LauncherExecutableSet(
string GraphicalHostPath,
string HeadlessHostPath,
string? WorkingDirectory = null)
public sealed class LauncherExecutableSet
{
private readonly Func<string, bool> _fileExists;
public LauncherExecutableSet(
string graphicalHostPath,
string headlessHostPath,
string? workingDirectory = null,
Func<string, bool>? fileExists = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
GraphicalHostPath = graphicalHostPath;
HeadlessHostPath = headlessHostPath;
WorkingDirectory = workingDirectory;
_fileExists = fileExists ?? File.Exists;
}
public string GraphicalHostPath { get; }
public string HeadlessHostPath { get; }
public string? WorkingDirectory { get; }
public LauncherCapability GetAvailability(LaunchMode mode)
{
string path = mode == LaunchMode.Headless
? HeadlessHostPath
: GraphicalHostPath;
if (_fileExists(path))
{
return LauncherCapability.Available;
}
string host = mode == LaunchMode.Headless
? "headless host"
: "graphical client";
return LauncherCapability.Unavailable(
$"The co-deployed {host} is missing at '{path}'. Reinstall or update "
+ "the client before launching.");
}
public LauncherProcessSpec CreatePlaySpec(
LaunchMode mode,
string configFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
RequireAvailable(mode);
return mode == LaunchMode.Headless
? new LauncherProcessSpec(
@ -33,6 +72,7 @@ public sealed record LauncherExecutableSet(
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
RequireAvailable(LaunchMode.Headless);
return new LauncherProcessSpec(
HeadlessHostPath,
["--config", configFilePath],
@ -49,4 +89,14 @@ public sealed record LauncherExecutableSet(
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
fullDirectory);
}
private void RequireAvailable(LaunchMode mode)
{
LauncherCapability capability = GetAvailability(mode);
if (!capability.IsAvailable)
{
throw new LauncherOperationException(
capability.Reason ?? "The selected launcher host is unavailable.");
}
}
}

View file

@ -99,6 +99,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
return platformCapability;
}
LauncherCapability executableCapability = _executables.GetAvailability(mode);
if (!executableCapability.IsAvailable)
{
return executableCapability;
}
lock (_gate)
{
ThrowIfDisposed();
@ -108,6 +114,33 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
}
public LauncherCapability GetAccountLaunchCapability(
string serverName,
string accountName,
LaunchMode mode)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
ArgumentException.ThrowIfNullOrWhiteSpace(accountName);
LauncherCapability capability = GetLaunchCapability(mode);
if (!capability.IsAvailable)
{
return capability;
}
lock (_gate)
{
ThrowIfDisposed();
_ = FindAccountLocked(serverName, accountName);
ManagedActivity? active = FindActiveActivityLocked(serverName, accountName);
return active is null
? LauncherCapability.Available
: LauncherCapability.Unavailable(
$"Stop the running {active.Kind.ToString().ToLowerInvariant()} "
+ "for this account before starting another activity.");
}
}
public LauncherCapability GetProbeCapability(string serverName, string accountName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
@ -120,6 +153,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
return platformCapability;
}
LauncherCapability executableCapability =
_executables.GetAvailability(LaunchMode.Headless);
if (!executableCapability.IsAvailable)
{
return executableCapability;
}
lock (_gate)
{
ThrowIfDisposed();
@ -254,7 +294,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string characterName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default)
{
@ -268,12 +308,38 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
lock (_gate)
{
ThrowIfDisposed();
if (FindActiveActivityLocked(serverName, accountName) is not null)
{
throw new LauncherOperationException(
"A session or character refresh is already running for this account.");
}
ServerProfile server = FindServerLocked(serverName);
AccountProfile account = FindAccountLocked(serverName, accountName);
CharacterProfile character = FindCharacterLocked(
serverName,
accountName,
characterName);
CharacterProfile character;
if (string.IsNullOrWhiteSpace(characterName))
{
if (mode != LaunchMode.GuiSelect)
{
throw new LauncherOperationException(
"Select a cached character for GUI or headless launch.");
}
character = new CharacterProfile
{
Name = string.Empty,
LaunchMode = LaunchMode.GuiSelect,
Plugins = [],
LoginCommands = [],
};
}
else
{
character = FindCharacterLocked(
serverName,
accountName,
characterName);
}
LauncherInstallRecord install = _installRecord
?? throw new LauncherOperationException(FirstRunRequired);
@ -283,7 +349,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
LauncherActivityKind.Play,
server.Name,
account.Account,
character.Name,
string.IsNullOrWhiteSpace(characterName) ? null : character.Name,
mode,
"Preparing session configuration…");
_activities.Add(activity);
@ -620,9 +686,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
lock (_gate)
{
request.Activity.State = LauncherActivityState.Cancelled;
request.Activity.Status = "Operation cancelled.";
request.Activity.Error = null;
if (!request.Activity.IsTerminal)
{
request.Activity.State = LauncherActivityState.Cancelled;
request.Activity.Status = "Operation cancelled.";
request.Activity.Error = null;
}
}
RaiseStateChanged();
@ -638,9 +707,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
password);
lock (_gate)
{
request.Activity.State = LauncherActivityState.Failed;
request.Activity.Status = message;
request.Activity.Error = message;
if (!request.Activity.IsTerminal)
{
request.Activity.State = LauncherActivityState.Failed;
request.Activity.Status = message;
request.Activity.Error = message;
}
}
RaiseStateChanged();
@ -681,15 +753,19 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
break;
case LauncherSessionState.Exited:
activity.ExitCode = activity.Supervisor?.ExitCode;
if (activity.State is not (
LauncherActivityState.Failed
or LauncherActivityState.Cancelled))
activity.ExitCode ??= activity.Supervisor?.ExitCode;
if (!activity.IsTerminal)
{
activity.State = LauncherActivityState.Exited;
activity.Status = activity.ExitCode is int code
? $"Host process exited with code {code}."
: "Host process exited.";
activity.Status = activity.HostTerminalStatus
?? (activity.ExitCode is int code
? $"Host process exited with code {code}."
: "Host process exited.");
}
else if (activity.State == LauncherActivityState.Exited
&& activity.HostTerminalStatus is not null)
{
activity.Status = activity.HostTerminalStatus;
}
break;
}
@ -723,6 +799,27 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
return;
}
if (activity.IsTerminal)
{
switch (statusEvent)
{
case ExitedStatusEvent exited:
activity.ExitCode ??= exited.Code;
activity.HostTerminalStatus ??=
$"Exited: {exited.Reason} (code {exited.Code}).";
if (activity.State == LauncherActivityState.Exited)
{
activity.Status = activity.HostTerminalStatus;
}
break;
case CharacterListStatusEvent roster:
ApplyRosterLocked(activity, roster, updateStatus: false);
break;
}
return;
}
switch (statusEvent)
{
case StartedStatusEvent:
@ -762,7 +859,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
case ExitedStatusEvent exited:
activity.State = LauncherActivityState.Exited;
activity.ExitCode = exited.Code;
activity.Status = $"Exited: {exited.Reason} (code {exited.Code}).";
activity.HostTerminalStatus =
$"Exited: {exited.Reason} (code {exited.Code}).";
activity.Status = activity.HostTerminalStatus;
break;
case MalformedStatusEvent malformed:
activity.Error = $"Malformed host status event: {malformed.Error}";
@ -778,7 +877,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private void ApplyRosterLocked(
ManagedActivity activity,
CharacterListStatusEvent roster)
CharacterListStatusEvent roster,
bool updateStatus = true)
{
if (!string.Equals(
roster.AccountName,
@ -792,19 +892,22 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
try
{
_profileStore.MergeRoster(
activity.ServerName,
activity.AccountName,
roster.Characters
.Select(character => new CharacterRosterEntry(
character.Id,
character.Name,
character.SecondsGreyedOut))
.ToArray());
_profileStore.Save();
activity.Status = roster.Characters.Count == 1
? "Character roster refreshed: 1 character."
: $"Character roster refreshed: {roster.Characters.Count} characters.";
_profileStore.ExecuteTransaction(() =>
_profileStore.MergeRoster(
activity.ServerName,
activity.AccountName,
roster.Characters
.Select(character => new CharacterRosterEntry(
character.Id,
character.Name,
character.SecondsGreyedOut))
.ToArray()));
if (updateStatus)
{
activity.Status = roster.Characters.Count == 1
? "Character roster refreshed: 1 character."
: $"Character roster refreshed: {roster.Characters.Count} characters.";
}
}
catch (Exception ex)
{
@ -812,7 +915,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
"Could not save the refreshed character roster",
ex,
secret: null);
activity.Status = activity.Error;
if (updateStatus)
{
activity.Status = activity.Error;
}
}
}
@ -879,8 +985,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
lock (_gate)
{
ThrowIfDisposed();
mutation();
_profileStore.Save();
_profileStore.ExecuteTransaction(mutation);
}
RaiseStateChanged();
@ -1125,6 +1230,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public string? Error { get; set; }
public string? HostTerminalStatus { get; set; }
public ILauncherProcessSupervisor? Supervisor { get; set; }
public EventHandler<LauncherSessionState>? SupervisorStateHandler { get; set; }
@ -1140,6 +1247,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
or LauncherActivityState.Failed
or LauncherActivityState.Cancelled);
public bool IsTerminal => !IsActive;
public LauncherSessionSnapshot ToSnapshot() =>
new(
SessionId,

View file

@ -81,6 +81,8 @@ public sealed class LauncherProfileStore
return false;
}
EnsureExistingCredentialFilePermissions();
LauncherProfileDocument? document;
using (FileStream stream = File.OpenRead(FilePath))
{
@ -110,6 +112,7 @@ public sealed class LauncherProfileStore
+ $"expected {CurrentVersion}.");
}
ValidateAndNormalizeDocument(document);
Document = document;
return true;
}
@ -152,6 +155,13 @@ public sealed class LauncherProfileStore
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
@ -160,10 +170,6 @@ public sealed class LauncherProfileStore
throw;
}
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
}
}
/// <summary>
@ -248,21 +254,21 @@ public sealed class LauncherProfileStore
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;
}
server.Name = newName ?? server.Name;
server.Host = newHost ?? server.Host;
server.Port = newPort ?? server.Port;
}
public void RemoveServer(string name)
@ -308,10 +314,10 @@ public sealed class LauncherProfileStore
throw new LauncherProfileException(
$"Account '{newAccount}' already exists on server '{serverName}'.");
}
profile.Account = newAccount;
}
profile.Account = newAccount ?? profile.Account;
if (newPassword is not null)
{
profile.Password = newPassword;
@ -345,6 +351,9 @@ public sealed class LauncherProfileStore
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);
@ -393,6 +402,8 @@ public sealed class LauncherProfileStore
AccountProfile profile = FindAccountOrThrow(server, account);
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
string? normalizedId = null;
if (newName is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
@ -402,13 +413,11 @@ public sealed class LauncherProfileStore
throw new LauncherProfileException(
$"Character '{newName}' already exists on account '{account}'.");
}
character.Name = newName;
}
if (newId is not null)
{
string? normalizedId = NormalizeCharacterId(newId);
normalizedId = NormalizeCharacterId(newId);
if (normalizedId is not null
&& profile.Characters.Any(candidate =>
!ReferenceEquals(candidate, character)
@ -417,7 +426,19 @@ public sealed class LauncherProfileStore
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;
}
@ -437,6 +458,57 @@ public sealed class LauncherProfileStore
}
}
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,
@ -472,38 +544,49 @@ public sealed class LauncherProfileStore
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);
// Normalize BOTH sides through TryParse/ToHexString rather
// than a raw string compare (Campaign LA plan §LA3 review
// finding F10): a stored id that round-trips to the same
// uint (different case, or — before this fix — no "0x"
// prefix) must match even though its text isn't byte-
// identical to the canonical form this method itself always
// writes.
CharacterProfile? existing = profile.Characters.Find(
character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
&& existingId == entry.Id);
// Defensive fallback for a row whose id is missing OR
// unparseable (e.g. a hand-edited id with no "0x" prefix,
// which TryParse now rejects outright) — match by name
// instead so a later merge self-heals the id into the
// canonical form rather than creating a permanent duplicate
// row.
existing ??= profile.Characters.Find(
character => !CharacterIdFormat.TryParse(character.Id, out _)
&& string.Equals(
character.Name,
entry.Name,
StringComparison.Ordinal));
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;
}
@ -583,6 +666,192 @@ public sealed class LauncherProfileStore
&& 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)

View file

@ -10,6 +10,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
</PropertyGroup>
<ItemGroup>

View file

@ -85,7 +85,8 @@
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
</StackPanel>
<TreeView Grid.Row="2"
<TreeView x:Name="ProfilesTree"
Grid.Row="2"
ItemsSource="{Binding Servers}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
<TreeView.DataTemplates>
@ -136,8 +137,11 @@
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Refresh characters"
<Button Content="Open character select"
Classes="primary"
AutomationProperties.Name="Open character select for account"
Command="{Binding LaunchAccountGuiSelectCommand}" />
<Button Content="Refresh characters"
Command="{Binding RefreshCharactersCommand}" />
<Button Content="Add cached character"
Command="{Binding AddCharacterCommand}" />
@ -145,6 +149,9 @@
<TextBlock Text="{Binding ProbeDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
</StackPanel>
</Border>
@ -192,11 +199,11 @@
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowLinuxGraphicalNotice}" />
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding IsFirstRunRequired}" />
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
</StackPanel>
</Border>
</StackPanel>
@ -260,6 +267,10 @@
<Border Grid.RowSpan="4"
ZIndex="20"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Profile editor modal dialog"
IsVisible="{Binding EditorDialog.IsOpen}">
<Border Classes="card"
Width="480"
@ -271,26 +282,36 @@
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
<TextBlock Text="Name" />
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBox x:Name="ServerNameTextBox"
AutomationProperties.Name="Server name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Host" />
<TextBox Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
<TextBox AutomationProperties.Name="Server host"
Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
<TextBlock Text="Port" />
<TextBox Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
<TextBox AutomationProperties.Name="Server port"
Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
</StackPanel>
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
<TextBlock Text="Account name" />
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBox x:Name="AccountNameTextBox"
AutomationProperties.Name="Account name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Password" />
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
AutomationProperties.Name="Account password"
PasswordChar="●" />
</StackPanel>
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
<TextBlock Text="Character name" />
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBox x:Name="CharacterNameTextBox"
AutomationProperties.Name="Character name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Character id (optional, 0x-prefixed)" />
<TextBox Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
<TextBox AutomationProperties.Name="Character id"
Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
</StackPanel>
<TextBlock Text="{Binding EditorDialog.Error}"
@ -298,9 +319,15 @@
IsVisible="{Binding EditorDialog.HasError}"
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel" Command="{Binding EditorDialog.CancelCommand}" />
<Button Content="{Binding EditorDialog.SubmitText}"
<Button Content="Cancel"
IsCancel="True"
AutomationProperties.Name="Cancel profile change"
Command="{Binding EditorDialog.CancelCommand}" />
<Button x:Name="EditorSubmitButton"
Content="{Binding EditorDialog.SubmitText}"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Confirm profile change"
Command="{Binding EditorDialog.SubmitCommand}" />
</StackPanel>
</StackPanel>
@ -310,6 +337,10 @@
<Border Grid.RowSpan="4"
ZIndex="30"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="First-run setup modal dialog"
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
@ -318,8 +349,12 @@
<Border Background="#24344B" Padding="10" CornerRadius="5">
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
</Border>
<Button Content="Close"
<Button x:Name="FirstRunCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close first-run setup"
Command="{Binding FirstRunWizardShell.CloseCommand}" />
</StackPanel>
</Border>
@ -328,6 +363,10 @@
<Border Grid.RowSpan="4"
ZIndex="30"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Update prompt modal dialog"
IsVisible="{Binding UpdatePromptShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
@ -336,8 +375,12 @@
<Border Background="#24344B" Padding="10" CornerRadius="5">
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
</Border>
<Button Content="Close"
<Button x:Name="UpdateCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close update prompt"
Command="{Binding UpdatePromptShell.CloseCommand}" />
</StackPanel>
</Border>

View file

@ -1,5 +1,7 @@
using System.ComponentModel;
using AcDream.Launcher.ViewModels;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
@ -8,6 +10,9 @@ namespace AcDream.Launcher;
public sealed partial class MainWindow : Window
{
private readonly DispatcherTimer _statusTimer;
private LauncherWindowViewModel? _observedViewModel;
private Control? _focusBeforeModal;
private bool _wasModalOpen;
public MainWindow()
{
@ -17,6 +22,7 @@ public sealed partial class MainWindow : Window
Interval = TimeSpan.FromMilliseconds(250),
};
_statusTimer.Tick += OnStatusTimerTick;
DataContextChanged += OnDataContextChanged;
Opened += OnOpened;
Closed += OnClosed;
}
@ -27,6 +33,8 @@ public sealed partial class MainWindow : Window
{
_statusTimer.Stop();
_statusTimer.Tick -= OnStatusTimerTick;
DataContextChanged -= OnDataContextChanged;
ObserveViewModel(null);
Opened -= OnOpened;
Closed -= OnClosed;
}
@ -38,4 +46,92 @@ public sealed partial class MainWindow : Window
viewModel.PollStatus();
}
}
private void OnDataContextChanged(object? sender, EventArgs e) =>
ObserveViewModel(DataContext as LauncherWindowViewModel);
private void ObserveViewModel(LauncherWindowViewModel? viewModel)
{
if (ReferenceEquals(_observedViewModel, viewModel))
{
return;
}
if (_observedViewModel is not null)
{
_observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
_observedViewModel = viewModel;
if (_observedViewModel is not null)
{
_observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
}
_wasModalOpen = viewModel?.IsModalOpen == true;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
|| sender is not LauncherWindowViewModel viewModel)
{
return;
}
bool isModalOpen = viewModel.IsModalOpen;
if (isModalOpen && !_wasModalOpen)
{
_focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
}
else if (!isModalOpen && _wasModalOpen)
{
Control? focusToRestore = _focusBeforeModal;
_focusBeforeModal = null;
Dispatcher.UIThread.Post(() =>
{
if (focusToRestore?.Focus() != true)
{
ProfilesTree.Focus();
}
});
}
_wasModalOpen = isModalOpen;
}
private void FocusActiveModal(LauncherWindowViewModel viewModel)
{
if (viewModel.EditorDialog.IsOpen)
{
Control target = viewModel.EditorDialog.Kind switch
{
ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
_ => EditorSubmitButton,
};
target.Focus();
}
else if (viewModel.FirstRunWizardShell.IsOpen)
{
FirstRunCloseButton.Focus();
}
else if (viewModel.UpdatePromptShell.IsOpen)
{
UpdateCloseButton.Focus();
}
}
private void OnModalKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
{
return;
}
viewModel.CloseActiveModal();
e.Handled = true;
}
}

View file

@ -5,8 +5,18 @@ namespace AcDream.Launcher;
internal static class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static int Main(string[] args)
{
if (args is ["--verify-publish"])
{
// A display-free execution probe for the packaged artifact. CI
// runs this with DOTNET_ROOT pointing at a missing directory; a
// framework-dependent publish cannot reach this return statement.
return 0;
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()

View file

@ -27,7 +27,13 @@ public sealed class RelayCommand : ICommand
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public void Execute(object? parameter)
{
if (CanExecute(parameter))
{
_execute(parameter);
}
}
public void NotifyCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);

View file

@ -6,7 +6,8 @@ public sealed class LauncherSessionRowViewModel
{
public LauncherSessionRowViewModel(
LauncherSessionSnapshot snapshot,
Func<string, Task> stop)
Func<string, Task> stop,
Func<bool>? canStop = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(stop);
@ -24,7 +25,7 @@ public sealed class LauncherSessionRowViewModel
IsActive = snapshot.IsActive;
StopCommand = new AsyncRelayCommand(
() => stop(SessionId),
() => IsActive);
() => IsActive && (canStop?.Invoke() ?? true));
}
public string SessionId { get; }
@ -44,4 +45,6 @@ public sealed class LauncherSessionRowViewModel
public bool IsActive { get; }
public AsyncRelayCommand StopCommand { get; }
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
}

View file

@ -4,12 +4,16 @@ public sealed class LauncherShellViewModel : ObservableObject
{
private bool _isOpen;
public LauncherShellViewModel(string title, string body, string status)
public LauncherShellViewModel(
string title,
string body,
string status,
Func<bool>? canOpen = null)
{
Title = title;
Body = body;
Status = status;
OpenCommand = new RelayCommand(() => IsOpen = true);
OpenCommand = new RelayCommand(() => IsOpen = true, canOpen);
CloseCommand = new RelayCommand(() => IsOpen = false);
}
@ -28,4 +32,10 @@ public sealed class LauncherShellViewModel : ObservableObject
public RelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
@ -33,22 +34,28 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
"Choose and validate the retail DAT directory, build acdream.pak, "
+ "and record the installed client. The installer transaction and "
+ "progress body land in Campaign LA slice LA9.",
"Installer shell ready — implementation arrives in LA9.");
"Installer shell ready — implementation arrives in LA9.",
() => CanInteract);
UpdatePromptShell = new LauncherShellViewModel(
"Client update",
"Review a signed release manifest, verify the downloaded archive, "
+ "and atomically switch the installed client version. The updater "
+ "transaction lands in Campaign LA slice LA10.",
"Updater shell ready — implementation arrives in LA10.");
"Updater shell ready — implementation arrives in LA10.",
() => CanInteract);
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
EditorDialog.PropertyChanged += OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
SaveCharacterSettingsCommand = new RelayCommand(
SaveCharacterSettings,
() => IsCharacterSelected && !IsBusy);
() => IsCharacterSelected && CanInteract);
RefreshCharactersCommand = new AsyncRelayCommand(
RefreshCharactersAsync,
() => CanProbe);
@ -58,15 +65,18 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
LaunchGuiSelectCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
() => CanLaunchGuiSelect);
LaunchAccountGuiSelectCommand = new AsyncRelayCommand(
LaunchSelectedAccountGuiSelectAsync,
() => CanLaunchAccountGuiSelect);
LaunchHeadlessCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.Headless),
() => CanLaunchHeadless);
CancelOperationCommand = new RelayCommand(
CancelOperation,
() => IsBusy && _operationCancellation is not null);
() => !IsModalOpen && IsBusy && _operationCancellation is not null);
ClearFinishedSessionsCommand = new RelayCommand(
_orchestrator.ClearFinishedSessions,
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
() => Sessions.Any(session => !session.IsActive) && CanInteract);
}
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
@ -99,6 +109,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
}
@ -118,6 +131,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
public bool IsModalOpen =>
EditorDialog.IsOpen
|| FirstRunWizardShell.IsOpen
|| UpdatePromptShell.IsOpen;
private bool CanInteract => !IsBusy && !IsModalOpen;
public string OperationStatus
{
get => _operationStatus;
@ -172,7 +192,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
public bool CanProbe =>
!IsBusy
CanInteract
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
@ -196,14 +216,49 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
public bool CanLaunchAccountGuiSelect =>
CanInteract
&& IsAccountSelected
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(
server,
account,
LaunchMode.GuiSelect).IsAvailable;
public string AccountGuiSelectDisabledReason
{
get
{
if (!TryGetSelectedAccount(out string server, out string account))
{
return "Select an account.";
}
return _orchestrator.GetAccountLaunchCapability(
server,
account,
LaunchMode.GuiSelect).Reason
?? "Character-select launch is available.";
}
}
public string GuiLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
GetSelectedAccountLaunchCapability(LaunchMode.Gui).Reason
?? "Graphical launch is available.";
public string HeadlessLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
GetSelectedAccountLaunchCapability(LaunchMode.Headless).Reason
?? "Headless launch is available.";
public bool ShowGuiLaunchDisabledReason =>
IsCharacterSelected
&& (!GetSelectedAccountLaunchCapability(LaunchMode.Gui).IsAvailable
|| !GetSelectedAccountLaunchCapability(LaunchMode.GuiSelect).IsAvailable);
public bool ShowHeadlessLaunchDisabledReason =>
IsCharacterSelected
&& !GetSelectedAccountLaunchCapability(LaunchMode.Headless).IsAvailable;
public RelayCommand AddServerCommand { get; }
public RelayCommand AddAccountCommand { get; }
@ -222,6 +277,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
public AsyncRelayCommand LaunchHeadlessCommand { get; }
public RelayCommand CancelOperationCommand { get; }
@ -272,6 +329,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_operationCancellation?.Dispose();
_operationCancellation = null;
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
@ -283,6 +343,41 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
});
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
{
return;
}
OnPropertyChanged(nameof(IsModalOpen));
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
public void CloseActiveModal()
{
if (EditorDialog.IsOpen)
{
EditorDialog.Close();
}
else if (FirstRunWizardShell.IsOpen)
{
FirstRunWizardShell.IsOpen = false;
}
else if (UpdatePromptShell.IsOpen)
{
UpdatePromptShell.IsOpen = false;
}
}
private void RefreshFromCore(SelectionKey? preferredSelection = null)
{
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
@ -298,7 +393,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
Sessions.Clear();
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
{
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
Sessions.Add(new LauncherSessionRowViewModel(
session,
StopSessionAsync,
() => CanInteract));
}
LauncherTreeNodeViewModel? restored = previousSelection is null
@ -317,8 +415,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
@ -353,6 +455,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
@ -387,7 +495,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
null));
});
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
private bool CanAddAccount() => CanInteract && SelectedNode is not null;
private void OpenAddAccountDialog()
{
@ -411,7 +519,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
private bool CanAddCharacter() =>
!IsBusy && TryGetSelectedAccount(out _, out _);
CanInteract && TryGetSelectedAccount(out _, out _);
private void OpenAddCharacterDialog()
{
@ -441,7 +549,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
+ "let you configure a known character while the server is unavailable.");
}
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
private bool CanEditSelected() => CanInteract && SelectedNode is not null;
private void OpenEditSelectedDialog()
{
@ -642,6 +750,24 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
$"{mode} session started for {character.Name}.");
}
private Task LaunchSelectedAccountGuiSelectAsync()
{
if (!TryGetSelectedAccount(out string serverName, out string accountName))
{
return Task.CompletedTask;
}
return RunOperationAsync(
token => _orchestrator.LaunchAsync(
serverName,
accountName,
null,
LaunchMode.GuiSelect,
token),
$"Opening character select for {accountName}…",
$"Character-select session started for {accountName}.");
}
private async Task RunOperationAsync(
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
string activeStatus,
@ -728,9 +854,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private void CancelOperation() => _operationCancellation?.Cancel();
private bool CanLaunch(LaunchMode mode) =>
!IsBusy
CanInteract
&& IsCharacterSelected
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
private LauncherCapability GetSelectedAccountLaunchCapability(LaunchMode mode) =>
TryGetSelectedAccount(out string server, out string account)
? _orchestrator.GetAccountLaunchCapability(server, account, mode)
: _orchestrator.GetLaunchCapability(mode);
private bool TryGetSelectedAccount(out string serverName, out string accountName)
{
@ -843,9 +975,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
RefreshCharactersCommand.NotifyCanExecuteChanged();
LaunchGuiCommand.NotifyCanExecuteChanged();
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
LaunchHeadlessCommand.NotifyCanExecuteChanged();
CancelOperationCommand.NotifyCanExecuteChanged();
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
foreach (LauncherSessionRowViewModel session in Sessions)
{
session.NotifyCommandState();
}
FirstRunWizardShell.NotifyCommandStates();
UpdatePromptShell.NotifyCommandStates();
}
private readonly record struct SelectionKey(