feat(launcher): Campaign LA add Avalonia desktop shell

This commit is contained in:
Erik 2026-08-14 18:15:14 +02:00
parent 6c4cd2bbc6
commit d0a9c65d85
31 changed files with 4831 additions and 16 deletions

View file

@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// Test seam for one supervised launcher child. The Avalonia orchestration
/// layer owns this interface through a factory and never constructs or drives
/// <see cref="System.Diagnostics.Process"/> directly.
/// </summary>
public interface ILauncherProcessSupervisor : IDisposable
{
LauncherSessionState State { get; }
int? ExitCode { get; }
event EventHandler<LauncherSessionState>? 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);
}
/// <summary>
/// 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.
/// </summary>
public sealed class LauncherProcessSupervisor : IDisposable
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
{
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();

View file

@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig(
string StatusFilePath,
SessionConfigDocument Document);
/// <summary>
/// Injectable composition/write seam used by the canonical launcher
/// orchestrator. Production delegates to <see cref="SessionConfigComposer"/>;
/// tests can capture the exact request without writing a file or starting a
/// client process.
/// </summary>
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);
}
/// <summary>
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
/// profile character + install record (Campaign LA spec §6). Passwords
@ -187,6 +245,23 @@ public static class SessionConfigComposer
sessionId,
loginCommandDelayMs);
return Write(composed);
}
/// <summary>Probe counterpart to <see cref="ComposeAndWrite"/>. It
/// writes the pinned <c>mode: "probe"</c> document and never includes
/// a character selector, policy, plugin set, login commands, or password.
/// </summary>
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))
{

View file

@ -0,0 +1,87 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// 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.
/// </summary>
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<string> plugins,
IReadOnlyList<string> loginCommands);
void RemoveCharacter(
string serverName,
string accountName,
string characterName);
Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string characterName,
LaunchMode mode,
CancellationToken cancellationToken = default);
Task<LauncherSessionSnapshot> ProbeAsync(
string serverName,
string accountName,
CancellationToken cancellationToken = default);
Task StopSessionAsync(
string sessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default);
void PollStatus();
void ClearFinishedSessions();
}

View file

@ -0,0 +1,52 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// Host executable paths supplied by the current installation. LA10 will
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
/// mapping injectable and host-agnostic.
/// </summary>
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);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
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.");
}
}

View file

@ -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<string> Plugins,
IReadOnlyList<string> LoginCommands,
bool HasRunningSession,
string SessionStatus);
/// <summary>
/// Password is deliberately absent. The account credential remains reachable
/// only inside <see cref="Profiles.LauncherProfileStore"/> and the transient
/// stdin handoff performed by <see cref="LauncherOrchestrator"/>.
/// </summary>
public sealed record LauncherAccountSnapshot(
string ServerName,
string AccountName,
IReadOnlyList<LauncherCharacterSnapshot> Characters,
bool HasRunningActivity,
string ActivityStatus);
public sealed record LauncherServerSnapshot(
string Name,
string Host,
int Port,
IReadOnlyList<LauncherAccountSnapshot> 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<LauncherServerSnapshot> Servers,
IReadOnlyList<LauncherSessionSnapshot> Sessions,
LauncherPlatformCapabilities Platform,
bool IsInstallationReady,
string InstallationStatus);
public sealed class LauncherOperationException : Exception
{
public LauncherOperationException(string message)
: base(message)
{
}
}

View file

@ -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 --------------------------
/// <summary>
/// Edits the user-owned settings of an existing character row. There
/// is no manual add/remove for characters — the roster (
/// <see cref="MergeRoster"/>) is the only source of new rows, per
/// spec §5/§6.
/// Adds a manually configured character row. Normal operation discovers
/// characters through <see cref="MergeRoster"/>, 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.
/// </summary>
public CharacterProfile AddCharacter(
string serverName,
string account,
string characterName,
string? id = null,
LaunchMode launchMode = LaunchMode.GuiSelect,
IReadOnlyList<string>? plugins = null,
IReadOnlyList<string>? 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;
}
/// <summary>
/// Edits the identity cache and/or user-owned settings of an existing
/// character row. Passing an empty <paramref name="newId"/> clears a
/// manually entered id so launches fall back to the character name.
/// </summary>
public void EditCharacter(
string serverName,
@ -339,12 +385,42 @@ public sealed class LauncherProfileStore
string characterName,
LaunchMode? launchMode = null,
IReadOnlyList<string>? plugins = null,
IReadOnlyList<string>? loginCommands = null)
IReadOnlyList<string>? 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);
}
/// <summary>
/// Folds a reported character roster into an account's
/// <see cref="AccountProfile.Characters"/> (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)

View file

@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status;
/// One tailer instance owns one file's read position; construct a new
/// one per session.
/// </summary>
public sealed class StatusFileTailer
public interface IStatusEventSource
{
IReadOnlyList<StatusEvent> ReadNewEvents();
}
/// <summary>Creates one independent status source per launched session.</summary>
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;