feat(launcher): Campaign LA add Avalonia desktop shell
This commit is contained in:
parent
6c4cd2bbc6
commit
d0a9c65d85
31 changed files with 4831 additions and 16 deletions
|
|
@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices;
|
|||
|
||||
namespace AcDream.Launcher.Core.Launching;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for one supervised launcher child. The Avalonia orchestration
|
||||
/// layer owns this interface through a factory and never constructs or drives
|
||||
/// <see cref="System.Diagnostics.Process"/> directly.
|
||||
/// </summary>
|
||||
public interface ILauncherProcessSupervisor : IDisposable
|
||||
{
|
||||
LauncherSessionState State { get; }
|
||||
|
||||
int? ExitCode { get; }
|
||||
|
||||
event EventHandler<LauncherSessionState>? StateChanged;
|
||||
|
||||
void Start(LauncherProcessSpec spec, string? password);
|
||||
|
||||
void Stop(TimeSpan timeout);
|
||||
}
|
||||
|
||||
public interface ILauncherProcessSupervisorFactory
|
||||
{
|
||||
ILauncherProcessSupervisor Create();
|
||||
}
|
||||
|
||||
public sealed class LauncherProcessSupervisorFactory(
|
||||
ILauncherChildProcessFactory? childProcessFactory = null)
|
||||
: ILauncherProcessSupervisorFactory
|
||||
{
|
||||
private readonly ILauncherChildProcessFactory _childProcessFactory =
|
||||
childProcessFactory ?? new SystemChildProcessFactory();
|
||||
|
||||
public ILauncherProcessSupervisor Create() =>
|
||||
new LauncherProcessSupervisor(_childProcessFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns a host process (App/Headless), feeds the account password to
|
||||
/// its stdin then closes it, and supervises its lifetime (Campaign LA
|
||||
/// spec §3/§6). One supervisor instance owns exactly one child process
|
||||
/// for its lifetime — start a new supervisor per launched session.
|
||||
/// </summary>
|
||||
public sealed class LauncherProcessSupervisor : IDisposable
|
||||
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||
{
|
||||
private readonly ILauncherChildProcessFactory _factory;
|
||||
private readonly object _gate = new();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig(
|
|||
string StatusFilePath,
|
||||
SessionConfigDocument Document);
|
||||
|
||||
/// <summary>
|
||||
/// Injectable composition/write seam used by the canonical launcher
|
||||
/// orchestrator. Production delegates to <see cref="SessionConfigComposer"/>;
|
||||
/// tests can capture the exact request without writing a file or starting a
|
||||
/// client process.
|
||||
/// </summary>
|
||||
public interface ILauncherSessionConfigService
|
||||
{
|
||||
ComposedSessionConfig ComposeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
CharacterProfile character,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId,
|
||||
int? loginCommandDelayMs = null);
|
||||
|
||||
ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId);
|
||||
}
|
||||
|
||||
public sealed class LauncherSessionConfigService : ILauncherSessionConfigService
|
||||
{
|
||||
public ComposedSessionConfig ComposeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
CharacterProfile character,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId,
|
||||
int? loginCommandDelayMs = null) =>
|
||||
SessionConfigComposer.ComposeAndWrite(
|
||||
server,
|
||||
account,
|
||||
character,
|
||||
install,
|
||||
paths,
|
||||
sessionId,
|
||||
loginCommandDelayMs);
|
||||
|
||||
public ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId) =>
|
||||
SessionConfigComposer.ComposeProbeAndWrite(
|
||||
server,
|
||||
account,
|
||||
install,
|
||||
paths,
|
||||
sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
|
||||
/// profile character + install record (Campaign LA spec §6). Passwords
|
||||
|
|
@ -187,6 +245,23 @@ public static class SessionConfigComposer
|
|||
sessionId,
|
||||
loginCommandDelayMs);
|
||||
|
||||
return Write(composed);
|
||||
}
|
||||
|
||||
/// <summary>Probe counterpart to <see cref="ComposeAndWrite"/>. It
|
||||
/// writes the pinned <c>mode: "probe"</c> document and never includes
|
||||
/// a character selector, policy, plugin set, login commands, or password.
|
||||
/// </summary>
|
||||
public static ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId) =>
|
||||
Write(ComposeProbe(server, account, install, paths, sessionId));
|
||||
|
||||
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical state/mutation surface projected by the Avalonia launcher. The UI
|
||||
/// never owns a second profile document, process map, status tail, or launch
|
||||
/// transaction; it asks for immutable snapshots and sends typed mutations here.
|
||||
/// </summary>
|
||||
public interface ILauncherOrchestrator : IDisposable
|
||||
{
|
||||
event EventHandler? StateChanged;
|
||||
|
||||
void LoadProfiles();
|
||||
|
||||
LauncherStateSnapshot GetSnapshot();
|
||||
|
||||
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
||||
|
||||
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
||||
|
||||
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||
|
||||
void AddServer(string name, string host, int port);
|
||||
|
||||
void EditServer(string name, string newName, string newHost, int newPort);
|
||||
|
||||
void RemoveServer(string name);
|
||||
|
||||
void AddAccount(string serverName, string accountName, string password);
|
||||
|
||||
void EditAccount(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string newAccountName,
|
||||
string? newPassword);
|
||||
|
||||
void RemoveAccount(string serverName, string accountName);
|
||||
|
||||
void AddCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string? characterId);
|
||||
|
||||
void EditCharacterIdentity(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string newCharacterName,
|
||||
string? newCharacterId);
|
||||
|
||||
void UpdateCharacterSettings(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode launchMode,
|
||||
IReadOnlyList<string> plugins,
|
||||
IReadOnlyList<string> loginCommands);
|
||||
|
||||
void RemoveCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName);
|
||||
|
||||
Task<LauncherSessionSnapshot> LaunchAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode mode,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LauncherSessionSnapshot> ProbeAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task StopSessionAsync(
|
||||
string sessionId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void PollStatus();
|
||||
|
||||
void ClearFinishedSessions();
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Host executable paths supplied by the current installation. LA10 will
|
||||
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
|
||||
/// mapping injectable and host-agnostic.
|
||||
/// </summary>
|
||||
public sealed record LauncherExecutableSet(
|
||||
string GraphicalHostPath,
|
||||
string HeadlessHostPath,
|
||||
string? WorkingDirectory = null)
|
||||
{
|
||||
public LauncherProcessSpec CreatePlaySpec(
|
||||
LaunchMode mode,
|
||||
string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
|
||||
return mode == LaunchMode.Headless
|
||||
? new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory)
|
||||
: new LauncherProcessSpec(
|
||||
GraphicalHostPath,
|
||||
["--session-config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
return new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public static LauncherExecutableSet FromDirectory(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
string fullDirectory = Path.GetFullPath(directory);
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return new LauncherExecutableSet(
|
||||
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
||||
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||
fullDirectory);
|
||||
}
|
||||
}
|
||||
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,83 @@
|
|||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
public readonly record struct LauncherCapability(bool IsAvailable, string? Reason)
|
||||
{
|
||||
public static LauncherCapability Available { get; } = new(true, null);
|
||||
|
||||
public static LauncherCapability Unavailable(string reason) =>
|
||||
new(false, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable platform row selected once at launcher startup. Campaign LA
|
||||
/// ships the Avalonia launcher, profile editor, probes, and headless sessions
|
||||
/// on Windows and Linux. Graphical client launches remain Windows-only until
|
||||
/// Modern Runtime Slice L resumes from its parked L1 checkpoint.
|
||||
/// </summary>
|
||||
public sealed record LauncherPlatformCapabilities(
|
||||
bool IsWindows,
|
||||
bool IsLinux,
|
||||
bool CanRunHeadless,
|
||||
bool CanLaunchGraphicalClient,
|
||||
string PlatformName,
|
||||
string? GraphicalLaunchDisabledReason)
|
||||
{
|
||||
public const string LinuxGraphicalLaunchDisabledReason =
|
||||
"GUI launches require the Linux graphical client (Modern Runtime Slice L), "
|
||||
+ "which is parked at L1 and will resume later. The launcher, character "
|
||||
+ "probe, and headless sessions remain available on Linux.";
|
||||
|
||||
public static LauncherPlatformCapabilities Detect()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: true,
|
||||
IsLinux: false,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: true,
|
||||
PlatformName: "Windows",
|
||||
GraphicalLaunchDisabledReason: null);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: false,
|
||||
IsLinux: true,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: false,
|
||||
PlatformName: "Linux",
|
||||
GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason);
|
||||
}
|
||||
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: false,
|
||||
IsLinux: false,
|
||||
CanRunHeadless: false,
|
||||
CanLaunchGraphicalClient: false,
|
||||
PlatformName: "Unsupported",
|
||||
GraphicalLaunchDisabledReason:
|
||||
"Graphical client launches are supported on Windows. Linux support "
|
||||
+ "requires Modern Runtime Slice L.");
|
||||
}
|
||||
|
||||
public LauncherCapability ForLaunchMode(LaunchMode mode)
|
||||
{
|
||||
if (mode == LaunchMode.Headless)
|
||||
{
|
||||
return CanRunHeadless
|
||||
? LauncherCapability.Available
|
||||
: LauncherCapability.Unavailable(
|
||||
"Headless launches are supported only on Windows and Linux.");
|
||||
}
|
||||
|
||||
return CanLaunchGraphicalClient
|
||||
? LauncherCapability.Available
|
||||
: LauncherCapability.Unavailable(
|
||||
GraphicalLaunchDisabledReason
|
||||
?? "The graphical client is unavailable on this platform.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
public sealed record LauncherCharacterSnapshot(
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
string Name,
|
||||
string? Id,
|
||||
LaunchMode LaunchMode,
|
||||
IReadOnlyList<string> Plugins,
|
||||
IReadOnlyList<string> LoginCommands,
|
||||
bool HasRunningSession,
|
||||
string SessionStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Password is deliberately absent. The account credential remains reachable
|
||||
/// only inside <see cref="Profiles.LauncherProfileStore"/> and the transient
|
||||
/// stdin handoff performed by <see cref="LauncherOrchestrator"/>.
|
||||
/// </summary>
|
||||
public sealed record LauncherAccountSnapshot(
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
IReadOnlyList<LauncherCharacterSnapshot> Characters,
|
||||
bool HasRunningActivity,
|
||||
string ActivityStatus);
|
||||
|
||||
public sealed record LauncherServerSnapshot(
|
||||
string Name,
|
||||
string Host,
|
||||
int Port,
|
||||
IReadOnlyList<LauncherAccountSnapshot> Accounts);
|
||||
|
||||
public enum LauncherActivityKind
|
||||
{
|
||||
Play,
|
||||
Probe,
|
||||
}
|
||||
|
||||
public enum LauncherActivityState
|
||||
{
|
||||
Starting,
|
||||
Running,
|
||||
Connected,
|
||||
InWorld,
|
||||
Disconnected,
|
||||
Stopping,
|
||||
Exited,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
public sealed record LauncherSessionSnapshot(
|
||||
string SessionId,
|
||||
LauncherActivityKind Kind,
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
string? CharacterName,
|
||||
LaunchMode? LaunchMode,
|
||||
LauncherActivityState State,
|
||||
string Status,
|
||||
int? ExitCode,
|
||||
string? Error,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsActive => State is not (
|
||||
LauncherActivityState.Exited
|
||||
or LauncherActivityState.Failed
|
||||
or LauncherActivityState.Cancelled);
|
||||
}
|
||||
|
||||
public sealed record LauncherStateSnapshot(
|
||||
IReadOnlyList<LauncherServerSnapshot> Servers,
|
||||
IReadOnlyList<LauncherSessionSnapshot> Sessions,
|
||||
LauncherPlatformCapabilities Platform,
|
||||
bool IsInstallationReady,
|
||||
string InstallationStatus);
|
||||
|
||||
public sealed class LauncherOperationException : Exception
|
||||
{
|
||||
public LauncherOperationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -325,13 +325,59 @@ public sealed class LauncherProfileStore
|
|||
server.Accounts.Remove(profile);
|
||||
}
|
||||
|
||||
// --- Character settings (roster-driven add/remove; user-edited settings) ---
|
||||
// --- Character CRUD / user-owned settings --------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Edits the user-owned settings of an existing character row. There
|
||||
/// is no manual add/remove for characters — the roster (
|
||||
/// <see cref="MergeRoster"/>) is the only source of new rows, per
|
||||
/// spec §5/§6.
|
||||
/// Adds a manually configured character row. Normal operation discovers
|
||||
/// characters through <see cref="MergeRoster"/>, but LA4's full in-UI
|
||||
/// CRUD contract also lets a user create a cached row before a successful
|
||||
/// probe (for example, to launch by a known character name while a server
|
||||
/// is temporarily unavailable). A later roster merge remains
|
||||
/// authoritative for the id/name pair and preserves these user settings.
|
||||
/// </summary>
|
||||
public CharacterProfile AddCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName,
|
||||
string? id = null,
|
||||
LaunchMode launchMode = LaunchMode.GuiSelect,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
|
||||
if (FindCharacter(profile, characterName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{characterName}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
string? normalizedId = NormalizeCharacterId(id);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
var character = new CharacterProfile
|
||||
{
|
||||
Name = characterName,
|
||||
Id = normalizedId,
|
||||
LaunchMode = launchMode,
|
||||
Plugins = plugins is null ? [] : [.. plugins],
|
||||
LoginCommands = loginCommands is null ? [] : [.. loginCommands],
|
||||
};
|
||||
profile.Characters.Add(character);
|
||||
return character;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits the identity cache and/or user-owned settings of an existing
|
||||
/// character row. Passing an empty <paramref name="newId"/> clears a
|
||||
/// manually entered id so launches fall back to the character name.
|
||||
/// </summary>
|
||||
public void EditCharacter(
|
||||
string serverName,
|
||||
|
|
@ -339,12 +385,42 @@ public sealed class LauncherProfileStore
|
|||
string characterName,
|
||||
LaunchMode? launchMode = null,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
string? newName = null,
|
||||
string? newId = null)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
|
||||
if (newName is not null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||
if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
|
||||
&& FindCharacter(profile, newName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{newName}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
character.Name = newName;
|
||||
}
|
||||
|
||||
if (newId is not null)
|
||||
{
|
||||
string? normalizedId = NormalizeCharacterId(newId);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(candidate =>
|
||||
!ReferenceEquals(candidate, character)
|
||||
&& CharacterIdsEqual(candidate.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
character.Id = normalizedId;
|
||||
}
|
||||
|
||||
if (launchMode is not null)
|
||||
{
|
||||
character.LaunchMode = launchMode.Value;
|
||||
|
|
@ -361,6 +437,17 @@ public sealed class LauncherProfileStore
|
|||
}
|
||||
}
|
||||
|
||||
public void RemoveCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
profile.Characters.Remove(character);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds a reported character roster into an account's
|
||||
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
|
||||
|
|
@ -456,20 +543,46 @@ public sealed class LauncherProfileStore
|
|||
$"No account '{account}' on server '{server.Name}'.");
|
||||
}
|
||||
|
||||
private static CharacterProfile? FindCharacter(
|
||||
AccountProfile profile,
|
||||
string characterName) =>
|
||||
profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
private static CharacterProfile FindCharacterOrThrow(
|
||||
AccountProfile profile,
|
||||
string characterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
return profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal))
|
||||
return FindCharacter(profile, characterName)
|
||||
?? throw new LauncherProfileException(
|
||||
$"No character '{characterName}' on account '{profile.Account}'.");
|
||||
}
|
||||
|
||||
private static string? NormalizeCharacterId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
"Character id must be a non-zero hexadecimal value with a 0x prefix.");
|
||||
}
|
||||
|
||||
return CharacterIdFormat.ToHexString(parsed);
|
||||
}
|
||||
|
||||
private static bool CharacterIdsEqual(string? left, string? right) =>
|
||||
CharacterIdFormat.TryParse(left, out uint leftId)
|
||||
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
||||
&& leftId == rightId;
|
||||
|
||||
private static void RequireValidPort(int port)
|
||||
{
|
||||
if (port is < 1 or > 65535)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status;
|
|||
/// One tailer instance owns one file's read position; construct a new
|
||||
/// one per session.
|
||||
/// </summary>
|
||||
public sealed class StatusFileTailer
|
||||
public interface IStatusEventSource
|
||||
{
|
||||
IReadOnlyList<StatusEvent> ReadNewEvents();
|
||||
}
|
||||
|
||||
/// <summary>Creates one independent status source per launched session.</summary>
|
||||
public interface IStatusEventSourceFactory
|
||||
{
|
||||
IStatusEventSource Create(string path);
|
||||
}
|
||||
|
||||
public sealed class StatusFileTailerFactory : IStatusEventSourceFactory
|
||||
{
|
||||
public IStatusEventSource Create(string path) => new StatusFileTailer(path);
|
||||
}
|
||||
|
||||
public sealed class StatusFileTailer : IStatusEventSource
|
||||
{
|
||||
private readonly string _path;
|
||||
private long _position;
|
||||
|
|
|
|||
24
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
24
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>acdream-launcher</AssemblyName>
|
||||
<RootNamespace>AcDream.Launcher</RootNamespace>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
8
src/AcDream.Launcher/App.axaml
Normal file
8
src/AcDream.Launcher/App.axaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="AcDream.Launcher.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
66
src/AcDream.Launcher/App.axaml.cs
Normal file
66
src/AcDream.Launcher/App.axaml.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using AcDream.Platform;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
public sealed partial class App : Application
|
||||
{
|
||||
private LauncherOrchestrator? _orchestrator;
|
||||
private LauncherWindowViewModel? _viewModel;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||
LauncherInstallRecord? install = ResolveDevelopmentInstallRecord();
|
||||
_orchestrator = new LauncherOrchestrator(
|
||||
profiles,
|
||||
paths,
|
||||
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
|
||||
install);
|
||||
_viewModel = new LauncherWindowViewModel(
|
||||
_orchestrator,
|
||||
new AvaloniaUiDispatcher());
|
||||
_viewModel.Initialize();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = _viewModel,
|
||||
};
|
||||
desktop.Exit += OnDesktopExit;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static LauncherInstallRecord? ResolveDevelopmentInstallRecord()
|
||||
{
|
||||
// LA9 owns persisted install discovery. LA4 accepts the existing
|
||||
// developer environment pair at this one composition root so the
|
||||
// launch/probe UI can be exercised before the first-run body lands.
|
||||
string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
|
||||
return !string.IsNullOrWhiteSpace(datDirectory)
|
||||
&& !string.IsNullOrWhiteSpace(preparedAssetPath)
|
||||
? new LauncherInstallRecord(datDirectory, preparedAssetPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
||||
{
|
||||
_viewModel?.Dispose();
|
||||
_orchestrator?.Dispose();
|
||||
_viewModel = null;
|
||||
_orchestrator = null;
|
||||
}
|
||||
}
|
||||
346
src/AcDream.Launcher/MainWindow.axaml
Normal file
346
src/AcDream.Launcher/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:AcDream.Launcher.ViewModels"
|
||||
x:Class="AcDream.Launcher.MainWindow"
|
||||
x:DataType="vm:LauncherWindowViewModel"
|
||||
Title="acdream launcher"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="900"
|
||||
MinHeight="620"
|
||||
Background="#10151D">
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="#18212D" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="#3C78D8" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="#8E3540" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="#A8B5C6" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Border Grid.Row="0" Background="#141C26" Padding="20,14">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="acdream" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="Servers, accounts, characters, and supervised sessions"
|
||||
Classes="muted" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="First-run setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
<Button Content="Check for updates"
|
||||
Command="{Binding UpdatePromptShell.OpenCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1" Margin="16,12,16,0" Spacing="8">
|
||||
<Border Classes="card"
|
||||
Padding="12"
|
||||
Background="#4B3820"
|
||||
IsVisible="{Binding IsFirstRunRequired}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Client setup required" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding InstallationStatus}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="Open setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Classes="card"
|
||||
Padding="12"
|
||||
Background="#24344B"
|
||||
IsVisible="{Binding ShowLinuxGraphicalNotice}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Linux graphical launch gate" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding LinuxGraphicalNotice}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2" Margin="16" ColumnDefinitions="330,12,*" RowDefinitions="*,12,220">
|
||||
<Border Grid.Column="0" Grid.RowSpan="3" Classes="card">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<TextBlock Text="Profiles" Classes="section" />
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="0,12,0,10">
|
||||
<Button Content="+ Server" Command="{Binding AddServerCommand}" />
|
||||
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
|
||||
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
|
||||
</StackPanel>
|
||||
<TreeView Grid.Row="2"
|
||||
ItemsSource="{Binding Servers}"
|
||||
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
|
||||
<TreeView.DataTemplates>
|
||||
<TreeDataTemplate DataType="{x:Type vm:LauncherTreeNodeViewModel}"
|
||||
ItemsSource="{Binding Children}">
|
||||
<StackPanel Margin="2,4" Spacing="1">
|
||||
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding SecondaryText}"
|
||||
Classes="muted"
|
||||
FontSize="11"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.DataTemplates>
|
||||
</TreeView>
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="0,10,0,0">
|
||||
<Button Content="Edit" Command="{Binding EditSelectedCommand}" />
|
||||
<Button Content="Remove"
|
||||
Classes="danger"
|
||||
Command="{Binding RemoveSelectedCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Grid.Row="0" Classes="card">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding SelectionTitle}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding SelectionSubtitle}" Classes="muted" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsServerSelected}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Server profile" Classes="section" />
|
||||
<TextBlock Text="Add accounts beneath this server, or edit its host and port." TextWrapping="Wrap" />
|
||||
<Button Content="Add account"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding AddAccountCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsAccountSelected}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Account profile" Classes="section" />
|
||||
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Refresh characters"
|
||||
Classes="primary"
|
||||
Command="{Binding RefreshCharactersCommand}" />
|
||||
<Button Content="Add cached character"
|
||||
Command="{Binding AddCharacterCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ProbeDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel IsVisible="{Binding IsCharacterSelected}" Spacing="14">
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Per-character launch settings" Classes="section" />
|
||||
<TextBlock Text="Default launch mode" Classes="muted" />
|
||||
<ComboBox ItemsSource="{Binding AvailableLaunchModes}"
|
||||
SelectedItem="{Binding CharacterLaunchMode, Mode=TwoWay}" />
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Plugins (one id per line)" Classes="muted" />
|
||||
<TextBox Text="{Binding CharacterPluginsText, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="96" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Login commands (ordered, one per line)" Classes="muted" />
|
||||
<TextBox Text="{Binding CharacterLoginCommandsText, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="96" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Button Content="Save settings"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding SaveCharacterSettingsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Launch" Classes="section" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="GUI — enter world"
|
||||
Classes="primary"
|
||||
Command="{Binding LaunchGuiCommand}" />
|
||||
<Button Content="GUI — character select"
|
||||
Command="{Binding LaunchGuiSelectCommand}" />
|
||||
<Button Content="Headless"
|
||||
Command="{Binding LaunchHeadlessCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ShowLinuxGraphicalNotice}" />
|
||||
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding IsFirstRunRequired}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Grid.Row="2" Classes="card">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="Sessions" Classes="section" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Clear finished"
|
||||
Command="{Binding ClearFinishedSessionsCommand}" />
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" Margin="0,10,0,0">
|
||||
<ItemsControl ItemsSource="{Binding Sessions}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LauncherSessionRowViewModel">
|
||||
<Border BorderBrush="#344559"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="4,8">
|
||||
<Grid ColumnDefinitions="2*,90,90,3*,Auto">
|
||||
<TextBlock Text="{Binding Target}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Mode}" Classes="muted" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding State}" />
|
||||
<StackPanel Grid.Column="3" Spacing="2">
|
||||
<TextBlock Text="{Binding Status}"
|
||||
TextTrimming="CharacterEllipsis" Classes="muted" />
|
||||
<TextBlock Text="{Binding Error}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding HasError}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="4" Content="Stop" Command="{Binding StopCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="3" Background="#141C26" Padding="16,10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding OperationStatus}" />
|
||||
<TextBlock Text="{Binding LastError}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding HasError}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="Cancel operation"
|
||||
Command="{Binding CancelOperationCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="20"
|
||||
Background="#C010151D"
|
||||
IsVisible="{Binding EditorDialog.IsOpen}">
|
||||
<Border Classes="card"
|
||||
Width="480"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding EditorDialog.Title}" FontSize="22" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding EditorDialog.Message}" Classes="muted" TextWrapping="Wrap" />
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
|
||||
<TextBlock Text="Name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Host" />
|
||||
<TextBox Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||
<TextBlock Text="Port" />
|
||||
<TextBox Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
||||
<TextBlock Text="Account name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Password" />
|
||||
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
||||
PasswordChar="●" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
||||
<TextBlock Text="Character name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
||||
<TextBox Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding EditorDialog.Error}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding EditorDialog.HasError}"
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel" Command="{Binding EditorDialog.CancelCommand}" />
|
||||
<Button Content="{Binding EditorDialog.SubmitText}"
|
||||
Classes="primary"
|
||||
Command="{Binding EditorDialog.SubmitCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
|
||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
|
||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
41
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
41
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using AcDream.Launcher.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly DispatcherTimer _statusTimer;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
_statusTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
_statusTimer.Tick += OnStatusTimerTick;
|
||||
Opened += OnOpened;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
|
||||
private void OnOpened(object? sender, EventArgs e) => _statusTimer.Start();
|
||||
|
||||
private void OnClosed(object? sender, EventArgs e)
|
||||
{
|
||||
_statusTimer.Stop();
|
||||
_statusTimer.Tick -= OnStatusTimerTick;
|
||||
Opened -= OnOpened;
|
||||
Closed -= OnClosed;
|
||||
}
|
||||
|
||||
private void OnStatusTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (DataContext is LauncherWindowViewModel viewModel)
|
||||
{
|
||||
viewModel.PollStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/AcDream.Launcher/Program.cs
Normal file
14
src/AcDream.Launcher/Program.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Avalonia;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect();
|
||||
}
|
||||
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Windows.Input;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object?> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public RelayCommand(
|
||||
Action<object?> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
|
||||
public void Execute(object? parameter) => _execute(parameter);
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public sealed class AsyncRelayCommand : ICommand
|
||||
{
|
||||
private readonly Func<object?, Task> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
private bool _isExecuting;
|
||||
|
||||
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand(
|
||||
Func<object?, Task> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) =>
|
||||
!_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
|
||||
|
||||
public async void Execute(object? parameter) =>
|
||||
await ExecuteAsync(parameter).ConfigureAwait(true);
|
||||
|
||||
public async Task ExecuteAsync(object? parameter = null)
|
||||
{
|
||||
if (!CanExecute(parameter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isExecuting = true;
|
||||
NotifyCanExecuteChanged();
|
||||
try
|
||||
{
|
||||
await _execute(parameter).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecuting = false;
|
||||
NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal file
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Avalonia.Threading;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
|
||||
public sealed class AvaloniaUiDispatcher : IUiDispatcher
|
||||
{
|
||||
public void Post(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
Dispatcher.UIThread.Post(action);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImmediateUiDispatcher : IUiDispatcher
|
||||
{
|
||||
public void Post(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherSessionRowViewModel
|
||||
{
|
||||
public LauncherSessionRowViewModel(
|
||||
LauncherSessionSnapshot snapshot,
|
||||
Func<string, Task> stop)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(stop);
|
||||
|
||||
SessionId = snapshot.SessionId;
|
||||
Target = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? $"{snapshot.ServerName} / {snapshot.AccountName} / character refresh"
|
||||
: $"{snapshot.ServerName} / {snapshot.AccountName} / {snapshot.CharacterName}";
|
||||
Mode = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? "Probe"
|
||||
: snapshot.LaunchMode?.ToString() ?? "Session";
|
||||
State = snapshot.State.ToString();
|
||||
Status = snapshot.Status;
|
||||
Error = snapshot.Error;
|
||||
IsActive = snapshot.IsActive;
|
||||
StopCommand = new AsyncRelayCommand(
|
||||
() => stop(SessionId),
|
||||
() => IsActive);
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Mode { get; }
|
||||
|
||||
public string State { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public bool IsActive { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
}
|
||||
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherShellViewModel : ObservableObject
|
||||
{
|
||||
private bool _isOpen;
|
||||
|
||||
public LauncherShellViewModel(string title, string body, string status)
|
||||
{
|
||||
Title = title;
|
||||
Body = body;
|
||||
Status = status;
|
||||
OpenCommand = new RelayCommand(() => IsOpen = true);
|
||||
CloseCommand = new RelayCommand(() => IsOpen = false);
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set => SetProperty(ref _isOpen, value);
|
||||
}
|
||||
|
||||
public RelayCommand OpenCommand { get; }
|
||||
|
||||
public RelayCommand CloseCommand { get; }
|
||||
}
|
||||
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal file
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public enum LauncherTreeNodeKind
|
||||
{
|
||||
Server,
|
||||
Account,
|
||||
Character,
|
||||
}
|
||||
|
||||
public sealed class LauncherTreeNodeViewModel
|
||||
{
|
||||
private LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind kind,
|
||||
string serverName,
|
||||
string? accountName,
|
||||
string? characterName,
|
||||
string displayName,
|
||||
string secondaryText)
|
||||
{
|
||||
Kind = kind;
|
||||
ServerName = serverName;
|
||||
AccountName = accountName;
|
||||
CharacterName = characterName;
|
||||
DisplayName = displayName;
|
||||
SecondaryText = secondaryText;
|
||||
}
|
||||
|
||||
public LauncherTreeNodeKind Kind { get; }
|
||||
|
||||
public string ServerName { get; }
|
||||
|
||||
public string? AccountName { get; }
|
||||
|
||||
public string? CharacterName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string SecondaryText { get; }
|
||||
|
||||
public ObservableCollection<LauncherTreeNodeViewModel> Children { get; } = [];
|
||||
|
||||
public static LauncherTreeNodeViewModel FromServer(LauncherServerSnapshot server)
|
||||
{
|
||||
var node = new LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind.Server,
|
||||
server.Name,
|
||||
accountName: null,
|
||||
characterName: null,
|
||||
server.Name,
|
||||
$"{server.Host}:{server.Port}");
|
||||
foreach (LauncherAccountSnapshot account in server.Accounts)
|
||||
{
|
||||
node.Children.Add(FromAccount(account));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static LauncherTreeNodeViewModel FromAccount(LauncherAccountSnapshot account)
|
||||
{
|
||||
var node = new LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind.Account,
|
||||
account.ServerName,
|
||||
account.AccountName,
|
||||
characterName: null,
|
||||
account.AccountName,
|
||||
account.ActivityStatus);
|
||||
foreach (LauncherCharacterSnapshot character in account.Characters)
|
||||
{
|
||||
node.Children.Add(FromCharacter(character));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static LauncherTreeNodeViewModel FromCharacter(
|
||||
LauncherCharacterSnapshot character) =>
|
||||
new(
|
||||
LauncherTreeNodeKind.Character,
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name,
|
||||
character.Name,
|
||||
character.HasRunningSession
|
||||
? character.SessionStatus
|
||||
: character.LaunchMode.ToString());
|
||||
}
|
||||
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly ILauncherOrchestrator _orchestrator;
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private LauncherStateSnapshot? _snapshot;
|
||||
private LauncherTreeNodeViewModel? _selectedNode;
|
||||
private CancellationTokenSource? _operationCancellation;
|
||||
private bool _isBusy;
|
||||
private bool _disposed;
|
||||
private string? _lastError;
|
||||
private string _operationStatus = "Ready";
|
||||
private LaunchMode _characterLaunchMode;
|
||||
private string _characterPluginsText = string.Empty;
|
||||
private string _characterLoginCommandsText = string.Empty;
|
||||
|
||||
public LauncherWindowViewModel(
|
||||
ILauncherOrchestrator orchestrator,
|
||||
IUiDispatcher dispatcher)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_orchestrator.StateChanged += OnOrchestratorStateChanged;
|
||||
|
||||
EditorDialog = new ProfileEditorDialogViewModel();
|
||||
FirstRunWizardShell = new LauncherShellViewModel(
|
||||
"First-run setup",
|
||||
"Choose and validate the retail DAT directory, build acdream.pak, "
|
||||
+ "and record the installed client. The installer transaction and "
|
||||
+ "progress body land in Campaign LA slice LA9.",
|
||||
"Installer shell ready — implementation arrives in LA9.");
|
||||
UpdatePromptShell = new LauncherShellViewModel(
|
||||
"Client update",
|
||||
"Review a signed release manifest, verify the downloaded archive, "
|
||||
+ "and atomically switch the installed client version. The updater "
|
||||
+ "transaction lands in Campaign LA slice LA10.",
|
||||
"Updater shell ready — implementation arrives in LA10.");
|
||||
|
||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
|
||||
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
|
||||
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
|
||||
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
|
||||
SaveCharacterSettingsCommand = new RelayCommand(
|
||||
SaveCharacterSettings,
|
||||
() => IsCharacterSelected && !IsBusy);
|
||||
RefreshCharactersCommand = new AsyncRelayCommand(
|
||||
RefreshCharactersAsync,
|
||||
() => CanProbe);
|
||||
LaunchGuiCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.Gui),
|
||||
() => CanLaunchGui);
|
||||
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
||||
() => CanLaunchGuiSelect);
|
||||
LaunchHeadlessCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.Headless),
|
||||
() => CanLaunchHeadless);
|
||||
CancelOperationCommand = new RelayCommand(
|
||||
CancelOperation,
|
||||
() => IsBusy && _operationCancellation is not null);
|
||||
ClearFinishedSessionsCommand = new RelayCommand(
|
||||
_orchestrator.ClearFinishedSessions,
|
||||
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
|
||||
}
|
||||
|
||||
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
||||
|
||||
public ObservableCollection<LauncherSessionRowViewModel> Sessions { get; } = [];
|
||||
|
||||
public IReadOnlyList<LaunchMode> AvailableLaunchModes { get; } =
|
||||
[LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless];
|
||||
|
||||
public ProfileEditorDialogViewModel EditorDialog { get; }
|
||||
|
||||
public LauncherShellViewModel FirstRunWizardShell { get; }
|
||||
|
||||
public LauncherShellViewModel UpdatePromptShell { get; }
|
||||
|
||||
public LauncherTreeNodeViewModel? SelectedNode
|
||||
{
|
||||
get => _selectedNode;
|
||||
set => SetSelectedNode(value, preserveCharacterDraft: false);
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastError
|
||||
{
|
||||
get => _lastError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _lastError, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
|
||||
|
||||
public string OperationStatus
|
||||
{
|
||||
get => _operationStatus;
|
||||
private set => SetProperty(ref _operationStatus, value);
|
||||
}
|
||||
|
||||
public bool IsServerSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Server;
|
||||
|
||||
public bool IsAccountSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Account;
|
||||
|
||||
public bool IsCharacterSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Character;
|
||||
|
||||
public bool HasSelection => SelectedNode is not null;
|
||||
|
||||
public string SelectionTitle => SelectedNode?.DisplayName ?? "Select a profile";
|
||||
|
||||
public string SelectionSubtitle => SelectedNode?.SecondaryText
|
||||
?? "Add a server to begin.";
|
||||
|
||||
public string SelectedServerName => SelectedNode?.ServerName ?? string.Empty;
|
||||
|
||||
public string SelectedAccountName => SelectedNode?.AccountName ?? string.Empty;
|
||||
|
||||
public string SelectedCharacterName => SelectedNode?.CharacterName ?? string.Empty;
|
||||
|
||||
public LaunchMode CharacterLaunchMode
|
||||
{
|
||||
get => _characterLaunchMode;
|
||||
set => SetProperty(ref _characterLaunchMode, value);
|
||||
}
|
||||
|
||||
public string CharacterPluginsText
|
||||
{
|
||||
get => _characterPluginsText;
|
||||
set => SetProperty(ref _characterPluginsText, value);
|
||||
}
|
||||
|
||||
public string CharacterLoginCommandsText
|
||||
{
|
||||
get => _characterLoginCommandsText;
|
||||
set => SetProperty(ref _characterLoginCommandsText, value);
|
||||
}
|
||||
|
||||
public bool IsFirstRunRequired => _snapshot is { IsInstallationReady: false };
|
||||
|
||||
public string InstallationStatus => _snapshot?.InstallationStatus
|
||||
?? "Installation state is loading.";
|
||||
|
||||
public bool ShowLinuxGraphicalNotice => _snapshot?.Platform.IsLinux == true;
|
||||
|
||||
public string LinuxGraphicalNotice =>
|
||||
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
|
||||
|
||||
public bool CanProbe =>
|
||||
!IsBusy
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
|
||||
|
||||
public string ProbeDisabledReason
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!TryGetSelectedAccount(out string server, out string account))
|
||||
{
|
||||
return "Select an account or one of its characters.";
|
||||
}
|
||||
|
||||
return _orchestrator.GetProbeCapability(server, account).Reason
|
||||
?? "Character refresh is available.";
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanLaunchGui => CanLaunch(LaunchMode.Gui);
|
||||
|
||||
public bool CanLaunchGuiSelect => CanLaunch(LaunchMode.GuiSelect);
|
||||
|
||||
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
||||
|
||||
public string GuiLaunchDisabledReason =>
|
||||
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
|
||||
?? "Graphical launch is available.";
|
||||
|
||||
public string HeadlessLaunchDisabledReason =>
|
||||
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
|
||||
?? "Headless launch is available.";
|
||||
|
||||
public RelayCommand AddServerCommand { get; }
|
||||
|
||||
public RelayCommand AddAccountCommand { get; }
|
||||
|
||||
public RelayCommand AddCharacterCommand { get; }
|
||||
|
||||
public RelayCommand EditSelectedCommand { get; }
|
||||
|
||||
public RelayCommand RemoveSelectedCommand { get; }
|
||||
|
||||
public RelayCommand SaveCharacterSettingsCommand { get; }
|
||||
|
||||
public AsyncRelayCommand RefreshCharactersCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchGuiCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||
|
||||
public RelayCommand CancelOperationCommand { get; }
|
||||
|
||||
public RelayCommand ClearFinishedSessionsCommand { get; }
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
try
|
||||
{
|
||||
_orchestrator.LoadProfiles();
|
||||
LastError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
}
|
||||
|
||||
RefreshFromCore();
|
||||
}
|
||||
|
||||
public void PollStatus()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_orchestrator.PollStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_operationCancellation?.Cancel();
|
||||
_operationCancellation?.Dispose();
|
||||
_operationCancellation = null;
|
||||
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||
}
|
||||
|
||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
RefreshFromCore();
|
||||
}
|
||||
});
|
||||
|
||||
private void RefreshFromCore(SelectionKey? preferredSelection = null)
|
||||
{
|
||||
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
|
||||
LauncherStateSnapshot snapshot = _orchestrator.GetSnapshot();
|
||||
_snapshot = snapshot;
|
||||
|
||||
Servers.Clear();
|
||||
foreach (LauncherServerSnapshot server in snapshot.Servers)
|
||||
{
|
||||
Servers.Add(LauncherTreeNodeViewModel.FromServer(server));
|
||||
}
|
||||
|
||||
Sessions.Clear();
|
||||
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
|
||||
{
|
||||
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
|
||||
}
|
||||
|
||||
LauncherTreeNodeViewModel? restored = previousSelection is null
|
||||
? Servers.FirstOrDefault()
|
||||
: FindNode(previousSelection.Value) ?? Servers.FirstOrDefault();
|
||||
bool preserveDraft = previousSelection is not null
|
||||
&& SelectionKey.From(restored) == previousSelection;
|
||||
SetSelectedNode(restored, preserveDraft);
|
||||
|
||||
OnPropertyChanged(nameof(IsFirstRunRequired));
|
||||
OnPropertyChanged(nameof(InstallationStatus));
|
||||
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
|
||||
OnPropertyChanged(nameof(LinuxGraphicalNotice));
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
|
||||
private void SetSelectedNode(
|
||||
LauncherTreeNodeViewModel? value,
|
||||
bool preserveCharacterDraft)
|
||||
{
|
||||
if (ReferenceEquals(_selectedNode, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedNode = value;
|
||||
OnPropertyChanged(nameof(SelectedNode));
|
||||
OnPropertyChanged(nameof(IsServerSelected));
|
||||
OnPropertyChanged(nameof(IsAccountSelected));
|
||||
OnPropertyChanged(nameof(IsCharacterSelected));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(SelectionTitle));
|
||||
OnPropertyChanged(nameof(SelectionSubtitle));
|
||||
OnPropertyChanged(nameof(SelectedServerName));
|
||||
OnPropertyChanged(nameof(SelectedAccountName));
|
||||
OnPropertyChanged(nameof(SelectedCharacterName));
|
||||
|
||||
if (!preserveCharacterDraft)
|
||||
{
|
||||
LoadCharacterDraft();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
|
||||
private void LoadCharacterDraft()
|
||||
{
|
||||
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
|
||||
CharacterLaunchMode = character?.LaunchMode ?? LaunchMode.GuiSelect;
|
||||
CharacterPluginsText = character is null
|
||||
? string.Empty
|
||||
: string.Join(Environment.NewLine, character.Plugins);
|
||||
CharacterLoginCommandsText = character is null
|
||||
? string.Empty
|
||||
: string.Join(Environment.NewLine, character.LoginCommands);
|
||||
}
|
||||
|
||||
private void OpenAddServerDialog() =>
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.AddServer,
|
||||
"Add server",
|
||||
dialog =>
|
||||
{
|
||||
if (!dialog.TryGetPort(out int port))
|
||||
{
|
||||
throw new LauncherOperationException("Port must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
_orchestrator.AddServer(dialog.Name.Trim(), dialog.Host.Trim(), port);
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Server,
|
||||
dialog.Name.Trim(),
|
||||
null,
|
||||
null));
|
||||
});
|
||||
|
||||
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
|
||||
|
||||
private void OpenAddAccountDialog()
|
||||
{
|
||||
string serverName = SelectedNode?.ServerName
|
||||
?? throw new LauncherOperationException("Select a server first.");
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.AddAccount,
|
||||
$"Add account to {serverName}",
|
||||
dialog =>
|
||||
{
|
||||
_orchestrator.AddAccount(
|
||||
serverName,
|
||||
dialog.Name.Trim(),
|
||||
dialog.Password);
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Account,
|
||||
serverName,
|
||||
dialog.Name.Trim(),
|
||||
null));
|
||||
});
|
||||
}
|
||||
|
||||
private bool CanAddCharacter() =>
|
||||
!IsBusy && TryGetSelectedAccount(out _, out _);
|
||||
|
||||
private void OpenAddCharacterDialog()
|
||||
{
|
||||
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||
{
|
||||
throw new LauncherOperationException("Select an account first.");
|
||||
}
|
||||
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.AddCharacter,
|
||||
$"Add cached character to {accountName}",
|
||||
dialog =>
|
||||
{
|
||||
string name = dialog.Name.Trim();
|
||||
_orchestrator.AddCharacter(
|
||||
serverName,
|
||||
accountName,
|
||||
name,
|
||||
NullIfWhiteSpace(dialog.CharacterId));
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Character,
|
||||
serverName,
|
||||
accountName,
|
||||
name));
|
||||
},
|
||||
message: "Normally Refresh Characters fills this list. Manual rows "
|
||||
+ "let you configure a known character while the server is unavailable.");
|
||||
}
|
||||
|
||||
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
|
||||
|
||||
private void OpenEditSelectedDialog()
|
||||
{
|
||||
LauncherTreeNodeViewModel node = SelectedNode
|
||||
?? throw new LauncherOperationException("Select a profile first.");
|
||||
switch (node.Kind)
|
||||
{
|
||||
case LauncherTreeNodeKind.Server:
|
||||
OpenEditServerDialog(node);
|
||||
break;
|
||||
case LauncherTreeNodeKind.Account:
|
||||
OpenEditAccountDialog(node);
|
||||
break;
|
||||
case LauncherTreeNodeKind.Character:
|
||||
OpenEditCharacterDialog(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenEditServerDialog(LauncherTreeNodeViewModel node)
|
||||
{
|
||||
LauncherServerSnapshot server = FindServerSnapshot(node.ServerName);
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.EditServer,
|
||||
$"Edit {server.Name}",
|
||||
dialog =>
|
||||
{
|
||||
if (!dialog.TryGetPort(out int port))
|
||||
{
|
||||
throw new LauncherOperationException("Port must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
string newName = dialog.Name.Trim();
|
||||
_orchestrator.EditServer(
|
||||
server.Name,
|
||||
newName,
|
||||
dialog.Host.Trim(),
|
||||
port);
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Server,
|
||||
newName,
|
||||
null,
|
||||
null));
|
||||
},
|
||||
server.Name,
|
||||
server.Host,
|
||||
server.Port);
|
||||
}
|
||||
|
||||
private void OpenEditAccountDialog(LauncherTreeNodeViewModel node)
|
||||
{
|
||||
LauncherAccountSnapshot account = FindAccountSnapshot(
|
||||
node.ServerName,
|
||||
node.AccountName!);
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.EditAccount,
|
||||
$"Edit {account.AccountName}",
|
||||
dialog =>
|
||||
{
|
||||
string newName = dialog.Name.Trim();
|
||||
_orchestrator.EditAccount(
|
||||
account.ServerName,
|
||||
account.AccountName,
|
||||
newName,
|
||||
string.IsNullOrEmpty(dialog.Password) ? null : dialog.Password);
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Account,
|
||||
account.ServerName,
|
||||
newName,
|
||||
null));
|
||||
},
|
||||
account.AccountName,
|
||||
message: "Leave Password blank to keep the stored credential unchanged.");
|
||||
}
|
||||
|
||||
private void OpenEditCharacterDialog(LauncherTreeNodeViewModel node)
|
||||
{
|
||||
LauncherCharacterSnapshot character = FindCharacterSnapshot(
|
||||
node.ServerName,
|
||||
node.AccountName!,
|
||||
node.CharacterName!);
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.EditCharacter,
|
||||
$"Edit {character.Name}",
|
||||
dialog =>
|
||||
{
|
||||
string newName = dialog.Name.Trim();
|
||||
_orchestrator.EditCharacterIdentity(
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name,
|
||||
newName,
|
||||
dialog.CharacterId.Trim());
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Character,
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
newName));
|
||||
},
|
||||
character.Name,
|
||||
characterId: character.Id ?? string.Empty,
|
||||
message: "A later character refresh remains authoritative for name and id.");
|
||||
}
|
||||
|
||||
private void OpenRemoveSelectedDialog()
|
||||
{
|
||||
LauncherTreeNodeViewModel node = SelectedNode
|
||||
?? throw new LauncherOperationException("Select a profile first.");
|
||||
EditorDialog.Open(
|
||||
ProfileEditorKind.Remove,
|
||||
$"Remove {node.DisplayName}?",
|
||||
_ =>
|
||||
{
|
||||
switch (node.Kind)
|
||||
{
|
||||
case LauncherTreeNodeKind.Server:
|
||||
_orchestrator.RemoveServer(node.ServerName);
|
||||
break;
|
||||
case LauncherTreeNodeKind.Account:
|
||||
_orchestrator.RemoveAccount(node.ServerName, node.AccountName!);
|
||||
break;
|
||||
case LauncherTreeNodeKind.Character:
|
||||
_orchestrator.RemoveCharacter(
|
||||
node.ServerName,
|
||||
node.AccountName!,
|
||||
node.CharacterName!);
|
||||
break;
|
||||
}
|
||||
|
||||
RefreshFromCore();
|
||||
},
|
||||
message: node.Kind switch
|
||||
{
|
||||
LauncherTreeNodeKind.Server =>
|
||||
"This removes the server and every account/character profile beneath it.",
|
||||
LauncherTreeNodeKind.Account =>
|
||||
"This removes the account, its plaintext credential, and cached characters.",
|
||||
_ => "This removes the cached character and its launch settings.",
|
||||
});
|
||||
}
|
||||
|
||||
private void SaveCharacterSettings()
|
||||
{
|
||||
LauncherCharacterSnapshot character = GetSelectedCharacterSnapshot()
|
||||
?? throw new LauncherOperationException("Select a character first.");
|
||||
try
|
||||
{
|
||||
_orchestrator.UpdateCharacterSettings(
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name,
|
||||
CharacterLaunchMode,
|
||||
ParseLines(CharacterPluginsText, distinct: true),
|
||||
ParseLines(CharacterLoginCommandsText, distinct: false));
|
||||
LastError = null;
|
||||
OperationStatus = $"Saved launch settings for {character.Name}.";
|
||||
RefreshFromCore(new SelectionKey(
|
||||
LauncherTreeNodeKind.Character,
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
}
|
||||
}
|
||||
|
||||
private Task RefreshCharactersAsync()
|
||||
{
|
||||
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunOperationAsync(
|
||||
token => _orchestrator.ProbeAsync(serverName, accountName, token),
|
||||
$"Refreshing characters for {accountName}…",
|
||||
"Character refresh started. The roster will update from host status.");
|
||||
}
|
||||
|
||||
private Task LaunchSelectedAsync(LaunchMode mode)
|
||||
{
|
||||
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
|
||||
if (character is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunOperationAsync(
|
||||
token => _orchestrator.LaunchAsync(
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name,
|
||||
mode,
|
||||
token),
|
||||
$"Launching {character.Name} ({mode})…",
|
||||
$"{mode} session started for {character.Name}.");
|
||||
}
|
||||
|
||||
private async Task RunOperationAsync(
|
||||
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
|
||||
string activeStatus,
|
||||
string completedStatus)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_operationCancellation = cancellation;
|
||||
IsBusy = true;
|
||||
LastError = null;
|
||||
OperationStatus = activeStatus;
|
||||
try
|
||||
{
|
||||
await operation(cancellation.Token).ConfigureAwait(true);
|
||||
OperationStatus = completedStatus;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
OperationStatus = "Operation cancelled.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
OperationStatus = "Operation failed.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_operationCancellation, cancellation))
|
||||
{
|
||||
_operationCancellation = null;
|
||||
}
|
||||
|
||||
IsBusy = false;
|
||||
RefreshFromCore();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopSessionAsync(string sessionId)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_operationCancellation = cancellation;
|
||||
IsBusy = true;
|
||||
LastError = null;
|
||||
OperationStatus = "Stopping session…";
|
||||
try
|
||||
{
|
||||
await _orchestrator.StopSessionAsync(
|
||||
sessionId,
|
||||
TimeSpan.FromSeconds(5),
|
||||
cancellation.Token)
|
||||
.ConfigureAwait(true);
|
||||
OperationStatus = "Stop requested.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
OperationStatus = "Stop cancelled.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
OperationStatus = "Stop failed.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_operationCancellation, cancellation))
|
||||
{
|
||||
_operationCancellation = null;
|
||||
}
|
||||
|
||||
IsBusy = false;
|
||||
RefreshFromCore();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelOperation() => _operationCancellation?.Cancel();
|
||||
|
||||
private bool CanLaunch(LaunchMode mode) =>
|
||||
!IsBusy
|
||||
&& IsCharacterSelected
|
||||
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
|
||||
|
||||
private bool TryGetSelectedAccount(out string serverName, out string accountName)
|
||||
{
|
||||
serverName = SelectedNode?.ServerName ?? string.Empty;
|
||||
accountName = SelectedNode?.AccountName ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(serverName)
|
||||
&& !string.IsNullOrWhiteSpace(accountName);
|
||||
}
|
||||
|
||||
private LauncherCharacterSnapshot? GetSelectedCharacterSnapshot()
|
||||
{
|
||||
if (_snapshot is null
|
||||
|| SelectedNode is not { Kind: LauncherTreeNodeKind.Character } node)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindCharacterSnapshotOrDefault(
|
||||
node.ServerName,
|
||||
node.AccountName!,
|
||||
node.CharacterName!);
|
||||
}
|
||||
|
||||
private LauncherServerSnapshot FindServerSnapshot(string serverName) =>
|
||||
_snapshot?.Servers.FirstOrDefault(server =>
|
||||
string.Equals(server.Name, serverName, StringComparison.Ordinal))
|
||||
?? throw new LauncherOperationException($"No server named '{serverName}'.");
|
||||
|
||||
private LauncherAccountSnapshot FindAccountSnapshot(
|
||||
string serverName,
|
||||
string accountName) =>
|
||||
FindServerSnapshot(serverName).Accounts.FirstOrDefault(account =>
|
||||
string.Equals(account.AccountName, accountName, StringComparison.Ordinal))
|
||||
?? throw new LauncherOperationException(
|
||||
$"No account '{accountName}' on server '{serverName}'.");
|
||||
|
||||
private LauncherCharacterSnapshot FindCharacterSnapshot(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName) =>
|
||||
FindCharacterSnapshotOrDefault(serverName, accountName, characterName)
|
||||
?? throw new LauncherOperationException(
|
||||
$"No character '{characterName}' on account '{accountName}'.");
|
||||
|
||||
private LauncherCharacterSnapshot? FindCharacterSnapshotOrDefault(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName) =>
|
||||
FindAccountSnapshot(serverName, accountName).Characters.FirstOrDefault(character =>
|
||||
string.Equals(character.Name, characterName, StringComparison.Ordinal));
|
||||
|
||||
private LauncherTreeNodeViewModel? FindNode(SelectionKey key)
|
||||
{
|
||||
LauncherTreeNodeViewModel? server = Servers.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.ServerName, key.ServerName, StringComparison.Ordinal));
|
||||
if (server is null || key.Kind == LauncherTreeNodeKind.Server)
|
||||
{
|
||||
return server;
|
||||
}
|
||||
|
||||
LauncherTreeNodeViewModel? account = server.Children.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.AccountName, key.AccountName, StringComparison.Ordinal));
|
||||
if (account is null || key.Kind == LauncherTreeNodeKind.Account)
|
||||
{
|
||||
return account;
|
||||
}
|
||||
|
||||
return account.Children.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.CharacterName, key.CharacterName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseLines(string text, bool distinct)
|
||||
{
|
||||
IEnumerable<string> values = text
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(value => value.Trim())
|
||||
.Where(value => value.Length > 0);
|
||||
if (distinct)
|
||||
{
|
||||
values = values.Distinct(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private static string? NullIfWhiteSpace(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string SafeDisplayError(Exception exception, string? secret)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
{
|
||||
message = message.Replace(secret, "[redacted]", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(message)
|
||||
? "The launcher operation failed."
|
||||
: message;
|
||||
}
|
||||
|
||||
private void NotifyCommandStates()
|
||||
{
|
||||
AddServerCommand.NotifyCanExecuteChanged();
|
||||
AddAccountCommand.NotifyCanExecuteChanged();
|
||||
AddCharacterCommand.NotifyCanExecuteChanged();
|
||||
EditSelectedCommand.NotifyCanExecuteChanged();
|
||||
RemoveSelectedCommand.NotifyCanExecuteChanged();
|
||||
SaveCharacterSettingsCommand.NotifyCanExecuteChanged();
|
||||
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
||||
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private readonly record struct SelectionKey(
|
||||
LauncherTreeNodeKind Kind,
|
||||
string ServerName,
|
||||
string? AccountName,
|
||||
string? CharacterName)
|
||||
{
|
||||
public static SelectionKey? From(LauncherTreeNodeViewModel? node) =>
|
||||
node is null
|
||||
? null
|
||||
: new SelectionKey(
|
||||
node.Kind,
|
||||
node.ServerName,
|
||||
node.AccountName,
|
||||
node.CharacterName);
|
||||
}
|
||||
}
|
||||
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal file
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool SetProperty<T>(
|
||||
ref T field,
|
||||
T value,
|
||||
[CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal file
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public enum ProfileEditorKind
|
||||
{
|
||||
AddServer,
|
||||
EditServer,
|
||||
AddAccount,
|
||||
EditAccount,
|
||||
AddCharacter,
|
||||
EditCharacter,
|
||||
Remove,
|
||||
}
|
||||
|
||||
public sealed class ProfileEditorDialogViewModel : ObservableObject
|
||||
{
|
||||
private Action<ProfileEditorDialogViewModel>? _submit;
|
||||
private bool _isOpen;
|
||||
private string _title = string.Empty;
|
||||
private string _message = string.Empty;
|
||||
private string _name = string.Empty;
|
||||
private string _host = string.Empty;
|
||||
private string _port = "9000";
|
||||
private string _password = string.Empty;
|
||||
private string _characterId = string.Empty;
|
||||
private string? _error;
|
||||
private ProfileEditorKind _kind;
|
||||
|
||||
public ProfileEditorDialogViewModel()
|
||||
{
|
||||
SubmitCommand = new RelayCommand(Submit, () => IsOpen);
|
||||
CancelCommand = new RelayCommand(Close, () => IsOpen);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isOpen, value))
|
||||
{
|
||||
SubmitCommand.NotifyCanExecuteChanged();
|
||||
CancelCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProfileEditorKind Kind
|
||||
{
|
||||
get => _kind;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _kind, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsServerEditor));
|
||||
OnPropertyChanged(nameof(IsAccountEditor));
|
||||
OnPropertyChanged(nameof(IsCharacterEditor));
|
||||
OnPropertyChanged(nameof(IsRemoveConfirmation));
|
||||
OnPropertyChanged(nameof(SubmitText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
private set => SetProperty(ref _title, value);
|
||||
}
|
||||
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
private set => SetProperty(ref _message, value);
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => SetProperty(ref _name, value);
|
||||
}
|
||||
|
||||
public string Host
|
||||
{
|
||||
get => _host;
|
||||
set => SetProperty(ref _host, value);
|
||||
}
|
||||
|
||||
public string Port
|
||||
{
|
||||
get => _port;
|
||||
set => SetProperty(ref _port, value);
|
||||
}
|
||||
|
||||
public string Password
|
||||
{
|
||||
get => _password;
|
||||
set => SetProperty(ref _password, value);
|
||||
}
|
||||
|
||||
public string CharacterId
|
||||
{
|
||||
get => _characterId;
|
||||
set => SetProperty(ref _characterId, value);
|
||||
}
|
||||
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _error, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public bool IsServerEditor => Kind is ProfileEditorKind.AddServer or ProfileEditorKind.EditServer;
|
||||
|
||||
public bool IsAccountEditor => Kind is ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount;
|
||||
|
||||
public bool IsCharacterEditor => Kind is ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter;
|
||||
|
||||
public bool IsRemoveConfirmation => Kind == ProfileEditorKind.Remove;
|
||||
|
||||
public string SubmitText => IsRemoveConfirmation ? "Remove" : "Save";
|
||||
|
||||
public RelayCommand SubmitCommand { get; }
|
||||
|
||||
public RelayCommand CancelCommand { get; }
|
||||
|
||||
public void Open(
|
||||
ProfileEditorKind kind,
|
||||
string title,
|
||||
Action<ProfileEditorDialogViewModel> submit,
|
||||
string name = "",
|
||||
string host = "",
|
||||
int port = 9000,
|
||||
string characterId = "",
|
||||
string message = "")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submit);
|
||||
Kind = kind;
|
||||
Title = title;
|
||||
Message = message;
|
||||
Name = name;
|
||||
Host = host;
|
||||
Port = port.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
Password = string.Empty;
|
||||
CharacterId = characterId;
|
||||
Error = null;
|
||||
_submit = submit;
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
public bool TryGetPort(out int port) =>
|
||||
int.TryParse(
|
||||
Port,
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out port)
|
||||
&& port is >= 1 and <= 65535;
|
||||
|
||||
public void Close()
|
||||
{
|
||||
Password = string.Empty;
|
||||
Error = null;
|
||||
_submit = null;
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
try
|
||||
{
|
||||
_submit?.Invoke(this);
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string message = ex.Message;
|
||||
if (!string.IsNullOrEmpty(Password))
|
||||
{
|
||||
message = message.Replace(
|
||||
Password,
|
||||
"[redacted]",
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Error = string.IsNullOrWhiteSpace(message)
|
||||
? "The profile change could not be saved."
|
||||
: message;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue