fix(launcher): close LA4 review findings
This commit is contained in:
parent
d0a9c65d85
commit
10a712d66b
19 changed files with 1631 additions and 134 deletions
57
.github/workflows/headless-portability.yml
vendored
57
.github/workflows/headless-portability.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
||||||
- "AcDream.slnx"
|
- "AcDream.slnx"
|
||||||
- "src/AcDream.Platform/**"
|
- "src/AcDream.Platform/**"
|
||||||
- "src/AcDream.Launcher.Core/**"
|
- "src/AcDream.Launcher.Core/**"
|
||||||
|
- "src/AcDream.Launcher/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -17,6 +18,7 @@ on:
|
||||||
- "src/AcDream.UI.Abstractions/**"
|
- "src/AcDream.UI.Abstractions/**"
|
||||||
- "tests/AcDream.Platform.Tests/**"
|
- "tests/AcDream.Platform.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Core.Tests/**"
|
- "tests/AcDream.Launcher.Core.Tests/**"
|
||||||
|
- "tests/AcDream.Launcher.Tests/**"
|
||||||
- "tests/AcDream.Core.Tests/**"
|
- "tests/AcDream.Core.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -33,6 +35,7 @@ on:
|
||||||
- "AcDream.slnx"
|
- "AcDream.slnx"
|
||||||
- "src/AcDream.Platform/**"
|
- "src/AcDream.Platform/**"
|
||||||
- "src/AcDream.Launcher.Core/**"
|
- "src/AcDream.Launcher.Core/**"
|
||||||
|
- "src/AcDream.Launcher/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -43,6 +46,7 @@ on:
|
||||||
- "src/AcDream.UI.Abstractions/**"
|
- "src/AcDream.UI.Abstractions/**"
|
||||||
- "tests/AcDream.Platform.Tests/**"
|
- "tests/AcDream.Platform.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Core.Tests/**"
|
- "tests/AcDream.Launcher.Core.Tests/**"
|
||||||
|
- "tests/AcDream.Launcher.Tests/**"
|
||||||
- "tests/AcDream.Core.Tests/**"
|
- "tests/AcDream.Core.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -131,6 +135,59 @@ jobs:
|
||||||
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
portable-launcher:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install .NET 10
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Build and test the portable launcher
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
dotnet build src/AcDream.Launcher/AcDream.Launcher.csproj -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
- name: Publish the self-contained Linux launcher
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-r linux-x64 `
|
||||||
|
-o artifacts/acdream-launcher-linux-x64
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
- name: Verify self-contained property and artifact execution
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
root=artifacts/acdream-launcher-linux-x64
|
||||||
|
self_contained=$(dotnet msbuild \
|
||||||
|
src/AcDream.Launcher/AcDream.Launcher.csproj \
|
||||||
|
-nologo \
|
||||||
|
-property:RuntimeIdentifier=linux-x64 \
|
||||||
|
-getProperty:SelfContained | tr -d '\r\n ')
|
||||||
|
test "$self_contained" = true
|
||||||
|
test -x "$root/acdream-launcher"
|
||||||
|
test ! -f "$root/acdream-launcher.dll"
|
||||||
|
DOTNET_ROOT=/definitely-not-installed \
|
||||||
|
DOTNET_ROOT_X64=/definitely-not-installed \
|
||||||
|
DOTNET_MULTILEVEL_LOOKUP=0 \
|
||||||
|
"$root/acdream-launcher" --verify-publish
|
||||||
|
|
||||||
linux-graphical:
|
linux-graphical:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ public interface ILauncherOrchestrator : IDisposable
|
||||||
|
|
||||||
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
||||||
|
|
||||||
|
LauncherCapability GetAccountLaunchCapability(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
LaunchMode mode);
|
||||||
|
|
||||||
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
||||||
|
|
||||||
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||||
|
|
@ -67,7 +72,7 @@ public interface ILauncherOrchestrator : IDisposable
|
||||||
Task<LauncherSessionSnapshot> LaunchAsync(
|
Task<LauncherSessionSnapshot> LaunchAsync(
|
||||||
string serverName,
|
string serverName,
|
||||||
string accountName,
|
string accountName,
|
||||||
string characterName,
|
string? characterName,
|
||||||
LaunchMode mode,
|
LaunchMode mode,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,59 @@ using AcDream.Launcher.Core.Profiles;
|
||||||
namespace AcDream.Launcher.Core.Orchestration;
|
namespace AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host executable paths supplied by the current installation. LA10 will
|
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
|
||||||
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
|
/// replace the directory lookup with its versioned-current resolver; until
|
||||||
/// mapping injectable and host-agnostic.
|
/// then a missing host disables the corresponding action instead of deferring
|
||||||
|
/// failure until process creation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LauncherExecutableSet(
|
public sealed class LauncherExecutableSet
|
||||||
string GraphicalHostPath,
|
|
||||||
string HeadlessHostPath,
|
|
||||||
string? WorkingDirectory = null)
|
|
||||||
{
|
{
|
||||||
|
private readonly Func<string, bool> _fileExists;
|
||||||
|
|
||||||
|
public LauncherExecutableSet(
|
||||||
|
string graphicalHostPath,
|
||||||
|
string headlessHostPath,
|
||||||
|
string? workingDirectory = null,
|
||||||
|
Func<string, bool>? fileExists = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
||||||
|
GraphicalHostPath = graphicalHostPath;
|
||||||
|
HeadlessHostPath = headlessHostPath;
|
||||||
|
WorkingDirectory = workingDirectory;
|
||||||
|
_fileExists = fileExists ?? File.Exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GraphicalHostPath { get; }
|
||||||
|
|
||||||
|
public string HeadlessHostPath { get; }
|
||||||
|
|
||||||
|
public string? WorkingDirectory { get; }
|
||||||
|
|
||||||
|
public LauncherCapability GetAvailability(LaunchMode mode)
|
||||||
|
{
|
||||||
|
string path = mode == LaunchMode.Headless
|
||||||
|
? HeadlessHostPath
|
||||||
|
: GraphicalHostPath;
|
||||||
|
if (_fileExists(path))
|
||||||
|
{
|
||||||
|
return LauncherCapability.Available;
|
||||||
|
}
|
||||||
|
|
||||||
|
string host = mode == LaunchMode.Headless
|
||||||
|
? "headless host"
|
||||||
|
: "graphical client";
|
||||||
|
return LauncherCapability.Unavailable(
|
||||||
|
$"The co-deployed {host} is missing at '{path}'. Reinstall or update "
|
||||||
|
+ "the client before launching.");
|
||||||
|
}
|
||||||
|
|
||||||
public LauncherProcessSpec CreatePlaySpec(
|
public LauncherProcessSpec CreatePlaySpec(
|
||||||
LaunchMode mode,
|
LaunchMode mode,
|
||||||
string configFilePath)
|
string configFilePath)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
|
RequireAvailable(mode);
|
||||||
|
|
||||||
return mode == LaunchMode.Headless
|
return mode == LaunchMode.Headless
|
||||||
? new LauncherProcessSpec(
|
? new LauncherProcessSpec(
|
||||||
|
|
@ -33,6 +72,7 @@ public sealed record LauncherExecutableSet(
|
||||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
|
RequireAvailable(LaunchMode.Headless);
|
||||||
return new LauncherProcessSpec(
|
return new LauncherProcessSpec(
|
||||||
HeadlessHostPath,
|
HeadlessHostPath,
|
||||||
["--config", configFilePath],
|
["--config", configFilePath],
|
||||||
|
|
@ -49,4 +89,14 @@ public sealed record LauncherExecutableSet(
|
||||||
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||||
fullDirectory);
|
fullDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RequireAvailable(LaunchMode mode)
|
||||||
|
{
|
||||||
|
LauncherCapability capability = GetAvailability(mode);
|
||||||
|
if (!capability.IsAvailable)
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException(
|
||||||
|
capability.Reason ?? "The selected launcher host is unavailable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
return platformCapability;
|
return platformCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LauncherCapability executableCapability = _executables.GetAvailability(mode);
|
||||||
|
if (!executableCapability.IsAvailable)
|
||||||
|
{
|
||||||
|
return executableCapability;
|
||||||
|
}
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
@ -108,6 +114,33 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LauncherCapability GetAccountLaunchCapability(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
LaunchMode mode)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(accountName);
|
||||||
|
|
||||||
|
LauncherCapability capability = GetLaunchCapability(mode);
|
||||||
|
if (!capability.IsAvailable)
|
||||||
|
{
|
||||||
|
return capability;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
_ = FindAccountLocked(serverName, accountName);
|
||||||
|
ManagedActivity? active = FindActiveActivityLocked(serverName, accountName);
|
||||||
|
return active is null
|
||||||
|
? LauncherCapability.Available
|
||||||
|
: LauncherCapability.Unavailable(
|
||||||
|
$"Stop the running {active.Kind.ToString().ToLowerInvariant()} "
|
||||||
|
+ "for this account before starting another activity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public LauncherCapability GetProbeCapability(string serverName, string accountName)
|
public LauncherCapability GetProbeCapability(string serverName, string accountName)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
||||||
|
|
@ -120,6 +153,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
return platformCapability;
|
return platformCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LauncherCapability executableCapability =
|
||||||
|
_executables.GetAvailability(LaunchMode.Headless);
|
||||||
|
if (!executableCapability.IsAvailable)
|
||||||
|
{
|
||||||
|
return executableCapability;
|
||||||
|
}
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
@ -254,7 +294,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
public Task<LauncherSessionSnapshot> LaunchAsync(
|
public Task<LauncherSessionSnapshot> LaunchAsync(
|
||||||
string serverName,
|
string serverName,
|
||||||
string accountName,
|
string accountName,
|
||||||
string characterName,
|
string? characterName,
|
||||||
LaunchMode mode,
|
LaunchMode mode,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|
@ -268,12 +308,38 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
if (FindActiveActivityLocked(serverName, accountName) is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException(
|
||||||
|
"A session or character refresh is already running for this account.");
|
||||||
|
}
|
||||||
|
|
||||||
ServerProfile server = FindServerLocked(serverName);
|
ServerProfile server = FindServerLocked(serverName);
|
||||||
AccountProfile account = FindAccountLocked(serverName, accountName);
|
AccountProfile account = FindAccountLocked(serverName, accountName);
|
||||||
CharacterProfile character = FindCharacterLocked(
|
CharacterProfile character;
|
||||||
serverName,
|
if (string.IsNullOrWhiteSpace(characterName))
|
||||||
accountName,
|
{
|
||||||
characterName);
|
if (mode != LaunchMode.GuiSelect)
|
||||||
|
{
|
||||||
|
throw new LauncherOperationException(
|
||||||
|
"Select a cached character for GUI or headless launch.");
|
||||||
|
}
|
||||||
|
|
||||||
|
character = new CharacterProfile
|
||||||
|
{
|
||||||
|
Name = string.Empty,
|
||||||
|
LaunchMode = LaunchMode.GuiSelect,
|
||||||
|
Plugins = [],
|
||||||
|
LoginCommands = [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
character = FindCharacterLocked(
|
||||||
|
serverName,
|
||||||
|
accountName,
|
||||||
|
characterName);
|
||||||
|
}
|
||||||
LauncherInstallRecord install = _installRecord
|
LauncherInstallRecord install = _installRecord
|
||||||
?? throw new LauncherOperationException(FirstRunRequired);
|
?? throw new LauncherOperationException(FirstRunRequired);
|
||||||
|
|
||||||
|
|
@ -283,7 +349,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
LauncherActivityKind.Play,
|
LauncherActivityKind.Play,
|
||||||
server.Name,
|
server.Name,
|
||||||
account.Account,
|
account.Account,
|
||||||
character.Name,
|
string.IsNullOrWhiteSpace(characterName) ? null : character.Name,
|
||||||
mode,
|
mode,
|
||||||
"Preparing session configuration…");
|
"Preparing session configuration…");
|
||||||
_activities.Add(activity);
|
_activities.Add(activity);
|
||||||
|
|
@ -620,9 +686,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
request.Activity.State = LauncherActivityState.Cancelled;
|
if (!request.Activity.IsTerminal)
|
||||||
request.Activity.Status = "Operation cancelled.";
|
{
|
||||||
request.Activity.Error = null;
|
request.Activity.State = LauncherActivityState.Cancelled;
|
||||||
|
request.Activity.Status = "Operation cancelled.";
|
||||||
|
request.Activity.Error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
|
|
@ -638,9 +707,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
password);
|
password);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
request.Activity.State = LauncherActivityState.Failed;
|
if (!request.Activity.IsTerminal)
|
||||||
request.Activity.Status = message;
|
{
|
||||||
request.Activity.Error = message;
|
request.Activity.State = LauncherActivityState.Failed;
|
||||||
|
request.Activity.Status = message;
|
||||||
|
request.Activity.Error = message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
|
|
@ -681,15 +753,19 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case LauncherSessionState.Exited:
|
case LauncherSessionState.Exited:
|
||||||
activity.ExitCode = activity.Supervisor?.ExitCode;
|
activity.ExitCode ??= activity.Supervisor?.ExitCode;
|
||||||
if (activity.State is not (
|
if (!activity.IsTerminal)
|
||||||
LauncherActivityState.Failed
|
|
||||||
or LauncherActivityState.Cancelled))
|
|
||||||
{
|
{
|
||||||
activity.State = LauncherActivityState.Exited;
|
activity.State = LauncherActivityState.Exited;
|
||||||
activity.Status = activity.ExitCode is int code
|
activity.Status = activity.HostTerminalStatus
|
||||||
? $"Host process exited with code {code}."
|
?? (activity.ExitCode is int code
|
||||||
: "Host process exited.";
|
? $"Host process exited with code {code}."
|
||||||
|
: "Host process exited.");
|
||||||
|
}
|
||||||
|
else if (activity.State == LauncherActivityState.Exited
|
||||||
|
&& activity.HostTerminalStatus is not null)
|
||||||
|
{
|
||||||
|
activity.Status = activity.HostTerminalStatus;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -723,6 +799,27 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.IsTerminal)
|
||||||
|
{
|
||||||
|
switch (statusEvent)
|
||||||
|
{
|
||||||
|
case ExitedStatusEvent exited:
|
||||||
|
activity.ExitCode ??= exited.Code;
|
||||||
|
activity.HostTerminalStatus ??=
|
||||||
|
$"Exited: {exited.Reason} (code {exited.Code}).";
|
||||||
|
if (activity.State == LauncherActivityState.Exited)
|
||||||
|
{
|
||||||
|
activity.Status = activity.HostTerminalStatus;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CharacterListStatusEvent roster:
|
||||||
|
ApplyRosterLocked(activity, roster, updateStatus: false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (statusEvent)
|
switch (statusEvent)
|
||||||
{
|
{
|
||||||
case StartedStatusEvent:
|
case StartedStatusEvent:
|
||||||
|
|
@ -762,7 +859,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
case ExitedStatusEvent exited:
|
case ExitedStatusEvent exited:
|
||||||
activity.State = LauncherActivityState.Exited;
|
activity.State = LauncherActivityState.Exited;
|
||||||
activity.ExitCode = exited.Code;
|
activity.ExitCode = exited.Code;
|
||||||
activity.Status = $"Exited: {exited.Reason} (code {exited.Code}).";
|
activity.HostTerminalStatus =
|
||||||
|
$"Exited: {exited.Reason} (code {exited.Code}).";
|
||||||
|
activity.Status = activity.HostTerminalStatus;
|
||||||
break;
|
break;
|
||||||
case MalformedStatusEvent malformed:
|
case MalformedStatusEvent malformed:
|
||||||
activity.Error = $"Malformed host status event: {malformed.Error}";
|
activity.Error = $"Malformed host status event: {malformed.Error}";
|
||||||
|
|
@ -778,7 +877,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
private void ApplyRosterLocked(
|
private void ApplyRosterLocked(
|
||||||
ManagedActivity activity,
|
ManagedActivity activity,
|
||||||
CharacterListStatusEvent roster)
|
CharacterListStatusEvent roster,
|
||||||
|
bool updateStatus = true)
|
||||||
{
|
{
|
||||||
if (!string.Equals(
|
if (!string.Equals(
|
||||||
roster.AccountName,
|
roster.AccountName,
|
||||||
|
|
@ -792,19 +892,22 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_profileStore.MergeRoster(
|
_profileStore.ExecuteTransaction(() =>
|
||||||
activity.ServerName,
|
_profileStore.MergeRoster(
|
||||||
activity.AccountName,
|
activity.ServerName,
|
||||||
roster.Characters
|
activity.AccountName,
|
||||||
.Select(character => new CharacterRosterEntry(
|
roster.Characters
|
||||||
character.Id,
|
.Select(character => new CharacterRosterEntry(
|
||||||
character.Name,
|
character.Id,
|
||||||
character.SecondsGreyedOut))
|
character.Name,
|
||||||
.ToArray());
|
character.SecondsGreyedOut))
|
||||||
_profileStore.Save();
|
.ToArray()));
|
||||||
activity.Status = roster.Characters.Count == 1
|
if (updateStatus)
|
||||||
? "Character roster refreshed: 1 character."
|
{
|
||||||
: $"Character roster refreshed: {roster.Characters.Count} characters.";
|
activity.Status = roster.Characters.Count == 1
|
||||||
|
? "Character roster refreshed: 1 character."
|
||||||
|
: $"Character roster refreshed: {roster.Characters.Count} characters.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -812,7 +915,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
"Could not save the refreshed character roster",
|
"Could not save the refreshed character roster",
|
||||||
ex,
|
ex,
|
||||||
secret: null);
|
secret: null);
|
||||||
activity.Status = activity.Error;
|
if (updateStatus)
|
||||||
|
{
|
||||||
|
activity.Status = activity.Error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,8 +985,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
mutation();
|
_profileStore.ExecuteTransaction(mutation);
|
||||||
_profileStore.Save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
|
|
@ -1125,6 +1230,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
public string? HostTerminalStatus { get; set; }
|
||||||
|
|
||||||
public ILauncherProcessSupervisor? Supervisor { get; set; }
|
public ILauncherProcessSupervisor? Supervisor { get; set; }
|
||||||
|
|
||||||
public EventHandler<LauncherSessionState>? SupervisorStateHandler { get; set; }
|
public EventHandler<LauncherSessionState>? SupervisorStateHandler { get; set; }
|
||||||
|
|
@ -1140,6 +1247,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
or LauncherActivityState.Failed
|
or LauncherActivityState.Failed
|
||||||
or LauncherActivityState.Cancelled);
|
or LauncherActivityState.Cancelled);
|
||||||
|
|
||||||
|
public bool IsTerminal => !IsActive;
|
||||||
|
|
||||||
public LauncherSessionSnapshot ToSnapshot() =>
|
public LauncherSessionSnapshot ToSnapshot() =>
|
||||||
new(
|
new(
|
||||||
SessionId,
|
SessionId,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ public sealed class LauncherProfileStore
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureExistingCredentialFilePermissions();
|
||||||
|
|
||||||
LauncherProfileDocument? document;
|
LauncherProfileDocument? document;
|
||||||
using (FileStream stream = File.OpenRead(FilePath))
|
using (FileStream stream = File.OpenRead(FilePath))
|
||||||
{
|
{
|
||||||
|
|
@ -110,6 +112,7 @@ public sealed class LauncherProfileStore
|
||||||
+ $"expected {CurrentVersion}.");
|
+ $"expected {CurrentVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValidateAndNormalizeDocument(document);
|
||||||
Document = document;
|
Document = document;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +155,13 @@ public sealed class LauncherProfileStore
|
||||||
JsonSerializer.Serialize(stream, Document, SerializerOptions);
|
JsonSerializer.Serialize(stream, Document, SerializerOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux()
|
||||||
|
&& File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode)
|
||||||
|
{
|
||||||
|
throw new IOException(
|
||||||
|
"The launcher credential temp file could not be secured to mode 0600.");
|
||||||
|
}
|
||||||
|
|
||||||
File.Move(tempPath, FilePath, overwrite: true);
|
File.Move(tempPath, FilePath, overwrite: true);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|
@ -160,10 +170,6 @@ public sealed class LauncherProfileStore
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -248,21 +254,21 @@ public sealed class LauncherProfileStore
|
||||||
throw new LauncherProfileException(
|
throw new LauncherProfileException(
|
||||||
$"A server named '{newName}' already exists.");
|
$"A server named '{newName}' already exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
server.Name = newName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newHost is not null)
|
if (newHost is not null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
|
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
|
||||||
server.Host = newHost;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPort is not null)
|
if (newPort is not null)
|
||||||
{
|
{
|
||||||
RequireValidPort(newPort.Value);
|
RequireValidPort(newPort.Value);
|
||||||
server.Port = newPort.Value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.Name = newName ?? server.Name;
|
||||||
|
server.Host = newHost ?? server.Host;
|
||||||
|
server.Port = newPort ?? server.Port;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveServer(string name)
|
public void RemoveServer(string name)
|
||||||
|
|
@ -308,10 +314,10 @@ public sealed class LauncherProfileStore
|
||||||
throw new LauncherProfileException(
|
throw new LauncherProfileException(
|
||||||
$"Account '{newAccount}' already exists on server '{serverName}'.");
|
$"Account '{newAccount}' already exists on server '{serverName}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.Account = newAccount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profile.Account = newAccount ?? profile.Account;
|
||||||
|
|
||||||
if (newPassword is not null)
|
if (newPassword is not null)
|
||||||
{
|
{
|
||||||
profile.Password = newPassword;
|
profile.Password = newPassword;
|
||||||
|
|
@ -345,6 +351,9 @@ public sealed class LauncherProfileStore
|
||||||
IReadOnlyList<string>? loginCommands = null)
|
IReadOnlyList<string>? loginCommands = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||||
|
RequireValidLaunchMode(launchMode);
|
||||||
|
ValidateStringList(plugins, "plugin", requireUnique: true);
|
||||||
|
ValidateStringList(loginCommands, "login command", requireUnique: false);
|
||||||
ServerProfile server = FindServerOrThrow(serverName);
|
ServerProfile server = FindServerOrThrow(serverName);
|
||||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
|
|
||||||
|
|
@ -393,6 +402,8 @@ public sealed class LauncherProfileStore
|
||||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||||
|
|
||||||
|
string? normalizedId = null;
|
||||||
|
|
||||||
if (newName is not null)
|
if (newName is not null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||||
|
|
@ -402,13 +413,11 @@ public sealed class LauncherProfileStore
|
||||||
throw new LauncherProfileException(
|
throw new LauncherProfileException(
|
||||||
$"Character '{newName}' already exists on account '{account}'.");
|
$"Character '{newName}' already exists on account '{account}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
character.Name = newName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newId is not null)
|
if (newId is not null)
|
||||||
{
|
{
|
||||||
string? normalizedId = NormalizeCharacterId(newId);
|
normalizedId = NormalizeCharacterId(newId);
|
||||||
if (normalizedId is not null
|
if (normalizedId is not null
|
||||||
&& profile.Characters.Any(candidate =>
|
&& profile.Characters.Any(candidate =>
|
||||||
!ReferenceEquals(candidate, character)
|
!ReferenceEquals(candidate, character)
|
||||||
|
|
@ -417,7 +426,19 @@ public sealed class LauncherProfileStore
|
||||||
throw new LauncherProfileException(
|
throw new LauncherProfileException(
|
||||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchMode is not null)
|
||||||
|
{
|
||||||
|
RequireValidLaunchMode(launchMode.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateStringList(plugins, "plugin", requireUnique: true);
|
||||||
|
ValidateStringList(loginCommands, "login command", requireUnique: false);
|
||||||
|
|
||||||
|
character.Name = newName ?? character.Name;
|
||||||
|
if (newId is not null)
|
||||||
|
{
|
||||||
character.Id = normalizedId;
|
character.Id = normalizedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,6 +458,57 @@ public sealed class LauncherProfileStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureExistingCredentialFilePermissions()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnixFileMode mode = File.GetUnixFileMode(FilePath);
|
||||||
|
if (mode != OwnerOnlyFileMode)
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
|
||||||
|
mode = File.GetUnixFileMode(FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode != OwnerOnlyFileMode)
|
||||||
|
{
|
||||||
|
throw new IOException($"Mode remained {mode} after normalization.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"'{FilePath}' could not be secured to owner-only mode 0600.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies one profile mutation and its atomic file replacement as a
|
||||||
|
/// single in-memory/on-disk transaction. Any validation or I/O failure
|
||||||
|
/// restores the exact pre-mutation document, including credentials.
|
||||||
|
/// </summary>
|
||||||
|
public void ExecuteTransaction(Action mutation)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(mutation);
|
||||||
|
LauncherProfileDocument before = CloneDocument(Document);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mutation();
|
||||||
|
ValidateAndNormalizeDocument(Document);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Document = before;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void RemoveCharacter(
|
public void RemoveCharacter(
|
||||||
string serverName,
|
string serverName,
|
||||||
string account,
|
string account,
|
||||||
|
|
@ -472,38 +544,49 @@ public sealed class LauncherProfileStore
|
||||||
ServerProfile server = FindServerOrThrow(serverName);
|
ServerProfile server = FindServerOrThrow(serverName);
|
||||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||||
|
|
||||||
|
var rosterIds = new HashSet<uint>();
|
||||||
|
var rosterNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (CharacterRosterEntry entry in roster)
|
||||||
|
{
|
||||||
|
if (entry.Id == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException("A roster character id cannot be zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name);
|
||||||
|
if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
"The reported character roster contains a duplicate id or name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (CharacterRosterEntry entry in roster)
|
foreach (CharacterRosterEntry entry in roster)
|
||||||
{
|
{
|
||||||
string idText = CharacterIdFormat.ToHexString(entry.Id);
|
string idText = CharacterIdFormat.ToHexString(entry.Id);
|
||||||
|
|
||||||
// Normalize BOTH sides through TryParse/ToHexString rather
|
CharacterProfile[] matches = profile.Characters
|
||||||
// than a raw string compare (Campaign LA plan §LA3 review
|
.Where(character =>
|
||||||
// finding F10): a stored id that round-trips to the same
|
(CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
||||||
// uint (different case, or — before this fix — no "0x"
|
&& existingId == entry.Id)
|
||||||
// prefix) must match even though its text isn't byte-
|
|| string.Equals(character.Name, entry.Name, StringComparison.Ordinal))
|
||||||
// identical to the canonical form this method itself always
|
.ToArray();
|
||||||
// writes.
|
CharacterProfile? existing = matches.FirstOrDefault(character =>
|
||||||
CharacterProfile? existing = profile.Characters.Find(
|
CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
||||||
character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
&& existingId == entry.Id)
|
||||||
&& existingId == entry.Id);
|
?? matches.FirstOrDefault();
|
||||||
|
|
||||||
// Defensive fallback for a row whose id is missing OR
|
|
||||||
// unparseable (e.g. a hand-edited id with no "0x" prefix,
|
|
||||||
// which TryParse now rejects outright) — match by name
|
|
||||||
// instead so a later merge self-heals the id into the
|
|
||||||
// canonical form rather than creating a permanent duplicate
|
|
||||||
// row.
|
|
||||||
existing ??= profile.Characters.Find(
|
|
||||||
character => !CharacterIdFormat.TryParse(character.Id, out _)
|
|
||||||
&& string.Equals(
|
|
||||||
character.Name,
|
|
||||||
entry.Name,
|
|
||||||
StringComparison.Ordinal));
|
|
||||||
|
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
existing.Id = idText;
|
existing.Id = idText;
|
||||||
existing.Name = entry.Name;
|
existing.Name = entry.Name;
|
||||||
|
foreach (CharacterProfile duplicate in matches)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(duplicate, existing))
|
||||||
|
{
|
||||||
|
profile.Characters.Remove(duplicate);
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,6 +666,192 @@ public sealed class LauncherProfileStore
|
||||||
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
||||||
&& leftId == rightId;
|
&& leftId == rightId;
|
||||||
|
|
||||||
|
private static LauncherProfileDocument CloneDocument(
|
||||||
|
LauncherProfileDocument source) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Version = source.Version,
|
||||||
|
Servers = source.Servers.Select(server => new ServerProfile
|
||||||
|
{
|
||||||
|
Name = server.Name,
|
||||||
|
Host = server.Host,
|
||||||
|
Port = server.Port,
|
||||||
|
Accounts = server.Accounts.Select(account => new AccountProfile
|
||||||
|
{
|
||||||
|
Account = account.Account,
|
||||||
|
Password = account.Password,
|
||||||
|
Characters = account.Characters.Select(character => new CharacterProfile
|
||||||
|
{
|
||||||
|
Name = character.Name,
|
||||||
|
Id = character.Id,
|
||||||
|
LaunchMode = character.LaunchMode,
|
||||||
|
Plugins = [.. character.Plugins],
|
||||||
|
LoginCommands = [.. character.LoginCommands],
|
||||||
|
}).ToList(),
|
||||||
|
}).ToList(),
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void ValidateAndNormalizeDocument(LauncherProfileDocument document)
|
||||||
|
{
|
||||||
|
if (document.Servers is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException("The servers collection cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var normalizedIds = new List<(CharacterProfile Character, uint Id)>();
|
||||||
|
foreach (ServerProfile? server in document.Servers)
|
||||||
|
{
|
||||||
|
if (server is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException("A server entry cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireLoadedText(server.Name, "server name");
|
||||||
|
RequireLoadedText(server.Host, $"host for server '{server.Name}'");
|
||||||
|
RequireValidPort(server.Port);
|
||||||
|
if (!serverNames.Add(server.Name))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"A server named '{server.Name}' appears more than once.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.Accounts is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"The accounts collection for server '{server.Name}' cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (AccountProfile? account in server.Accounts)
|
||||||
|
{
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"A null account appears under server '{server.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireLoadedText(account.Account, "account name");
|
||||||
|
if (account.Password is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Password for account '{account.Account}' cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountNames.Add(account.Account))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Account '{account.Account}' appears more than once on server '{server.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.Characters is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"The characters collection for account '{account.Account}' cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var characterIds = new HashSet<uint>();
|
||||||
|
foreach (CharacterProfile? character in account.Characters)
|
||||||
|
{
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"A null character appears under account '{account.Account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireLoadedText(character.Name, "character name");
|
||||||
|
if (!characterNames.Add(character.Name))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character '{character.Name}' appears more than once on account '{account.Account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireValidLaunchMode(character.LaunchMode);
|
||||||
|
if (character.Id is not null)
|
||||||
|
{
|
||||||
|
if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character '{character.Name}' has an invalid id '{character.Id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!characterIds.Add(id))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character id '{character.Id}' appears more than once on account '{account.Account}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedIds.Add((character, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character.Plugins is null || character.LoginCommands is null)
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"Character '{character.Name}' has a null settings collection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateStringList(character.Plugins, "plugin", requireUnique: true);
|
||||||
|
ValidateStringList(
|
||||||
|
character.LoginCommands,
|
||||||
|
"login command",
|
||||||
|
requireUnique: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((CharacterProfile character, uint id) in normalizedIds)
|
||||||
|
{
|
||||||
|
character.Id = CharacterIdFormat.ToHexString(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateStringList(
|
||||||
|
IReadOnlyList<string>? values,
|
||||||
|
string valueName,
|
||||||
|
bool requireUnique)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string>? seen = requireUnique
|
||||||
|
? new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
: null;
|
||||||
|
foreach (string? value in values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"A {valueName} cannot be null or whitespace.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen is not null && !seen.Add(value))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException(
|
||||||
|
$"The {valueName} '{value}' appears more than once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireLoadedText(string? value, string field)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException($"The {field} cannot be null or whitespace.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireValidLaunchMode(LaunchMode mode)
|
||||||
|
{
|
||||||
|
if (!Enum.IsDefined(mode))
|
||||||
|
{
|
||||||
|
throw new LauncherProfileException($"Launch mode '{mode}' is not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RequireValidPort(int port)
|
private static void RequireValidPort(int port)
|
||||||
{
|
{
|
||||||
if (port is < 1 or > 65535)
|
if (port is < 1 or > 65535)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,8 @@
|
||||||
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
|
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
|
||||||
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
|
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TreeView Grid.Row="2"
|
<TreeView x:Name="ProfilesTree"
|
||||||
|
Grid.Row="2"
|
||||||
ItemsSource="{Binding Servers}"
|
ItemsSource="{Binding Servers}"
|
||||||
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
|
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
|
||||||
<TreeView.DataTemplates>
|
<TreeView.DataTemplates>
|
||||||
|
|
@ -136,8 +137,11 @@
|
||||||
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
|
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="Refresh characters"
|
<Button Content="Open character select"
|
||||||
Classes="primary"
|
Classes="primary"
|
||||||
|
AutomationProperties.Name="Open character select for account"
|
||||||
|
Command="{Binding LaunchAccountGuiSelectCommand}" />
|
||||||
|
<Button Content="Refresh characters"
|
||||||
Command="{Binding RefreshCharactersCommand}" />
|
Command="{Binding RefreshCharactersCommand}" />
|
||||||
<Button Content="Add cached character"
|
<Button Content="Add cached character"
|
||||||
Command="{Binding AddCharacterCommand}" />
|
Command="{Binding AddCharacterCommand}" />
|
||||||
|
|
@ -145,6 +149,9 @@
|
||||||
<TextBlock Text="{Binding ProbeDisabledReason}"
|
<TextBlock Text="{Binding ProbeDisabledReason}"
|
||||||
Classes="muted"
|
Classes="muted"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
|
||||||
|
Classes="muted"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
@ -192,11 +199,11 @@
|
||||||
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
|
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
|
||||||
Classes="muted"
|
Classes="muted"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding ShowLinuxGraphicalNotice}" />
|
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
|
||||||
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
||||||
Classes="muted"
|
Classes="muted"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding IsFirstRunRequired}" />
|
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
@ -260,6 +267,10 @@
|
||||||
<Border Grid.RowSpan="4"
|
<Border Grid.RowSpan="4"
|
||||||
ZIndex="20"
|
ZIndex="20"
|
||||||
Background="#C010151D"
|
Background="#C010151D"
|
||||||
|
Focusable="True"
|
||||||
|
KeyboardNavigation.TabNavigation="Cycle"
|
||||||
|
KeyDown="OnModalKeyDown"
|
||||||
|
AutomationProperties.Name="Profile editor modal dialog"
|
||||||
IsVisible="{Binding EditorDialog.IsOpen}">
|
IsVisible="{Binding EditorDialog.IsOpen}">
|
||||||
<Border Classes="card"
|
<Border Classes="card"
|
||||||
Width="480"
|
Width="480"
|
||||||
|
|
@ -271,26 +282,36 @@
|
||||||
|
|
||||||
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
|
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
|
||||||
<TextBlock Text="Name" />
|
<TextBlock Text="Name" />
|
||||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
<TextBox x:Name="ServerNameTextBox"
|
||||||
|
AutomationProperties.Name="Server name"
|
||||||
|
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
<TextBlock Text="Host" />
|
<TextBlock Text="Host" />
|
||||||
<TextBox Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
<TextBox AutomationProperties.Name="Server host"
|
||||||
|
Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||||
<TextBlock Text="Port" />
|
<TextBlock Text="Port" />
|
||||||
<TextBox Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
<TextBox AutomationProperties.Name="Server port"
|
||||||
|
Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
||||||
<TextBlock Text="Account name" />
|
<TextBlock Text="Account name" />
|
||||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
<TextBox x:Name="AccountNameTextBox"
|
||||||
|
AutomationProperties.Name="Account name"
|
||||||
|
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
<TextBlock Text="Password" />
|
<TextBlock Text="Password" />
|
||||||
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
||||||
|
AutomationProperties.Name="Account password"
|
||||||
PasswordChar="●" />
|
PasswordChar="●" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
||||||
<TextBlock Text="Character name" />
|
<TextBlock Text="Character name" />
|
||||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
<TextBox x:Name="CharacterNameTextBox"
|
||||||
|
AutomationProperties.Name="Character name"
|
||||||
|
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||||
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
||||||
<TextBox Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
<TextBox AutomationProperties.Name="Character id"
|
||||||
|
Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Text="{Binding EditorDialog.Error}"
|
<TextBlock Text="{Binding EditorDialog.Error}"
|
||||||
|
|
@ -298,9 +319,15 @@
|
||||||
IsVisible="{Binding EditorDialog.HasError}"
|
IsVisible="{Binding EditorDialog.HasError}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
<Button Content="Cancel" Command="{Binding EditorDialog.CancelCommand}" />
|
<Button Content="Cancel"
|
||||||
<Button Content="{Binding EditorDialog.SubmitText}"
|
IsCancel="True"
|
||||||
|
AutomationProperties.Name="Cancel profile change"
|
||||||
|
Command="{Binding EditorDialog.CancelCommand}" />
|
||||||
|
<Button x:Name="EditorSubmitButton"
|
||||||
|
Content="{Binding EditorDialog.SubmitText}"
|
||||||
Classes="primary"
|
Classes="primary"
|
||||||
|
IsDefault="True"
|
||||||
|
AutomationProperties.Name="Confirm profile change"
|
||||||
Command="{Binding EditorDialog.SubmitCommand}" />
|
Command="{Binding EditorDialog.SubmitCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
@ -310,6 +337,10 @@
|
||||||
<Border Grid.RowSpan="4"
|
<Border Grid.RowSpan="4"
|
||||||
ZIndex="30"
|
ZIndex="30"
|
||||||
Background="#C010151D"
|
Background="#C010151D"
|
||||||
|
Focusable="True"
|
||||||
|
KeyboardNavigation.TabNavigation="Cycle"
|
||||||
|
KeyDown="OnModalKeyDown"
|
||||||
|
AutomationProperties.Name="First-run setup modal dialog"
|
||||||
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
||||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
|
|
@ -318,8 +349,12 @@
|
||||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||||
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button Content="Close"
|
<Button x:Name="FirstRunCloseButton"
|
||||||
|
Content="Close"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
|
IsCancel="True"
|
||||||
|
IsDefault="True"
|
||||||
|
AutomationProperties.Name="Close first-run setup"
|
||||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
@ -328,6 +363,10 @@
|
||||||
<Border Grid.RowSpan="4"
|
<Border Grid.RowSpan="4"
|
||||||
ZIndex="30"
|
ZIndex="30"
|
||||||
Background="#C010151D"
|
Background="#C010151D"
|
||||||
|
Focusable="True"
|
||||||
|
KeyboardNavigation.TabNavigation="Cycle"
|
||||||
|
KeyDown="OnModalKeyDown"
|
||||||
|
AutomationProperties.Name="Update prompt modal dialog"
|
||||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
||||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
|
|
@ -336,8 +375,12 @@
|
||||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||||
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button Content="Close"
|
<Button x:Name="UpdateCloseButton"
|
||||||
|
Content="Close"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
|
IsCancel="True"
|
||||||
|
IsDefault="True"
|
||||||
|
AutomationProperties.Name="Close update prompt"
|
||||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
using System.ComponentModel;
|
||||||
using AcDream.Launcher.ViewModels;
|
using AcDream.Launcher.ViewModels;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
|
@ -8,6 +10,9 @@ namespace AcDream.Launcher;
|
||||||
public sealed partial class MainWindow : Window
|
public sealed partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private readonly DispatcherTimer _statusTimer;
|
private readonly DispatcherTimer _statusTimer;
|
||||||
|
private LauncherWindowViewModel? _observedViewModel;
|
||||||
|
private Control? _focusBeforeModal;
|
||||||
|
private bool _wasModalOpen;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
|
|
@ -17,6 +22,7 @@ public sealed partial class MainWindow : Window
|
||||||
Interval = TimeSpan.FromMilliseconds(250),
|
Interval = TimeSpan.FromMilliseconds(250),
|
||||||
};
|
};
|
||||||
_statusTimer.Tick += OnStatusTimerTick;
|
_statusTimer.Tick += OnStatusTimerTick;
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
Opened += OnOpened;
|
Opened += OnOpened;
|
||||||
Closed += OnClosed;
|
Closed += OnClosed;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +33,8 @@ public sealed partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
_statusTimer.Stop();
|
_statusTimer.Stop();
|
||||||
_statusTimer.Tick -= OnStatusTimerTick;
|
_statusTimer.Tick -= OnStatusTimerTick;
|
||||||
|
DataContextChanged -= OnDataContextChanged;
|
||||||
|
ObserveViewModel(null);
|
||||||
Opened -= OnOpened;
|
Opened -= OnOpened;
|
||||||
Closed -= OnClosed;
|
Closed -= OnClosed;
|
||||||
}
|
}
|
||||||
|
|
@ -38,4 +46,92 @@ public sealed partial class MainWindow : Window
|
||||||
viewModel.PollStatus();
|
viewModel.PollStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e) =>
|
||||||
|
ObserveViewModel(DataContext as LauncherWindowViewModel);
|
||||||
|
|
||||||
|
private void ObserveViewModel(LauncherWindowViewModel? viewModel)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_observedViewModel, viewModel))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_observedViewModel is not null)
|
||||||
|
{
|
||||||
|
_observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_observedViewModel = viewModel;
|
||||||
|
if (_observedViewModel is not null)
|
||||||
|
{
|
||||||
|
_observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wasModalOpen = viewModel?.IsModalOpen == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
|
||||||
|
|| sender is not LauncherWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isModalOpen = viewModel.IsModalOpen;
|
||||||
|
if (isModalOpen && !_wasModalOpen)
|
||||||
|
{
|
||||||
|
_focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
|
||||||
|
Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
|
||||||
|
}
|
||||||
|
else if (!isModalOpen && _wasModalOpen)
|
||||||
|
{
|
||||||
|
Control? focusToRestore = _focusBeforeModal;
|
||||||
|
_focusBeforeModal = null;
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (focusToRestore?.Focus() != true)
|
||||||
|
{
|
||||||
|
ProfilesTree.Focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_wasModalOpen = isModalOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FocusActiveModal(LauncherWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (viewModel.EditorDialog.IsOpen)
|
||||||
|
{
|
||||||
|
Control target = viewModel.EditorDialog.Kind switch
|
||||||
|
{
|
||||||
|
ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
|
||||||
|
ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
|
||||||
|
ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
|
||||||
|
_ => EditorSubmitButton,
|
||||||
|
};
|
||||||
|
target.Focus();
|
||||||
|
}
|
||||||
|
else if (viewModel.FirstRunWizardShell.IsOpen)
|
||||||
|
{
|
||||||
|
FirstRunCloseButton.Focus();
|
||||||
|
}
|
||||||
|
else if (viewModel.UpdatePromptShell.IsOpen)
|
||||||
|
{
|
||||||
|
UpdateCloseButton.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnModalKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.CloseActiveModal();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@ namespace AcDream.Launcher;
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) =>
|
public static int Main(string[] args)
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
{
|
||||||
|
if (args is ["--verify-publish"])
|
||||||
|
{
|
||||||
|
// A display-free execution probe for the packaged artifact. CI
|
||||||
|
// runs this with DOTNET_ROOT pointing at a missing directory; a
|
||||||
|
// framework-dependent publish cannot reach this return statement.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
AppBuilder.Configure<App>()
|
AppBuilder.Configure<App>()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,13 @@ public sealed class RelayCommand : ICommand
|
||||||
|
|
||||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||||
|
|
||||||
public void Execute(object? parameter) => _execute(parameter);
|
public void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
if (CanExecute(parameter))
|
||||||
|
{
|
||||||
|
_execute(parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void NotifyCanExecuteChanged() =>
|
public void NotifyCanExecuteChanged() =>
|
||||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ public sealed class LauncherSessionRowViewModel
|
||||||
{
|
{
|
||||||
public LauncherSessionRowViewModel(
|
public LauncherSessionRowViewModel(
|
||||||
LauncherSessionSnapshot snapshot,
|
LauncherSessionSnapshot snapshot,
|
||||||
Func<string, Task> stop)
|
Func<string, Task> stop,
|
||||||
|
Func<bool>? canStop = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(snapshot);
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
ArgumentNullException.ThrowIfNull(stop);
|
ArgumentNullException.ThrowIfNull(stop);
|
||||||
|
|
@ -24,7 +25,7 @@ public sealed class LauncherSessionRowViewModel
|
||||||
IsActive = snapshot.IsActive;
|
IsActive = snapshot.IsActive;
|
||||||
StopCommand = new AsyncRelayCommand(
|
StopCommand = new AsyncRelayCommand(
|
||||||
() => stop(SessionId),
|
() => stop(SessionId),
|
||||||
() => IsActive);
|
() => IsActive && (canStop?.Invoke() ?? true));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string SessionId { get; }
|
public string SessionId { get; }
|
||||||
|
|
@ -44,4 +45,6 @@ public sealed class LauncherSessionRowViewModel
|
||||||
public bool IsActive { get; }
|
public bool IsActive { get; }
|
||||||
|
|
||||||
public AsyncRelayCommand StopCommand { get; }
|
public AsyncRelayCommand StopCommand { get; }
|
||||||
|
|
||||||
|
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ public sealed class LauncherShellViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private bool _isOpen;
|
private bool _isOpen;
|
||||||
|
|
||||||
public LauncherShellViewModel(string title, string body, string status)
|
public LauncherShellViewModel(
|
||||||
|
string title,
|
||||||
|
string body,
|
||||||
|
string status,
|
||||||
|
Func<bool>? canOpen = null)
|
||||||
{
|
{
|
||||||
Title = title;
|
Title = title;
|
||||||
Body = body;
|
Body = body;
|
||||||
Status = status;
|
Status = status;
|
||||||
OpenCommand = new RelayCommand(() => IsOpen = true);
|
OpenCommand = new RelayCommand(() => IsOpen = true, canOpen);
|
||||||
CloseCommand = new RelayCommand(() => IsOpen = false);
|
CloseCommand = new RelayCommand(() => IsOpen = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,4 +32,10 @@ public sealed class LauncherShellViewModel : ObservableObject
|
||||||
public RelayCommand OpenCommand { get; }
|
public RelayCommand OpenCommand { get; }
|
||||||
|
|
||||||
public RelayCommand CloseCommand { get; }
|
public RelayCommand CloseCommand { get; }
|
||||||
|
|
||||||
|
public void NotifyCommandStates()
|
||||||
|
{
|
||||||
|
OpenCommand.NotifyCanExecuteChanged();
|
||||||
|
CloseCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
|
@ -33,22 +34,28 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
"Choose and validate the retail DAT directory, build acdream.pak, "
|
"Choose and validate the retail DAT directory, build acdream.pak, "
|
||||||
+ "and record the installed client. The installer transaction and "
|
+ "and record the installed client. The installer transaction and "
|
||||||
+ "progress body land in Campaign LA slice LA9.",
|
+ "progress body land in Campaign LA slice LA9.",
|
||||||
"Installer shell ready — implementation arrives in LA9.");
|
"Installer shell ready — implementation arrives in LA9.",
|
||||||
|
() => CanInteract);
|
||||||
UpdatePromptShell = new LauncherShellViewModel(
|
UpdatePromptShell = new LauncherShellViewModel(
|
||||||
"Client update",
|
"Client update",
|
||||||
"Review a signed release manifest, verify the downloaded archive, "
|
"Review a signed release manifest, verify the downloaded archive, "
|
||||||
+ "and atomically switch the installed client version. The updater "
|
+ "and atomically switch the installed client version. The updater "
|
||||||
+ "transaction lands in Campaign LA slice LA10.",
|
+ "transaction lands in Campaign LA slice LA10.",
|
||||||
"Updater shell ready — implementation arrives in LA10.");
|
"Updater shell ready — implementation arrives in LA10.",
|
||||||
|
() => CanInteract);
|
||||||
|
|
||||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
|
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
||||||
|
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
||||||
|
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
|
||||||
|
|
||||||
|
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
||||||
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||||
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
|
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
|
||||||
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
|
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
|
||||||
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
|
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
|
||||||
SaveCharacterSettingsCommand = new RelayCommand(
|
SaveCharacterSettingsCommand = new RelayCommand(
|
||||||
SaveCharacterSettings,
|
SaveCharacterSettings,
|
||||||
() => IsCharacterSelected && !IsBusy);
|
() => IsCharacterSelected && CanInteract);
|
||||||
RefreshCharactersCommand = new AsyncRelayCommand(
|
RefreshCharactersCommand = new AsyncRelayCommand(
|
||||||
RefreshCharactersAsync,
|
RefreshCharactersAsync,
|
||||||
() => CanProbe);
|
() => CanProbe);
|
||||||
|
|
@ -58,15 +65,18 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
||||||
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
||||||
() => CanLaunchGuiSelect);
|
() => CanLaunchGuiSelect);
|
||||||
|
LaunchAccountGuiSelectCommand = new AsyncRelayCommand(
|
||||||
|
LaunchSelectedAccountGuiSelectAsync,
|
||||||
|
() => CanLaunchAccountGuiSelect);
|
||||||
LaunchHeadlessCommand = new AsyncRelayCommand(
|
LaunchHeadlessCommand = new AsyncRelayCommand(
|
||||||
() => LaunchSelectedAsync(LaunchMode.Headless),
|
() => LaunchSelectedAsync(LaunchMode.Headless),
|
||||||
() => CanLaunchHeadless);
|
() => CanLaunchHeadless);
|
||||||
CancelOperationCommand = new RelayCommand(
|
CancelOperationCommand = new RelayCommand(
|
||||||
CancelOperation,
|
CancelOperation,
|
||||||
() => IsBusy && _operationCancellation is not null);
|
() => !IsModalOpen && IsBusy && _operationCancellation is not null);
|
||||||
ClearFinishedSessionsCommand = new RelayCommand(
|
ClearFinishedSessionsCommand = new RelayCommand(
|
||||||
_orchestrator.ClearFinishedSessions,
|
_orchestrator.ClearFinishedSessions,
|
||||||
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
|
() => Sessions.Any(session => !session.IsActive) && CanInteract);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
||||||
|
|
@ -99,6 +109,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
OnPropertyChanged(nameof(CanLaunchGui));
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||||
NotifyCommandStates();
|
NotifyCommandStates();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +131,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
|
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
|
||||||
|
|
||||||
|
public bool IsModalOpen =>
|
||||||
|
EditorDialog.IsOpen
|
||||||
|
|| FirstRunWizardShell.IsOpen
|
||||||
|
|| UpdatePromptShell.IsOpen;
|
||||||
|
|
||||||
|
private bool CanInteract => !IsBusy && !IsModalOpen;
|
||||||
|
|
||||||
public string OperationStatus
|
public string OperationStatus
|
||||||
{
|
{
|
||||||
get => _operationStatus;
|
get => _operationStatus;
|
||||||
|
|
@ -172,7 +192,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
|
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
|
||||||
|
|
||||||
public bool CanProbe =>
|
public bool CanProbe =>
|
||||||
!IsBusy
|
CanInteract
|
||||||
&& TryGetSelectedAccount(out string server, out string account)
|
&& TryGetSelectedAccount(out string server, out string account)
|
||||||
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
|
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
|
||||||
|
|
||||||
|
|
@ -196,14 +216,49 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
||||||
|
|
||||||
|
public bool CanLaunchAccountGuiSelect =>
|
||||||
|
CanInteract
|
||||||
|
&& IsAccountSelected
|
||||||
|
&& TryGetSelectedAccount(out string server, out string account)
|
||||||
|
&& _orchestrator.GetAccountLaunchCapability(
|
||||||
|
server,
|
||||||
|
account,
|
||||||
|
LaunchMode.GuiSelect).IsAvailable;
|
||||||
|
|
||||||
|
public string AccountGuiSelectDisabledReason
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedAccount(out string server, out string account))
|
||||||
|
{
|
||||||
|
return "Select an account.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _orchestrator.GetAccountLaunchCapability(
|
||||||
|
server,
|
||||||
|
account,
|
||||||
|
LaunchMode.GuiSelect).Reason
|
||||||
|
?? "Character-select launch is available.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string GuiLaunchDisabledReason =>
|
public string GuiLaunchDisabledReason =>
|
||||||
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
|
GetSelectedAccountLaunchCapability(LaunchMode.Gui).Reason
|
||||||
?? "Graphical launch is available.";
|
?? "Graphical launch is available.";
|
||||||
|
|
||||||
public string HeadlessLaunchDisabledReason =>
|
public string HeadlessLaunchDisabledReason =>
|
||||||
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
|
GetSelectedAccountLaunchCapability(LaunchMode.Headless).Reason
|
||||||
?? "Headless launch is available.";
|
?? "Headless launch is available.";
|
||||||
|
|
||||||
|
public bool ShowGuiLaunchDisabledReason =>
|
||||||
|
IsCharacterSelected
|
||||||
|
&& (!GetSelectedAccountLaunchCapability(LaunchMode.Gui).IsAvailable
|
||||||
|
|| !GetSelectedAccountLaunchCapability(LaunchMode.GuiSelect).IsAvailable);
|
||||||
|
|
||||||
|
public bool ShowHeadlessLaunchDisabledReason =>
|
||||||
|
IsCharacterSelected
|
||||||
|
&& !GetSelectedAccountLaunchCapability(LaunchMode.Headless).IsAvailable;
|
||||||
|
|
||||||
public RelayCommand AddServerCommand { get; }
|
public RelayCommand AddServerCommand { get; }
|
||||||
|
|
||||||
public RelayCommand AddAccountCommand { get; }
|
public RelayCommand AddAccountCommand { get; }
|
||||||
|
|
@ -222,6 +277,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
|
||||||
|
|
||||||
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||||
|
|
||||||
public RelayCommand CancelOperationCommand { get; }
|
public RelayCommand CancelOperationCommand { get; }
|
||||||
|
|
@ -272,6 +329,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
_operationCancellation?.Dispose();
|
_operationCancellation?.Dispose();
|
||||||
_operationCancellation = null;
|
_operationCancellation = null;
|
||||||
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||||
|
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||||
|
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||||
|
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||||
|
|
@ -283,6 +343,41 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
||||||
|
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(IsModalOpen));
|
||||||
|
OnPropertyChanged(nameof(CanProbe));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseActiveModal()
|
||||||
|
{
|
||||||
|
if (EditorDialog.IsOpen)
|
||||||
|
{
|
||||||
|
EditorDialog.Close();
|
||||||
|
}
|
||||||
|
else if (FirstRunWizardShell.IsOpen)
|
||||||
|
{
|
||||||
|
FirstRunWizardShell.IsOpen = false;
|
||||||
|
}
|
||||||
|
else if (UpdatePromptShell.IsOpen)
|
||||||
|
{
|
||||||
|
UpdatePromptShell.IsOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshFromCore(SelectionKey? preferredSelection = null)
|
private void RefreshFromCore(SelectionKey? preferredSelection = null)
|
||||||
{
|
{
|
||||||
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
|
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
|
||||||
|
|
@ -298,7 +393,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
Sessions.Clear();
|
Sessions.Clear();
|
||||||
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
|
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
|
||||||
{
|
{
|
||||||
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
|
Sessions.Add(new LauncherSessionRowViewModel(
|
||||||
|
session,
|
||||||
|
StopSessionAsync,
|
||||||
|
() => CanInteract));
|
||||||
}
|
}
|
||||||
|
|
||||||
LauncherTreeNodeViewModel? restored = previousSelection is null
|
LauncherTreeNodeViewModel? restored = previousSelection is null
|
||||||
|
|
@ -317,8 +415,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
OnPropertyChanged(nameof(CanLaunchGui));
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||||
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||||
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||||
NotifyCommandStates();
|
NotifyCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,6 +455,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
OnPropertyChanged(nameof(CanLaunchGui));
|
OnPropertyChanged(nameof(CanLaunchGui));
|
||||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||||
|
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||||
|
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||||
NotifyCommandStates();
|
NotifyCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,7 +495,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
null));
|
null));
|
||||||
});
|
});
|
||||||
|
|
||||||
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
|
private bool CanAddAccount() => CanInteract && SelectedNode is not null;
|
||||||
|
|
||||||
private void OpenAddAccountDialog()
|
private void OpenAddAccountDialog()
|
||||||
{
|
{
|
||||||
|
|
@ -411,7 +519,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanAddCharacter() =>
|
private bool CanAddCharacter() =>
|
||||||
!IsBusy && TryGetSelectedAccount(out _, out _);
|
CanInteract && TryGetSelectedAccount(out _, out _);
|
||||||
|
|
||||||
private void OpenAddCharacterDialog()
|
private void OpenAddCharacterDialog()
|
||||||
{
|
{
|
||||||
|
|
@ -441,7 +549,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
+ "let you configure a known character while the server is unavailable.");
|
+ "let you configure a known character while the server is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
|
private bool CanEditSelected() => CanInteract && SelectedNode is not null;
|
||||||
|
|
||||||
private void OpenEditSelectedDialog()
|
private void OpenEditSelectedDialog()
|
||||||
{
|
{
|
||||||
|
|
@ -642,6 +750,24 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
$"{mode} session started for {character.Name}.");
|
$"{mode} session started for {character.Name}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task LaunchSelectedAccountGuiSelectAsync()
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunOperationAsync(
|
||||||
|
token => _orchestrator.LaunchAsync(
|
||||||
|
serverName,
|
||||||
|
accountName,
|
||||||
|
null,
|
||||||
|
LaunchMode.GuiSelect,
|
||||||
|
token),
|
||||||
|
$"Opening character select for {accountName}…",
|
||||||
|
$"Character-select session started for {accountName}.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RunOperationAsync(
|
private async Task RunOperationAsync(
|
||||||
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
|
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
|
||||||
string activeStatus,
|
string activeStatus,
|
||||||
|
|
@ -728,9 +854,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
private void CancelOperation() => _operationCancellation?.Cancel();
|
private void CancelOperation() => _operationCancellation?.Cancel();
|
||||||
|
|
||||||
private bool CanLaunch(LaunchMode mode) =>
|
private bool CanLaunch(LaunchMode mode) =>
|
||||||
!IsBusy
|
CanInteract
|
||||||
&& IsCharacterSelected
|
&& IsCharacterSelected
|
||||||
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
|
&& TryGetSelectedAccount(out string server, out string account)
|
||||||
|
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
|
||||||
|
|
||||||
|
private LauncherCapability GetSelectedAccountLaunchCapability(LaunchMode mode) =>
|
||||||
|
TryGetSelectedAccount(out string server, out string account)
|
||||||
|
? _orchestrator.GetAccountLaunchCapability(server, account, mode)
|
||||||
|
: _orchestrator.GetLaunchCapability(mode);
|
||||||
|
|
||||||
private bool TryGetSelectedAccount(out string serverName, out string accountName)
|
private bool TryGetSelectedAccount(out string serverName, out string accountName)
|
||||||
{
|
{
|
||||||
|
|
@ -843,9 +975,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
||||||
LaunchGuiCommand.NotifyCanExecuteChanged();
|
LaunchGuiCommand.NotifyCanExecuteChanged();
|
||||||
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
||||||
|
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
|
||||||
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||||
CancelOperationCommand.NotifyCanExecuteChanged();
|
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||||
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
||||||
|
foreach (LauncherSessionRowViewModel session in Sessions)
|
||||||
|
{
|
||||||
|
session.NotifyCommandState();
|
||||||
|
}
|
||||||
|
FirstRunWizardShell.NotifyCommandStates();
|
||||||
|
UpdatePromptShell.NotifyCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct SelectionKey(
|
private readonly record struct SelectionKey(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Orchestration;
|
||||||
|
|
||||||
|
public sealed class LauncherExecutableSetTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-launcher-layout-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDirectoryResolvesThePublishedCoDeploymentLayout()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
|
string graphical = Path.Combine(_root, "AcDream.App" + suffix);
|
||||||
|
string headless = Path.Combine(_root, "acdream-headless" + suffix);
|
||||||
|
File.WriteAllText(graphical, string.Empty);
|
||||||
|
File.WriteAllText(headless, string.Empty);
|
||||||
|
|
||||||
|
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(_root), set.WorkingDirectory);
|
||||||
|
Assert.Equal(graphical, set.GraphicalHostPath);
|
||||||
|
Assert.Equal(headless, set.HeadlessHostPath);
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||||
|
Assert.Equal(
|
||||||
|
graphical,
|
||||||
|
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json").ExecutablePath);
|
||||||
|
Assert.Equal(
|
||||||
|
headless,
|
||||||
|
set.CreateProbeSpec("session.json").ExecutablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingPublishedHostsHaveSpecificUnavailableReasons()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||||
|
|
||||||
|
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
|
||||||
|
LauncherCapability probe = set.GetAvailability(LaunchMode.Headless);
|
||||||
|
|
||||||
|
Assert.False(gui.IsAvailable);
|
||||||
|
Assert.Contains("graphical client", gui.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(set.GraphicalHostPath, gui.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.False(probe.IsAvailable);
|
||||||
|
Assert.Contains("headless host", probe.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(set.HeadlessHostPath, probe.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Throws<LauncherOperationException>(() =>
|
||||||
|
set.CreatePlaySpec(LaunchMode.Gui, "session.json"));
|
||||||
|
Assert.Throws<LauncherOperationException>(() =>
|
||||||
|
set.CreateProbeSpec("session.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
|
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
|
||||||
|
{
|
||||||
|
var config = new RecordingConfigService();
|
||||||
|
var supervisors = new FakeSupervisorFactory();
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
includeCharacter: false,
|
||||||
|
configService: config,
|
||||||
|
supervisorFactory: supervisors);
|
||||||
|
|
||||||
|
Assert.True(orchestrator.GetAccountLaunchCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
LaunchMode.GuiSelect).IsAvailable);
|
||||||
|
|
||||||
|
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
characterName: null,
|
||||||
|
LaunchMode.GuiSelect);
|
||||||
|
|
||||||
|
Assert.Null(launched.CharacterName);
|
||||||
|
Assert.Equal(LaunchMode.GuiSelect, config.LastCharacter!.LaunchMode);
|
||||||
|
Assert.Equal(string.Empty, config.LastCharacter.Name);
|
||||||
|
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
|
||||||
|
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("gui-host", Assert.Single(supervisors.Created).Spec!.ExecutablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LaunchMode.Gui)]
|
||||||
|
[InlineData(LaunchMode.Headless)]
|
||||||
|
public async Task AccountLaunchWithoutACharacterOnlyAcceptsGuiSelect(LaunchMode mode)
|
||||||
|
{
|
||||||
|
var config = new RecordingConfigService();
|
||||||
|
var supervisors = new FakeSupervisorFactory();
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
includeCharacter: false,
|
||||||
|
configService: config,
|
||||||
|
supervisorFactory: supervisors);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||||
|
orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
characterName: null,
|
||||||
|
mode));
|
||||||
|
|
||||||
|
Assert.Equal(0, config.PlayCallCount);
|
||||||
|
Assert.Empty(supervisors.Created);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ProbeIsRefusedWhileTheAccountHasARunningLauncherActivity()
|
public async Task ProbeIsRefusedWhileTheAccountHasARunningLauncherActivity()
|
||||||
{
|
{
|
||||||
|
|
@ -126,6 +179,58 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
orchestrator.ProbeAsync("Local ACE", "testaccount"));
|
orchestrator.ProbeAsync("Local ACE", "testaccount"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LaunchIsRefusedWhileTheAccountProbeIsRunning()
|
||||||
|
{
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||||
|
await orchestrator.ProbeAsync("Local ACE", "testaccount");
|
||||||
|
|
||||||
|
LauncherCapability capability = orchestrator.GetAccountLaunchCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
LaunchMode.Headless);
|
||||||
|
|
||||||
|
Assert.False(capability.IsAvailable);
|
||||||
|
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||||
|
orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentPlayReservationsAllowExactlyOneActivityPerAccount()
|
||||||
|
{
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||||
|
|
||||||
|
Task<LauncherSessionSnapshot>[] attempts = Enumerable.Range(0, 2)
|
||||||
|
.Select(_ => Task.Run(async () => await orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(attempts);
|
||||||
|
}
|
||||||
|
catch (LauncherOperationException)
|
||||||
|
{
|
||||||
|
// The losing reservation is the behavior under test.
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<LauncherSessionSnapshot> successful = Assert.Single(
|
||||||
|
attempts,
|
||||||
|
attempt => attempt.Status == TaskStatus.RanToCompletion);
|
||||||
|
Task<LauncherSessionSnapshot> rejected = Assert.Single(attempts, attempt =>
|
||||||
|
attempt.Exception?.GetBaseException() is LauncherOperationException);
|
||||||
|
Assert.True(successful.IsCompletedSuccessfully);
|
||||||
|
Assert.True(rejected.IsFaulted);
|
||||||
|
Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
|
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
|
||||||
{
|
{
|
||||||
|
|
@ -206,6 +311,103 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
Assert.Empty(supervisors.Created);
|
Assert.Empty(supervisors.Created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MissingCoDeployedHostsDisableActionsBeforeCompositionOrSpawn()
|
||||||
|
{
|
||||||
|
var config = new RecordingConfigService();
|
||||||
|
var supervisors = new FakeSupervisorFactory();
|
||||||
|
var executables = new LauncherExecutableSet(
|
||||||
|
"missing-gui",
|
||||||
|
"missing-headless",
|
||||||
|
fileExists: _ => false);
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
configService: config,
|
||||||
|
supervisorFactory: supervisors,
|
||||||
|
executables: executables);
|
||||||
|
|
||||||
|
LauncherCapability gui = orchestrator.GetAccountLaunchCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
LaunchMode.GuiSelect);
|
||||||
|
LauncherCapability headless = orchestrator.GetAccountLaunchCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
LaunchMode.Headless);
|
||||||
|
LauncherCapability probe = orchestrator.GetProbeCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount");
|
||||||
|
|
||||||
|
Assert.False(gui.IsAvailable);
|
||||||
|
Assert.Contains("missing-gui", gui.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.False(headless.IsAvailable);
|
||||||
|
Assert.Contains("missing-headless", headless.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.False(probe.IsAvailable);
|
||||||
|
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||||
|
orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless));
|
||||||
|
Assert.Equal(0, config.PlayCallCount);
|
||||||
|
Assert.Empty(supervisors.Created);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessExitIsTerminalAndLateStatusCannotResurrectTheAccount()
|
||||||
|
{
|
||||||
|
var supervisors = new FakeSupervisorFactory();
|
||||||
|
var statusSources = new QueueStatusSourceFactory();
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
supervisorFactory: supervisors,
|
||||||
|
statusSourceFactory: statusSources);
|
||||||
|
await orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless);
|
||||||
|
|
||||||
|
Assert.Single(supervisors.Created).Exit(23);
|
||||||
|
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||||
|
source.Enqueue(Connected("s1"));
|
||||||
|
source.Enqueue(EnteredWorld("s1", "+Acdream"));
|
||||||
|
source.Enqueue(Exited("s1", 23, "host crash detail"));
|
||||||
|
orchestrator.PollStatus();
|
||||||
|
|
||||||
|
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||||
|
Assert.Equal(LauncherActivityState.Exited, session.State);
|
||||||
|
Assert.Equal(23, session.ExitCode);
|
||||||
|
Assert.Contains("host crash detail", session.Status, StringComparison.Ordinal);
|
||||||
|
Assert.True(orchestrator.GetProbeCapability("Local ACE", "testaccount").IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostExitReasonSurvivesTheLaterProcessExitCallback()
|
||||||
|
{
|
||||||
|
var supervisors = new FakeSupervisorFactory();
|
||||||
|
var statusSources = new QueueStatusSourceFactory();
|
||||||
|
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
supervisorFactory: supervisors,
|
||||||
|
statusSourceFactory: statusSources);
|
||||||
|
await orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless);
|
||||||
|
|
||||||
|
Assert.Single(statusSources.Created).Enqueue(
|
||||||
|
Exited("s1", 0, "graceful host shutdown"));
|
||||||
|
orchestrator.PollStatus();
|
||||||
|
Assert.Single(supervisors.Created).Exit(0);
|
||||||
|
|
||||||
|
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||||
|
Assert.Equal(LauncherActivityState.Exited, session.State);
|
||||||
|
Assert.Contains("graceful host shutdown", session.Status, StringComparison.Ordinal);
|
||||||
|
Assert.True(orchestrator.GetAccountLaunchCapability(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
LaunchMode.Headless).IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartFailureIsVisibleButRedactsTheCredentialEverywhere()
|
public async Task StartFailureIsVisibleButRedactsTheCredentialEverywhere()
|
||||||
{
|
{
|
||||||
|
|
@ -286,7 +488,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
LauncherPlatformCapabilities? platform = null,
|
LauncherPlatformCapabilities? platform = null,
|
||||||
ILauncherSessionConfigService? configService = null,
|
ILauncherSessionConfigService? configService = null,
|
||||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||||
IStatusEventSourceFactory? statusSourceFactory = null)
|
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||||
|
LauncherExecutableSet? executables = null)
|
||||||
{
|
{
|
||||||
string profilePath = Path.Combine(
|
string profilePath = Path.Combine(
|
||||||
_paths.ConfigDirectory,
|
_paths.ConfigDirectory,
|
||||||
|
|
@ -312,7 +515,10 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
var orchestrator = new LauncherOrchestrator(
|
var orchestrator = new LauncherOrchestrator(
|
||||||
store,
|
store,
|
||||||
_paths,
|
_paths,
|
||||||
new LauncherExecutableSet("gui-host", "headless-host"),
|
executables ?? new LauncherExecutableSet(
|
||||||
|
"gui-host",
|
||||||
|
"headless-host",
|
||||||
|
fileExists: _ => true),
|
||||||
new LauncherInstallRecord("dats", "pak"),
|
new LauncherInstallRecord("dats", "pak"),
|
||||||
platform ?? WindowsCapabilities(),
|
platform ?? WindowsCapabilities(),
|
||||||
configService,
|
configService,
|
||||||
|
|
@ -357,6 +563,20 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
CharacterName = characterName,
|
CharacterName = characterName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static ExitedStatusEvent Exited(
|
||||||
|
string sessionId,
|
||||||
|
int code,
|
||||||
|
string reason) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
V = 1,
|
||||||
|
E = "exited",
|
||||||
|
T = DateTimeOffset.UtcNow,
|
||||||
|
SessionId = sessionId,
|
||||||
|
Code = code,
|
||||||
|
Reason = reason,
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class RecordingConfigService : ILauncherSessionConfigService
|
private sealed class RecordingConfigService : ILauncherSessionConfigService
|
||||||
{
|
{
|
||||||
public int PlayCallCount { get; private set; }
|
public int PlayCallCount { get; private set; }
|
||||||
|
|
@ -462,6 +682,13 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
StateChanged?.Invoke(this, State);
|
StateChanged?.Invoke(this, State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Exit(int code)
|
||||||
|
{
|
||||||
|
State = LauncherSessionState.Exited;
|
||||||
|
ExitCode = code;
|
||||||
|
StateChanged?.Invoke(this, State);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Profiles;
|
||||||
|
|
||||||
|
public sealed class LauncherProfileHardeningTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-launcher-profile-hardening-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly string _filePath;
|
||||||
|
|
||||||
|
public LauncherProfileHardeningTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
_filePath = Path.Combine(_root, "launcher-profiles.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiFieldEditValidatesEverythingBeforeChangingAnything()
|
||||||
|
{
|
||||||
|
LauncherProfileStore store = CreatePopulatedStore();
|
||||||
|
|
||||||
|
Assert.Throws<LauncherProfileException>(() =>
|
||||||
|
store.EditServer(
|
||||||
|
"Local ACE",
|
||||||
|
newName: "Partially renamed",
|
||||||
|
newHost: "changed.example.test",
|
||||||
|
newPort: 0));
|
||||||
|
ServerProfile server = Assert.Single(store.Document.Servers);
|
||||||
|
Assert.Equal("Local ACE", server.Name);
|
||||||
|
Assert.Equal("127.0.0.1", server.Host);
|
||||||
|
Assert.Equal(9000, server.Port);
|
||||||
|
|
||||||
|
Assert.Throws<LauncherProfileException>(() =>
|
||||||
|
store.EditCharacter(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
newName: "+PartiallyRenamed",
|
||||||
|
newId: "not-an-id"));
|
||||||
|
CharacterProfile character = Assert.Single(server.Accounts.Single().Characters);
|
||||||
|
Assert.Equal("+Acdream", character.Name);
|
||||||
|
Assert.Equal("0x5000000A", character.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransactionRestoresTheExactDocumentWhenPersistenceFails()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_filePath);
|
||||||
|
var store = new LauncherProfileStore(_filePath);
|
||||||
|
store.Load();
|
||||||
|
|
||||||
|
Assert.ThrowsAny<Exception>(() =>
|
||||||
|
store.ExecuteTransaction(() =>
|
||||||
|
store.AddServer("Should roll back", "host", 9000)));
|
||||||
|
|
||||||
|
Assert.Empty(store.Document.Servers);
|
||||||
|
Assert.False(File.Exists(_filePath + ".tmp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransactionRestoresNestedCredentialAndSettingsOnMutationFailure()
|
||||||
|
{
|
||||||
|
LauncherProfileStore store = CreatePopulatedStore();
|
||||||
|
string before = JsonSerializer.Serialize(store.Document);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
store.ExecuteTransaction(() =>
|
||||||
|
{
|
||||||
|
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
|
||||||
|
account.Password = "transient-secret";
|
||||||
|
account.Characters.Single().Plugins.Add("Transient.Plugin");
|
||||||
|
throw new InvalidOperationException("simulated mutation failure");
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal(before, JsonSerializer.Serialize(store.Document));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<string> InvalidDocuments => new()
|
||||||
|
{
|
||||||
|
{ """{"version":1,"servers":null}""" },
|
||||||
|
{ """{"version":1,"servers":[null]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":" ","host":"h","port":9000,"accounts":[]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":" ","port":9000,"accounts":[]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":0,"accounts":[]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[]},{"name":"s","host":"h2","port":9001,"accounts":[]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[]},{"account":"a","password":"p2","characters":[]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":null,"characters":[]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":null}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x00000000","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"invalid","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":null,"loginCommands":[]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":["P","P"],"loginCommands":[]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[" "]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c","id":"0x50000002","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||||
|
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c1","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c2","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidDocuments))]
|
||||||
|
public void LoadRejectsSemanticallyInvalidDocuments(string json)
|
||||||
|
{
|
||||||
|
File.WriteAllText(_filePath, json);
|
||||||
|
var store = new LauncherProfileStore(_filePath);
|
||||||
|
|
||||||
|
Assert.Throws<LauncherProfileException>(() => store.Load());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedLoadDoesNotReplaceAnAlreadyLoadedDocument()
|
||||||
|
{
|
||||||
|
LauncherProfileStore store = CreatePopulatedStore();
|
||||||
|
File.WriteAllText(_filePath, """{"version":1,"servers":null}""");
|
||||||
|
|
||||||
|
Assert.Throws<LauncherProfileException>(() => store.Load());
|
||||||
|
|
||||||
|
Assert.Equal("Local ACE", Assert.Single(store.Document.Servers).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LinuxLoadNormalizesAnExistingCredentialFileTo0600BeforeReading()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(_filePath, """{"version":1,"servers":[]}""");
|
||||||
|
File.SetUnixFileMode(
|
||||||
|
_filePath,
|
||||||
|
UnixFileMode.UserRead
|
||||||
|
| UnixFileMode.UserWrite
|
||||||
|
| UnixFileMode.GroupRead
|
||||||
|
| UnixFileMode.OtherRead);
|
||||||
|
|
||||||
|
var store = new LauncherProfileStore(_filePath);
|
||||||
|
Assert.True(store.Load());
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
UnixFileMode.UserRead | UnixFileMode.UserWrite,
|
||||||
|
File.GetUnixFileMode(_filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LauncherProfileStore CreatePopulatedStore()
|
||||||
|
{
|
||||||
|
var store = new LauncherProfileStore(_filePath);
|
||||||
|
store.Load();
|
||||||
|
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||||
|
store.AddAccount("Local ACE", "testaccount", "password");
|
||||||
|
store.AddCharacter(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
"0x5000000A",
|
||||||
|
LaunchMode.Headless,
|
||||||
|
["ExamplePlugin"],
|
||||||
|
["/vt start"]);
|
||||||
|
store.Save();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,66 @@ public sealed class RosterMergeTests
|
||||||
Assert.Equal("+Acdream", character.Name);
|
Assert.Equal("+Acdream", character.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameNameRosterEntryCorrectsAWrongValidIdAndPreservesSettings()
|
||||||
|
{
|
||||||
|
LauncherProfileStore store = NewStoreWithServerAndAccount();
|
||||||
|
store.AddCharacter(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
"0x50000001",
|
||||||
|
LaunchMode.Headless,
|
||||||
|
["ExamplePlugin"],
|
||||||
|
["/vt start"]);
|
||||||
|
|
||||||
|
store.MergeRoster(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
|
||||||
|
|
||||||
|
CharacterProfile character = Assert.Single(
|
||||||
|
store.Document.Servers.Single().Accounts.Single().Characters);
|
||||||
|
Assert.Equal("0x5000000A", character.Id);
|
||||||
|
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
|
||||||
|
Assert.Equal(["ExamplePlugin"], character.Plugins);
|
||||||
|
Assert.Equal(["/vt start"], character.LoginCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthoritativeMergeCollapsesCorrectIdAndSameNameDuplicates()
|
||||||
|
{
|
||||||
|
LauncherProfileStore store = NewStoreWithServerAndAccount();
|
||||||
|
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
|
||||||
|
account.Characters.Add(new CharacterProfile
|
||||||
|
{
|
||||||
|
Id = "0x5000000A",
|
||||||
|
Name = "+OldName",
|
||||||
|
LaunchMode = LaunchMode.Headless,
|
||||||
|
Plugins = ["Canonical.Plugin"],
|
||||||
|
LoginCommands = ["/canonical"],
|
||||||
|
});
|
||||||
|
account.Characters.Add(new CharacterProfile
|
||||||
|
{
|
||||||
|
Id = "0x50000001",
|
||||||
|
Name = "+Acdream",
|
||||||
|
LaunchMode = LaunchMode.Gui,
|
||||||
|
Plugins = ["Duplicate.Plugin"],
|
||||||
|
LoginCommands = ["/duplicate"],
|
||||||
|
});
|
||||||
|
|
||||||
|
store.MergeRoster(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
|
||||||
|
|
||||||
|
CharacterProfile character = Assert.Single(account.Characters);
|
||||||
|
Assert.Equal("0x5000000A", character.Id);
|
||||||
|
Assert.Equal("+Acdream", character.Name);
|
||||||
|
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
|
||||||
|
Assert.Equal(["Canonical.Plugin"], character.Plugins);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MergeThrowsForUnknownServerOrAccount()
|
public void MergeThrowsForUnknownServerOrAccount()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Tests;
|
namespace AcDream.Launcher.Tests;
|
||||||
|
|
@ -56,6 +57,95 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", paths);
|
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LinuxPublishEvaluatesAsSelfContainedSingleFile()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string projectPath = Path.Combine(
|
||||||
|
root,
|
||||||
|
"src",
|
||||||
|
"AcDream.Launcher",
|
||||||
|
"AcDream.Launcher.csproj");
|
||||||
|
|
||||||
|
Assert.Equal("true", EvaluateProperty(projectPath, "SelfContained"));
|
||||||
|
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string markup = File.ReadAllText(Path.Combine(
|
||||||
|
root,
|
||||||
|
"src",
|
||||||
|
"AcDream.Launcher",
|
||||||
|
"MainWindow.axaml"));
|
||||||
|
string codeBehind = File.ReadAllText(Path.Combine(
|
||||||
|
root,
|
||||||
|
"src",
|
||||||
|
"AcDream.Launcher",
|
||||||
|
"MainWindow.axaml.cs"));
|
||||||
|
|
||||||
|
Assert.Equal(3, Count(markup, "KeyboardNavigation.TabNavigation=\"Cycle\""));
|
||||||
|
Assert.Equal(3, Count(markup, "KeyDown=\"OnModalKeyDown\""));
|
||||||
|
Assert.True(Count(markup, "AutomationProperties.Name=") >= 13);
|
||||||
|
Assert.True(Count(markup, "IsDefault=\"True\"") >= 3);
|
||||||
|
Assert.True(Count(markup, "IsCancel=\"True\"") >= 3);
|
||||||
|
Assert.Contains("ServerNameTextBox", markup, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AccountNameTextBox", markup, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CharacterNameTextBox", markup, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FocusActiveModal", codeBehind, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("_focusBeforeModal", codeBehind, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Key.Escape", codeBehind, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PortabilityWorkflowBuildsTestsPublishesAndExecutesTheLauncher()
|
||||||
|
{
|
||||||
|
string workflow = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepositoryRoot(),
|
||||||
|
".github",
|
||||||
|
"workflows",
|
||||||
|
"headless-portability.yml"));
|
||||||
|
|
||||||
|
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EvaluateProperty(string projectPath, string property)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo("dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("msbuild");
|
||||||
|
startInfo.ArgumentList.Add(projectPath);
|
||||||
|
startInfo.ArgumentList.Add("-nologo");
|
||||||
|
startInfo.ArgumentList.Add("-property:RuntimeIdentifier=linux-x64");
|
||||||
|
startInfo.ArgumentList.Add($"-getProperty:{property}");
|
||||||
|
|
||||||
|
using Process process = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not start dotnet msbuild.");
|
||||||
|
string output = process.StandardOutput.ReadToEnd();
|
||||||
|
string error = process.StandardError.ReadToEnd();
|
||||||
|
Assert.True(process.WaitForExit(30_000), "dotnet msbuild did not exit.");
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"dotnet msbuild exited {process.ExitCode}: {error}");
|
||||||
|
return output.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Count(string text, string value) =>
|
||||||
|
text.Split(value, StringSplitOptions.None).Length - 1;
|
||||||
|
|
||||||
private static string FindRepositoryRoot()
|
private static string FindRepositoryRoot()
|
||||||
{
|
{
|
||||||
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
|
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,28 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.Equal("Headless session started for +Acdream.", viewModel.OperationStatus);
|
Assert.Equal("Headless session started for +Acdream.", viewModel.OperationStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccountGuiSelectWorksWithoutAnyCachedCharacter()
|
||||||
|
{
|
||||||
|
using var orchestrator = new FakeLauncherOrchestrator
|
||||||
|
{
|
||||||
|
IncludeCharacter = false,
|
||||||
|
};
|
||||||
|
using var viewModel = CreateInitialized(orchestrator);
|
||||||
|
SelectAccount(viewModel);
|
||||||
|
|
||||||
|
Assert.Empty(viewModel.SelectedNode!.Children);
|
||||||
|
Assert.True(viewModel.CanLaunchAccountGuiSelect);
|
||||||
|
await viewModel.LaunchAccountGuiSelectCommand.ExecuteAsync();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
("Local ACE", "testaccount", (string?)null, LaunchMode.GuiSelect),
|
||||||
|
orchestrator.LaunchRequest);
|
||||||
|
Assert.Equal(
|
||||||
|
"Character-select session started for testaccount.",
|
||||||
|
viewModel.OperationStatus);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
|
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
|
||||||
{
|
{
|
||||||
|
|
@ -201,6 +223,25 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.Contains("parked at L1", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
Assert.Contains("parked at L1", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingCoDeployedHostReasonIsVisibleForCharacterActions()
|
||||||
|
{
|
||||||
|
using var orchestrator = new FakeLauncherOrchestrator
|
||||||
|
{
|
||||||
|
AccountLaunchCapability = LauncherCapability.Unavailable(
|
||||||
|
"The co-deployed host is missing; reinstall or update the client."),
|
||||||
|
};
|
||||||
|
using var viewModel = CreateInitialized(orchestrator);
|
||||||
|
SelectCharacter(viewModel);
|
||||||
|
|
||||||
|
Assert.False(viewModel.CanLaunchGui);
|
||||||
|
Assert.False(viewModel.CanLaunchHeadless);
|
||||||
|
Assert.True(viewModel.ShowGuiLaunchDisabledReason);
|
||||||
|
Assert.True(viewModel.ShowHeadlessLaunchDisabledReason);
|
||||||
|
Assert.Contains("missing", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("missing", viewModel.HeadlessLaunchDisabledReason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LaunchErrorAndCancellationBecomeSafeVisibleState()
|
public async Task LaunchErrorAndCancellationBecomeSafeVisibleState()
|
||||||
{
|
{
|
||||||
|
|
@ -254,6 +295,36 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.False(dialog.IsOpen);
|
Assert.False(dialog.IsOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ModalShellsBlockBackgroundCommandsAndAreMutuallyExclusive()
|
||||||
|
{
|
||||||
|
using var orchestrator = new FakeLauncherOrchestrator();
|
||||||
|
using var viewModel = CreateInitialized(orchestrator);
|
||||||
|
|
||||||
|
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
||||||
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
|
Assert.True(viewModel.IsModalOpen);
|
||||||
|
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
||||||
|
Assert.False(viewModel.UpdatePromptShell.OpenCommand.CanExecute(null));
|
||||||
|
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
||||||
|
|
||||||
|
// ICommand.Execute cannot bypass the modal gate.
|
||||||
|
viewModel.AddServerCommand.Execute(null);
|
||||||
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||||
|
Assert.Null(orchestrator.AddedServer);
|
||||||
|
|
||||||
|
viewModel.CloseActiveModal();
|
||||||
|
Assert.False(viewModel.IsModalOpen);
|
||||||
|
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
||||||
|
|
||||||
|
viewModel.AddServerCommand.Execute(null);
|
||||||
|
Assert.True(viewModel.EditorDialog.IsOpen);
|
||||||
|
Assert.False(viewModel.FirstRunWizardShell.OpenCommand.CanExecute(null));
|
||||||
|
Assert.False(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
|
||||||
|
viewModel.CloseActiveModal();
|
||||||
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
||||||
{
|
{
|
||||||
|
|
@ -320,6 +391,10 @@ public sealed class LauncherWindowViewModelTests
|
||||||
|
|
||||||
public LauncherCapability ProbeCapability { get; set; } = LauncherCapability.Available;
|
public LauncherCapability ProbeCapability { get; set; } = LauncherCapability.Available;
|
||||||
|
|
||||||
|
public LauncherCapability? AccountLaunchCapability { get; set; }
|
||||||
|
|
||||||
|
public bool IncludeCharacter { get; init; } = true;
|
||||||
|
|
||||||
public LauncherSessionSnapshot Session { get; set; } = CreateSession();
|
public LauncherSessionSnapshot Session { get; set; } = CreateSession();
|
||||||
|
|
||||||
public Func<CancellationToken, Task<LauncherSessionSnapshot>>? LaunchHandler { get; set; }
|
public Func<CancellationToken, Task<LauncherSessionSnapshot>>? LaunchHandler { get; set; }
|
||||||
|
|
@ -344,7 +419,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
|
|
||||||
public (LaunchMode Mode, IReadOnlyList<string> Plugins, IReadOnlyList<string> Commands)? SettingsUpdate { 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, string? Character, LaunchMode Mode)? LaunchRequest { get; private set; }
|
||||||
|
|
||||||
public (string Server, string Account)? ProbeRequest { get; private set; }
|
public (string Server, string Account)? ProbeRequest { get; private set; }
|
||||||
|
|
||||||
|
|
@ -362,6 +437,12 @@ public sealed class LauncherWindowViewModelTests
|
||||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||||
Platform.ForLaunchMode(mode);
|
Platform.ForLaunchMode(mode);
|
||||||
|
|
||||||
|
public LauncherCapability GetAccountLaunchCapability(
|
||||||
|
string serverName,
|
||||||
|
string accountName,
|
||||||
|
LaunchMode mode) =>
|
||||||
|
AccountLaunchCapability ?? GetLaunchCapability(mode);
|
||||||
|
|
||||||
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
||||||
ProbeCapability;
|
ProbeCapability;
|
||||||
|
|
||||||
|
|
@ -428,7 +509,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
public Task<LauncherSessionSnapshot> LaunchAsync(
|
public Task<LauncherSessionSnapshot> LaunchAsync(
|
||||||
string serverName,
|
string serverName,
|
||||||
string accountName,
|
string accountName,
|
||||||
string characterName,
|
string? characterName,
|
||||||
LaunchMode mode,
|
LaunchMode mode,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|
@ -481,7 +562,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Error: null,
|
Error: null,
|
||||||
CreatedAt: DateTimeOffset.UnixEpoch);
|
CreatedAt: DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
private static LauncherServerSnapshot CreateServerSnapshot() => new(
|
private LauncherServerSnapshot CreateServerSnapshot() => new(
|
||||||
"Local ACE",
|
"Local ACE",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
9000,
|
9000,
|
||||||
|
|
@ -489,18 +570,21 @@ public sealed class LauncherWindowViewModelTests
|
||||||
new LauncherAccountSnapshot(
|
new LauncherAccountSnapshot(
|
||||||
"Local ACE",
|
"Local ACE",
|
||||||
"testaccount",
|
"testaccount",
|
||||||
[
|
IncludeCharacter
|
||||||
new LauncherCharacterSnapshot(
|
?
|
||||||
"Local ACE",
|
[
|
||||||
"testaccount",
|
new LauncherCharacterSnapshot(
|
||||||
"+Acdream",
|
"Local ACE",
|
||||||
"0x5000000A",
|
"testaccount",
|
||||||
LaunchMode.GuiSelect,
|
"+Acdream",
|
||||||
["Existing.Plugin"],
|
"0x5000000A",
|
||||||
["/tell someone, ready"],
|
LaunchMode.GuiSelect,
|
||||||
HasRunningSession: true,
|
["Existing.Plugin"],
|
||||||
SessionStatus: "Connected."),
|
["/tell someone, ready"],
|
||||||
],
|
HasRunningSession: true,
|
||||||
|
SessionStatus: "Connected."),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
HasRunningActivity: true,
|
HasRunningActivity: true,
|
||||||
ActivityStatus: "Connected."),
|
ActivityStatus: "Connected."),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue