diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml
index 6facc5ed..801f3476 100644
--- a/.github/workflows/headless-portability.yml
+++ b/.github/workflows/headless-portability.yml
@@ -7,6 +7,7 @@ on:
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
+ - "src/AcDream.Launcher/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
@@ -17,6 +18,7 @@ on:
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
+ - "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
@@ -33,6 +35,7 @@ on:
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
+ - "src/AcDream.Launcher/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
@@ -43,6 +46,7 @@ on:
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
+ - "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
@@ -131,6 +135,66 @@ jobs:
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ - name: Verify Linux headless host executable permission
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless
+
+ portable-launcher:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Build and test the portable launcher
+ shell: pwsh
+ run: |
+ dotnet build src/AcDream.Launcher/AcDream.Launcher.csproj -c Release
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Publish the self-contained Linux launcher
+ if: runner.os == 'Linux'
+ shell: pwsh
+ run: |
+ dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
+ -c Release `
+ -r linux-x64 `
+ -o artifacts/acdream-launcher-linux-x64
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Verify self-contained property and artifact execution
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+ root=artifacts/acdream-launcher-linux-x64
+ self_contained=$(dotnet msbuild \
+ src/AcDream.Launcher/AcDream.Launcher.csproj \
+ -nologo \
+ -property:RuntimeIdentifier=linux-x64 \
+ -getProperty:SelfContained | tr -d '\r\n ')
+ test "$self_contained" = true
+ test -x "$root/acdream-launcher"
+ test ! -f "$root/acdream-launcher.dll"
+ DOTNET_ROOT=/definitely-not-installed \
+ DOTNET_ROOT_X64=/definitely-not-installed \
+ DOTNET_MULTILEVEL_LOOKUP=0 \
+ "$root/acdream-launcher" --verify-publish
+
linux-graphical:
runs-on: ubuntu-latest
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 a5b4530d..37753218 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -282,9 +282,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..e52c443f
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs
@@ -0,0 +1,92 @@
+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 GetAccountLaunchCapability(
+ string serverName,
+ string accountName,
+ 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..ef4635c5
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
@@ -0,0 +1,137 @@
+using AcDream.Launcher.Core.Launching;
+using AcDream.Launcher.Core.Profiles;
+
+namespace AcDream.Launcher.Core.Orchestration;
+
+///
+/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
+/// replace the directory lookup with its versioned-current resolver; until
+/// then a missing host disables the corresponding action instead of deferring
+/// failure until process creation.
+///
+public sealed class LauncherExecutableSet
+{
+ private readonly Func _fileExists;
+ private readonly Func _hasUnixExecutePermission;
+
+ public LauncherExecutableSet(
+ string graphicalHostPath,
+ string headlessHostPath,
+ string? workingDirectory = null,
+ Func? fileExists = null,
+ Func? hasUnixExecutePermission = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
+ GraphicalHostPath = graphicalHostPath;
+ HeadlessHostPath = headlessHostPath;
+ WorkingDirectory = workingDirectory;
+ _fileExists = fileExists ?? File.Exists;
+ _hasUnixExecutePermission =
+ hasUnixExecutePermission ?? HasUnixExecutePermission;
+ }
+
+ public string GraphicalHostPath { get; }
+
+ public string HeadlessHostPath { get; }
+
+ public string? WorkingDirectory { get; }
+
+ public LauncherCapability GetAvailability(LaunchMode mode)
+ {
+ string path = mode == LaunchMode.Headless
+ ? HeadlessHostPath
+ : GraphicalHostPath;
+ string host = mode == LaunchMode.Headless
+ ? "headless host"
+ : "graphical client";
+ if (!_fileExists(path))
+ {
+ return LauncherCapability.Unavailable(
+ $"The co-deployed {host} is missing at '{path}'. Reinstall or update "
+ + "the client before launching.");
+ }
+
+ if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path))
+ {
+ return LauncherCapability.Unavailable(
+ $"The co-deployed {host} at '{path}' exists but is not executable. "
+ + "Restore its executable permission (for example, chmod +x) or "
+ + "reinstall/update the client before launching.");
+ }
+
+ return LauncherCapability.Available;
+ }
+
+ public LauncherProcessSpec CreatePlaySpec(
+ LaunchMode mode,
+ string configFilePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
+ RequireAvailable(mode);
+
+ 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);
+ RequireAvailable(LaunchMode.Headless);
+ 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);
+ }
+
+ private void RequireAvailable(LaunchMode mode)
+ {
+ LauncherCapability capability = GetAvailability(mode);
+ if (!capability.IsAvailable)
+ {
+ throw new LauncherOperationException(
+ capability.Reason ?? "The selected launcher host is unavailable.");
+ }
+ }
+
+ private static bool HasUnixExecutePermission(string path)
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return true;
+ }
+
+ try
+ {
+ const UnixFileMode executeBits =
+ UnixFileMode.UserExecute
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherExecute;
+ return (File.GetUnixFileMode(path) & executeBits) != 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Fail closed if the file vanished or its metadata cannot be read
+ // after the existence check. The next capability refresh retries.
+ return false;
+ }
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
new file mode 100644
index 00000000..57d344c9
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
@@ -0,0 +1,1293 @@
+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;
+ }
+}
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..f3a1a1b3 100644
--- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
@@ -81,6 +81,8 @@ public sealed class LauncherProfileStore
return false;
}
+ EnsureExistingCredentialFilePermissions();
+
LauncherProfileDocument? document;
using (FileStream stream = File.OpenRead(FilePath))
{
@@ -110,6 +112,7 @@ public sealed class LauncherProfileStore
+ $"expected {CurrentVersion}.");
}
+ ValidateAndNormalizeDocument(document);
Document = document;
return true;
}
@@ -152,6 +155,13 @@ public sealed class LauncherProfileStore
JsonSerializer.Serialize(stream, Document, SerializerOptions);
}
+ if (OperatingSystem.IsLinux()
+ && File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode)
+ {
+ throw new IOException(
+ "The launcher credential temp file could not be secured to mode 0600.");
+ }
+
File.Move(tempPath, FilePath, overwrite: true);
}
catch
@@ -160,10 +170,6 @@ public sealed class LauncherProfileStore
throw;
}
- if (OperatingSystem.IsLinux())
- {
- File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
- }
}
///
@@ -248,21 +254,21 @@ public sealed class LauncherProfileStore
throw new LauncherProfileException(
$"A server named '{newName}' already exists.");
}
-
- server.Name = newName;
}
if (newHost is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
- server.Host = newHost;
}
if (newPort is not null)
{
RequireValidPort(newPort.Value);
- server.Port = newPort.Value;
}
+
+ server.Name = newName ?? server.Name;
+ server.Host = newHost ?? server.Host;
+ server.Port = newPort ?? server.Port;
}
public void RemoveServer(string name)
@@ -308,10 +314,10 @@ public sealed class LauncherProfileStore
throw new LauncherProfileException(
$"Account '{newAccount}' already exists on server '{serverName}'.");
}
-
- profile.Account = newAccount;
}
+ profile.Account = newAccount ?? profile.Account;
+
if (newPassword is not null)
{
profile.Password = newPassword;
@@ -325,13 +331,62 @@ 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);
+ RequireValidLaunchMode(launchMode);
+ ValidateStringList(plugins, "plugin", requireUnique: true);
+ ValidateStringList(loginCommands, "login command", requireUnique: false);
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+
+ if (FindCharacter(profile, characterName) is not null)
+ {
+ throw new LauncherProfileException(
+ $"Character '{characterName}' already exists on account '{account}'.");
+ }
+
+ string? normalizedId = NormalizeCharacterId(id);
+ if (normalizedId is not null
+ && profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
+ {
+ throw new LauncherProfileException(
+ $"Character id '{normalizedId}' already exists on account '{account}'.");
+ }
+
+ var character = new CharacterProfile
+ {
+ Name = characterName,
+ Id = normalizedId,
+ LaunchMode = launchMode,
+ Plugins = plugins is null ? [] : [.. plugins],
+ LoginCommands = loginCommands is null ? [] : [.. loginCommands],
+ };
+ profile.Characters.Add(character);
+ return character;
+ }
+
+ ///
+ /// 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 +394,54 @@ 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);
+ string? normalizedId = null;
+
+ if (newName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(newName);
+ if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
+ && FindCharacter(profile, newName) is not null)
+ {
+ throw new LauncherProfileException(
+ $"Character '{newName}' already exists on account '{account}'.");
+ }
+ }
+
+ if (newId is not null)
+ {
+ normalizedId = NormalizeCharacterId(newId);
+ if (normalizedId is not null
+ && profile.Characters.Any(candidate =>
+ !ReferenceEquals(candidate, character)
+ && CharacterIdsEqual(candidate.Id, normalizedId)))
+ {
+ throw new LauncherProfileException(
+ $"Character id '{normalizedId}' already exists on account '{account}'.");
+ }
+ }
+
+ if (launchMode is not null)
+ {
+ RequireValidLaunchMode(launchMode.Value);
+ }
+
+ ValidateStringList(plugins, "plugin", requireUnique: true);
+ ValidateStringList(loginCommands, "login command", requireUnique: false);
+
+ character.Name = newName ?? character.Name;
+ if (newId is not null)
+ {
+ character.Id = normalizedId;
+ }
+
if (launchMode is not null)
{
character.LaunchMode = launchMode.Value;
@@ -361,6 +458,68 @@ public sealed class LauncherProfileStore
}
}
+ private void EnsureExistingCredentialFilePermissions()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ try
+ {
+ UnixFileMode mode = File.GetUnixFileMode(FilePath);
+ if (mode != OwnerOnlyFileMode)
+ {
+ File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
+ mode = File.GetUnixFileMode(FilePath);
+ }
+
+ if (mode != OwnerOnlyFileMode)
+ {
+ throw new IOException($"Mode remained {mode} after normalization.");
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ throw new LauncherProfileException(
+ $"'{FilePath}' could not be secured to owner-only mode 0600.",
+ ex);
+ }
+ }
+
+ ///
+ /// Applies one profile mutation and its atomic file replacement as a
+ /// single in-memory/on-disk transaction. Any validation or I/O failure
+ /// restores the exact pre-mutation document, including credentials.
+ ///
+ public void ExecuteTransaction(Action mutation)
+ {
+ ArgumentNullException.ThrowIfNull(mutation);
+ LauncherProfileDocument before = CloneDocument(Document);
+ try
+ {
+ mutation();
+ ValidateAndNormalizeDocument(Document);
+ Save();
+ }
+ catch
+ {
+ Document = before;
+ throw;
+ }
+ }
+
+ public void RemoveCharacter(
+ string serverName,
+ string account,
+ string characterName)
+ {
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+ CharacterProfile character = FindCharacterOrThrow(profile, characterName);
+ profile.Characters.Remove(character);
+ }
+
///
/// Folds a reported character roster into an account's
/// (Campaign LA spec §3/§5/
@@ -385,38 +544,49 @@ public sealed class LauncherProfileStore
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
+ var rosterIds = new HashSet();
+ var rosterNames = new HashSet(StringComparer.Ordinal);
+ foreach (CharacterRosterEntry entry in roster)
+ {
+ if (entry.Id == 0)
+ {
+ throw new LauncherProfileException("A roster character id cannot be zero.");
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name);
+ if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name))
+ {
+ throw new LauncherProfileException(
+ "The reported character roster contains a duplicate id or name.");
+ }
+ }
+
foreach (CharacterRosterEntry entry in roster)
{
string idText = CharacterIdFormat.ToHexString(entry.Id);
- // Normalize BOTH sides through TryParse/ToHexString rather
- // than a raw string compare (Campaign LA plan §LA3 review
- // finding F10): a stored id that round-trips to the same
- // uint (different case, or — before this fix — no "0x"
- // prefix) must match even though its text isn't byte-
- // identical to the canonical form this method itself always
- // writes.
- CharacterProfile? existing = profile.Characters.Find(
- character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
- && existingId == entry.Id);
-
- // Defensive fallback for a row whose id is missing OR
- // unparseable (e.g. a hand-edited id with no "0x" prefix,
- // which TryParse now rejects outright) — match by name
- // instead so a later merge self-heals the id into the
- // canonical form rather than creating a permanent duplicate
- // row.
- existing ??= profile.Characters.Find(
- character => !CharacterIdFormat.TryParse(character.Id, out _)
- && string.Equals(
- character.Name,
- entry.Name,
- StringComparison.Ordinal));
+ CharacterProfile[] matches = profile.Characters
+ .Where(character =>
+ (CharacterIdFormat.TryParse(character.Id, out uint existingId)
+ && existingId == entry.Id)
+ || string.Equals(character.Name, entry.Name, StringComparison.Ordinal))
+ .ToArray();
+ CharacterProfile? existing = matches.FirstOrDefault(character =>
+ CharacterIdFormat.TryParse(character.Id, out uint existingId)
+ && existingId == entry.Id)
+ ?? matches.FirstOrDefault();
if (existing is not null)
{
existing.Id = idText;
existing.Name = entry.Name;
+ foreach (CharacterProfile duplicate in matches)
+ {
+ if (!ReferenceEquals(duplicate, existing))
+ {
+ profile.Characters.Remove(duplicate);
+ }
+ }
continue;
}
@@ -456,20 +626,232 @@ 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 LauncherProfileDocument CloneDocument(
+ LauncherProfileDocument source) =>
+ new()
+ {
+ Version = source.Version,
+ Servers = source.Servers.Select(server => new ServerProfile
+ {
+ Name = server.Name,
+ Host = server.Host,
+ Port = server.Port,
+ Accounts = server.Accounts.Select(account => new AccountProfile
+ {
+ Account = account.Account,
+ Password = account.Password,
+ Characters = account.Characters.Select(character => new CharacterProfile
+ {
+ Name = character.Name,
+ Id = character.Id,
+ LaunchMode = character.LaunchMode,
+ Plugins = [.. character.Plugins],
+ LoginCommands = [.. character.LoginCommands],
+ }).ToList(),
+ }).ToList(),
+ }).ToList(),
+ };
+
+ private static void ValidateAndNormalizeDocument(LauncherProfileDocument document)
+ {
+ if (document.Servers is null)
+ {
+ throw new LauncherProfileException("The servers collection cannot be null.");
+ }
+
+ var serverNames = new HashSet(StringComparer.Ordinal);
+ var normalizedIds = new List<(CharacterProfile Character, uint Id)>();
+ foreach (ServerProfile? server in document.Servers)
+ {
+ if (server is null)
+ {
+ throw new LauncherProfileException("A server entry cannot be null.");
+ }
+
+ RequireLoadedText(server.Name, "server name");
+ RequireLoadedText(server.Host, $"host for server '{server.Name}'");
+ RequireValidPort(server.Port);
+ if (!serverNames.Add(server.Name))
+ {
+ throw new LauncherProfileException(
+ $"A server named '{server.Name}' appears more than once.");
+ }
+
+ if (server.Accounts is null)
+ {
+ throw new LauncherProfileException(
+ $"The accounts collection for server '{server.Name}' cannot be null.");
+ }
+
+ var accountNames = new HashSet(StringComparer.Ordinal);
+ foreach (AccountProfile? account in server.Accounts)
+ {
+ if (account is null)
+ {
+ throw new LauncherProfileException(
+ $"A null account appears under server '{server.Name}'.");
+ }
+
+ RequireLoadedText(account.Account, "account name");
+ if (account.Password is null)
+ {
+ throw new LauncherProfileException(
+ $"Password for account '{account.Account}' cannot be null.");
+ }
+
+ if (!accountNames.Add(account.Account))
+ {
+ throw new LauncherProfileException(
+ $"Account '{account.Account}' appears more than once on server '{server.Name}'.");
+ }
+
+ if (account.Characters is null)
+ {
+ throw new LauncherProfileException(
+ $"The characters collection for account '{account.Account}' cannot be null.");
+ }
+
+ var characterNames = new HashSet(StringComparer.Ordinal);
+ var characterIds = new HashSet();
+ foreach (CharacterProfile? character in account.Characters)
+ {
+ if (character is null)
+ {
+ throw new LauncherProfileException(
+ $"A null character appears under account '{account.Account}'.");
+ }
+
+ RequireLoadedText(character.Name, "character name");
+ if (!characterNames.Add(character.Name))
+ {
+ throw new LauncherProfileException(
+ $"Character '{character.Name}' appears more than once on account '{account.Account}'.");
+ }
+
+ RequireValidLaunchMode(character.LaunchMode);
+ if (character.Id is not null)
+ {
+ if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0)
+ {
+ throw new LauncherProfileException(
+ $"Character '{character.Name}' has an invalid id '{character.Id}'.");
+ }
+
+ if (!characterIds.Add(id))
+ {
+ throw new LauncherProfileException(
+ $"Character id '{character.Id}' appears more than once on account '{account.Account}'.");
+ }
+
+ normalizedIds.Add((character, id));
+ }
+
+ if (character.Plugins is null || character.LoginCommands is null)
+ {
+ throw new LauncherProfileException(
+ $"Character '{character.Name}' has a null settings collection.");
+ }
+
+ ValidateStringList(character.Plugins, "plugin", requireUnique: true);
+ ValidateStringList(
+ character.LoginCommands,
+ "login command",
+ requireUnique: false);
+ }
+ }
+ }
+
+ foreach ((CharacterProfile character, uint id) in normalizedIds)
+ {
+ character.Id = CharacterIdFormat.ToHexString(id);
+ }
+ }
+
+ private static void ValidateStringList(
+ IReadOnlyList? values,
+ string valueName,
+ bool requireUnique)
+ {
+ if (values is null)
+ {
+ return;
+ }
+
+ HashSet? seen = requireUnique
+ ? new HashSet(StringComparer.Ordinal)
+ : null;
+ foreach (string? value in values)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new LauncherProfileException(
+ $"A {valueName} cannot be null or whitespace.");
+ }
+
+ if (seen is not null && !seen.Add(value))
+ {
+ throw new LauncherProfileException(
+ $"The {valueName} '{value}' appears more than once.");
+ }
+ }
+ }
+
+ private static void RequireLoadedText(string? value, string field)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new LauncherProfileException($"The {field} cannot be null or whitespace.");
+ }
+ }
+
+ private static void RequireValidLaunchMode(LaunchMode mode)
+ {
+ if (!Enum.IsDefined(mode))
+ {
+ throw new LauncherProfileException($"Launch mode '{mode}' is not supported.");
+ }
+ }
+
private static void RequireValidPort(int port)
{
if (port is < 1 or > 65535)
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..a4c20260
--- /dev/null
+++ b/src/AcDream.Launcher/AcDream.Launcher.csproj
@@ -0,0 +1,25 @@
+
+
+ WinExe
+ acdream-launcher
+ AcDream.Launcher
+ net10.0
+ enable
+ enable
+ latest
+ true
+ 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..f85d1e8a
--- /dev/null
+++ b/src/AcDream.Launcher/MainWindow.axaml
@@ -0,0 +1,389 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Launcher/MainWindow.axaml.cs b/src/AcDream.Launcher/MainWindow.axaml.cs
new file mode 100644
index 00000000..a8df2d9a
--- /dev/null
+++ b/src/AcDream.Launcher/MainWindow.axaml.cs
@@ -0,0 +1,137 @@
+using System.ComponentModel;
+using AcDream.Launcher.ViewModels;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+
+namespace AcDream.Launcher;
+
+public sealed partial class MainWindow : Window
+{
+ private readonly DispatcherTimer _statusTimer;
+ private LauncherWindowViewModel? _observedViewModel;
+ private Control? _focusBeforeModal;
+ private bool _wasModalOpen;
+
+ public MainWindow()
+ {
+ AvaloniaXamlLoader.Load(this);
+ _statusTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(250),
+ };
+ _statusTimer.Tick += OnStatusTimerTick;
+ DataContextChanged += OnDataContextChanged;
+ Opened += OnOpened;
+ Closed += OnClosed;
+ }
+
+ private void OnOpened(object? sender, EventArgs e) => _statusTimer.Start();
+
+ private void OnClosed(object? sender, EventArgs e)
+ {
+ _statusTimer.Stop();
+ _statusTimer.Tick -= OnStatusTimerTick;
+ DataContextChanged -= OnDataContextChanged;
+ ObserveViewModel(null);
+ Opened -= OnOpened;
+ Closed -= OnClosed;
+ }
+
+ private void OnStatusTimerTick(object? sender, EventArgs e)
+ {
+ if (DataContext is LauncherWindowViewModel viewModel)
+ {
+ viewModel.PollStatus();
+ }
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e) =>
+ ObserveViewModel(DataContext as LauncherWindowViewModel);
+
+ private void ObserveViewModel(LauncherWindowViewModel? viewModel)
+ {
+ if (ReferenceEquals(_observedViewModel, viewModel))
+ {
+ return;
+ }
+
+ if (_observedViewModel is not null)
+ {
+ _observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
+ }
+
+ _observedViewModel = viewModel;
+ if (_observedViewModel is not null)
+ {
+ _observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
+ }
+
+ _wasModalOpen = viewModel?.IsModalOpen == true;
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
+ || sender is not LauncherWindowViewModel viewModel)
+ {
+ return;
+ }
+
+ bool isModalOpen = viewModel.IsModalOpen;
+ if (isModalOpen && !_wasModalOpen)
+ {
+ _focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
+ Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
+ }
+ else if (!isModalOpen && _wasModalOpen)
+ {
+ Control? focusToRestore = _focusBeforeModal;
+ _focusBeforeModal = null;
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (focusToRestore?.Focus() != true)
+ {
+ ProfilesTree.Focus();
+ }
+ });
+ }
+
+ _wasModalOpen = isModalOpen;
+ }
+
+ private void FocusActiveModal(LauncherWindowViewModel viewModel)
+ {
+ if (viewModel.EditorDialog.IsOpen)
+ {
+ Control target = viewModel.EditorDialog.Kind switch
+ {
+ ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
+ ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
+ ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
+ _ => EditorSubmitButton,
+ };
+ target.Focus();
+ }
+ else if (viewModel.FirstRunWizardShell.IsOpen)
+ {
+ FirstRunCloseButton.Focus();
+ }
+ else if (viewModel.UpdatePromptShell.IsOpen)
+ {
+ UpdateCloseButton.Focus();
+ }
+ }
+
+ private void OnModalKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
+ {
+ return;
+ }
+
+ viewModel.CloseActiveModal();
+ e.Handled = true;
+ }
+}
diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs
new file mode 100644
index 00000000..807010ef
--- /dev/null
+++ b/src/AcDream.Launcher/Program.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+
+namespace AcDream.Launcher;
+
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ if (args is ["--verify-publish"])
+ {
+ // A display-free execution probe for the packaged artifact. CI
+ // runs this with DOTNET_ROOT pointing at a missing directory; a
+ // framework-dependent publish cannot reach this return statement.
+ return 0;
+ }
+
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect();
+}
diff --git a/src/AcDream.Launcher/ViewModels/Commands.cs b/src/AcDream.Launcher/ViewModels/Commands.cs
new file mode 100644
index 00000000..40078d09
--- /dev/null
+++ b/src/AcDream.Launcher/ViewModels/Commands.cs
@@ -0,0 +1,94 @@
+using System.Windows.Input;
+
+namespace AcDream.Launcher.ViewModels;
+
+public sealed class RelayCommand : ICommand
+{
+ private readonly Action