acdream/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs

1293 lines
43 KiB
C#

using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Status;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// The one launcher-side state/orchestration owner. It owns the profile store,
/// config composition transaction, supervised child set, status tails, roster
/// folding, and platform capability gates. Avalonia receives immutable,
/// credential-free snapshots and sends typed commands back through
/// <see cref="ILauncherOrchestrator"/>.
/// </summary>
public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
private const string FirstRunRequired =
"Client content is not configured. Complete the first-run setup before launching.";
private readonly object _gate = new();
private readonly LauncherProfileStore _profileStore;
private readonly ApplicationPathSet _paths;
private readonly LauncherExecutableSet _executables;
private readonly LauncherPlatformCapabilities _platform;
private readonly ILauncherSessionConfigService _configService;
private readonly ILauncherProcessSupervisorFactory _supervisorFactory;
private readonly IStatusEventSourceFactory _statusSourceFactory;
private readonly Func<string> _sessionIdFactory;
private readonly List<ManagedActivity> _activities = [];
private LauncherInstallRecord? _installRecord;
private bool _disposed;
public LauncherOrchestrator(
LauncherProfileStore profileStore,
ApplicationPathSet paths,
LauncherExecutableSet executables,
LauncherInstallRecord? installRecord = null,
LauncherPlatformCapabilities? platform = null,
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
Func<string>? sessionIdFactory = null)
{
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
_executables = executables ?? throw new ArgumentNullException(nameof(executables));
_installRecord = installRecord;
_platform = platform ?? LauncherPlatformCapabilities.Detect();
_configService = configService ?? new LauncherSessionConfigService();
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
}
public event EventHandler? StateChanged;
public void LoadProfiles()
{
lock (_gate)
{
ThrowIfDisposed();
_profileStore.Load();
}
RaiseStateChanged();
}
public LauncherStateSnapshot GetSnapshot()
{
lock (_gate)
{
ThrowIfDisposed();
LauncherServerSnapshot[] servers = _profileStore.Document.Servers
.Select(CreateServerSnapshotLocked)
.ToArray();
LauncherSessionSnapshot[] sessions = _activities
.OrderByDescending(activity => activity.CreatedAt)
.Select(activity => activity.ToSnapshot())
.ToArray();
return new LauncherStateSnapshot(
servers,
sessions,
_platform,
_installRecord is not null,
_installRecord is null
? FirstRunRequired
: "Client content paths are configured.");
}
}
public LauncherCapability GetLaunchCapability(LaunchMode mode)
{
LauncherCapability platformCapability = _platform.ForLaunchMode(mode);
if (!platformCapability.IsAvailable)
{
return platformCapability;
}
LauncherCapability executableCapability = _executables.GetAvailability(mode);
if (!executableCapability.IsAvailable)
{
return executableCapability;
}
lock (_gate)
{
ThrowIfDisposed();
return _installRecord is null
? LauncherCapability.Unavailable(FirstRunRequired)
: LauncherCapability.Available;
}
}
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);
ArgumentException.ThrowIfNullOrWhiteSpace(accountName);
LauncherCapability platformCapability =
_platform.ForLaunchMode(LaunchMode.Headless);
if (!platformCapability.IsAvailable)
{
return platformCapability;
}
LauncherCapability executableCapability =
_executables.GetAvailability(LaunchMode.Headless);
if (!executableCapability.IsAvailable)
{
return executableCapability;
}
lock (_gate)
{
ThrowIfDisposed();
_ = FindAccountLocked(serverName, accountName);
if (_installRecord is null)
{
return LauncherCapability.Unavailable(FirstRunRequired);
}
ManagedActivity? active = FindActiveActivityLocked(serverName, accountName);
return active is null
? LauncherCapability.Available
: LauncherCapability.Unavailable(
$"Stop the running {active.Kind.ToString().ToLowerInvariant()} "
+ "for this account before refreshing its characters.");
}
}
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
lock (_gate)
{
ThrowIfDisposed();
_installRecord = installRecord;
}
RaiseStateChanged();
}
public void AddServer(string name, string host, int port) =>
MutateProfiles(() => _profileStore.AddServer(name, host, port));
public void EditServer(string name, string newName, string newHost, int newPort) =>
MutateProfiles(() =>
{
EnsureServerIdleLocked(name);
_profileStore.EditServer(
name,
newName: newName,
newHost: newHost,
newPort: newPort);
});
public void RemoveServer(string name) =>
MutateProfiles(() =>
{
EnsureServerIdleLocked(name);
_profileStore.RemoveServer(name);
});
public void AddAccount(string serverName, string accountName, string password) =>
MutateProfiles(() =>
_profileStore.AddAccount(serverName, accountName, password));
public void EditAccount(
string serverName,
string accountName,
string newAccountName,
string? newPassword) =>
MutateProfiles(() =>
{
EnsureAccountIdleLocked(serverName, accountName);
_profileStore.EditAccount(
serverName,
accountName,
newAccount: newAccountName,
newPassword: newPassword);
});
public void RemoveAccount(string serverName, string accountName) =>
MutateProfiles(() =>
{
EnsureAccountIdleLocked(serverName, accountName);
_profileStore.RemoveAccount(serverName, accountName);
});
public void AddCharacter(
string serverName,
string accountName,
string characterName,
string? characterId) =>
MutateProfiles(() =>
_profileStore.AddCharacter(
serverName,
accountName,
characterName,
characterId));
public void EditCharacterIdentity(
string serverName,
string accountName,
string characterName,
string newCharacterName,
string? newCharacterId) =>
MutateProfiles(() =>
{
EnsureCharacterIdleLocked(serverName, accountName, characterName);
_profileStore.EditCharacter(
serverName,
accountName,
characterName,
newName: newCharacterName,
newId: newCharacterId);
});
public void UpdateCharacterSettings(
string serverName,
string accountName,
string characterName,
LaunchMode launchMode,
IReadOnlyList<string> plugins,
IReadOnlyList<string> loginCommands) =>
MutateProfiles(() =>
_profileStore.EditCharacter(
serverName,
accountName,
characterName,
launchMode: launchMode,
plugins: plugins,
loginCommands: loginCommands));
public void RemoveCharacter(
string serverName,
string accountName,
string characterName) =>
MutateProfiles(() =>
{
EnsureCharacterIdleLocked(serverName, accountName, characterName);
_profileStore.RemoveCharacter(serverName, accountName, characterName);
});
public Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default)
{
LauncherCapability capability = GetLaunchCapability(mode);
if (!capability.IsAvailable)
{
throw new LauncherOperationException(capability.Reason ?? "Launch is unavailable.");
}
StartRequest request;
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;
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);
string sessionId = ReserveSessionIdLocked();
var activity = new ManagedActivity(
sessionId,
LauncherActivityKind.Play,
server.Name,
account.Account,
string.IsNullOrWhiteSpace(characterName) ? null : character.Name,
mode,
"Preparing session configuration…");
_activities.Add(activity);
request = new StartRequest(
activity,
CloneServer(server),
CloneAccountWithoutCharacters(account),
CloneCharacter(character, mode),
install,
account.Password,
isProbe: false,
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken));
activity.StartCancellation = request.Cancellation;
}
RaiseStateChanged();
return StartActivityAsync(request);
}
public Task<LauncherSessionSnapshot> ProbeAsync(
string serverName,
string accountName,
CancellationToken cancellationToken = default)
{
LauncherCapability capability = GetProbeCapability(serverName, accountName);
if (!capability.IsAvailable)
{
throw new LauncherOperationException(
capability.Reason ?? "Character refresh is unavailable.");
}
StartRequest request;
lock (_gate)
{
ThrowIfDisposed();
// Repeat the active-account check while reserving the activity so
// two concurrent probes cannot both pass the public capability
// query and then start for the same account.
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);
LauncherInstallRecord install = _installRecord
?? throw new LauncherOperationException(FirstRunRequired);
string sessionId = ReserveSessionIdLocked();
var activity = new ManagedActivity(
sessionId,
LauncherActivityKind.Probe,
server.Name,
account.Account,
characterName: null,
launchMode: null,
"Preparing character refresh…");
_activities.Add(activity);
request = new StartRequest(
activity,
CloneServer(server),
CloneAccountWithoutCharacters(account),
character: null,
install,
account.Password,
isProbe: true,
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken));
activity.StartCancellation = request.Cancellation;
}
RaiseStateChanged();
return StartActivityAsync(request);
}
public async Task StopSessionAsync(
string sessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (timeout < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout));
}
ILauncherProcessSupervisor? supervisor;
CancellationTokenSource? startCancellation;
lock (_gate)
{
ThrowIfDisposed();
ManagedActivity activity = FindActivityLocked(sessionId);
if (!activity.IsActive)
{
return;
}
activity.State = LauncherActivityState.Stopping;
activity.Status = "Stopping session…";
supervisor = activity.Supervisor;
startCancellation = activity.StartCancellation;
}
RaiseStateChanged();
cancellationToken.ThrowIfCancellationRequested();
startCancellation?.Cancel();
if (supervisor is null)
{
return;
}
try
{
await Task.Run(
() => supervisor.Stop(timeout),
cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lock (_gate)
{
ManagedActivity activity = FindActivityLocked(sessionId);
activity.Error = SafeError("Could not stop the session", ex, secret: null);
activity.Status = activity.Error;
}
RaiseStateChanged();
throw new LauncherOperationException(
SafeError("Could not stop the session", ex, secret: null));
}
}
public void PollStatus()
{
ManagedActivity[] activities;
lock (_gate)
{
ThrowIfDisposed();
activities = _activities
.Where(activity => activity.StatusSource is not null)
.ToArray();
}
bool changed = false;
foreach (ManagedActivity activity in activities)
{
IReadOnlyList<StatusEvent> events;
try
{
lock (activity.StatusReadGate)
{
events = activity.StatusSource!.ReadNewEvents();
}
}
catch (Exception ex)
{
lock (_gate)
{
if (_activities.Contains(activity))
{
activity.Error = SafeError(
"Could not read the host status stream",
ex,
secret: null);
changed = true;
}
}
continue;
}
foreach (StatusEvent statusEvent in events)
{
ApplyStatusEvent(activity, statusEvent);
changed = true;
}
}
if (changed)
{
RaiseStateChanged();
}
}
public void ClearFinishedSessions()
{
ManagedActivity[] removed;
lock (_gate)
{
ThrowIfDisposed();
removed = _activities.Where(activity => !activity.IsActive).ToArray();
foreach (ManagedActivity activity in removed)
{
_activities.Remove(activity);
}
}
foreach (ManagedActivity activity in removed)
{
DisposeActivity(activity);
}
if (removed.Length > 0)
{
RaiseStateChanged();
}
}
public void Dispose()
{
ManagedActivity[] activities;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
activities = _activities.ToArray();
_activities.Clear();
}
foreach (ManagedActivity activity in activities)
{
DisposeActivity(activity);
}
}
private async Task<LauncherSessionSnapshot> StartActivityAsync(StartRequest request)
{
try
{
await Task.Run(
() => StartActivityCore(request),
CancellationToken.None)
.ConfigureAwait(false);
lock (_gate)
{
return request.Activity.ToSnapshot();
}
}
finally
{
request.Password = null;
lock (_gate)
{
if (ReferenceEquals(
request.Activity.StartCancellation,
request.Cancellation))
{
request.Activity.StartCancellation = null;
}
}
request.Cancellation.Dispose();
}
}
private void StartActivityCore(StartRequest request)
{
ILauncherProcessSupervisor? supervisor = null;
string? password = request.Password;
try
{
request.Cancellation.Token.ThrowIfCancellationRequested();
ComposedSessionConfig composed = request.IsProbe
? _configService.ComposeProbeAndWrite(
request.Server,
request.Account,
request.Install,
_paths,
request.Activity.SessionId)
: _configService.ComposeAndWrite(
request.Server,
request.Account,
request.Character!,
request.Install,
_paths,
request.Activity.SessionId);
request.Cancellation.Token.ThrowIfCancellationRequested();
supervisor = _supervisorFactory.Create();
EventHandler<LauncherSessionState> stateHandler =
(_, state) => ApplySupervisorState(request.Activity, state);
supervisor.StateChanged += stateHandler;
IStatusEventSource statusSource =
_statusSourceFactory.Create(composed.StatusFilePath);
lock (_gate)
{
request.Activity.Supervisor = supervisor;
request.Activity.SupervisorStateHandler = stateHandler;
request.Activity.StatusSource = statusSource;
request.Activity.Status = "Starting host process…";
}
RaiseStateChanged();
request.Cancellation.Token.ThrowIfCancellationRequested();
LauncherProcessSpec processSpec = request.IsProbe
? _executables.CreateProbeSpec(composed.ConfigFilePath)
: _executables.CreatePlaySpec(
request.Activity.LaunchMode!.Value,
composed.ConfigFilePath);
supervisor.Start(processSpec, password);
request.Password = null;
password = null;
if (request.Cancellation.IsCancellationRequested)
{
TryStop(supervisor);
request.Cancellation.Token.ThrowIfCancellationRequested();
}
}
catch (OperationCanceledException)
{
if (supervisor is not null)
{
TryStop(supervisor);
}
lock (_gate)
{
if (!request.Activity.IsTerminal)
{
request.Activity.State = LauncherActivityState.Cancelled;
request.Activity.Status = "Operation cancelled.";
request.Activity.Error = null;
}
}
RaiseStateChanged();
throw;
}
catch (Exception ex)
{
string message = SafeError(
request.IsProbe
? "Could not refresh characters"
: "Could not launch the client",
ex,
password);
lock (_gate)
{
if (!request.Activity.IsTerminal)
{
request.Activity.State = LauncherActivityState.Failed;
request.Activity.Status = message;
request.Activity.Error = message;
}
}
RaiseStateChanged();
throw new LauncherOperationException(message);
}
finally
{
request.Password = null;
}
}
private void ApplySupervisorState(
ManagedActivity activity,
LauncherSessionState processState)
{
try
{
lock (_gate)
{
if (!_activities.Contains(activity))
{
return;
}
switch (processState)
{
case LauncherSessionState.Starting:
if (activity.State == LauncherActivityState.Starting)
{
activity.Status = "Starting host process…";
}
break;
case LauncherSessionState.Running:
if (activity.State is LauncherActivityState.Starting)
{
activity.State = LauncherActivityState.Running;
activity.Status = "Host process running; waiting for connection…";
}
break;
case LauncherSessionState.Exited:
activity.ExitCode ??= activity.Supervisor?.ExitCode;
if (!activity.IsTerminal)
{
activity.State = LauncherActivityState.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;
}
}
RaiseStateChanged();
}
catch
{
// Process lifecycle callbacks are observational. A presentation
// subscriber or disposal race must never throw back through the
// supervised child process's Exited event.
}
}
private void ApplyStatusEvent(ManagedActivity activity, StatusEvent statusEvent)
{
lock (_gate)
{
if (!_activities.Contains(activity))
{
return;
}
if (!string.Equals(
statusEvent.SessionId,
activity.SessionId,
StringComparison.Ordinal))
{
activity.Error = "Ignored a status event for a different session id.";
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:
activity.Status = "Host started.";
break;
case ConnectedStatusEvent:
if (activity.State != LauncherActivityState.Stopping)
{
activity.State = LauncherActivityState.Connected;
}
activity.Status = "Connected; waiting for character roster…";
break;
case CharacterListStatusEvent roster:
ApplyRosterLocked(activity, roster);
break;
case EnteredWorldStatusEvent enteredWorld:
if (activity.State != LauncherActivityState.Stopping)
{
activity.State = LauncherActivityState.InWorld;
}
activity.Status = $"In world as {enteredWorld.CharacterName}.";
break;
case PluginLoadedStatusEvent loaded:
activity.Status = $"Plugin loaded: {loaded.Plugin}.";
break;
case PluginFailedStatusEvent failed:
activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}";
activity.Status = activity.Error;
break;
case DisconnectedStatusEvent disconnected:
if (activity.State != LauncherActivityState.Stopping)
{
activity.State = LauncherActivityState.Disconnected;
}
activity.Status = $"Disconnected: {disconnected.Reason}.";
break;
case ExitedStatusEvent exited:
activity.State = LauncherActivityState.Exited;
activity.ExitCode = 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}";
break;
case UnknownStatusEvent unknown:
activity.Status = string.IsNullOrWhiteSpace(unknown.E)
? "Ignored an unreadable host status event."
: $"Ignored unknown host event '{unknown.E}'.";
break;
}
}
}
private void ApplyRosterLocked(
ManagedActivity activity,
CharacterListStatusEvent roster,
bool updateStatus = true)
{
if (!string.Equals(
roster.AccountName,
activity.AccountName,
StringComparison.Ordinal))
{
activity.Error =
"Ignored a character roster whose account did not match the launched account.";
return;
}
try
{
_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)
{
activity.Error = SafeError(
"Could not save the refreshed character roster",
ex,
secret: null);
if (updateStatus)
{
activity.Status = activity.Error;
}
}
}
private LauncherServerSnapshot CreateServerSnapshotLocked(ServerProfile server)
{
LauncherAccountSnapshot[] accounts = server.Accounts
.Select(account => CreateAccountSnapshotLocked(server, account))
.ToArray();
return new LauncherServerSnapshot(
server.Name,
server.Host,
server.Port,
accounts);
}
private LauncherAccountSnapshot CreateAccountSnapshotLocked(
ServerProfile server,
AccountProfile account)
{
ManagedActivity? active = FindActiveActivityLocked(server.Name, account.Account);
LauncherCharacterSnapshot[] characters = account.Characters
.Select(character =>
{
ManagedActivity? characterActivity = _activities
.LastOrDefault(candidate =>
candidate.IsActive
&& candidate.Kind == LauncherActivityKind.Play
&& string.Equals(
candidate.ServerName,
server.Name,
StringComparison.Ordinal)
&& string.Equals(
candidate.AccountName,
account.Account,
StringComparison.Ordinal)
&& string.Equals(
candidate.CharacterName,
character.Name,
StringComparison.Ordinal));
return new LauncherCharacterSnapshot(
server.Name,
account.Account,
character.Name,
character.Id,
character.LaunchMode,
character.Plugins.ToArray(),
character.LoginCommands.ToArray(),
characterActivity is not null,
characterActivity?.Status ?? "Not running");
})
.ToArray();
return new LauncherAccountSnapshot(
server.Name,
account.Account,
characters,
active is not null,
active?.Status ?? "Idle");
}
private void MutateProfiles(Action mutation)
{
ArgumentNullException.ThrowIfNull(mutation);
lock (_gate)
{
ThrowIfDisposed();
_profileStore.ExecuteTransaction(mutation);
}
RaiseStateChanged();
}
private ServerProfile FindServerLocked(string serverName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
return _profileStore.Document.Servers.Find(server =>
string.Equals(server.Name, serverName, StringComparison.Ordinal))
?? throw new LauncherProfileException($"No server named '{serverName}'.");
}
private AccountProfile FindAccountLocked(string serverName, string accountName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountName);
ServerProfile server = FindServerLocked(serverName);
return server.Accounts.Find(account =>
string.Equals(account.Account, accountName, StringComparison.Ordinal))
?? throw new LauncherProfileException(
$"No account '{accountName}' on server '{serverName}'.");
}
private CharacterProfile FindCharacterLocked(
string serverName,
string accountName,
string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
AccountProfile account = FindAccountLocked(serverName, accountName);
return account.Characters.Find(character =>
string.Equals(character.Name, characterName, StringComparison.Ordinal))
?? throw new LauncherProfileException(
$"No character '{characterName}' on account '{accountName}'.");
}
private ManagedActivity FindActivityLocked(string sessionId) =>
_activities.Find(activity =>
string.Equals(activity.SessionId, sessionId, StringComparison.Ordinal))
?? throw new LauncherOperationException($"No launcher session '{sessionId}'.");
private ManagedActivity? FindActiveActivityLocked(
string serverName,
string accountName) =>
_activities.LastOrDefault(activity =>
activity.IsActive
&& string.Equals(activity.ServerName, serverName, StringComparison.Ordinal)
&& string.Equals(activity.AccountName, accountName, StringComparison.Ordinal));
private void EnsureServerIdleLocked(string serverName)
{
if (_activities.Any(activity =>
activity.IsActive
&& string.Equals(activity.ServerName, serverName, StringComparison.Ordinal)))
{
throw new LauncherOperationException(
"Stop this server's running launcher sessions before editing or removing it.");
}
}
private void EnsureAccountIdleLocked(string serverName, string accountName)
{
if (FindActiveActivityLocked(serverName, accountName) is not null)
{
throw new LauncherOperationException(
"Stop this account's running launcher session before editing or removing it.");
}
}
private void EnsureCharacterIdleLocked(
string serverName,
string accountName,
string characterName)
{
if (_activities.Any(activity =>
activity.IsActive
&& activity.Kind == LauncherActivityKind.Play
&& string.Equals(activity.ServerName, serverName, StringComparison.Ordinal)
&& string.Equals(activity.AccountName, accountName, StringComparison.Ordinal)
&& string.Equals(activity.CharacterName, characterName, StringComparison.Ordinal)))
{
throw new LauncherOperationException(
"Stop this character's running session before editing or removing it.");
}
}
private string ReserveSessionIdLocked()
{
string sessionId = _sessionIdFactory();
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (_activities.Any(activity => string.Equals(
activity.SessionId,
sessionId,
StringComparison.Ordinal)))
{
throw new LauncherOperationException(
$"The launcher generated duplicate session id '{sessionId}'.");
}
return sessionId;
}
private static ServerProfile CloneServer(ServerProfile source) =>
new()
{
Name = source.Name,
Host = source.Host,
Port = source.Port,
};
private static AccountProfile CloneAccountWithoutCharacters(AccountProfile source) =>
new()
{
Account = source.Account,
// Composition needs only the public account name. Keep the
// credential exclusively in StartRequest.Password until the one
// supervisor stdin handoff, then clear that reference.
Password = string.Empty,
};
private static CharacterProfile CloneCharacter(
CharacterProfile source,
LaunchMode mode) =>
new()
{
Name = source.Name,
Id = source.Id,
LaunchMode = mode,
Plugins = [.. source.Plugins],
LoginCommands = [.. source.LoginCommands],
};
private static string CreateSessionId() =>
$"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}";
private static void TryStop(ILauncherProcessSupervisor supervisor)
{
try
{
supervisor.Stop(TimeSpan.FromSeconds(5));
}
catch
{
// Cancellation cleanup is best-effort. The activity remains
// visibly Cancelled and never reports a successful launch.
}
}
private static string SafeError(string prefix, Exception exception, string? secret)
{
string detail = exception.Message;
if (!string.IsNullOrEmpty(secret))
{
detail = detail.Replace(secret, "[redacted]", StringComparison.Ordinal);
}
return string.IsNullOrWhiteSpace(detail)
? prefix + "."
: $"{prefix}: {detail}";
}
private void RaiseStateChanged()
{
Delegate[] subscribers = StateChanged?.GetInvocationList() ?? [];
foreach (Delegate subscriber in subscribers)
{
try
{
((EventHandler)subscriber)(this, EventArgs.Empty);
}
catch
{
// This is an observation seam. A view that is closing or a
// faulty subscriber must not break process/session lifetime.
}
}
}
private static void DisposeActivity(ManagedActivity activity)
{
activity.StartCancellation?.Cancel();
activity.StartCancellation?.Dispose();
activity.StartCancellation = null;
if (activity.Supervisor is not null)
{
if (activity.SupervisorStateHandler is not null)
{
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
}
activity.Supervisor.Dispose();
activity.Supervisor = null;
}
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class ManagedActivity
{
public ManagedActivity(
string sessionId,
LauncherActivityKind kind,
string serverName,
string accountName,
string? characterName,
LaunchMode? launchMode,
string status)
{
SessionId = sessionId;
Kind = kind;
ServerName = serverName;
AccountName = accountName;
CharacterName = characterName;
LaunchMode = launchMode;
Status = status;
CreatedAt = DateTimeOffset.UtcNow;
}
public string SessionId { get; }
public LauncherActivityKind Kind { get; }
public string ServerName { get; }
public string AccountName { get; }
public string? CharacterName { get; }
public LaunchMode? LaunchMode { get; }
public DateTimeOffset CreatedAt { get; }
public LauncherActivityState State { get; set; } = LauncherActivityState.Starting;
public string Status { get; set; }
public int? ExitCode { get; set; }
public string? Error { get; set; }
public string? HostTerminalStatus { get; set; }
public ILauncherProcessSupervisor? Supervisor { get; set; }
public EventHandler<LauncherSessionState>? SupervisorStateHandler { get; set; }
public IStatusEventSource? StatusSource { get; set; }
public CancellationTokenSource? StartCancellation { get; set; }
public object StatusReadGate { get; } = new();
public bool IsActive => State is not (
LauncherActivityState.Exited
or LauncherActivityState.Failed
or LauncherActivityState.Cancelled);
public bool IsTerminal => !IsActive;
public LauncherSessionSnapshot ToSnapshot() =>
new(
SessionId,
Kind,
ServerName,
AccountName,
CharacterName,
LaunchMode,
State,
Status,
ExitCode,
Error,
CreatedAt);
}
private sealed class StartRequest(
ManagedActivity activity,
ServerProfile server,
AccountProfile account,
CharacterProfile? character,
LauncherInstallRecord install,
string password,
bool isProbe,
CancellationTokenSource cancellation)
{
public ManagedActivity Activity { get; } = activity;
public ServerProfile Server { get; } = server;
public AccountProfile Account { get; } = account;
public CharacterProfile? Character { get; } = character;
public LauncherInstallRecord Install { get; } = install;
public string? Password { get; set; } = password;
public bool IsProbe { get; } = isProbe;
public CancellationTokenSource Cancellation { get; } = cancellation;
}
}