feat(launcher): Campaign LA add Avalonia desktop shell
This commit is contained in:
parent
6c4cd2bbc6
commit
d0a9c65d85
31 changed files with 4831 additions and 16 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||||
<Project Path="src/AcDream.Headless/AcDream.Headless.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.Launcher.Core/AcDream.Launcher.Core.csproj" />
|
||||||
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
|
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
|
||||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.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.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.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.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.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -279,9 +279,22 @@ src/
|
||||||
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
||||||
Runtime and App reference it directly; Headless reaches it
|
Runtime and App reference it directly; Headless reaches it
|
||||||
transitively through Runtime (K0 guard: Headless declares exactly
|
transitively through Runtime (K0 guard: Headless declares exactly
|
||||||
one project reference); the external launcher (AcDream.Launcher.Core,
|
one project reference)
|
||||||
Campaign LA — under construction) references ONLY this project from
|
|
||||||
the game solution
|
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
|
AcDream.Headless/ Linux/Windows no-window production host
|
||||||
Program.cs -> CLI entry only
|
Program.cs -> CLI entry only
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Launching;
|
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>
|
/// <summary>
|
||||||
/// Spawns a host process (App/Headless), feeds the account password to
|
/// Spawns a host process (App/Headless), feeds the account password to
|
||||||
/// its stdin then closes it, and supervises its lifetime (Campaign LA
|
/// its stdin then closes it, and supervises its lifetime (Campaign LA
|
||||||
/// spec §3/§6). One supervisor instance owns exactly one child process
|
/// spec §3/§6). One supervisor instance owns exactly one child process
|
||||||
/// for its lifetime — start a new supervisor per launched session.
|
/// for its lifetime — start a new supervisor per launched session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LauncherProcessSupervisor : IDisposable
|
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
{
|
{
|
||||||
private readonly ILauncherChildProcessFactory _factory;
|
private readonly ILauncherChildProcessFactory _factory;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig(
|
||||||
string StatusFilePath,
|
string StatusFilePath,
|
||||||
SessionConfigDocument Document);
|
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>
|
/// <summary>
|
||||||
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
|
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
|
||||||
/// profile character + install record (Campaign LA spec §6). Passwords
|
/// profile character + install record (Campaign LA spec §6). Passwords
|
||||||
|
|
@ -187,6 +245,23 @@ public static class SessionConfigComposer
|
||||||
sessionId,
|
sessionId,
|
||||||
loginCommandDelayMs);
|
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);
|
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
|
||||||
if (!string.IsNullOrEmpty(directory))
|
if (!string.IsNullOrEmpty(directory))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical state/mutation surface projected by the Avalonia launcher. The UI
|
||||||
|
/// never owns a second profile document, process map, status tail, or launch
|
||||||
|
/// transaction; it asks for immutable snapshots and sends typed mutations here.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILauncherOrchestrator : IDisposable
|
||||||
|
{
|
||||||
|
event EventHandler? StateChanged;
|
||||||
|
|
||||||
|
void LoadProfiles();
|
||||||
|
|
||||||
|
LauncherStateSnapshot GetSnapshot();
|
||||||
|
|
||||||
|
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
||||||
|
|
||||||
|
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
||||||
|
|
||||||
|
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||||
|
|
||||||
|
void AddServer(string name, string host, int port);
|
||||||
|
|
||||||
|
void EditServer(string name, string newName, string newHost, int newPort);
|
||||||
|
|
||||||
|
void RemoveServer(string name);
|
||||||
|
|
||||||
|
void AddAccount(string serverName, string accountName, string password);
|
||||||
|
|
||||||
|
void EditAccount(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string newAccountName,
|
||||||
|
string? newPassword);
|
||||||
|
|
||||||
|
void RemoveAccount(string serverName, string accountName);
|
||||||
|
|
||||||
|
void AddCharacter(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName,
|
||||||
|
string? characterId);
|
||||||
|
|
||||||
|
void EditCharacterIdentity(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName,
|
||||||
|
string newCharacterName,
|
||||||
|
string? newCharacterId);
|
||||||
|
|
||||||
|
void UpdateCharacterSettings(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName,
|
||||||
|
LaunchMode launchMode,
|
||||||
|
IReadOnlyList<string> plugins,
|
||||||
|
IReadOnlyList<string> loginCommands);
|
||||||
|
|
||||||
|
void RemoveCharacter(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName);
|
||||||
|
|
||||||
|
Task<LauncherSessionSnapshot> LaunchAsync(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName,
|
||||||
|
LaunchMode mode,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<LauncherSessionSnapshot> ProbeAsync(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task StopSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
void PollStatus();
|
||||||
|
|
||||||
|
void ClearFinishedSessions();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host executable paths supplied by the current installation. LA10 will
|
||||||
|
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
|
||||||
|
/// mapping injectable and host-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LauncherExecutableSet(
|
||||||
|
string GraphicalHostPath,
|
||||||
|
string HeadlessHostPath,
|
||||||
|
string? WorkingDirectory = null)
|
||||||
|
{
|
||||||
|
public LauncherProcessSpec CreatePlaySpec(
|
||||||
|
LaunchMode mode,
|
||||||
|
string configFilePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
|
|
||||||
|
return mode == LaunchMode.Headless
|
||||||
|
? new LauncherProcessSpec(
|
||||||
|
HeadlessHostPath,
|
||||||
|
["--config", configFilePath],
|
||||||
|
WorkingDirectory)
|
||||||
|
: new LauncherProcessSpec(
|
||||||
|
GraphicalHostPath,
|
||||||
|
["--session-config", configFilePath],
|
||||||
|
WorkingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
|
return new LauncherProcessSpec(
|
||||||
|
HeadlessHostPath,
|
||||||
|
["--config", configFilePath],
|
||||||
|
WorkingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LauncherExecutableSet FromDirectory(string directory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||||
|
string fullDirectory = Path.GetFullPath(directory);
|
||||||
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
|
return new LauncherExecutableSet(
|
||||||
|
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
||||||
|
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||||
|
fullDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -325,13 +325,59 @@ public sealed class LauncherProfileStore
|
||||||
server.Accounts.Remove(profile);
|
server.Accounts.Remove(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Character settings (roster-driven add/remove; user-edited settings) ---
|
// --- Character CRUD / user-owned settings --------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Edits the user-owned settings of an existing character row. There
|
/// Adds a manually configured character row. Normal operation discovers
|
||||||
/// is no manual add/remove for characters — the roster (
|
/// characters through <see cref="MergeRoster"/>, but LA4's full in-UI
|
||||||
/// <see cref="MergeRoster"/>) is the only source of new rows, per
|
/// CRUD contract also lets a user create a cached row before a successful
|
||||||
/// spec §5/§6.
|
/// probe (for example, to launch by a known character name while a server
|
||||||
|
/// is temporarily unavailable). A later roster merge remains
|
||||||
|
/// authoritative for the id/name pair and preserves these user settings.
|
||||||
|
/// </summary>
|
||||||
|
public CharacterProfile AddCharacter(
|
||||||
|
string serverName,
|
||||||
|
string account,
|
||||||
|
string characterName,
|
||||||
|
string? id = null,
|
||||||
|
LaunchMode launchMode = LaunchMode.GuiSelect,
|
||||||
|
IReadOnlyList<string>? plugins = null,
|
||||||
|
IReadOnlyList<string>? loginCommands = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||||
|
ServerProfile server = FindServerOrThrow(serverName);
|
||||||
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
|
|
||||||
|
if (FindCharacter(profile, characterName) is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character '{characterName}' already exists on account '{account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? normalizedId = NormalizeCharacterId(id);
|
||||||
|
if (normalizedId is not null
|
||||||
|
&& profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var character = new CharacterProfile
|
||||||
|
{
|
||||||
|
Name = characterName,
|
||||||
|
Id = normalizedId,
|
||||||
|
LaunchMode = launchMode,
|
||||||
|
Plugins = plugins is null ? [] : [.. plugins],
|
||||||
|
LoginCommands = loginCommands is null ? [] : [.. loginCommands],
|
||||||
|
};
|
||||||
|
profile.Characters.Add(character);
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edits the identity cache and/or user-owned settings of an existing
|
||||||
|
/// character row. Passing an empty <paramref name="newId"/> clears a
|
||||||
|
/// manually entered id so launches fall back to the character name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void EditCharacter(
|
public void EditCharacter(
|
||||||
string serverName,
|
string serverName,
|
||||||
|
|
@ -339,12 +385,42 @@ public sealed class LauncherProfileStore
|
||||||
string characterName,
|
string characterName,
|
||||||
LaunchMode? launchMode = null,
|
LaunchMode? launchMode = null,
|
||||||
IReadOnlyList<string>? plugins = null,
|
IReadOnlyList<string>? plugins = null,
|
||||||
IReadOnlyList<string>? loginCommands = null)
|
IReadOnlyList<string>? loginCommands = null,
|
||||||
|
string? newName = null,
|
||||||
|
string? newId = null)
|
||||||
{
|
{
|
||||||
ServerProfile server = FindServerOrThrow(serverName);
|
ServerProfile server = FindServerOrThrow(serverName);
|
||||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||||
|
|
||||||
|
if (newName is not null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||||
|
if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
|
||||||
|
&& FindCharacter(profile, newName) is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character '{newName}' already exists on account '{account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
character.Name = newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newId is not null)
|
||||||
|
{
|
||||||
|
string? normalizedId = NormalizeCharacterId(newId);
|
||||||
|
if (normalizedId is not null
|
||||||
|
&& profile.Characters.Any(candidate =>
|
||||||
|
!ReferenceEquals(candidate, character)
|
||||||
|
&& CharacterIdsEqual(candidate.Id, normalizedId)))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
character.Id = normalizedId;
|
||||||
|
}
|
||||||
|
|
||||||
if (launchMode is not null)
|
if (launchMode is not null)
|
||||||
{
|
{
|
||||||
character.LaunchMode = launchMode.Value;
|
character.LaunchMode = launchMode.Value;
|
||||||
|
|
@ -361,6 +437,17 @@ public sealed class LauncherProfileStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RemoveCharacter(
|
||||||
|
string serverName,
|
||||||
|
string account,
|
||||||
|
string characterName)
|
||||||
|
{
|
||||||
|
ServerProfile server = FindServerOrThrow(serverName);
|
||||||
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
|
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||||
|
profile.Characters.Remove(character);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Folds a reported character roster into an account's
|
/// Folds a reported character roster into an account's
|
||||||
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
|
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
|
||||||
|
|
@ -456,20 +543,46 @@ public sealed class LauncherProfileStore
|
||||||
$"No account '{account}' on server '{server.Name}'.");
|
$"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(
|
private static CharacterProfile FindCharacterOrThrow(
|
||||||
AccountProfile profile,
|
AccountProfile profile,
|
||||||
string characterName)
|
string characterName)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||||
return profile.Characters.Find(
|
return FindCharacter(profile, characterName)
|
||||||
character => string.Equals(
|
|
||||||
character.Name,
|
|
||||||
characterName,
|
|
||||||
StringComparison.Ordinal))
|
|
||||||
?? throw new LauncherProfileException(
|
?? throw new LauncherProfileException(
|
||||||
$"No character '{characterName}' on account '{profile.Account}'.");
|
$"No character '{characterName}' on account '{profile.Account}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeCharacterId(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
"Character id must be a non-zero hexadecimal value with a 0x prefix.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return CharacterIdFormat.ToHexString(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CharacterIdsEqual(string? left, string? right) =>
|
||||||
|
CharacterIdFormat.TryParse(left, out uint leftId)
|
||||||
|
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
||||||
|
&& leftId == rightId;
|
||||||
|
|
||||||
private static void RequireValidPort(int port)
|
private static void RequireValidPort(int port)
|
||||||
{
|
{
|
||||||
if (port is < 1 or > 65535)
|
if (port is < 1 or > 65535)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status;
|
||||||
/// One tailer instance owns one file's read position; construct a new
|
/// One tailer instance owns one file's read position; construct a new
|
||||||
/// one per session.
|
/// one per session.
|
||||||
/// </summary>
|
/// </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 readonly string _path;
|
||||||
private long _position;
|
private long _position;
|
||||||
|
|
|
||||||
24
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
24
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
8
src/AcDream.Launcher/App.axaml
Normal file
8
src/AcDream.Launcher/App.axaml
Normal 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>
|
||||||
66
src/AcDream.Launcher/App.axaml.cs
Normal file
66
src/AcDream.Launcher/App.axaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/AcDream.Launcher/MainWindow.axaml
Normal file
346
src/AcDream.Launcher/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<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 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="Refresh characters"
|
||||||
|
Classes="primary"
|
||||||
|
Command="{Binding RefreshCharactersCommand}" />
|
||||||
|
<Button Content="Add cached character"
|
||||||
|
Command="{Binding AddCharacterCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding ProbeDisabledReason}"
|
||||||
|
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 ShowLinuxGraphicalNotice}" />
|
||||||
|
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
||||||
|
Classes="muted"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding IsFirstRunRequired}" />
|
||||||
|
</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"
|
||||||
|
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 Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
|
<TextBlock Text="Host" />
|
||||||
|
<TextBox Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||||
|
<TextBlock Text="Port" />
|
||||||
|
<TextBox Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
||||||
|
<TextBlock Text="Account name" />
|
||||||
|
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
|
<TextBlock Text="Password" />
|
||||||
|
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
||||||
|
PasswordChar="●" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
||||||
|
<TextBlock Text="Character name" />
|
||||||
|
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
|
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
||||||
|
<TextBox 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" Command="{Binding EditorDialog.CancelCommand}" />
|
||||||
|
<Button Content="{Binding EditorDialog.SubmitText}"
|
||||||
|
Classes="primary"
|
||||||
|
Command="{Binding EditorDialog.SubmitCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.RowSpan="4"
|
||||||
|
ZIndex="30"
|
||||||
|
Background="#C010151D"
|
||||||
|
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 Content="Close"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.RowSpan="4"
|
||||||
|
ZIndex="30"
|
||||||
|
Background="#C010151D"
|
||||||
|
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 Content="Close"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
41
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
41
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using AcDream.Launcher.ViewModels;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher;
|
||||||
|
|
||||||
|
public sealed partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _statusTimer;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
_statusTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(250),
|
||||||
|
};
|
||||||
|
_statusTimer.Tick += OnStatusTimerTick;
|
||||||
|
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;
|
||||||
|
Opened -= OnOpened;
|
||||||
|
Closed -= OnClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStatusTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is LauncherWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
viewModel.PollStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/AcDream.Launcher/Program.cs
Normal file
14
src/AcDream.Launcher/Program.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) =>
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect();
|
||||||
|
}
|
||||||
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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) => _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);
|
||||||
|
}
|
||||||
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal file
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
public sealed class LauncherSessionRowViewModel
|
||||||
|
{
|
||||||
|
public LauncherSessionRowViewModel(
|
||||||
|
LauncherSessionSnapshot snapshot,
|
||||||
|
Func<string, Task> stop)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
public sealed class LauncherShellViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private bool _isOpen;
|
||||||
|
|
||||||
|
public LauncherShellViewModel(string title, string body, string status)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Body = body;
|
||||||
|
Status = status;
|
||||||
|
OpenCommand = new RelayCommand(() => IsOpen = true);
|
||||||
|
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; }
|
||||||
|
}
|
||||||
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal file
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal 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());
|
||||||
|
}
|
||||||
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,866 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILauncherOrchestrator _orchestrator;
|
||||||
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private LauncherStateSnapshot? _snapshot;
|
||||||
|
private LauncherTreeNodeViewModel? _selectedNode;
|
||||||
|
private CancellationTokenSource? _operationCancellation;
|
||||||
|
private bool _isBusy;
|
||||||
|
private bool _disposed;
|
||||||
|
private string? _lastError;
|
||||||
|
private string _operationStatus = "Ready";
|
||||||
|
private LaunchMode _characterLaunchMode;
|
||||||
|
private string _characterPluginsText = string.Empty;
|
||||||
|
private string _characterLoginCommandsText = string.Empty;
|
||||||
|
|
||||||
|
public LauncherWindowViewModel(
|
||||||
|
ILauncherOrchestrator orchestrator,
|
||||||
|
IUiDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
_orchestrator.StateChanged += OnOrchestratorStateChanged;
|
||||||
|
|
||||||
|
EditorDialog = new ProfileEditorDialogViewModel();
|
||||||
|
FirstRunWizardShell = new LauncherShellViewModel(
|
||||||
|
"First-run setup",
|
||||||
|
"Choose and validate the retail DAT directory, build acdream.pak, "
|
||||||
|
+ "and record the installed client. The installer transaction and "
|
||||||
|
+ "progress body land in Campaign LA slice LA9.",
|
||||||
|
"Installer shell ready — implementation arrives in LA9.");
|
||||||
|
UpdatePromptShell = new LauncherShellViewModel(
|
||||||
|
"Client update",
|
||||||
|
"Review a signed release manifest, verify the downloaded archive, "
|
||||||
|
+ "and atomically switch the installed client version. The updater "
|
||||||
|
+ "transaction lands in Campaign LA slice LA10.",
|
||||||
|
"Updater shell ready — implementation arrives in LA10.");
|
||||||
|
|
||||||
|
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
|
||||||
|
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||||
|
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
|
||||||
|
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
|
||||||
|
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
|
||||||
|
SaveCharacterSettingsCommand = new RelayCommand(
|
||||||
|
SaveCharacterSettings,
|
||||||
|
() => IsCharacterSelected && !IsBusy);
|
||||||
|
RefreshCharactersCommand = new AsyncRelayCommand(
|
||||||
|
RefreshCharactersAsync,
|
||||||
|
() => CanProbe);
|
||||||
|
LaunchGuiCommand = new AsyncRelayCommand(
|
||||||
|
() => LaunchSelectedAsync(LaunchMode.Gui),
|
||||||
|
() => CanLaunchGui);
|
||||||
|
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
||||||
|
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
||||||
|
() => CanLaunchGuiSelect);
|
||||||
|
LaunchHeadlessCommand = new AsyncRelayCommand(
|
||||||
|
() => LaunchSelectedAsync(LaunchMode.Headless),
|
||||||
|
() => CanLaunchHeadless);
|
||||||
|
CancelOperationCommand = new RelayCommand(
|
||||||
|
CancelOperation,
|
||||||
|
() => IsBusy && _operationCancellation is not null);
|
||||||
|
ClearFinishedSessionsCommand = new RelayCommand(
|
||||||
|
_orchestrator.ClearFinishedSessions,
|
||||||
|
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<LauncherSessionRowViewModel> Sessions { get; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<LaunchMode> AvailableLaunchModes { get; } =
|
||||||
|
[LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless];
|
||||||
|
|
||||||
|
public ProfileEditorDialogViewModel EditorDialog { get; }
|
||||||
|
|
||||||
|
public LauncherShellViewModel FirstRunWizardShell { get; }
|
||||||
|
|
||||||
|
public LauncherShellViewModel UpdatePromptShell { get; }
|
||||||
|
|
||||||
|
public LauncherTreeNodeViewModel? SelectedNode
|
||||||
|
{
|
||||||
|
get => _selectedNode;
|
||||||
|
set => SetSelectedNode(value, preserveCharacterDraft: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanProbe));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? LastError
|
||||||
|
{
|
||||||
|
get => _lastError;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _lastError, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
|
||||||
|
|
||||||
|
public string OperationStatus
|
||||||
|
{
|
||||||
|
get => _operationStatus;
|
||||||
|
private set => SetProperty(ref _operationStatus, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsServerSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Server;
|
||||||
|
|
||||||
|
public bool IsAccountSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Account;
|
||||||
|
|
||||||
|
public bool IsCharacterSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Character;
|
||||||
|
|
||||||
|
public bool HasSelection => SelectedNode is not null;
|
||||||
|
|
||||||
|
public string SelectionTitle => SelectedNode?.DisplayName ?? "Select a profile";
|
||||||
|
|
||||||
|
public string SelectionSubtitle => SelectedNode?.SecondaryText
|
||||||
|
?? "Add a server to begin.";
|
||||||
|
|
||||||
|
public string SelectedServerName => SelectedNode?.ServerName ?? string.Empty;
|
||||||
|
|
||||||
|
public string SelectedAccountName => SelectedNode?.AccountName ?? string.Empty;
|
||||||
|
|
||||||
|
public string SelectedCharacterName => SelectedNode?.CharacterName ?? string.Empty;
|
||||||
|
|
||||||
|
public LaunchMode CharacterLaunchMode
|
||||||
|
{
|
||||||
|
get => _characterLaunchMode;
|
||||||
|
set => SetProperty(ref _characterLaunchMode, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CharacterPluginsText
|
||||||
|
{
|
||||||
|
get => _characterPluginsText;
|
||||||
|
set => SetProperty(ref _characterPluginsText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CharacterLoginCommandsText
|
||||||
|
{
|
||||||
|
get => _characterLoginCommandsText;
|
||||||
|
set => SetProperty(ref _characterLoginCommandsText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsFirstRunRequired => _snapshot is { IsInstallationReady: false };
|
||||||
|
|
||||||
|
public string InstallationStatus => _snapshot?.InstallationStatus
|
||||||
|
?? "Installation state is loading.";
|
||||||
|
|
||||||
|
public bool ShowLinuxGraphicalNotice => _snapshot?.Platform.IsLinux == true;
|
||||||
|
|
||||||
|
public string LinuxGraphicalNotice =>
|
||||||
|
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
|
||||||
|
|
||||||
|
public bool CanProbe =>
|
||||||
|
!IsBusy
|
||||||
|
&& TryGetSelectedAccount(out string server, out string account)
|
||||||
|
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
|
||||||
|
|
||||||
|
public string ProbeDisabledReason
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedAccount(out string server, out string account))
|
||||||
|
{
|
||||||
|
return "Select an account or one of its characters.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _orchestrator.GetProbeCapability(server, account).Reason
|
||||||
|
?? "Character refresh is available.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanLaunchGui => CanLaunch(LaunchMode.Gui);
|
||||||
|
|
||||||
|
public bool CanLaunchGuiSelect => CanLaunch(LaunchMode.GuiSelect);
|
||||||
|
|
||||||
|
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
||||||
|
|
||||||
|
public string GuiLaunchDisabledReason =>
|
||||||
|
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
|
||||||
|
?? "Graphical launch is available.";
|
||||||
|
|
||||||
|
public string HeadlessLaunchDisabledReason =>
|
||||||
|
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
|
||||||
|
?? "Headless launch is available.";
|
||||||
|
|
||||||
|
public RelayCommand AddServerCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand AddAccountCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand AddCharacterCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand EditSelectedCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand RemoveSelectedCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand SaveCharacterSettingsCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand RefreshCharactersCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand LaunchGuiCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand CancelOperationCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand ClearFinishedSessionsCommand { get; }
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_orchestrator.LoadProfiles();
|
||||||
|
LastError = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = SafeDisplayError(ex, secret: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PollStatus()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_orchestrator.PollStatus();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = SafeDisplayError(ex, secret: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_operationCancellation?.Cancel();
|
||||||
|
_operationCancellation?.Dispose();
|
||||||
|
_operationCancellation = null;
|
||||||
|
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||||
|
_dispatcher.Post(() =>
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private void RefreshFromCore(SelectionKey? preferredSelection = null)
|
||||||
|
{
|
||||||
|
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
|
||||||
|
LauncherStateSnapshot snapshot = _orchestrator.GetSnapshot();
|
||||||
|
_snapshot = snapshot;
|
||||||
|
|
||||||
|
Servers.Clear();
|
||||||
|
foreach (LauncherServerSnapshot server in snapshot.Servers)
|
||||||
|
{
|
||||||
|
Servers.Add(LauncherTreeNodeViewModel.FromServer(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
Sessions.Clear();
|
||||||
|
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
|
||||||
|
{
|
||||||
|
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherTreeNodeViewModel? restored = previousSelection is null
|
||||||
|
? Servers.FirstOrDefault()
|
||||||
|
: FindNode(previousSelection.Value) ?? Servers.FirstOrDefault();
|
||||||
|
bool preserveDraft = previousSelection is not null
|
||||||
|
&& SelectionKey.From(restored) == previousSelection;
|
||||||
|
SetSelectedNode(restored, preserveDraft);
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(IsFirstRunRequired));
|
||||||
|
OnPropertyChanged(nameof(InstallationStatus));
|
||||||
|
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
|
||||||
|
OnPropertyChanged(nameof(LinuxGraphicalNotice));
|
||||||
|
OnPropertyChanged(nameof(CanProbe));
|
||||||
|
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetSelectedNode(
|
||||||
|
LauncherTreeNodeViewModel? value,
|
||||||
|
bool preserveCharacterDraft)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_selectedNode, value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedNode = value;
|
||||||
|
OnPropertyChanged(nameof(SelectedNode));
|
||||||
|
OnPropertyChanged(nameof(IsServerSelected));
|
||||||
|
OnPropertyChanged(nameof(IsAccountSelected));
|
||||||
|
OnPropertyChanged(nameof(IsCharacterSelected));
|
||||||
|
OnPropertyChanged(nameof(HasSelection));
|
||||||
|
OnPropertyChanged(nameof(SelectionTitle));
|
||||||
|
OnPropertyChanged(nameof(SelectionSubtitle));
|
||||||
|
OnPropertyChanged(nameof(SelectedServerName));
|
||||||
|
OnPropertyChanged(nameof(SelectedAccountName));
|
||||||
|
OnPropertyChanged(nameof(SelectedCharacterName));
|
||||||
|
|
||||||
|
if (!preserveCharacterDraft)
|
||||||
|
{
|
||||||
|
LoadCharacterDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CanProbe));
|
||||||
|
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadCharacterDraft()
|
||||||
|
{
|
||||||
|
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
|
||||||
|
CharacterLaunchMode = character?.LaunchMode ?? LaunchMode.GuiSelect;
|
||||||
|
CharacterPluginsText = character is null
|
||||||
|
? string.Empty
|
||||||
|
: string.Join(Environment.NewLine, character.Plugins);
|
||||||
|
CharacterLoginCommandsText = character is null
|
||||||
|
? string.Empty
|
||||||
|
: string.Join(Environment.NewLine, character.LoginCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAddServerDialog() =>
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.AddServer,
|
||||||
|
"Add server",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
if (!dialog.TryGetPort(out int port))
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException("Port must be between 1 and 65535.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_orchestrator.AddServer(dialog.Name.Trim(), dialog.Host.Trim(), port);
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Server,
|
||||||
|
dialog.Name.Trim(),
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
});
|
||||||
|
|
||||||
|
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
|
||||||
|
|
||||||
|
private void OpenAddAccountDialog()
|
||||||
|
{
|
||||||
|
string serverName = SelectedNode?.ServerName
|
||||||
|
?? throw new LauncherOperationException("Select a server first.");
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.AddAccount,
|
||||||
|
$"Add account to {serverName}",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
_orchestrator.AddAccount(
|
||||||
|
serverName,
|
||||||
|
dialog.Name.Trim(),
|
||||||
|
dialog.Password);
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Account,
|
||||||
|
serverName,
|
||||||
|
dialog.Name.Trim(),
|
||||||
|
null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanAddCharacter() =>
|
||||||
|
!IsBusy && TryGetSelectedAccount(out _, out _);
|
||||||
|
|
||||||
|
private void OpenAddCharacterDialog()
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException("Select an account first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.AddCharacter,
|
||||||
|
$"Add cached character to {accountName}",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
string name = dialog.Name.Trim();
|
||||||
|
_orchestrator.AddCharacter(
|
||||||
|
serverName,
|
||||||
|
accountName,
|
||||||
|
name,
|
||||||
|
NullIfWhiteSpace(dialog.CharacterId));
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Character,
|
||||||
|
serverName,
|
||||||
|
accountName,
|
||||||
|
name));
|
||||||
|
},
|
||||||
|
message: "Normally Refresh Characters fills this list. Manual rows "
|
||||||
|
+ "let you configure a known character while the server is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
|
||||||
|
|
||||||
|
private void OpenEditSelectedDialog()
|
||||||
|
{
|
||||||
|
LauncherTreeNodeViewModel node = SelectedNode
|
||||||
|
?? throw new LauncherOperationException("Select a profile first.");
|
||||||
|
switch (node.Kind)
|
||||||
|
{
|
||||||
|
case LauncherTreeNodeKind.Server:
|
||||||
|
OpenEditServerDialog(node);
|
||||||
|
break;
|
||||||
|
case LauncherTreeNodeKind.Account:
|
||||||
|
OpenEditAccountDialog(node);
|
||||||
|
break;
|
||||||
|
case LauncherTreeNodeKind.Character:
|
||||||
|
OpenEditCharacterDialog(node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditServerDialog(LauncherTreeNodeViewModel node)
|
||||||
|
{
|
||||||
|
LauncherServerSnapshot server = FindServerSnapshot(node.ServerName);
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.EditServer,
|
||||||
|
$"Edit {server.Name}",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
if (!dialog.TryGetPort(out int port))
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException("Port must be between 1 and 65535.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string newName = dialog.Name.Trim();
|
||||||
|
_orchestrator.EditServer(
|
||||||
|
server.Name,
|
||||||
|
newName,
|
||||||
|
dialog.Host.Trim(),
|
||||||
|
port);
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Server,
|
||||||
|
newName,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
},
|
||||||
|
server.Name,
|
||||||
|
server.Host,
|
||||||
|
server.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditAccountDialog(LauncherTreeNodeViewModel node)
|
||||||
|
{
|
||||||
|
LauncherAccountSnapshot account = FindAccountSnapshot(
|
||||||
|
node.ServerName,
|
||||||
|
node.AccountName!);
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.EditAccount,
|
||||||
|
$"Edit {account.AccountName}",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
string newName = dialog.Name.Trim();
|
||||||
|
_orchestrator.EditAccount(
|
||||||
|
account.ServerName,
|
||||||
|
account.AccountName,
|
||||||
|
newName,
|
||||||
|
string.IsNullOrEmpty(dialog.Password) ? null : dialog.Password);
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Account,
|
||||||
|
account.ServerName,
|
||||||
|
newName,
|
||||||
|
null));
|
||||||
|
},
|
||||||
|
account.AccountName,
|
||||||
|
message: "Leave Password blank to keep the stored credential unchanged.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditCharacterDialog(LauncherTreeNodeViewModel node)
|
||||||
|
{
|
||||||
|
LauncherCharacterSnapshot character = FindCharacterSnapshot(
|
||||||
|
node.ServerName,
|
||||||
|
node.AccountName!,
|
||||||
|
node.CharacterName!);
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.EditCharacter,
|
||||||
|
$"Edit {character.Name}",
|
||||||
|
dialog =>
|
||||||
|
{
|
||||||
|
string newName = dialog.Name.Trim();
|
||||||
|
_orchestrator.EditCharacterIdentity(
|
||||||
|
character.ServerName,
|
||||||
|
character.AccountName,
|
||||||
|
character.Name,
|
||||||
|
newName,
|
||||||
|
dialog.CharacterId.Trim());
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Character,
|
||||||
|
character.ServerName,
|
||||||
|
character.AccountName,
|
||||||
|
newName));
|
||||||
|
},
|
||||||
|
character.Name,
|
||||||
|
characterId: character.Id ?? string.Empty,
|
||||||
|
message: "A later character refresh remains authoritative for name and id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRemoveSelectedDialog()
|
||||||
|
{
|
||||||
|
LauncherTreeNodeViewModel node = SelectedNode
|
||||||
|
?? throw new LauncherOperationException("Select a profile first.");
|
||||||
|
EditorDialog.Open(
|
||||||
|
ProfileEditorKind.Remove,
|
||||||
|
$"Remove {node.DisplayName}?",
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
switch (node.Kind)
|
||||||
|
{
|
||||||
|
case LauncherTreeNodeKind.Server:
|
||||||
|
_orchestrator.RemoveServer(node.ServerName);
|
||||||
|
break;
|
||||||
|
case LauncherTreeNodeKind.Account:
|
||||||
|
_orchestrator.RemoveAccount(node.ServerName, node.AccountName!);
|
||||||
|
break;
|
||||||
|
case LauncherTreeNodeKind.Character:
|
||||||
|
_orchestrator.RemoveCharacter(
|
||||||
|
node.ServerName,
|
||||||
|
node.AccountName!,
|
||||||
|
node.CharacterName!);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshFromCore();
|
||||||
|
},
|
||||||
|
message: node.Kind switch
|
||||||
|
{
|
||||||
|
LauncherTreeNodeKind.Server =>
|
||||||
|
"This removes the server and every account/character profile beneath it.",
|
||||||
|
LauncherTreeNodeKind.Account =>
|
||||||
|
"This removes the account, its plaintext credential, and cached characters.",
|
||||||
|
_ => "This removes the cached character and its launch settings.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveCharacterSettings()
|
||||||
|
{
|
||||||
|
LauncherCharacterSnapshot character = GetSelectedCharacterSnapshot()
|
||||||
|
?? throw new LauncherOperationException("Select a character first.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_orchestrator.UpdateCharacterSettings(
|
||||||
|
character.ServerName,
|
||||||
|
character.AccountName,
|
||||||
|
character.Name,
|
||||||
|
CharacterLaunchMode,
|
||||||
|
ParseLines(CharacterPluginsText, distinct: true),
|
||||||
|
ParseLines(CharacterLoginCommandsText, distinct: false));
|
||||||
|
LastError = null;
|
||||||
|
OperationStatus = $"Saved launch settings for {character.Name}.";
|
||||||
|
RefreshFromCore(new SelectionKey(
|
||||||
|
LauncherTreeNodeKind.Character,
|
||||||
|
character.ServerName,
|
||||||
|
character.AccountName,
|
||||||
|
character.Name));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = SafeDisplayError(ex, secret: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RefreshCharactersAsync()
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunOperationAsync(
|
||||||
|
token => _orchestrator.ProbeAsync(serverName, accountName, token),
|
||||||
|
$"Refreshing characters for {accountName}…",
|
||||||
|
"Character refresh started. The roster will update from host status.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task LaunchSelectedAsync(LaunchMode mode)
|
||||||
|
{
|
||||||
|
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunOperationAsync(
|
||||||
|
token => _orchestrator.LaunchAsync(
|
||||||
|
character.ServerName,
|
||||||
|
character.AccountName,
|
||||||
|
character.Name,
|
||||||
|
mode,
|
||||||
|
token),
|
||||||
|
$"Launching {character.Name} ({mode})…",
|
||||||
|
$"{mode} session started for {character.Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunOperationAsync(
|
||||||
|
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
|
||||||
|
string activeStatus,
|
||||||
|
string completedStatus)
|
||||||
|
{
|
||||||
|
if (IsBusy)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_operationCancellation = cancellation;
|
||||||
|
IsBusy = true;
|
||||||
|
LastError = null;
|
||||||
|
OperationStatus = activeStatus;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await operation(cancellation.Token).ConfigureAwait(true);
|
||||||
|
OperationStatus = completedStatus;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
OperationStatus = "Operation cancelled.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = SafeDisplayError(ex, secret: null);
|
||||||
|
OperationStatus = "Operation failed.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_operationCancellation, cancellation))
|
||||||
|
{
|
||||||
|
_operationCancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopSessionAsync(string sessionId)
|
||||||
|
{
|
||||||
|
if (IsBusy)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_operationCancellation = cancellation;
|
||||||
|
IsBusy = true;
|
||||||
|
LastError = null;
|
||||||
|
OperationStatus = "Stopping session…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _orchestrator.StopSessionAsync(
|
||||||
|
sessionId,
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
cancellation.Token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
OperationStatus = "Stop requested.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
OperationStatus = "Stop cancelled.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = SafeDisplayError(ex, secret: null);
|
||||||
|
OperationStatus = "Stop failed.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_operationCancellation, cancellation))
|
||||||
|
{
|
||||||
|
_operationCancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelOperation() => _operationCancellation?.Cancel();
|
||||||
|
|
||||||
|
private bool CanLaunch(LaunchMode mode) =>
|
||||||
|
!IsBusy
|
||||||
|
&& IsCharacterSelected
|
||||||
|
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
|
||||||
|
|
||||||
|
private bool TryGetSelectedAccount(out string serverName, out string accountName)
|
||||||
|
{
|
||||||
|
serverName = SelectedNode?.ServerName ?? string.Empty;
|
||||||
|
accountName = SelectedNode?.AccountName ?? string.Empty;
|
||||||
|
return !string.IsNullOrWhiteSpace(serverName)
|
||||||
|
&& !string.IsNullOrWhiteSpace(accountName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LauncherCharacterSnapshot? GetSelectedCharacterSnapshot()
|
||||||
|
{
|
||||||
|
if (_snapshot is null
|
||||||
|
|| SelectedNode is not { Kind: LauncherTreeNodeKind.Character } node)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindCharacterSnapshotOrDefault(
|
||||||
|
node.ServerName,
|
||||||
|
node.AccountName!,
|
||||||
|
node.CharacterName!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LauncherServerSnapshot FindServerSnapshot(string serverName) =>
|
||||||
|
_snapshot?.Servers.FirstOrDefault(server =>
|
||||||
|
string.Equals(server.Name, serverName, StringComparison.Ordinal))
|
||||||
|
?? throw new LauncherOperationException($"No server named '{serverName}'.");
|
||||||
|
|
||||||
|
private LauncherAccountSnapshot FindAccountSnapshot(
|
||||||
|
string serverName,
|
||||||
|
string accountName) =>
|
||||||
|
FindServerSnapshot(serverName).Accounts.FirstOrDefault(account =>
|
||||||
|
string.Equals(account.AccountName, accountName, StringComparison.Ordinal))
|
||||||
|
?? throw new LauncherOperationException(
|
||||||
|
$"No account '{accountName}' on server '{serverName}'.");
|
||||||
|
|
||||||
|
private LauncherCharacterSnapshot FindCharacterSnapshot(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName) =>
|
||||||
|
FindCharacterSnapshotOrDefault(serverName, accountName, characterName)
|
||||||
|
?? throw new LauncherOperationException(
|
||||||
|
$"No character '{characterName}' on account '{accountName}'.");
|
||||||
|
|
||||||
|
private LauncherCharacterSnapshot? FindCharacterSnapshotOrDefault(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
string characterName) =>
|
||||||
|
FindAccountSnapshot(serverName, accountName).Characters.FirstOrDefault(character =>
|
||||||
|
string.Equals(character.Name, characterName, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private LauncherTreeNodeViewModel? FindNode(SelectionKey key)
|
||||||
|
{
|
||||||
|
LauncherTreeNodeViewModel? server = Servers.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.ServerName, key.ServerName, StringComparison.Ordinal));
|
||||||
|
if (server is null || key.Kind == LauncherTreeNodeKind.Server)
|
||||||
|
{
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherTreeNodeViewModel? account = server.Children.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.AccountName, key.AccountName, StringComparison.Ordinal));
|
||||||
|
if (account is null || key.Kind == LauncherTreeNodeKind.Account)
|
||||||
|
{
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.Children.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.CharacterName, key.CharacterName, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ParseLines(string text, bool distinct)
|
||||||
|
{
|
||||||
|
IEnumerable<string> values = text
|
||||||
|
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(value => value.Trim())
|
||||||
|
.Where(value => value.Length > 0);
|
||||||
|
if (distinct)
|
||||||
|
{
|
||||||
|
values = values.Distinct(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static string SafeDisplayError(Exception exception, string? secret)
|
||||||
|
{
|
||||||
|
string message = exception.Message;
|
||||||
|
if (!string.IsNullOrEmpty(secret))
|
||||||
|
{
|
||||||
|
message = message.Replace(secret, "[redacted]", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(message)
|
||||||
|
? "The launcher operation failed."
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyCommandStates()
|
||||||
|
{
|
||||||
|
AddServerCommand.NotifyCanExecuteChanged();
|
||||||
|
AddAccountCommand.NotifyCanExecuteChanged();
|
||||||
|
AddCharacterCommand.NotifyCanExecuteChanged();
|
||||||
|
EditSelectedCommand.NotifyCanExecuteChanged();
|
||||||
|
RemoveSelectedCommand.NotifyCanExecuteChanged();
|
||||||
|
SaveCharacterSettingsCommand.NotifyCanExecuteChanged();
|
||||||
|
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
||||||
|
LaunchGuiCommand.NotifyCanExecuteChanged();
|
||||||
|
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
||||||
|
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||||
|
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct SelectionKey(
|
||||||
|
LauncherTreeNodeKind Kind,
|
||||||
|
string ServerName,
|
||||||
|
string? AccountName,
|
||||||
|
string? CharacterName)
|
||||||
|
{
|
||||||
|
public static SelectionKey? From(LauncherTreeNodeViewModel? node) =>
|
||||||
|
node is null
|
||||||
|
? null
|
||||||
|
: new SelectionKey(
|
||||||
|
node.Kind,
|
||||||
|
node.ServerName,
|
||||||
|
node.AccountName,
|
||||||
|
node.CharacterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal file
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal 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));
|
||||||
|
}
|
||||||
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal file
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
private static JsonObject ParseRoot(ComposedSessionConfig composed)
|
||||||
{
|
{
|
||||||
string json = SessionConfigComposer.Serialize(composed.Document);
|
string json = SessionConfigComposer.Serialize(composed.Document);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,502 @@
|
||||||
|
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 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 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 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)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
new LauncherExecutableSet("gui-host", "headless-host"),
|
||||||
|
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 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,60 @@ public sealed class LauncherProfileStoreTests : IDisposable
|
||||||
Assert.Equal("0x5000000A", character.Id);
|
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]
|
[Fact]
|
||||||
public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk()
|
public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
25
tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj
Normal file
25
tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj
Normal 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>
|
||||||
77
tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
Normal file
77
tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
508
tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
Normal file
508
tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
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 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 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 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 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 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 static LauncherServerSnapshot CreateServerSnapshot() => new(
|
||||||
|
"Local ACE",
|
||||||
|
"127.0.0.1",
|
||||||
|
9000,
|
||||||
|
[
|
||||||
|
new LauncherAccountSnapshot(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
[
|
||||||
|
new LauncherCharacterSnapshot(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
"0x5000000A",
|
||||||
|
LaunchMode.GuiSelect,
|
||||||
|
["Existing.Plugin"],
|
||||||
|
["/tell someone, ready"],
|
||||||
|
HasRunningSession: true,
|
||||||
|
SessionStatus: "Connected."),
|
||||||
|
],
|
||||||
|
HasRunningActivity: true,
|
||||||
|
ActivityStatus: "Connected."),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue