merge: Campaign LA LA4 - Avalonia launcher review-closed

This commit is contained in:
Erik 2026-08-14 19:14:25 +02:00
commit 60f627998c
35 changed files with 6485 additions and 49 deletions

View file

@ -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

View file

@ -7,6 +7,7 @@
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
<Project Path="src/AcDream.Launcher/AcDream.Launcher.csproj" />
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
@ -27,6 +28,7 @@
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />

View file

@ -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

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,92 @@
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 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<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,137 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// 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.
/// </summary>
public sealed class LauncherExecutableSet
{
private readonly Func<string, bool> _fileExists;
private readonly Func<string, bool> _hasUnixExecutePermission;
public LauncherExecutableSet(
string graphicalHostPath,
string headlessHostPath,
string? workingDirectory = null,
Func<string, bool>? fileExists = null,
Func<string, bool>? 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;
}
}
}

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

@ -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);
}
}
/// <summary>
@ -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 --------------------------
/// <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);
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;
}
/// <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 +394,54 @@ 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);
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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Folds a reported character roster into an account's
/// <see cref="AccountProfile.Characters"/> (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<uint>();
var rosterNames = new HashSet<string>(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<string>(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<string>(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<string>(StringComparer.Ordinal);
var characterIds = new HashSet<uint>();
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<string>? values,
string valueName,
bool requireUnique)
{
if (values is null)
{
return;
}
HashSet<string>? seen = requireUnique
? new HashSet<string>(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)

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;

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<AssemblyName>acdream-launcher</AssemblyName>
<RootNamespace>AcDream.Launcher</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="AcDream.Launcher.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View file

@ -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;
}
}

View file

@ -0,0 +1,389 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:AcDream.Launcher.ViewModels"
x:Class="AcDream.Launcher.MainWindow"
x:DataType="vm:LauncherWindowViewModel"
Title="acdream launcher"
Width="1180"
Height="760"
MinWidth="900"
MinHeight="620"
Background="#10151D">
<Window.Styles>
<Style Selector="Border.card">
<Setter Property="Background" Value="#18212D" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="16" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="#3C78D8" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="#8E3540" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="#A8B5C6" />
</Style>
<Style Selector="TextBlock.section">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Border Grid.Row="0" Background="#141C26" Padding="20,14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="2">
<TextBlock Text="acdream" FontSize="24" FontWeight="Bold" />
<TextBlock Text="Servers, accounts, characters, and supervised sessions"
Classes="muted" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="First-run setup"
Command="{Binding FirstRunWizardShell.OpenCommand}" />
<Button Content="Check for updates"
Command="{Binding UpdatePromptShell.OpenCommand}" />
</StackPanel>
</Grid>
</Border>
<StackPanel Grid.Row="1" Margin="16,12,16,0" Spacing="8">
<Border Classes="card"
Padding="12"
Background="#4B3820"
IsVisible="{Binding IsFirstRunRequired}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Client setup required" FontWeight="SemiBold" />
<TextBlock Text="{Binding InstallationStatus}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
Content="Open setup"
Command="{Binding FirstRunWizardShell.OpenCommand}" />
</Grid>
</Border>
<Border Classes="card"
Padding="12"
Background="#24344B"
IsVisible="{Binding ShowLinuxGraphicalNotice}">
<StackPanel Spacing="3">
<TextBlock Text="Linux graphical launch gate" FontWeight="SemiBold" />
<TextBlock Text="{Binding LinuxGraphicalNotice}" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
<Grid Grid.Row="2" Margin="16" ColumnDefinitions="330,12,*" RowDefinitions="*,12,220">
<Border Grid.Column="0" Grid.RowSpan="3" Classes="card">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<TextBlock Text="Profiles" Classes="section" />
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="0,12,0,10">
<Button Content="+ Server" Command="{Binding AddServerCommand}" />
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
</StackPanel>
<TreeView x:Name="ProfilesTree"
Grid.Row="2"
ItemsSource="{Binding Servers}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
<TreeView.DataTemplates>
<TreeDataTemplate DataType="{x:Type vm:LauncherTreeNodeViewModel}"
ItemsSource="{Binding Children}">
<StackPanel Margin="2,4" Spacing="1">
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SecondaryText}"
Classes="muted"
FontSize="11"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="0,10,0,0">
<Button Content="Edit" Command="{Binding EditSelectedCommand}" />
<Button Content="Remove"
Classes="danger"
Command="{Binding RemoveSelectedCommand}" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Column="2" Grid.Row="0" Classes="card">
<ScrollViewer>
<StackPanel Spacing="14">
<StackPanel Spacing="3">
<TextBlock Text="{Binding SelectionTitle}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding SelectionSubtitle}" Classes="muted" />
</StackPanel>
<Border Background="#213044" CornerRadius="6" Padding="12"
IsVisible="{Binding IsServerSelected}">
<StackPanel Spacing="8">
<TextBlock Text="Server profile" Classes="section" />
<TextBlock Text="Add accounts beneath this server, or edit its host and port." TextWrapping="Wrap" />
<Button Content="Add account"
HorizontalAlignment="Left"
Command="{Binding AddAccountCommand}" />
</StackPanel>
</Border>
<Border Background="#213044" CornerRadius="6" Padding="12"
IsVisible="{Binding IsAccountSelected}">
<StackPanel Spacing="8">
<TextBlock Text="Account profile" Classes="section" />
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Open character select"
Classes="primary"
AutomationProperties.Name="Open character select for account"
Command="{Binding LaunchAccountGuiSelectCommand}" />
<Button Content="Refresh characters"
Command="{Binding RefreshCharactersCommand}" />
<Button Content="Add cached character"
Command="{Binding AddCharacterCommand}" />
</StackPanel>
<TextBlock Text="{Binding ProbeDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<StackPanel IsVisible="{Binding IsCharacterSelected}" Spacing="14">
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Per-character launch settings" Classes="section" />
<TextBlock Text="Default launch mode" Classes="muted" />
<ComboBox ItemsSource="{Binding AvailableLaunchModes}"
SelectedItem="{Binding CharacterLaunchMode, Mode=TwoWay}" />
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="5">
<TextBlock Text="Plugins (one id per line)" Classes="muted" />
<TextBox Text="{Binding CharacterPluginsText, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="96" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Login commands (ordered, one per line)" Classes="muted" />
<TextBox Text="{Binding CharacterLoginCommandsText, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="96" />
</StackPanel>
</Grid>
<Button Content="Save settings"
HorizontalAlignment="Left"
Command="{Binding SaveCharacterSettingsCommand}" />
</StackPanel>
</Border>
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Launch" Classes="section" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="GUI — enter world"
Classes="primary"
Command="{Binding LaunchGuiCommand}" />
<Button Content="GUI — character select"
Command="{Binding LaunchGuiSelectCommand}" />
<Button Content="Headless"
Command="{Binding LaunchHeadlessCommand}" />
</StackPanel>
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<Border Grid.Column="2" Grid.Row="2" Classes="card">
<Grid RowDefinitions="Auto,*">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Sessions" Classes="section" />
<Button Grid.Column="1"
Content="Clear finished"
Command="{Binding ClearFinishedSessionsCommand}" />
</Grid>
<ScrollViewer Grid.Row="1" Margin="0,10,0,0">
<ItemsControl ItemsSource="{Binding Sessions}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LauncherSessionRowViewModel">
<Border BorderBrush="#344559"
BorderThickness="0,0,0,1"
Padding="4,8">
<Grid ColumnDefinitions="2*,90,90,3*,Auto">
<TextBlock Text="{Binding Target}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding Mode}" Classes="muted" />
<TextBlock Grid.Column="2" Text="{Binding State}" />
<StackPanel Grid.Column="3" Spacing="2">
<TextBlock Text="{Binding Status}"
TextTrimming="CharacterEllipsis" Classes="muted" />
<TextBlock Text="{Binding Error}"
Foreground="#FF9A9A"
IsVisible="{Binding HasError}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Grid.Column="4" Content="Stop" Command="{Binding StopCommand}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<Border Grid.Row="3" Background="#141C26" Padding="16,10">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="2">
<TextBlock Text="{Binding OperationStatus}" />
<TextBlock Text="{Binding LastError}"
Foreground="#FF9A9A"
IsVisible="{Binding HasError}"
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
Content="Cancel operation"
Command="{Binding CancelOperationCommand}" />
</Grid>
</Border>
<Border Grid.RowSpan="4"
ZIndex="20"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Profile editor modal dialog"
IsVisible="{Binding EditorDialog.IsOpen}">
<Border Classes="card"
Width="480"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<StackPanel Spacing="10">
<TextBlock Text="{Binding EditorDialog.Title}" FontSize="22" FontWeight="Bold" />
<TextBlock Text="{Binding EditorDialog.Message}" Classes="muted" TextWrapping="Wrap" />
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
<TextBlock Text="Name" />
<TextBox x:Name="ServerNameTextBox"
AutomationProperties.Name="Server name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Host" />
<TextBox AutomationProperties.Name="Server host"
Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
<TextBlock Text="Port" />
<TextBox AutomationProperties.Name="Server port"
Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
</StackPanel>
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
<TextBlock Text="Account name" />
<TextBox x:Name="AccountNameTextBox"
AutomationProperties.Name="Account name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Password" />
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
AutomationProperties.Name="Account password"
PasswordChar="●" />
</StackPanel>
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
<TextBlock Text="Character name" />
<TextBox x:Name="CharacterNameTextBox"
AutomationProperties.Name="Character name"
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
<TextBlock Text="Character id (optional, 0x-prefixed)" />
<TextBox AutomationProperties.Name="Character id"
Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
</StackPanel>
<TextBlock Text="{Binding EditorDialog.Error}"
Foreground="#FF9A9A"
IsVisible="{Binding EditorDialog.HasError}"
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel"
IsCancel="True"
AutomationProperties.Name="Cancel profile change"
Command="{Binding EditorDialog.CancelCommand}" />
<Button x:Name="EditorSubmitButton"
Content="{Binding EditorDialog.SubmitText}"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Confirm profile change"
Command="{Binding EditorDialog.SubmitCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Border>
<Border Grid.RowSpan="4"
ZIndex="30"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="First-run setup modal dialog"
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
<Border Background="#24344B" Padding="10" CornerRadius="5">
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
</Border>
<Button x:Name="FirstRunCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close first-run setup"
Command="{Binding FirstRunWizardShell.CloseCommand}" />
</StackPanel>
</Border>
</Border>
<Border Grid.RowSpan="4"
ZIndex="30"
Background="#C010151D"
Focusable="True"
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Update prompt modal dialog"
IsVisible="{Binding UpdatePromptShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
<Border Background="#24344B" Padding="10" CornerRadius="5">
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
</Border>
<Button x:Name="UpdateCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close update prompt"
Command="{Binding UpdatePromptShell.CloseCommand}" />
</StackPanel>
</Border>
</Border>
</Grid>
</Window>

View file

@ -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;
}
}

View file

@ -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<App>()
.UsePlatformDetect();
}

View file

@ -0,0 +1,94 @@
using System.Windows.Input;
namespace AcDream.Launcher.ViewModels;
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(
_ => execute(),
canExecute is null ? null : _ => canExecute())
{
ArgumentNullException.ThrowIfNull(execute);
}
public RelayCommand(
Action<object?> execute,
Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter)
{
if (CanExecute(parameter))
{
_execute(parameter);
}
}
public void NotifyCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<object?, Task> _execute;
private readonly Func<object?, bool>? _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
: this(
_ => execute(),
canExecute is null ? null : _ => canExecute())
{
ArgumentNullException.ThrowIfNull(execute);
}
public AsyncRelayCommand(
Func<object?, Task> execute,
Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) =>
!_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
public async void Execute(object? parameter) =>
await ExecuteAsync(parameter).ConfigureAwait(true);
public async Task ExecuteAsync(object? parameter = null)
{
if (!CanExecute(parameter))
{
return;
}
_isExecuting = true;
NotifyCanExecuteChanged();
try
{
await _execute(parameter).ConfigureAwait(true);
}
finally
{
_isExecuting = false;
NotifyCanExecuteChanged();
}
}
public void NotifyCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View file

@ -0,0 +1,26 @@
using Avalonia.Threading;
namespace AcDream.Launcher.ViewModels;
public interface IUiDispatcher
{
void Post(Action action);
}
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
public void Post(Action action)
{
ArgumentNullException.ThrowIfNull(action);
Dispatcher.UIThread.Post(action);
}
}
public sealed class ImmediateUiDispatcher : IUiDispatcher
{
public void Post(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
}
}

View file

@ -0,0 +1,50 @@
using AcDream.Launcher.Core.Orchestration;
namespace AcDream.Launcher.ViewModels;
public sealed class LauncherSessionRowViewModel
{
public LauncherSessionRowViewModel(
LauncherSessionSnapshot snapshot,
Func<string, Task> stop,
Func<bool>? canStop = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(stop);
SessionId = snapshot.SessionId;
Target = snapshot.Kind == LauncherActivityKind.Probe
? $"{snapshot.ServerName} / {snapshot.AccountName} / character refresh"
: $"{snapshot.ServerName} / {snapshot.AccountName} / {snapshot.CharacterName}";
Mode = snapshot.Kind == LauncherActivityKind.Probe
? "Probe"
: snapshot.LaunchMode?.ToString() ?? "Session";
State = snapshot.State.ToString();
Status = snapshot.Status;
Error = snapshot.Error;
IsActive = snapshot.IsActive;
StopCommand = new AsyncRelayCommand(
() => stop(SessionId),
() => IsActive && (canStop?.Invoke() ?? true));
}
public string SessionId { get; }
public string Target { get; }
public string Mode { get; }
public string State { get; }
public string Status { get; }
public string? Error { get; }
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public bool IsActive { get; }
public AsyncRelayCommand StopCommand { get; }
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
}

View file

@ -0,0 +1,41 @@
namespace AcDream.Launcher.ViewModels;
public sealed class LauncherShellViewModel : ObservableObject
{
private bool _isOpen;
public LauncherShellViewModel(
string title,
string body,
string status,
Func<bool>? canOpen = null)
{
Title = title;
Body = body;
Status = status;
OpenCommand = new RelayCommand(() => IsOpen = true, canOpen);
CloseCommand = new RelayCommand(() => IsOpen = false);
}
public string Title { get; }
public string Body { get; }
public string Status { get; }
public bool IsOpen
{
get => _isOpen;
set => SetProperty(ref _isOpen, value);
}
public RelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
}
}

View file

@ -0,0 +1,90 @@
using System.Collections.ObjectModel;
using AcDream.Launcher.Core.Orchestration;
namespace AcDream.Launcher.ViewModels;
public enum LauncherTreeNodeKind
{
Server,
Account,
Character,
}
public sealed class LauncherTreeNodeViewModel
{
private LauncherTreeNodeViewModel(
LauncherTreeNodeKind kind,
string serverName,
string? accountName,
string? characterName,
string displayName,
string secondaryText)
{
Kind = kind;
ServerName = serverName;
AccountName = accountName;
CharacterName = characterName;
DisplayName = displayName;
SecondaryText = secondaryText;
}
public LauncherTreeNodeKind Kind { get; }
public string ServerName { get; }
public string? AccountName { get; }
public string? CharacterName { get; }
public string DisplayName { get; }
public string SecondaryText { get; }
public ObservableCollection<LauncherTreeNodeViewModel> Children { get; } = [];
public static LauncherTreeNodeViewModel FromServer(LauncherServerSnapshot server)
{
var node = new LauncherTreeNodeViewModel(
LauncherTreeNodeKind.Server,
server.Name,
accountName: null,
characterName: null,
server.Name,
$"{server.Host}:{server.Port}");
foreach (LauncherAccountSnapshot account in server.Accounts)
{
node.Children.Add(FromAccount(account));
}
return node;
}
private static LauncherTreeNodeViewModel FromAccount(LauncherAccountSnapshot account)
{
var node = new LauncherTreeNodeViewModel(
LauncherTreeNodeKind.Account,
account.ServerName,
account.AccountName,
characterName: null,
account.AccountName,
account.ActivityStatus);
foreach (LauncherCharacterSnapshot character in account.Characters)
{
node.Children.Add(FromCharacter(character));
}
return node;
}
private static LauncherTreeNodeViewModel FromCharacter(
LauncherCharacterSnapshot character) =>
new(
LauncherTreeNodeKind.Character,
character.ServerName,
character.AccountName,
character.Name,
character.Name,
character.HasRunningSession
? character.SessionStatus
: character.LaunchMode.ToString());
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace AcDream.Launcher.ViewModels;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

View file

@ -0,0 +1,196 @@
namespace AcDream.Launcher.ViewModels;
public enum ProfileEditorKind
{
AddServer,
EditServer,
AddAccount,
EditAccount,
AddCharacter,
EditCharacter,
Remove,
}
public sealed class ProfileEditorDialogViewModel : ObservableObject
{
private Action<ProfileEditorDialogViewModel>? _submit;
private bool _isOpen;
private string _title = string.Empty;
private string _message = string.Empty;
private string _name = string.Empty;
private string _host = string.Empty;
private string _port = "9000";
private string _password = string.Empty;
private string _characterId = string.Empty;
private string? _error;
private ProfileEditorKind _kind;
public ProfileEditorDialogViewModel()
{
SubmitCommand = new RelayCommand(Submit, () => IsOpen);
CancelCommand = new RelayCommand(Close, () => IsOpen);
}
public bool IsOpen
{
get => _isOpen;
private set
{
if (SetProperty(ref _isOpen, value))
{
SubmitCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
}
}
public ProfileEditorKind Kind
{
get => _kind;
private set
{
if (SetProperty(ref _kind, value))
{
OnPropertyChanged(nameof(IsServerEditor));
OnPropertyChanged(nameof(IsAccountEditor));
OnPropertyChanged(nameof(IsCharacterEditor));
OnPropertyChanged(nameof(IsRemoveConfirmation));
OnPropertyChanged(nameof(SubmitText));
}
}
}
public string Title
{
get => _title;
private set => SetProperty(ref _title, value);
}
public string Message
{
get => _message;
private set => SetProperty(ref _message, value);
}
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
public string Host
{
get => _host;
set => SetProperty(ref _host, value);
}
public string Port
{
get => _port;
set => SetProperty(ref _port, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public string CharacterId
{
get => _characterId;
set => SetProperty(ref _characterId, value);
}
public string? Error
{
get => _error;
private set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public bool IsServerEditor => Kind is ProfileEditorKind.AddServer or ProfileEditorKind.EditServer;
public bool IsAccountEditor => Kind is ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount;
public bool IsCharacterEditor => Kind is ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter;
public bool IsRemoveConfirmation => Kind == ProfileEditorKind.Remove;
public string SubmitText => IsRemoveConfirmation ? "Remove" : "Save";
public RelayCommand SubmitCommand { get; }
public RelayCommand CancelCommand { get; }
public void Open(
ProfileEditorKind kind,
string title,
Action<ProfileEditorDialogViewModel> submit,
string name = "",
string host = "",
int port = 9000,
string characterId = "",
string message = "")
{
ArgumentNullException.ThrowIfNull(submit);
Kind = kind;
Title = title;
Message = message;
Name = name;
Host = host;
Port = port.ToString(System.Globalization.CultureInfo.InvariantCulture);
Password = string.Empty;
CharacterId = characterId;
Error = null;
_submit = submit;
IsOpen = true;
}
public bool TryGetPort(out int port) =>
int.TryParse(
Port,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out port)
&& port is >= 1 and <= 65535;
public void Close()
{
Password = string.Empty;
Error = null;
_submit = null;
IsOpen = false;
}
private void Submit()
{
try
{
_submit?.Invoke(this);
Close();
}
catch (Exception ex)
{
string message = ex.Message;
if (!string.IsNullOrEmpty(Password))
{
message = message.Replace(
Password,
"[redacted]",
StringComparison.Ordinal);
}
Error = string.IsNullOrWhiteSpace(message)
? "The profile change could not be saved."
: message;
}
}
}

View file

@ -370,6 +370,41 @@ public sealed class SessionConfigComposerTests
}
}
[Fact]
public void ComposeProbeAndWriteWritesThePasswordFreeProbeDocument()
{
string root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-probe-writer-tests",
Guid.NewGuid().ToString("N"));
try
{
var paths = new ApplicationPathSet(
Path.Combine(root, "cfg"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
null);
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbeAndWrite(
Server(),
Account(),
Install,
paths,
"probe-write");
string json = File.ReadAllText(composed.ConfigFilePath);
Assert.Contains("\"mode\": \"probe\"", json, StringComparison.Ordinal);
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
Assert.DoesNotContain(Account().Password, json, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
private static JsonObject ParseRoot(ComposedSessionConfig composed)
{
string json = SessionConfigComposer.Serialize(composed.Document);

View file

@ -0,0 +1,143 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Orchestration;
public sealed class LauncherExecutableSetTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-layout-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void FromDirectoryResolvesThePublishedCoDeploymentLayout()
{
Directory.CreateDirectory(_root);
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
string graphical = Path.Combine(_root, "AcDream.App" + suffix);
string headless = Path.Combine(_root, "acdream-headless" + suffix);
File.WriteAllText(graphical, string.Empty);
File.WriteAllText(headless, string.Empty);
MakeExecutableOnLinux(graphical);
MakeExecutableOnLinux(headless);
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
Assert.Equal(Path.GetFullPath(_root), set.WorkingDirectory);
Assert.Equal(graphical, set.GraphicalHostPath);
Assert.Equal(headless, set.HeadlessHostPath);
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
Assert.Equal(
graphical,
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json").ExecutablePath);
Assert.Equal(
headless,
set.CreateProbeSpec("session.json").ExecutablePath);
}
[Fact]
public void MissingPublishedHostsHaveSpecificUnavailableReasons()
{
Directory.CreateDirectory(_root);
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
LauncherCapability probe = set.GetAvailability(LaunchMode.Headless);
Assert.False(gui.IsAvailable);
Assert.Contains("graphical client", gui.Reason, StringComparison.Ordinal);
Assert.Contains(set.GraphicalHostPath, gui.Reason, StringComparison.Ordinal);
Assert.False(probe.IsAvailable);
Assert.Contains("headless host", probe.Reason, StringComparison.Ordinal);
Assert.Contains(set.HeadlessHostPath, probe.Reason, StringComparison.Ordinal);
Assert.Throws<LauncherOperationException>(() =>
set.CreatePlaySpec(LaunchMode.Gui, "session.json"));
Assert.Throws<LauncherOperationException>(() =>
set.CreateProbeSpec("session.json"));
}
[Fact]
public void LinuxRequiresExecutePermissionForBothCoDeployedHosts()
{
if (!OperatingSystem.IsLinux())
{
return;
}
Directory.CreateDirectory(_root);
string graphical = Path.Combine(_root, "AcDream.App");
string headless = Path.Combine(_root, "acdream-headless");
File.WriteAllText(graphical, string.Empty);
File.WriteAllText(headless, string.Empty);
UnixFileMode notExecutable = UnixFileMode.UserRead | UnixFileMode.UserWrite
| UnixFileMode.GroupRead | UnixFileMode.OtherRead;
File.SetUnixFileMode(graphical, notExecutable);
File.SetUnixFileMode(headless, notExecutable);
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
LauncherCapability headlessCapability =
set.GetAvailability(LaunchMode.Headless);
Assert.False(gui.IsAvailable);
Assert.Contains("not executable", gui.Reason, StringComparison.Ordinal);
Assert.Contains("chmod +x", gui.Reason, StringComparison.Ordinal);
Assert.False(headlessCapability.IsAvailable);
Assert.Contains("not executable", headlessCapability.Reason, StringComparison.Ordinal);
Assert.Throws<LauncherOperationException>(() =>
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json"));
Assert.Throws<LauncherOperationException>(() =>
set.CreateProbeSpec("session.json"));
MakeExecutableOnLinux(graphical);
MakeExecutableOnLinux(headless);
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
}
[Fact]
public void WindowsPreservesExistenceOnlyAvailability()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var set = new LauncherExecutableSet(
"graphical.exe",
"headless.exe",
fileExists: _ => true,
hasUnixExecutePermission: _ => false);
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
}
private static void MakeExecutableOnLinux(string path)
{
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(
path,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute);
}
}
}

View file

@ -0,0 +1,730 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Status;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Orchestration;
public sealed class LauncherOrchestratorTests : IDisposable
{
private const string Password = "launcher-only-secret";
private readonly string _root;
private readonly ApplicationPathSet _paths;
public LauncherOrchestratorTests()
{
_root = Path.Combine(
Path.GetTempPath(),
"acdream-la4-orchestrator-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
LauncherStateSnapshot snapshot = orchestrator.GetSnapshot();
LauncherServerSnapshot server = Assert.Single(snapshot.Servers);
LauncherAccountSnapshot account = Assert.Single(server.Accounts);
LauncherCharacterSnapshot character = Assert.Single(account.Characters);
Assert.Equal("Local ACE", server.Name);
Assert.Equal("testaccount", account.AccountName);
Assert.Equal("+Acdream", character.Name);
string serialized = System.Text.Json.JsonSerializer.Serialize(snapshot);
Assert.DoesNotContain(Password, serialized, StringComparison.Ordinal);
Assert.DoesNotContain(
typeof(LauncherAccountSnapshot).GetProperties(),
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase)
|| property.Name.Contains("credential", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task LaunchComposesTheSelectedModeAndSpawnsThroughTheInjectedSeam()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
configService: config,
supervisorFactory: supervisors,
statusSourceFactory: statusSources);
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Gui);
Assert.Equal(LauncherActivityState.Running, launched.State);
Assert.Equal(1, config.PlayCallCount);
Assert.Equal(0, config.ProbeCallCount);
Assert.Equal(LaunchMode.Gui, config.LastCharacter!.LaunchMode);
Assert.Equal(["ExamplePlugin"], config.LastCharacter.Plugins);
Assert.Equal(["/vt start"], config.LastCharacter.LoginCommands);
Assert.Equal(string.Empty, config.LastAccountPassword);
FakeSupervisor supervisor = Assert.Single(supervisors.Created);
Assert.Equal("gui-host", supervisor.Spec!.ExecutablePath);
Assert.Equal(
["--session-config", config.LastComposed!.ConfigFilePath],
supervisor.Spec.Arguments);
Assert.Equal(Password, supervisor.PasswordWrittenToStdin);
Assert.DoesNotContain(
Password,
string.Join(' ', supervisor.Spec.Arguments),
StringComparison.Ordinal);
Assert.DoesNotContain(
Password,
SessionConfigComposer.Serialize(config.LastComposed.Document),
StringComparison.Ordinal);
QueueStatusSource source = Assert.Single(statusSources.Created);
source.Enqueue(Connected("s1"));
source.Enqueue(EnteredWorld("s1", "+Acdream"));
orchestrator.PollStatus();
LauncherSessionSnapshot inWorld = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.InWorld, inWorld.State);
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
}
[Fact]
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
configService: config,
supervisorFactory: supervisors);
Assert.True(orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.GuiSelect).IsAvailable);
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
characterName: null,
LaunchMode.GuiSelect);
Assert.Null(launched.CharacterName);
Assert.Equal(LaunchMode.GuiSelect, config.LastCharacter!.LaunchMode);
Assert.Equal(string.Empty, config.LastCharacter.Name);
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
Assert.Equal("gui-host", Assert.Single(supervisors.Created).Spec!.ExecutablePath);
}
[Theory]
[InlineData(LaunchMode.Gui)]
[InlineData(LaunchMode.Headless)]
public async Task AccountLaunchWithoutACharacterOnlyAcceptsGuiSelect(LaunchMode mode)
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
configService: config,
supervisorFactory: supervisors);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
characterName: null,
mode));
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task ProbeIsRefusedWhileTheAccountHasARunningLauncherActivity()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
LauncherCapability capability = orchestrator.GetProbeCapability(
"Local ACE",
"testaccount");
Assert.False(capability.IsAvailable);
Assert.Contains("Stop", capability.Reason, StringComparison.OrdinalIgnoreCase);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.ProbeAsync("Local ACE", "testaccount"));
}
[Fact]
public async Task LaunchIsRefusedWhileTheAccountProbeIsRunning()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
await orchestrator.ProbeAsync("Local ACE", "testaccount");
LauncherCapability capability = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless);
Assert.False(capability.IsAvailable);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless));
}
[Fact]
public async Task ConcurrentPlayReservationsAllowExactlyOneActivityPerAccount()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
Task<LauncherSessionSnapshot>[] attempts = Enumerable.Range(0, 2)
.Select(_ => Task.Run(async () => await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless)))
.ToArray();
try
{
await Task.WhenAll(attempts);
}
catch (LauncherOperationException)
{
// The losing reservation is the behavior under test.
}
Task<LauncherSessionSnapshot> successful = Assert.Single(
attempts,
attempt => attempt.Status == TaskStatus.RanToCompletion);
Task<LauncherSessionSnapshot> rejected = Assert.Single(attempts, attempt =>
attempt.Exception?.GetBaseException() is LauncherOperationException);
Assert.True(successful.IsCompletedSuccessfully);
Assert.True(rejected.IsFaulted);
Assert.Single(orchestrator.GetSnapshot().Sessions);
}
[Fact]
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
{
var config = new RecordingConfigService();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
configService: config,
statusSourceFactory: statusSources);
LauncherSessionSnapshot probe = await orchestrator.ProbeAsync(
"Local ACE",
"testaccount");
Assert.Equal(LauncherActivityKind.Probe, probe.Kind);
Assert.Equal(0, config.PlayCallCount);
Assert.Equal(1, config.ProbeCallCount);
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
Assert.Contains("\"mode\": \"probe\"", json, StringComparison.Ordinal);
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
QueueStatusSource source = Assert.Single(statusSources.Created);
source.Enqueue(new CharacterListStatusEvent
{
V = 1,
E = "characterList",
T = DateTimeOffset.UtcNow,
SessionId = "s1",
AccountName = "testaccount",
SlotCount = 6,
Characters =
[
new StatusCharacterEntry(0x5000000Au, "+Acdream", 0),
new StatusCharacterEntry(0x5000000Bu, "+Second", 0),
],
});
orchestrator.PollStatus();
LauncherAccountSnapshot account = Assert.Single(
Assert.Single(orchestrator.GetSnapshot().Servers).Accounts);
Assert.Equal(2, account.Characters.Count);
Assert.Contains(account.Characters, character => character.Name == "+Acdream");
Assert.Contains(account.Characters, character => character.Name == "+Second");
var reloaded = new LauncherProfileStore(
Path.Combine(_paths.ConfigDirectory, "launcher-profiles.json"));
Assert.True(reloaded.Load());
Assert.Equal(
2,
reloaded.Document.Servers.Single().Accounts.Single().Characters.Count);
}
[Fact]
public async Task LinuxRejectsBothGraphicalModesBeforeCompositionOrSpawnWithSliceLExplanation()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
platform: LinuxCapabilities(),
configService: config,
supervisorFactory: supervisors);
foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect })
{
LauncherOperationException exception = await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
mode));
Assert.Contains("Slice L", exception.Message, StringComparison.Ordinal);
Assert.Contains("parked at L1", exception.Message, StringComparison.Ordinal);
}
Assert.True(orchestrator.GetLaunchCapability(LaunchMode.Headless).IsAvailable);
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task MissingCoDeployedHostsDisableActionsBeforeCompositionOrSpawn()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
var executables = new LauncherExecutableSet(
"missing-gui",
"missing-headless",
fileExists: _ => false);
using LauncherOrchestrator orchestrator = CreateOrchestrator(
configService: config,
supervisorFactory: supervisors,
executables: executables);
LauncherCapability gui = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.GuiSelect);
LauncherCapability headless = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless);
LauncherCapability probe = orchestrator.GetProbeCapability(
"Local ACE",
"testaccount");
Assert.False(gui.IsAvailable);
Assert.Contains("missing-gui", gui.Reason, StringComparison.Ordinal);
Assert.False(headless.IsAvailable);
Assert.Contains("missing-headless", headless.Reason, StringComparison.Ordinal);
Assert.False(probe.IsAvailable);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless));
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task ProcessExitIsTerminalAndLateStatusCannotResurrectTheAccount()
{
var supervisors = new FakeSupervisorFactory();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
statusSourceFactory: statusSources);
await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
Assert.Single(supervisors.Created).Exit(23);
QueueStatusSource source = Assert.Single(statusSources.Created);
source.Enqueue(Connected("s1"));
source.Enqueue(EnteredWorld("s1", "+Acdream"));
source.Enqueue(Exited("s1", 23, "host crash detail"));
orchestrator.PollStatus();
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.Exited, session.State);
Assert.Equal(23, session.ExitCode);
Assert.Contains("host crash detail", session.Status, StringComparison.Ordinal);
Assert.True(orchestrator.GetProbeCapability("Local ACE", "testaccount").IsAvailable);
}
[Fact]
public async Task HostExitReasonSurvivesTheLaterProcessExitCallback()
{
var supervisors = new FakeSupervisorFactory();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
statusSourceFactory: statusSources);
await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
Assert.Single(statusSources.Created).Enqueue(
Exited("s1", 0, "graceful host shutdown"));
orchestrator.PollStatus();
Assert.Single(supervisors.Created).Exit(0);
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.Exited, session.State);
Assert.Contains("graceful host shutdown", session.Status, StringComparison.Ordinal);
Assert.True(orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless).IsAvailable);
}
[Fact]
public async Task StartFailureIsVisibleButRedactsTheCredentialEverywhere()
{
var supervisors = new FakeSupervisorFactory(
startExceptionFactory: password =>
new IOException($"simulated pipe failure containing {password}"));
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors);
LauncherOperationException exception = await Assert.ThrowsAsync<LauncherOperationException>(
() => orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless));
Assert.DoesNotContain(Password, exception.Message, StringComparison.Ordinal);
Assert.Contains("[redacted]", exception.Message, StringComparison.Ordinal);
LauncherSessionSnapshot failed = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.Failed, failed.State);
Assert.DoesNotContain(Password, failed.Error ?? string.Empty, StringComparison.Ordinal);
}
[Fact]
public async Task PreCancelledLaunchNeverComposesOrSpawnsAndEndsCancelled()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
configService: config,
supervisorFactory: supervisors);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless,
cancellation.Token));
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
Assert.Equal(
LauncherActivityState.Cancelled,
Assert.Single(orchestrator.GetSnapshot().Sessions).State);
}
[Fact]
public async Task StopRunsThroughTheSupervisorOffThreadAndMakesTheAccountProbeableAgain()
{
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors);
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
await orchestrator.StopSessionAsync(
launched.SessionId,
TimeSpan.FromMilliseconds(10));
FakeSupervisor supervisor = Assert.Single(supervisors.Created);
Assert.Equal(1, supervisor.StopCallCount);
Assert.Equal(
LauncherActivityState.Exited,
Assert.Single(orchestrator.GetSnapshot().Sessions).State);
Assert.True(orchestrator.GetProbeCapability(
"Local ACE",
"testaccount").IsAvailable);
}
private LauncherOrchestrator CreateOrchestrator(
bool includeCharacter = true,
LauncherPlatformCapabilities? platform = null,
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
LauncherExecutableSet? executables = null)
{
string profilePath = Path.Combine(
_paths.ConfigDirectory,
"launcher-profiles.json");
var store = new LauncherProfileStore(profilePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", Password);
if (includeCharacter)
{
store.AddCharacter(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.GuiSelect,
["ExamplePlugin"],
["/vt start"]);
}
store.Save();
int nextSession = 0;
var orchestrator = new LauncherOrchestrator(
store,
_paths,
executables ?? new LauncherExecutableSet(
"gui-host",
"headless-host",
fileExists: _ => true,
hasUnixExecutePermission: _ => true),
new LauncherInstallRecord("dats", "pak"),
platform ?? WindowsCapabilities(),
configService,
supervisorFactory ?? new FakeSupervisorFactory(),
statusSourceFactory ?? new QueueStatusSourceFactory(),
() => $"s{Interlocked.Increment(ref nextSession)}");
orchestrator.LoadProfiles();
return orchestrator;
}
private static LauncherPlatformCapabilities WindowsCapabilities() =>
new(true, false, true, true, "Windows", null);
private static LauncherPlatformCapabilities LinuxCapabilities() =>
new(
false,
true,
true,
false,
"Linux",
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason);
private static ConnectedStatusEvent Connected(string sessionId) =>
new()
{
V = 1,
E = "connected",
T = DateTimeOffset.UtcNow,
SessionId = sessionId,
};
private static EnteredWorldStatusEvent EnteredWorld(
string sessionId,
string characterName) =>
new()
{
V = 1,
E = "enteredWorld",
T = DateTimeOffset.UtcNow,
SessionId = sessionId,
CharacterId = 0x5000000Au,
CharacterName = characterName,
};
private static ExitedStatusEvent Exited(
string sessionId,
int code,
string reason) =>
new()
{
V = 1,
E = "exited",
T = DateTimeOffset.UtcNow,
SessionId = sessionId,
Code = code,
Reason = reason,
};
private sealed class RecordingConfigService : ILauncherSessionConfigService
{
public int PlayCallCount { get; private set; }
public int ProbeCallCount { get; private set; }
public CharacterProfile? LastCharacter { get; private set; }
public string? LastAccountPassword { get; private set; }
public ComposedSessionConfig? LastComposed { get; private set; }
public ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null)
{
PlayCallCount++;
LastCharacter = character;
LastAccountPassword = account.Password;
LastComposed = SessionConfigComposer.Compose(
server,
account,
character,
install,
paths,
sessionId,
loginCommandDelayMs);
return LastComposed;
}
public ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId)
{
ProbeCallCount++;
LastAccountPassword = account.Password;
LastComposed = SessionConfigComposer.ComposeProbe(
server,
account,
install,
paths,
sessionId);
return LastComposed;
}
}
private sealed class FakeSupervisorFactory(
Func<string?, Exception>? startExceptionFactory = null)
: ILauncherProcessSupervisorFactory
{
public List<FakeSupervisor> Created { get; } = [];
public ILauncherProcessSupervisor Create()
{
var supervisor = new FakeSupervisor(startExceptionFactory);
Created.Add(supervisor);
return supervisor;
}
}
private sealed class FakeSupervisor(Func<string?, Exception>? startExceptionFactory)
: ILauncherProcessSupervisor
{
public LauncherSessionState State { get; private set; } =
LauncherSessionState.Starting;
public int? ExitCode { get; private set; }
public LauncherProcessSpec? Spec { get; private set; }
public string? PasswordWrittenToStdin { get; private set; }
public int StopCallCount { get; private set; }
public event EventHandler<LauncherSessionState>? StateChanged;
public void Start(LauncherProcessSpec spec, string? password)
{
Spec = spec;
PasswordWrittenToStdin = password;
if (startExceptionFactory is not null)
{
throw startExceptionFactory(password);
}
State = LauncherSessionState.Running;
StateChanged?.Invoke(this, State);
}
public void Stop(TimeSpan timeout)
{
StopCallCount++;
State = LauncherSessionState.Exited;
ExitCode = 0;
StateChanged?.Invoke(this, State);
}
public void Exit(int code)
{
State = LauncherSessionState.Exited;
ExitCode = code;
StateChanged?.Invoke(this, State);
}
public void Dispose()
{
}
}
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
{
public List<QueueStatusSource> Created { get; } = [];
public IStatusEventSource Create(string path)
{
var source = new QueueStatusSource();
Created.Add(source);
return source;
}
}
private sealed class QueueStatusSource : IStatusEventSource
{
private readonly Queue<StatusEvent> _events = [];
public void Enqueue(StatusEvent statusEvent) => _events.Enqueue(statusEvent);
public IReadOnlyList<StatusEvent> ReadNewEvents()
{
var result = new List<StatusEvent>();
while (_events.TryDequeue(out StatusEvent? statusEvent))
{
if (statusEvent is not null)
{
result.Add(statusEvent);
}
}
return result;
}
}
}

View file

@ -0,0 +1,171 @@
using System.Text.Json;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
public sealed class LauncherProfileHardeningTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-profile-hardening-tests",
Guid.NewGuid().ToString("N"));
private readonly string _filePath;
public LauncherProfileHardeningTests()
{
Directory.CreateDirectory(_root);
_filePath = Path.Combine(_root, "launcher-profiles.json");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void MultiFieldEditValidatesEverythingBeforeChangingAnything()
{
LauncherProfileStore store = CreatePopulatedStore();
Assert.Throws<LauncherProfileException>(() =>
store.EditServer(
"Local ACE",
newName: "Partially renamed",
newHost: "changed.example.test",
newPort: 0));
ServerProfile server = Assert.Single(store.Document.Servers);
Assert.Equal("Local ACE", server.Name);
Assert.Equal("127.0.0.1", server.Host);
Assert.Equal(9000, server.Port);
Assert.Throws<LauncherProfileException>(() =>
store.EditCharacter(
"Local ACE",
"testaccount",
"+Acdream",
newName: "+PartiallyRenamed",
newId: "not-an-id"));
CharacterProfile character = Assert.Single(server.Accounts.Single().Characters);
Assert.Equal("+Acdream", character.Name);
Assert.Equal("0x5000000A", character.Id);
}
[Fact]
public void TransactionRestoresTheExactDocumentWhenPersistenceFails()
{
Directory.CreateDirectory(_filePath);
var store = new LauncherProfileStore(_filePath);
store.Load();
Assert.ThrowsAny<Exception>(() =>
store.ExecuteTransaction(() =>
store.AddServer("Should roll back", "host", 9000)));
Assert.Empty(store.Document.Servers);
Assert.False(File.Exists(_filePath + ".tmp"));
}
[Fact]
public void TransactionRestoresNestedCredentialAndSettingsOnMutationFailure()
{
LauncherProfileStore store = CreatePopulatedStore();
string before = JsonSerializer.Serialize(store.Document);
Assert.Throws<InvalidOperationException>(() =>
store.ExecuteTransaction(() =>
{
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
account.Password = "transient-secret";
account.Characters.Single().Plugins.Add("Transient.Plugin");
throw new InvalidOperationException("simulated mutation failure");
}));
Assert.Equal(before, JsonSerializer.Serialize(store.Document));
}
public static TheoryData<string> InvalidDocuments => new()
{
{ """{"version":1,"servers":null}""" },
{ """{"version":1,"servers":[null]}""" },
{ """{"version":1,"servers":[{"name":" ","host":"h","port":9000,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":" ","port":9000,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":0,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[]},{"name":"s","host":"h2","port":9001,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[]},{"account":"a","password":"p2","characters":[]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":null,"characters":[]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":null}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x00000000","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"invalid","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":null,"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":["P","P"],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[" "]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c","id":"0x50000002","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c1","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c2","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
};
[Theory]
[MemberData(nameof(InvalidDocuments))]
public void LoadRejectsSemanticallyInvalidDocuments(string json)
{
File.WriteAllText(_filePath, json);
var store = new LauncherProfileStore(_filePath);
Assert.Throws<LauncherProfileException>(() => store.Load());
}
[Fact]
public void FailedLoadDoesNotReplaceAnAlreadyLoadedDocument()
{
LauncherProfileStore store = CreatePopulatedStore();
File.WriteAllText(_filePath, """{"version":1,"servers":null}""");
Assert.Throws<LauncherProfileException>(() => store.Load());
Assert.Equal("Local ACE", Assert.Single(store.Document.Servers).Name);
}
[Fact]
public void LinuxLoadNormalizesAnExistingCredentialFileTo0600BeforeReading()
{
if (!OperatingSystem.IsLinux())
{
return;
}
File.WriteAllText(_filePath, """{"version":1,"servers":[]}""");
File.SetUnixFileMode(
_filePath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.GroupRead
| UnixFileMode.OtherRead);
var store = new LauncherProfileStore(_filePath);
Assert.True(store.Load());
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite,
File.GetUnixFileMode(_filePath));
}
private LauncherProfileStore CreatePopulatedStore()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "password");
store.AddCharacter(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.Headless,
["ExamplePlugin"],
["/vt start"]);
store.Save();
return store;
}
}

View file

@ -187,6 +187,60 @@ public sealed class LauncherProfileStoreTests : IDisposable
Assert.Equal("0x5000000A", character.Id);
}
[Fact]
public void AddEditAndRemoveCachedCharacterRoundTripsThroughTheCrudSurface()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "pw");
store.AddCharacter(
"Local ACE",
"testaccount",
"+Manual",
"0x5000000a");
CharacterProfile character = Assert.Single(
store.Document.Servers.Single().Accounts.Single().Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal(LaunchMode.GuiSelect, character.LaunchMode);
store.EditCharacter(
"Local ACE",
"testaccount",
"+Manual",
newName: "+Renamed",
newId: "",
launchMode: LaunchMode.Headless);
character = Assert.Single(
store.Document.Servers.Single().Accounts.Single().Characters);
Assert.Equal("+Renamed", character.Name);
Assert.Null(character.Id);
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
store.RemoveCharacter("Local ACE", "testaccount", "+Renamed");
Assert.Empty(store.Document.Servers.Single().Accounts.Single().Characters);
}
[Theory]
[InlineData("5000000A")]
[InlineData("0x00000000")]
[InlineData("not-an-id")]
public void AddCharacterRejectsAnAmbiguousOrInvalidId(string id)
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "pw");
Assert.Throws<LauncherProfileException>(() =>
store.AddCharacter(
"Local ACE",
"testaccount",
"+Manual",
id));
}
[Fact]
public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk()
{

View file

@ -152,6 +152,66 @@ public sealed class RosterMergeTests
Assert.Equal("+Acdream", character.Name);
}
[Fact]
public void SameNameRosterEntryCorrectsAWrongValidIdAndPreservesSettings()
{
LauncherProfileStore store = NewStoreWithServerAndAccount();
store.AddCharacter(
"Local ACE",
"testaccount",
"+Acdream",
"0x50000001",
LaunchMode.Headless,
["ExamplePlugin"],
["/vt start"]);
store.MergeRoster(
"Local ACE",
"testaccount",
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
CharacterProfile character = Assert.Single(
store.Document.Servers.Single().Accounts.Single().Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
Assert.Equal(["ExamplePlugin"], character.Plugins);
Assert.Equal(["/vt start"], character.LoginCommands);
}
[Fact]
public void AuthoritativeMergeCollapsesCorrectIdAndSameNameDuplicates()
{
LauncherProfileStore store = NewStoreWithServerAndAccount();
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
account.Characters.Add(new CharacterProfile
{
Id = "0x5000000A",
Name = "+OldName",
LaunchMode = LaunchMode.Headless,
Plugins = ["Canonical.Plugin"],
LoginCommands = ["/canonical"],
});
account.Characters.Add(new CharacterProfile
{
Id = "0x50000001",
Name = "+Acdream",
LaunchMode = LaunchMode.Gui,
Plugins = ["Duplicate.Plugin"],
LoginCommands = ["/duplicate"],
});
store.MergeRoster(
"Local ACE",
"testaccount",
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
CharacterProfile character = Assert.Single(account.Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal("+Acdream", character.Name);
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
Assert.Equal(["Canonical.Plugin"], character.Plugins);
}
[Fact]
public void MergeThrowsForUnknownServerOrAccount()
{

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher\AcDream.Launcher.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,172 @@
using System.Diagnostics;
using System.Xml.Linq;
namespace AcDream.Launcher.Tests;
public sealed class LauncherProjectBoundaryTests
{
private static readonly string[] ExpectedAvaloniaPackages =
[
"Avalonia",
"Avalonia.Desktop",
"Avalonia.Themes.Fluent",
];
[Fact]
public void LauncherReferencesOnlyLauncherCoreAndPinsOneAvaloniaVersion()
{
string root = FindRepositoryRoot();
string projectPath = Path.Combine(
root,
"src",
"AcDream.Launcher",
"AcDream.Launcher.csproj");
XDocument project = XDocument.Load(projectPath);
string projectReference = Assert.Single(
project.Descendants("ProjectReference")
.Select(element => element.Attribute("Include")?.Value ?? string.Empty));
Assert.EndsWith(
"AcDream.Launcher.Core\\AcDream.Launcher.Core.csproj",
projectReference,
StringComparison.Ordinal);
(string Name, string Version)[] packages = project
.Descendants("PackageReference")
.Select(element => (
element.Attribute("Include")?.Value ?? string.Empty,
element.Attribute("Version")?.Value ?? string.Empty))
.OrderBy(package => package.Item1, StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedAvaloniaPackages, packages.Select(package => package.Name));
Assert.All(packages, package => Assert.Equal("12.1.1", package.Version));
}
[Fact]
public void LauncherAndItsTestsAreSolutionMembers()
{
string root = FindRepositoryRoot();
XDocument solution = XDocument.Load(Path.Combine(root, "AcDream.slnx"));
string[] paths = solution.Descendants("Project")
.Select(element => (element.Attribute("Path")?.Value ?? string.Empty)
.Replace('\\', '/'))
.ToArray();
Assert.Contains("src/AcDream.Launcher/AcDream.Launcher.csproj", paths);
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", paths);
}
[Fact]
public void LinuxPublishEvaluatesAsSelfContainedSingleFile()
{
string root = FindRepositoryRoot();
string projectPath = Path.Combine(
root,
"src",
"AcDream.Launcher",
"AcDream.Launcher.csproj");
Assert.Equal("true", EvaluateProperty(projectPath, "SelfContained"));
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
}
[Fact]
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
{
string root = FindRepositoryRoot();
string markup = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.Launcher",
"MainWindow.axaml"));
string codeBehind = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.Launcher",
"MainWindow.axaml.cs"));
Assert.Equal(3, Count(markup, "KeyboardNavigation.TabNavigation=\"Cycle\""));
Assert.Equal(3, Count(markup, "KeyDown=\"OnModalKeyDown\""));
Assert.True(Count(markup, "AutomationProperties.Name=") >= 13);
Assert.True(Count(markup, "IsDefault=\"True\"") >= 3);
Assert.True(Count(markup, "IsCancel=\"True\"") >= 3);
Assert.Contains("ServerNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("AccountNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("CharacterNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("FocusActiveModal", codeBehind, StringComparison.Ordinal);
Assert.Contains("_focusBeforeModal", codeBehind, StringComparison.Ordinal);
Assert.Contains("Key.Escape", codeBehind, StringComparison.Ordinal);
}
[Fact]
public void PortabilityWorkflowBuildsTestsPublishesAndExecutesTheLauncher()
{
string workflow = File.ReadAllText(Path.Combine(
FindRepositoryRoot(),
".github",
"workflows",
"headless-portability.yml"));
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
Assert.Contains(
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
workflow,
StringComparison.Ordinal);
Assert.Contains("test -x \"$root/AcDream.App\"", workflow, StringComparison.Ordinal);
}
private static string EvaluateProperty(string projectPath, string property)
{
var startInfo = new ProcessStartInfo("dotnet")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add("msbuild");
startInfo.ArgumentList.Add(projectPath);
startInfo.ArgumentList.Add("-nologo");
startInfo.ArgumentList.Add("-property:RuntimeIdentifier=linux-x64");
startInfo.ArgumentList.Add($"-getProperty:{property}");
using Process process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start dotnet msbuild.");
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "dotnet msbuild did not exit.");
Assert.True(
process.ExitCode == 0,
$"dotnet msbuild exited {process.ExitCode}: {error}");
return output.Trim();
}
private static int Count(string text, string value) =>
text.Split(value, StringSplitOptions.None).Length - 1;
private static string FindRepositoryRoot()
{
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -0,0 +1,592 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
public sealed class LauncherWindowViewModelTests
{
[Fact]
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher());
viewModel.Initialize();
Assert.True(orchestrator.LoadCalled);
LauncherTreeNodeViewModel server = Assert.Single(viewModel.Servers);
Assert.Equal(LauncherTreeNodeKind.Server, server.Kind);
LauncherTreeNodeViewModel account = Assert.Single(server.Children);
Assert.Equal(LauncherTreeNodeKind.Account, account.Kind);
LauncherTreeNodeViewModel character = Assert.Single(account.Children);
Assert.Equal("+Acdream", character.DisplayName);
Assert.Same(server, viewModel.SelectedNode);
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
Assert.Equal("Gui", session.Mode);
Assert.Equal("Connected", session.State);
Assert.True(session.IsActive);
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
}
[Fact]
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
viewModel.AddServerCommand.Execute(null);
Assert.Equal(ProfileEditorKind.AddServer, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.Name = "Remote ACE";
viewModel.EditorDialog.Host = "ace.example.test";
viewModel.EditorDialog.Port = "9001";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(("Remote ACE", "ace.example.test", 9001), orchestrator.AddedServer);
SelectServer(viewModel);
viewModel.AddAccountCommand.Execute(null);
Assert.Equal(ProfileEditorKind.AddAccount, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.Name = "second-account";
viewModel.EditorDialog.Password = "one-use-secret";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "second-account", "one-use-secret"),
orchestrator.AddedAccount);
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
SelectAccount(viewModel);
viewModel.AddCharacterCommand.Execute(null);
Assert.Equal(ProfileEditorKind.AddCharacter, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.Name = "+Second";
viewModel.EditorDialog.CharacterId = "0x5000000B";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "testaccount", "+Second", "0x5000000B"),
orchestrator.AddedCharacter);
SelectCharacter(viewModel);
viewModel.EditSelectedCommand.Execute(null);
Assert.Equal(ProfileEditorKind.EditCharacter, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.Name = "+Renamed";
viewModel.EditorDialog.CharacterId = "0x5000000C";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "testaccount", "+Acdream", "+Renamed", "0x5000000C"),
orchestrator.EditedCharacter);
SelectCharacter(viewModel);
viewModel.RemoveSelectedCommand.Execute(null);
Assert.Equal(ProfileEditorKind.Remove, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "testaccount", "+Acdream"),
orchestrator.RemovedCharacter);
}
[Fact]
public void ServerAndAccountEditRemoveDialogsRouteEveryMutationThroughCore()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
SelectServer(viewModel);
viewModel.EditSelectedCommand.Execute(null);
Assert.Equal(ProfileEditorKind.EditServer, viewModel.EditorDialog.Kind);
Assert.Equal("127.0.0.1", viewModel.EditorDialog.Host);
viewModel.EditorDialog.Name = "Renamed ACE";
viewModel.EditorDialog.Host = "renamed.example.test";
viewModel.EditorDialog.Port = "9010";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "Renamed ACE", "renamed.example.test", 9010),
orchestrator.EditedServer);
SelectAccount(viewModel);
viewModel.EditSelectedCommand.Execute(null);
Assert.Equal(ProfileEditorKind.EditAccount, viewModel.EditorDialog.Kind);
viewModel.EditorDialog.Name = "renamed-account";
viewModel.EditorDialog.Password = "replacement-secret";
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(
("Local ACE", "testaccount", "renamed-account", "replacement-secret"),
orchestrator.EditedAccount);
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
SelectAccount(viewModel);
viewModel.RemoveSelectedCommand.Execute(null);
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal(("Local ACE", "testaccount"), orchestrator.RemovedAccount);
SelectServer(viewModel);
viewModel.RemoveSelectedCommand.Execute(null);
viewModel.EditorDialog.SubmitCommand.Execute(null);
Assert.Equal("Local ACE", orchestrator.RemovedServer);
}
[Fact]
public async Task CharacterSettingsAndLaunchActionsPreserveTheirTypedSemantics()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
SelectCharacter(viewModel);
viewModel.CharacterLaunchMode = LaunchMode.Headless;
viewModel.CharacterPluginsText = "Plugin.One\nPlugin.Two\nPlugin.One";
viewModel.CharacterLoginCommandsText = " /tell someone, hi \n/vt start\n/tell someone, hi";
viewModel.SaveCharacterSettingsCommand.Execute(null);
Assert.NotNull(orchestrator.SettingsUpdate);
Assert.Equal(LaunchMode.Headless, orchestrator.SettingsUpdate.Value.Mode);
Assert.Equal(["Plugin.One", "Plugin.Two"], orchestrator.SettingsUpdate.Value.Plugins);
Assert.Equal(
["/tell someone, hi", "/vt start", "/tell someone, hi"],
orchestrator.SettingsUpdate.Value.Commands);
await viewModel.LaunchHeadlessCommand.ExecuteAsync();
Assert.Equal(
("Local ACE", "testaccount", "+Acdream", LaunchMode.Headless),
orchestrator.LaunchRequest);
Assert.Equal("Headless session started for +Acdream.", viewModel.OperationStatus);
}
[Fact]
public async Task AccountGuiSelectWorksWithoutAnyCachedCharacter()
{
using var orchestrator = new FakeLauncherOrchestrator
{
IncludeCharacter = false,
};
using var viewModel = CreateInitialized(orchestrator);
SelectAccount(viewModel);
Assert.Empty(viewModel.SelectedNode!.Children);
Assert.True(viewModel.CanLaunchAccountGuiSelect);
await viewModel.LaunchAccountGuiSelectCommand.ExecuteAsync();
Assert.Equal(
("Local ACE", "testaccount", (string?)null, LaunchMode.GuiSelect),
orchestrator.LaunchRequest);
Assert.Equal(
"Character-select session started for testaccount.",
viewModel.OperationStatus);
}
[Fact]
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
SelectAccount(viewModel);
Assert.True(viewModel.CanProbe);
await viewModel.RefreshCharactersCommand.ExecuteAsync();
Assert.Equal(("Local ACE", "testaccount"), orchestrator.ProbeRequest);
orchestrator.ProbeCapability = LauncherCapability.Unavailable(
"Stop the active session before refreshing this account.");
orchestrator.RaiseStateChanged();
SelectAccount(viewModel);
Assert.False(viewModel.CanProbe);
Assert.Contains("Stop", viewModel.ProbeDisabledReason, StringComparison.Ordinal);
}
[Fact]
public void LinuxKeepsLauncherAndHeadlessAvailableButExplainsDisabledGuiModes()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Platform = LinuxPlatform(),
};
using var viewModel = CreateInitialized(orchestrator);
SelectCharacter(viewModel);
Assert.True(viewModel.ShowLinuxGraphicalNotice);
Assert.False(viewModel.CanLaunchGui);
Assert.False(viewModel.CanLaunchGuiSelect);
Assert.True(viewModel.CanLaunchHeadless);
Assert.Equal(
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason,
viewModel.LinuxGraphicalNotice);
Assert.Contains("Slice L", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
Assert.Contains("parked at L1", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
}
[Fact]
public void MissingCoDeployedHostReasonIsVisibleForCharacterActions()
{
using var orchestrator = new FakeLauncherOrchestrator
{
AccountLaunchCapability = LauncherCapability.Unavailable(
"The co-deployed host is missing; reinstall or update the client."),
};
using var viewModel = CreateInitialized(orchestrator);
SelectCharacter(viewModel);
Assert.False(viewModel.CanLaunchGui);
Assert.False(viewModel.CanLaunchHeadless);
Assert.True(viewModel.ShowGuiLaunchDisabledReason);
Assert.True(viewModel.ShowHeadlessLaunchDisabledReason);
Assert.Contains("missing", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
Assert.Contains("missing", viewModel.HeadlessLaunchDisabledReason, StringComparison.Ordinal);
}
[Fact]
public async Task LaunchErrorAndCancellationBecomeSafeVisibleState()
{
using var orchestrator = new FakeLauncherOrchestrator
{
LaunchHandler = _ => throw new LauncherOperationException("spawn failed safely"),
};
using var viewModel = CreateInitialized(orchestrator);
SelectCharacter(viewModel);
await viewModel.LaunchGuiCommand.ExecuteAsync();
Assert.Equal("spawn failed safely", viewModel.LastError);
Assert.Equal("Operation failed.", viewModel.OperationStatus);
Assert.False(viewModel.IsBusy);
orchestrator.LaunchHandler = async cancellationToken =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return FakeLauncherOrchestrator.CreateSession();
};
Task launch = viewModel.LaunchGuiCommand.ExecuteAsync();
Assert.True(viewModel.IsBusy);
Assert.True(viewModel.CancelOperationCommand.CanExecute(null));
viewModel.CancelOperationCommand.Execute(null);
await launch;
Assert.Equal("Operation cancelled.", viewModel.OperationStatus);
Assert.False(viewModel.HasError);
Assert.False(viewModel.IsBusy);
}
[Fact]
public void ProfileDialogRedactsARejectedCredentialAndClearsItOnClose()
{
var dialog = new ProfileEditorDialogViewModel();
dialog.Open(
ProfileEditorKind.AddAccount,
"Add account",
candidate => throw new InvalidOperationException(
$"rejected {candidate.Password}"));
dialog.Password = "do-not-display";
dialog.SubmitCommand.Execute(null);
Assert.True(dialog.IsOpen);
Assert.DoesNotContain("do-not-display", dialog.Error ?? string.Empty, StringComparison.Ordinal);
Assert.Contains("[redacted]", dialog.Error ?? string.Empty, StringComparison.Ordinal);
dialog.CancelCommand.Execute(null);
Assert.Equal(string.Empty, dialog.Password);
Assert.False(dialog.IsOpen);
}
[Fact]
public void ModalShellsBlockBackgroundCommandsAndAreMutuallyExclusive()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
Assert.True(viewModel.AddServerCommand.CanExecute(null));
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.IsModalOpen);
Assert.False(viewModel.AddServerCommand.CanExecute(null));
Assert.False(viewModel.UpdatePromptShell.OpenCommand.CanExecute(null));
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
// ICommand.Execute cannot bypass the modal gate.
viewModel.AddServerCommand.Execute(null);
Assert.False(viewModel.EditorDialog.IsOpen);
Assert.Null(orchestrator.AddedServer);
viewModel.CloseActiveModal();
Assert.False(viewModel.IsModalOpen);
Assert.True(viewModel.AddServerCommand.CanExecute(null));
viewModel.AddServerCommand.Execute(null);
Assert.True(viewModel.EditorDialog.IsOpen);
Assert.False(viewModel.FirstRunWizardShell.OpenCommand.CanExecute(null));
Assert.False(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
viewModel.CloseActiveModal();
Assert.False(viewModel.EditorDialog.IsOpen);
}
[Fact]
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
await session.StopCommand.ExecuteAsync();
Assert.Equal("session-1", orchestrator.StoppedSessionId);
orchestrator.Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly.");
orchestrator.RaiseStateChanged();
Assert.True(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
viewModel.ClearFinishedSessionsCommand.Execute(null);
Assert.True(orchestrator.ClearCalled);
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator)
{
var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher());
viewModel.Initialize();
return viewModel;
}
private static void SelectServer(LauncherWindowViewModel viewModel) =>
viewModel.SelectedNode = Assert.Single(viewModel.Servers);
private static void SelectAccount(LauncherWindowViewModel viewModel) =>
viewModel.SelectedNode = Assert.Single(Assert.Single(viewModel.Servers).Children);
private static void SelectCharacter(LauncherWindowViewModel viewModel) =>
viewModel.SelectedNode = Assert.Single(
Assert.Single(Assert.Single(viewModel.Servers).Children).Children);
private static LauncherPlatformCapabilities LinuxPlatform() => new(
IsWindows: false,
IsLinux: true,
CanRunHeadless: true,
CanLaunchGraphicalClient: false,
PlatformName: "Linux",
GraphicalLaunchDisabledReason:
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason);
private sealed class FakeLauncherOrchestrator : ILauncherOrchestrator
{
public event EventHandler? StateChanged;
public bool LoadCalled { get; private set; }
public bool ClearCalled { get; private set; }
public LauncherPlatformCapabilities Platform { get; init; } = new(
IsWindows: true,
IsLinux: false,
CanRunHeadless: true,
CanLaunchGraphicalClient: true,
PlatformName: "Windows",
GraphicalLaunchDisabledReason: null);
public LauncherCapability ProbeCapability { get; set; } = LauncherCapability.Available;
public LauncherCapability? AccountLaunchCapability { get; set; }
public bool IncludeCharacter { get; init; } = true;
public LauncherSessionSnapshot Session { get; set; } = CreateSession();
public Func<CancellationToken, Task<LauncherSessionSnapshot>>? LaunchHandler { get; set; }
public (string Name, string Host, int Port)? AddedServer { get; private set; }
public (string Name, string NewName, string NewHost, int NewPort)? EditedServer { get; private set; }
public string? RemovedServer { get; private set; }
public (string Server, string Account, string Password)? AddedAccount { get; private set; }
public (string Server, string Account, string NewAccount, string? Password)? EditedAccount { get; private set; }
public (string Server, string Account)? RemovedAccount { get; private set; }
public (string Server, string Account, string Character, string? Id)? AddedCharacter { get; private set; }
public (string Server, string Account, string Character, string NewName, string? Id)? EditedCharacter { get; private set; }
public (string Server, string Account, string Character)? RemovedCharacter { get; private set; }
public (LaunchMode Mode, IReadOnlyList<string> Plugins, IReadOnlyList<string> Commands)? SettingsUpdate { get; private set; }
public (string Server, string Account, string? Character, LaunchMode Mode)? LaunchRequest { get; private set; }
public (string Server, string Account)? ProbeRequest { get; private set; }
public string? StoppedSessionId { get; private set; }
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
[CreateServerSnapshot()],
[Session],
Platform,
IsInstallationReady: false,
InstallationStatus: "No installed client is configured.");
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
public LauncherCapability GetAccountLaunchCapability(
string serverName,
string accountName,
LaunchMode mode) =>
AccountLaunchCapability ?? GetLaunchCapability(mode);
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
ProbeCapability;
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
}
public void AddServer(string name, string host, int port) =>
AddedServer = (name, host, port);
public void EditServer(string name, string newName, string newHost, int newPort) =>
EditedServer = (name, newName, newHost, newPort);
public void RemoveServer(string name) => RemovedServer = name;
public void AddAccount(string serverName, string accountName, string password) =>
AddedAccount = (serverName, accountName, password);
public void EditAccount(
string serverName,
string accountName,
string newAccountName,
string? newPassword) =>
EditedAccount = (serverName, accountName, newAccountName, newPassword);
public void RemoveAccount(string serverName, string accountName) =>
RemovedAccount = (serverName, accountName);
public void AddCharacter(
string serverName,
string accountName,
string characterName,
string? characterId) =>
AddedCharacter = (serverName, accountName, characterName, characterId);
public void EditCharacterIdentity(
string serverName,
string accountName,
string characterName,
string newCharacterName,
string? newCharacterId) =>
EditedCharacter = (
serverName,
accountName,
characterName,
newCharacterName,
newCharacterId);
public void UpdateCharacterSettings(
string serverName,
string accountName,
string characterName,
LaunchMode launchMode,
IReadOnlyList<string> plugins,
IReadOnlyList<string> loginCommands) =>
SettingsUpdate = (launchMode, plugins, loginCommands);
public void RemoveCharacter(
string serverName,
string accountName,
string characterName) =>
RemovedCharacter = (serverName, accountName, characterName);
public Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default)
{
LaunchRequest = (serverName, accountName, characterName, mode);
return LaunchHandler?.Invoke(cancellationToken) ?? Task.FromResult(Session);
}
public Task<LauncherSessionSnapshot> ProbeAsync(
string serverName,
string accountName,
CancellationToken cancellationToken = default)
{
ProbeRequest = (serverName, accountName);
return Task.FromResult(Session with { Kind = LauncherActivityKind.Probe });
}
public Task StopSessionAsync(
string sessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
StoppedSessionId = sessionId;
return Task.CompletedTask;
}
public void PollStatus()
{
}
public void ClearFinishedSessions() => ClearCalled = true;
public void Dispose()
{
}
public void RaiseStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty);
public static LauncherSessionSnapshot CreateSession(
LauncherActivityState state = LauncherActivityState.Connected,
string status = "Connected.") => new(
"session-1",
LauncherActivityKind.Play,
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Gui,
state,
status,
ExitCode: state == LauncherActivityState.Exited ? 0 : null,
Error: null,
CreatedAt: DateTimeOffset.UnixEpoch);
private LauncherServerSnapshot CreateServerSnapshot() => new(
"Local ACE",
"127.0.0.1",
9000,
[
new LauncherAccountSnapshot(
"Local ACE",
"testaccount",
IncludeCharacter
?
[
new LauncherCharacterSnapshot(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.GuiSelect,
["Existing.Plugin"],
["/tell someone, ready"],
HasRunningSession: true,
SessionStatus: "Connected."),
]
: [],
HasRunningActivity: true,
ActivityStatus: "Connected."),
]);
}
}