using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Profiles; using AcDream.Launcher.Core.Status; using AcDream.Platform; namespace AcDream.Launcher.Core.Orchestration; /// /// 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 /// . /// 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 _sessionIdFactory; private readonly List _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? 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 plugins, IReadOnlyList 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 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 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 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 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 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? 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; } }