From d0a9c65d85103bca8b0ccb91b32d6905cc5c2d82 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:15:14 +0200 Subject: [PATCH] feat(launcher): Campaign LA add Avalonia desktop shell --- AcDream.slnx | 2 + docs/architecture/acdream-architecture.md | 19 +- .../Launching/LauncherProcessSupervisor.cs | 36 +- .../Launching/SessionConfigComposer.cs | 75 ++ .../Orchestration/ILauncherOrchestrator.cs | 87 ++ .../Orchestration/LauncherExecutableSet.cs | 52 + .../Orchestration/LauncherOrchestrator.cs | 1184 +++++++++++++++++ .../LauncherPlatformCapabilities.cs | 83 ++ .../Orchestration/LauncherStateSnapshot.cs | 85 ++ .../Profiles/LauncherProfileStore.cs | 135 +- .../Status/StatusFileTailer.cs | 18 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 24 + src/AcDream.Launcher/App.axaml | 8 + src/AcDream.Launcher/App.axaml.cs | 66 + src/AcDream.Launcher/MainWindow.axaml | 346 +++++ src/AcDream.Launcher/MainWindow.axaml.cs | 41 + src/AcDream.Launcher/Program.cs | 14 + src/AcDream.Launcher/ViewModels/Commands.cs | 88 ++ .../ViewModels/IUiDispatcher.cs | 26 + .../ViewModels/LauncherSessionRowViewModel.cs | 47 + .../ViewModels/LauncherShellViewModel.cs | 31 + .../ViewModels/LauncherTreeNodeViewModel.cs | 90 ++ .../ViewModels/LauncherWindowViewModel.cs | 866 ++++++++++++ .../ViewModels/ObservableObject.cs | 27 + .../ProfileEditorDialogViewModel.cs | 196 +++ .../Launching/SessionConfigComposerTests.cs | 35 + .../LauncherOrchestratorTests.cs | 502 +++++++ .../Profiles/LauncherProfileStoreTests.cs | 54 + .../AcDream.Launcher.Tests.csproj | 25 + .../LauncherProjectBoundaryTests.cs | 77 ++ .../LauncherWindowViewModelTests.cs | 508 +++++++ 31 files changed, 4831 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs create mode 100644 src/AcDream.Launcher/AcDream.Launcher.csproj create mode 100644 src/AcDream.Launcher/App.axaml create mode 100644 src/AcDream.Launcher/App.axaml.cs create mode 100644 src/AcDream.Launcher/MainWindow.axaml create mode 100644 src/AcDream.Launcher/MainWindow.axaml.cs create mode 100644 src/AcDream.Launcher/Program.cs create mode 100644 src/AcDream.Launcher/ViewModels/Commands.cs create mode 100644 src/AcDream.Launcher/ViewModels/IUiDispatcher.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/ObservableObject.cs create mode 100644 src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs create mode 100644 tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj create mode 100644 tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index 20f17f98..0771d163 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4696577b..56353d45 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -279,9 +279,22 @@ src/ tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); Runtime and App reference it directly; Headless reaches it transitively through Runtime (K0 guard: Headless declares exactly - one project reference); the external launcher (AcDream.Launcher.Core, - Campaign LA — under construction) references ONLY this project from - the game solution + one project reference) + + AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner + Profiles/ -> sole credential/profile document + CRUD owner + Launching/ -> config composition and supervised process seams + Status/ -> incremental host-status parsing/tailing + Orchestration/ -> immutable UI snapshots, typed actions, + capability gates, and running-session lifetime + -> references Platform only; no Avalonia or game-host dependency + + AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + ViewModels/ -> thin MVVM projection over Launcher.Core + -> references Launcher.Core only (Platform transitively); it never owns + a second profile, process, status, or credential state graph + -> Linux launcher/probe/headless flows remain portable; graphical-client + actions are explicitly disabled until Modern Runtime Slice L resumes AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index 0f7fcac1..dd0db580 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices; namespace AcDream.Launcher.Core.Launching; +/// +/// Test seam for one supervised launcher child. The Avalonia orchestration +/// layer owns this interface through a factory and never constructs or drives +/// directly. +/// +public interface ILauncherProcessSupervisor : IDisposable +{ + LauncherSessionState State { get; } + + int? ExitCode { get; } + + event EventHandler? StateChanged; + + void Start(LauncherProcessSpec spec, string? password); + + void Stop(TimeSpan timeout); +} + +public interface ILauncherProcessSupervisorFactory +{ + ILauncherProcessSupervisor Create(); +} + +public sealed class LauncherProcessSupervisorFactory( + ILauncherChildProcessFactory? childProcessFactory = null) + : ILauncherProcessSupervisorFactory +{ + private readonly ILauncherChildProcessFactory _childProcessFactory = + childProcessFactory ?? new SystemChildProcessFactory(); + + public ILauncherProcessSupervisor Create() => + new LauncherProcessSupervisor(_childProcessFactory); +} + /// /// Spawns a host process (App/Headless), feeds the account password to /// its stdin then closes it, and supervises its lifetime (Campaign LA /// spec §3/§6). One supervisor instance owns exactly one child process /// for its lifetime — start a new supervisor per launched session. /// -public sealed class LauncherProcessSupervisor : IDisposable +public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor { private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 1b4349d2..419e2e2d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig( string StatusFilePath, SessionConfigDocument Document); +/// +/// Injectable composition/write seam used by the canonical launcher +/// orchestrator. Production delegates to ; +/// tests can capture the exact request without writing a file or starting a +/// client process. +/// +public interface ILauncherSessionConfigService +{ + ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null); + + ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId); +} + +public sealed class LauncherSessionConfigService : ILauncherSessionConfigService +{ + public ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) => + SessionConfigComposer.ComposeAndWrite( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + public ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + SessionConfigComposer.ComposeProbeAndWrite( + server, + account, + install, + paths, + sessionId); +} + /// /// Builds the per-launch from a /// profile character + install record (Campaign LA spec §6). Passwords @@ -187,6 +245,23 @@ public static class SessionConfigComposer sessionId, loginCommandDelayMs); + return Write(composed); + } + + /// Probe counterpart to . It + /// writes the pinned mode: "probe" document and never includes + /// a character selector, policy, plugin set, login commands, or password. + /// + public static ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + Write(ComposeProbe(server, account, install, paths, sessionId)); + + private static ComposedSessionConfig Write(ComposedSessionConfig composed) + { string? directory = Path.GetDirectoryName(composed.ConfigFilePath); if (!string.IsNullOrEmpty(directory)) { diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs new file mode 100644 index 00000000..c2003b95 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -0,0 +1,87 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Canonical state/mutation surface projected by the Avalonia launcher. The UI +/// never owns a second profile document, process map, status tail, or launch +/// transaction; it asks for immutable snapshots and sends typed mutations here. +/// +public interface ILauncherOrchestrator : IDisposable +{ + event EventHandler? StateChanged; + + void LoadProfiles(); + + LauncherStateSnapshot GetSnapshot(); + + LauncherCapability GetLaunchCapability(LaunchMode mode); + + LauncherCapability GetProbeCapability(string serverName, string accountName); + + void SetInstallRecord(LauncherInstallRecord? installRecord); + + void AddServer(string name, string host, int port); + + void EditServer(string name, string newName, string newHost, int newPort); + + void RemoveServer(string name); + + void AddAccount(string serverName, string accountName, string password); + + void EditAccount( + string serverName, + string accountName, + string newAccountName, + string? newPassword); + + void RemoveAccount(string serverName, string accountName); + + void AddCharacter( + string serverName, + string accountName, + string characterName, + string? characterId); + + void EditCharacterIdentity( + string serverName, + string accountName, + string characterName, + string newCharacterName, + string? newCharacterId); + + void UpdateCharacterSettings( + string serverName, + string accountName, + string characterName, + LaunchMode launchMode, + IReadOnlyList plugins, + IReadOnlyList loginCommands); + + void RemoveCharacter( + string serverName, + string accountName, + string characterName); + + Task LaunchAsync( + string serverName, + string accountName, + string characterName, + LaunchMode mode, + CancellationToken cancellationToken = default); + + Task ProbeAsync( + string serverName, + string accountName, + CancellationToken cancellationToken = default); + + Task StopSessionAsync( + string sessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + void PollStatus(); + + void ClearFinishedSessions(); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs new file mode 100644 index 00000000..c3a4fa31 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -0,0 +1,52 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Host executable paths supplied by the current installation. LA10 will +/// resolve these from the versioned app/current pointer; LA4 keeps the +/// mapping injectable and host-agnostic. +/// +public sealed record LauncherExecutableSet( + string GraphicalHostPath, + string HeadlessHostPath, + string? WorkingDirectory = null) +{ + public LauncherProcessSpec CreatePlaySpec( + LaunchMode mode, + string configFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + + return mode == LaunchMode.Headless + ? new LauncherProcessSpec( + HeadlessHostPath, + ["--config", configFilePath], + WorkingDirectory) + : new LauncherProcessSpec( + GraphicalHostPath, + ["--session-config", configFilePath], + WorkingDirectory); + } + + public LauncherProcessSpec CreateProbeSpec(string configFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + return new LauncherProcessSpec( + HeadlessHostPath, + ["--config", configFilePath], + WorkingDirectory); + } + + public static LauncherExecutableSet FromDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new LauncherExecutableSet( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs new file mode 100644 index 00000000..fe9a426f --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -0,0 +1,1184 @@ +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; + } + + lock (_gate) + { + ThrowIfDisposed(); + return _installRecord is null + ? LauncherCapability.Unavailable(FirstRunRequired) + : LauncherCapability.Available; + } + } + + public LauncherCapability GetProbeCapability(string serverName, string accountName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + + LauncherCapability platformCapability = + _platform.ForLaunchMode(LaunchMode.Headless); + if (!platformCapability.IsAvailable) + { + return platformCapability; + } + + 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(); + ServerProfile server = FindServerLocked(serverName); + AccountProfile account = FindAccountLocked(serverName, accountName); + CharacterProfile 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, + 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) + { + 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) + { + 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.State is not ( + LauncherActivityState.Failed + or LauncherActivityState.Cancelled)) + { + activity.State = LauncherActivityState.Exited; + activity.Status = activity.ExitCode is int code + ? $"Host process exited with code {code}." + : "Host process exited."; + } + 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; + } + + 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.Status = $"Exited: {exited.Reason} (code {exited.Code})."; + 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) + { + 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.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."; + } + catch (Exception ex) + { + activity.Error = SafeError( + "Could not save the refreshed character roster", + ex, + secret: null); + 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(); + mutation(); + _profileStore.Save(); + } + + 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 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 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; + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs new file mode 100644 index 00000000..57be9979 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs @@ -0,0 +1,83 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public readonly record struct LauncherCapability(bool IsAvailable, string? Reason) +{ + public static LauncherCapability Available { get; } = new(true, null); + + public static LauncherCapability Unavailable(string reason) => + new(false, reason); +} + +/// +/// Immutable platform row selected once at launcher startup. Campaign LA +/// ships the Avalonia launcher, profile editor, probes, and headless sessions +/// on Windows and Linux. Graphical client launches remain Windows-only until +/// Modern Runtime Slice L resumes from its parked L1 checkpoint. +/// +public sealed record LauncherPlatformCapabilities( + bool IsWindows, + bool IsLinux, + bool CanRunHeadless, + bool CanLaunchGraphicalClient, + string PlatformName, + string? GraphicalLaunchDisabledReason) +{ + public const string LinuxGraphicalLaunchDisabledReason = + "GUI launches require the Linux graphical client (Modern Runtime Slice L), " + + "which is parked at L1 and will resume later. The launcher, character " + + "probe, and headless sessions remain available on Linux."; + + public static LauncherPlatformCapabilities Detect() + { + if (OperatingSystem.IsWindows()) + { + return new LauncherPlatformCapabilities( + IsWindows: true, + IsLinux: false, + CanRunHeadless: true, + CanLaunchGraphicalClient: true, + PlatformName: "Windows", + GraphicalLaunchDisabledReason: null); + } + + if (OperatingSystem.IsLinux()) + { + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: true, + CanRunHeadless: true, + CanLaunchGraphicalClient: false, + PlatformName: "Linux", + GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason); + } + + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: false, + CanRunHeadless: false, + CanLaunchGraphicalClient: false, + PlatformName: "Unsupported", + GraphicalLaunchDisabledReason: + "Graphical client launches are supported on Windows. Linux support " + + "requires Modern Runtime Slice L."); + } + + public LauncherCapability ForLaunchMode(LaunchMode mode) + { + if (mode == LaunchMode.Headless) + { + return CanRunHeadless + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + "Headless launches are supported only on Windows and Linux."); + } + + return CanLaunchGraphicalClient + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + GraphicalLaunchDisabledReason + ?? "The graphical client is unavailable on this platform."); + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs new file mode 100644 index 00000000..918e1065 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs @@ -0,0 +1,85 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public sealed record LauncherCharacterSnapshot( + string ServerName, + string AccountName, + string Name, + string? Id, + LaunchMode LaunchMode, + IReadOnlyList Plugins, + IReadOnlyList LoginCommands, + bool HasRunningSession, + string SessionStatus); + +/// +/// Password is deliberately absent. The account credential remains reachable +/// only inside and the transient +/// stdin handoff performed by . +/// +public sealed record LauncherAccountSnapshot( + string ServerName, + string AccountName, + IReadOnlyList Characters, + bool HasRunningActivity, + string ActivityStatus); + +public sealed record LauncherServerSnapshot( + string Name, + string Host, + int Port, + IReadOnlyList Accounts); + +public enum LauncherActivityKind +{ + Play, + Probe, +} + +public enum LauncherActivityState +{ + Starting, + Running, + Connected, + InWorld, + Disconnected, + Stopping, + Exited, + Failed, + Cancelled, +} + +public sealed record LauncherSessionSnapshot( + string SessionId, + LauncherActivityKind Kind, + string ServerName, + string AccountName, + string? CharacterName, + LaunchMode? LaunchMode, + LauncherActivityState State, + string Status, + int? ExitCode, + string? Error, + DateTimeOffset CreatedAt) +{ + public bool IsActive => State is not ( + LauncherActivityState.Exited + or LauncherActivityState.Failed + or LauncherActivityState.Cancelled); +} + +public sealed record LauncherStateSnapshot( + IReadOnlyList Servers, + IReadOnlyList Sessions, + LauncherPlatformCapabilities Platform, + bool IsInstallationReady, + string InstallationStatus); + +public sealed class LauncherOperationException : Exception +{ + public LauncherOperationException(string message) + : base(message) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 25d30787..01717400 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -325,13 +325,59 @@ public sealed class LauncherProfileStore server.Accounts.Remove(profile); } - // --- Character settings (roster-driven add/remove; user-edited settings) --- + // --- Character CRUD / user-owned settings -------------------------- /// - /// Edits the user-owned settings of an existing character row. There - /// is no manual add/remove for characters — the roster ( - /// ) is the only source of new rows, per - /// spec §5/§6. + /// Adds a manually configured character row. Normal operation discovers + /// characters through , but LA4's full in-UI + /// CRUD contract also lets a user create a cached row before a successful + /// probe (for example, to launch by a known character name while a server + /// is temporarily unavailable). A later roster merge remains + /// authoritative for the id/name pair and preserves these user settings. + /// + public CharacterProfile AddCharacter( + string serverName, + string account, + string characterName, + string? id = null, + LaunchMode launchMode = LaunchMode.GuiSelect, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (FindCharacter(profile, characterName) is not null) + { + throw new LauncherProfileException( + $"Character '{characterName}' already exists on account '{account}'."); + } + + string? normalizedId = NormalizeCharacterId(id); + if (normalizedId is not null + && profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + + var character = new CharacterProfile + { + Name = characterName, + Id = normalizedId, + LaunchMode = launchMode, + Plugins = plugins is null ? [] : [.. plugins], + LoginCommands = loginCommands is null ? [] : [.. loginCommands], + }; + profile.Characters.Add(character); + return character; + } + + /// + /// Edits the identity cache and/or user-owned settings of an existing + /// character row. Passing an empty clears a + /// manually entered id so launches fall back to the character name. /// public void EditCharacter( string serverName, @@ -339,12 +385,42 @@ public sealed class LauncherProfileStore string characterName, LaunchMode? launchMode = null, IReadOnlyList? plugins = null, - IReadOnlyList? loginCommands = null) + IReadOnlyList? loginCommands = null, + string? newName = null, + string? newId = null) { ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); CharacterProfile character = FindCharacterOrThrow(profile, characterName); + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, character.Name, StringComparison.Ordinal) + && FindCharacter(profile, newName) is not null) + { + throw new LauncherProfileException( + $"Character '{newName}' already exists on account '{account}'."); + } + + character.Name = newName; + } + + if (newId is not null) + { + string? normalizedId = NormalizeCharacterId(newId); + if (normalizedId is not null + && profile.Characters.Any(candidate => + !ReferenceEquals(candidate, character) + && CharacterIdsEqual(candidate.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + + character.Id = normalizedId; + } + if (launchMode is not null) { character.LaunchMode = launchMode.Value; @@ -361,6 +437,17 @@ public sealed class LauncherProfileStore } } + public void RemoveCharacter( + string serverName, + string account, + string characterName) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + profile.Characters.Remove(character); + } + /// /// Folds a reported character roster into an account's /// (Campaign LA spec §3/§5/ @@ -456,20 +543,46 @@ public sealed class LauncherProfileStore $"No account '{account}' on server '{server.Name}'."); } + private static CharacterProfile? FindCharacter( + AccountProfile profile, + string characterName) => + profile.Characters.Find( + character => string.Equals( + character.Name, + characterName, + StringComparison.Ordinal)); + private static CharacterProfile FindCharacterOrThrow( AccountProfile profile, string characterName) { ArgumentException.ThrowIfNullOrWhiteSpace(characterName); - return profile.Characters.Find( - character => string.Equals( - character.Name, - characterName, - StringComparison.Ordinal)) + return FindCharacter(profile, characterName) ?? throw new LauncherProfileException( $"No character '{characterName}' on account '{profile.Account}'."); } + private static string? NormalizeCharacterId(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0) + { + throw new LauncherProfileException( + "Character id must be a non-zero hexadecimal value with a 0x prefix."); + } + + return CharacterIdFormat.ToHexString(parsed); + } + + private static bool CharacterIdsEqual(string? left, string? right) => + CharacterIdFormat.TryParse(left, out uint leftId) + && CharacterIdFormat.TryParse(right, out uint rightId) + && leftId == rightId; + private static void RequireValidPort(int port) { if (port is < 1 or > 65535) diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs index a37b4075..7450ee58 100644 --- a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status; /// One tailer instance owns one file's read position; construct a new /// one per session. /// -public sealed class StatusFileTailer +public interface IStatusEventSource +{ + IReadOnlyList ReadNewEvents(); +} + +/// Creates one independent status source per launched session. +public interface IStatusEventSourceFactory +{ + IStatusEventSource Create(string path); +} + +public sealed class StatusFileTailerFactory : IStatusEventSourceFactory +{ + public IStatusEventSource Create(string path) => new StatusFileTailer(path); +} + +public sealed class StatusFileTailer : IStatusEventSource { private readonly string _path; private long _position; diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj new file mode 100644 index 00000000..33b07cd5 --- /dev/null +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -0,0 +1,24 @@ + + + WinExe + acdream-launcher + AcDream.Launcher + net10.0 + enable + enable + latest + true + true + true + + + + + + + + + + + + diff --git a/src/AcDream.Launcher/App.axaml b/src/AcDream.Launcher/App.axaml new file mode 100644 index 00000000..3f6dbf49 --- /dev/null +++ b/src/AcDream.Launcher/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs new file mode 100644 index 00000000..75dd93fe --- /dev/null +++ b/src/AcDream.Launcher/App.axaml.cs @@ -0,0 +1,66 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AcDream.Launcher; + +public sealed partial class App : Application +{ + private LauncherOrchestrator? _orchestrator; + private LauncherWindowViewModel? _viewModel; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); + LauncherInstallRecord? install = ResolveDevelopmentInstallRecord(); + _orchestrator = new LauncherOrchestrator( + profiles, + paths, + LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), + install); + _viewModel = new LauncherWindowViewModel( + _orchestrator, + new AvaloniaUiDispatcher()); + _viewModel.Initialize(); + + desktop.MainWindow = new MainWindow + { + DataContext = _viewModel, + }; + desktop.Exit += OnDesktopExit; + } + + base.OnFrameworkInitializationCompleted(); + } + + private static LauncherInstallRecord? ResolveDevelopmentInstallRecord() + { + // LA9 owns persisted install discovery. LA4 accepts the existing + // developer environment pair at this one composition root so the + // launch/probe UI can be exercised before the first-run body lands. + string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH"); + return !string.IsNullOrWhiteSpace(datDirectory) + && !string.IsNullOrWhiteSpace(preparedAssetPath) + ? new LauncherInstallRecord(datDirectory, preparedAssetPath) + : null; + } + + private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + { + _viewModel?.Dispose(); + _orchestrator?.Dispose(); + _viewModel = null; + _orchestrator = null; + } +} diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml new file mode 100644 index 00000000..cc63bb44 --- /dev/null +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + +