merge: Campaign LA LA4 - Avalonia launcher review-closed
This commit is contained in:
commit
60f627998c
35 changed files with 6485 additions and 49 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,92 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical state/mutation surface projected by the Avalonia launcher. The UI
|
||||
/// never owns a second profile document, process map, status tail, or launch
|
||||
/// transaction; it asks for immutable snapshots and sends typed mutations here.
|
||||
/// </summary>
|
||||
public interface ILauncherOrchestrator : IDisposable
|
||||
{
|
||||
event EventHandler? StateChanged;
|
||||
|
||||
void LoadProfiles();
|
||||
|
||||
LauncherStateSnapshot GetSnapshot();
|
||||
|
||||
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
||||
|
||||
LauncherCapability GetAccountLaunchCapability(
|
||||
string serverName,
|
||||
string accountName,
|
||||
LaunchMode mode);
|
||||
|
||||
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
||||
|
||||
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||
|
||||
void AddServer(string name, string host, int port);
|
||||
|
||||
void EditServer(string name, string newName, string newHost, int newPort);
|
||||
|
||||
void RemoveServer(string name);
|
||||
|
||||
void AddAccount(string serverName, string accountName, string password);
|
||||
|
||||
void EditAccount(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string newAccountName,
|
||||
string? newPassword);
|
||||
|
||||
void RemoveAccount(string serverName, string accountName);
|
||||
|
||||
void AddCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string? characterId);
|
||||
|
||||
void EditCharacterIdentity(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string newCharacterName,
|
||||
string? newCharacterId);
|
||||
|
||||
void UpdateCharacterSettings(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode launchMode,
|
||||
IReadOnlyList<string> plugins,
|
||||
IReadOnlyList<string> loginCommands);
|
||||
|
||||
void RemoveCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName);
|
||||
|
||||
Task<LauncherSessionSnapshot> LaunchAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string? characterName,
|
||||
LaunchMode mode,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LauncherSessionSnapshot> ProbeAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task StopSessionAsync(
|
||||
string sessionId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void PollStatus();
|
||||
|
||||
void ClearFinishedSessions();
|
||||
}
|
||||
137
src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
Normal file
137
src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
|
||||
/// replace the directory lookup with its versioned-current resolver; until
|
||||
/// then a missing host disables the corresponding action instead of deferring
|
||||
/// failure until process creation.
|
||||
/// </summary>
|
||||
public sealed class LauncherExecutableSet
|
||||
{
|
||||
private readonly Func<string, bool> _fileExists;
|
||||
private readonly Func<string, bool> _hasUnixExecutePermission;
|
||||
|
||||
public LauncherExecutableSet(
|
||||
string graphicalHostPath,
|
||||
string headlessHostPath,
|
||||
string? workingDirectory = null,
|
||||
Func<string, bool>? fileExists = null,
|
||||
Func<string, bool>? hasUnixExecutePermission = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
||||
GraphicalHostPath = graphicalHostPath;
|
||||
HeadlessHostPath = headlessHostPath;
|
||||
WorkingDirectory = workingDirectory;
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
_hasUnixExecutePermission =
|
||||
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
||||
}
|
||||
|
||||
public string GraphicalHostPath { get; }
|
||||
|
||||
public string HeadlessHostPath { get; }
|
||||
|
||||
public string? WorkingDirectory { get; }
|
||||
|
||||
public LauncherCapability GetAvailability(LaunchMode mode)
|
||||
{
|
||||
string path = mode == LaunchMode.Headless
|
||||
? HeadlessHostPath
|
||||
: GraphicalHostPath;
|
||||
string host = mode == LaunchMode.Headless
|
||||
? "headless host"
|
||||
: "graphical client";
|
||||
if (!_fileExists(path))
|
||||
{
|
||||
return LauncherCapability.Unavailable(
|
||||
$"The co-deployed {host} is missing at '{path}'. Reinstall or update "
|
||||
+ "the client before launching.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path))
|
||||
{
|
||||
return LauncherCapability.Unavailable(
|
||||
$"The co-deployed {host} at '{path}' exists but is not executable. "
|
||||
+ "Restore its executable permission (for example, chmod +x) or "
|
||||
+ "reinstall/update the client before launching.");
|
||||
}
|
||||
|
||||
return LauncherCapability.Available;
|
||||
}
|
||||
|
||||
public LauncherProcessSpec CreatePlaySpec(
|
||||
LaunchMode mode,
|
||||
string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
RequireAvailable(mode);
|
||||
|
||||
return mode == LaunchMode.Headless
|
||||
? new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory)
|
||||
: new LauncherProcessSpec(
|
||||
GraphicalHostPath,
|
||||
["--session-config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
RequireAvailable(LaunchMode.Headless);
|
||||
return new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public static LauncherExecutableSet FromDirectory(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
string fullDirectory = Path.GetFullPath(directory);
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return new LauncherExecutableSet(
|
||||
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
||||
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||
fullDirectory);
|
||||
}
|
||||
|
||||
private void RequireAvailable(LaunchMode mode)
|
||||
{
|
||||
LauncherCapability capability = GetAvailability(mode);
|
||||
if (!capability.IsAvailable)
|
||||
{
|
||||
throw new LauncherOperationException(
|
||||
capability.Reason ?? "The selected launcher host is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasUnixExecutePermission(string path)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const UnixFileMode executeBits =
|
||||
UnixFileMode.UserExecute
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherExecute;
|
||||
return (File.GetUnixFileMode(path) & executeBits) != 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Fail closed if the file vanished or its metadata cannot be read
|
||||
// after the existence check. The next capability refresh retries.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
1293
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
1293
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,8 @@ public sealed class LauncherProfileStore
|
|||
return false;
|
||||
}
|
||||
|
||||
EnsureExistingCredentialFilePermissions();
|
||||
|
||||
LauncherProfileDocument? document;
|
||||
using (FileStream stream = File.OpenRead(FilePath))
|
||||
{
|
||||
|
|
@ -110,6 +112,7 @@ public sealed class LauncherProfileStore
|
|||
+ $"expected {CurrentVersion}.");
|
||||
}
|
||||
|
||||
ValidateAndNormalizeDocument(document);
|
||||
Document = document;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -152,6 +155,13 @@ public sealed class LauncherProfileStore
|
|||
JsonSerializer.Serialize(stream, Document, SerializerOptions);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux()
|
||||
&& File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode)
|
||||
{
|
||||
throw new IOException(
|
||||
"The launcher credential temp file could not be secured to mode 0600.");
|
||||
}
|
||||
|
||||
File.Move(tempPath, FilePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
|
|
@ -160,10 +170,6 @@ public sealed class LauncherProfileStore
|
|||
throw;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -248,21 +254,21 @@ public sealed class LauncherProfileStore
|
|||
throw new LauncherProfileException(
|
||||
$"A server named '{newName}' already exists.");
|
||||
}
|
||||
|
||||
server.Name = newName;
|
||||
}
|
||||
|
||||
if (newHost is not null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
|
||||
server.Host = newHost;
|
||||
}
|
||||
|
||||
if (newPort is not null)
|
||||
{
|
||||
RequireValidPort(newPort.Value);
|
||||
server.Port = newPort.Value;
|
||||
}
|
||||
|
||||
server.Name = newName ?? server.Name;
|
||||
server.Host = newHost ?? server.Host;
|
||||
server.Port = newPort ?? server.Port;
|
||||
}
|
||||
|
||||
public void RemoveServer(string name)
|
||||
|
|
@ -308,10 +314,10 @@ public sealed class LauncherProfileStore
|
|||
throw new LauncherProfileException(
|
||||
$"Account '{newAccount}' already exists on server '{serverName}'.");
|
||||
}
|
||||
|
||||
profile.Account = newAccount;
|
||||
}
|
||||
|
||||
profile.Account = newAccount ?? profile.Account;
|
||||
|
||||
if (newPassword is not null)
|
||||
{
|
||||
profile.Password = newPassword;
|
||||
|
|
@ -325,13 +331,62 @@ public sealed class LauncherProfileStore
|
|||
server.Accounts.Remove(profile);
|
||||
}
|
||||
|
||||
// --- Character settings (roster-driven add/remove; user-edited settings) ---
|
||||
// --- Character CRUD / user-owned settings --------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Edits the user-owned settings of an existing character row. There
|
||||
/// is no manual add/remove for characters — the roster (
|
||||
/// <see cref="MergeRoster"/>) is the only source of new rows, per
|
||||
/// spec §5/§6.
|
||||
/// Adds a manually configured character row. Normal operation discovers
|
||||
/// characters through <see cref="MergeRoster"/>, but LA4's full in-UI
|
||||
/// CRUD contract also lets a user create a cached row before a successful
|
||||
/// probe (for example, to launch by a known character name while a server
|
||||
/// is temporarily unavailable). A later roster merge remains
|
||||
/// authoritative for the id/name pair and preserves these user settings.
|
||||
/// </summary>
|
||||
public CharacterProfile AddCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName,
|
||||
string? id = null,
|
||||
LaunchMode launchMode = LaunchMode.GuiSelect,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
RequireValidLaunchMode(launchMode);
|
||||
ValidateStringList(plugins, "plugin", requireUnique: true);
|
||||
ValidateStringList(loginCommands, "login command", requireUnique: false);
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
|
||||
if (FindCharacter(profile, characterName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{characterName}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
string? normalizedId = NormalizeCharacterId(id);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
var character = new CharacterProfile
|
||||
{
|
||||
Name = characterName,
|
||||
Id = normalizedId,
|
||||
LaunchMode = launchMode,
|
||||
Plugins = plugins is null ? [] : [.. plugins],
|
||||
LoginCommands = loginCommands is null ? [] : [.. loginCommands],
|
||||
};
|
||||
profile.Characters.Add(character);
|
||||
return character;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits the identity cache and/or user-owned settings of an existing
|
||||
/// character row. Passing an empty <paramref name="newId"/> clears a
|
||||
/// manually entered id so launches fall back to the character name.
|
||||
/// </summary>
|
||||
public void EditCharacter(
|
||||
string serverName,
|
||||
|
|
@ -339,12 +394,54 @@ public sealed class LauncherProfileStore
|
|||
string characterName,
|
||||
LaunchMode? launchMode = null,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
string? newName = null,
|
||||
string? newId = null)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
|
||||
string? normalizedId = null;
|
||||
|
||||
if (newName is not null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||
if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
|
||||
&& FindCharacter(profile, newName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{newName}' already exists on account '{account}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (newId is not null)
|
||||
{
|
||||
normalizedId = NormalizeCharacterId(newId);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(candidate =>
|
||||
!ReferenceEquals(candidate, character)
|
||||
&& CharacterIdsEqual(candidate.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (launchMode is not null)
|
||||
{
|
||||
RequireValidLaunchMode(launchMode.Value);
|
||||
}
|
||||
|
||||
ValidateStringList(plugins, "plugin", requireUnique: true);
|
||||
ValidateStringList(loginCommands, "login command", requireUnique: false);
|
||||
|
||||
character.Name = newName ?? character.Name;
|
||||
if (newId is not null)
|
||||
{
|
||||
character.Id = normalizedId;
|
||||
}
|
||||
|
||||
if (launchMode is not null)
|
||||
{
|
||||
character.LaunchMode = launchMode.Value;
|
||||
|
|
@ -361,6 +458,68 @@ public sealed class LauncherProfileStore
|
|||
}
|
||||
}
|
||||
|
||||
private void EnsureExistingCredentialFilePermissions()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
UnixFileMode mode = File.GetUnixFileMode(FilePath);
|
||||
if (mode != OwnerOnlyFileMode)
|
||||
{
|
||||
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
|
||||
mode = File.GetUnixFileMode(FilePath);
|
||||
}
|
||||
|
||||
if (mode != OwnerOnlyFileMode)
|
||||
{
|
||||
throw new IOException($"Mode remained {mode} after normalization.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"'{FilePath}' could not be secured to owner-only mode 0600.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies one profile mutation and its atomic file replacement as a
|
||||
/// single in-memory/on-disk transaction. Any validation or I/O failure
|
||||
/// restores the exact pre-mutation document, including credentials.
|
||||
/// </summary>
|
||||
public void ExecuteTransaction(Action mutation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mutation);
|
||||
LauncherProfileDocument before = CloneDocument(Document);
|
||||
try
|
||||
{
|
||||
mutation();
|
||||
ValidateAndNormalizeDocument(Document);
|
||||
Save();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Document = before;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
profile.Characters.Remove(character);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds a reported character roster into an account's
|
||||
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
|
||||
|
|
@ -385,38 +544,49 @@ public sealed class LauncherProfileStore
|
|||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
|
||||
var rosterIds = new HashSet<uint>();
|
||||
var rosterNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (CharacterRosterEntry entry in roster)
|
||||
{
|
||||
if (entry.Id == 0)
|
||||
{
|
||||
throw new LauncherProfileException("A roster character id cannot be zero.");
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name);
|
||||
if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
"The reported character roster contains a duplicate id or name.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (CharacterRosterEntry entry in roster)
|
||||
{
|
||||
string idText = CharacterIdFormat.ToHexString(entry.Id);
|
||||
|
||||
// Normalize BOTH sides through TryParse/ToHexString rather
|
||||
// than a raw string compare (Campaign LA plan §LA3 review
|
||||
// finding F10): a stored id that round-trips to the same
|
||||
// uint (different case, or — before this fix — no "0x"
|
||||
// prefix) must match even though its text isn't byte-
|
||||
// identical to the canonical form this method itself always
|
||||
// writes.
|
||||
CharacterProfile? existing = profile.Characters.Find(
|
||||
character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
||||
&& existingId == entry.Id);
|
||||
|
||||
// Defensive fallback for a row whose id is missing OR
|
||||
// unparseable (e.g. a hand-edited id with no "0x" prefix,
|
||||
// which TryParse now rejects outright) — match by name
|
||||
// instead so a later merge self-heals the id into the
|
||||
// canonical form rather than creating a permanent duplicate
|
||||
// row.
|
||||
existing ??= profile.Characters.Find(
|
||||
character => !CharacterIdFormat.TryParse(character.Id, out _)
|
||||
&& string.Equals(
|
||||
character.Name,
|
||||
entry.Name,
|
||||
StringComparison.Ordinal));
|
||||
CharacterProfile[] matches = profile.Characters
|
||||
.Where(character =>
|
||||
(CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
||||
&& existingId == entry.Id)
|
||||
|| string.Equals(character.Name, entry.Name, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
CharacterProfile? existing = matches.FirstOrDefault(character =>
|
||||
CharacterIdFormat.TryParse(character.Id, out uint existingId)
|
||||
&& existingId == entry.Id)
|
||||
?? matches.FirstOrDefault();
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Id = idText;
|
||||
existing.Name = entry.Name;
|
||||
foreach (CharacterProfile duplicate in matches)
|
||||
{
|
||||
if (!ReferenceEquals(duplicate, existing))
|
||||
{
|
||||
profile.Characters.Remove(duplicate);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -456,20 +626,232 @@ public sealed class LauncherProfileStore
|
|||
$"No account '{account}' on server '{server.Name}'.");
|
||||
}
|
||||
|
||||
private static CharacterProfile? FindCharacter(
|
||||
AccountProfile profile,
|
||||
string characterName) =>
|
||||
profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
private static CharacterProfile FindCharacterOrThrow(
|
||||
AccountProfile profile,
|
||||
string characterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
return profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal))
|
||||
return FindCharacter(profile, characterName)
|
||||
?? throw new LauncherProfileException(
|
||||
$"No character '{characterName}' on account '{profile.Account}'.");
|
||||
}
|
||||
|
||||
private static string? NormalizeCharacterId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
"Character id must be a non-zero hexadecimal value with a 0x prefix.");
|
||||
}
|
||||
|
||||
return CharacterIdFormat.ToHexString(parsed);
|
||||
}
|
||||
|
||||
private static bool CharacterIdsEqual(string? left, string? right) =>
|
||||
CharacterIdFormat.TryParse(left, out uint leftId)
|
||||
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
||||
&& leftId == rightId;
|
||||
|
||||
private static LauncherProfileDocument CloneDocument(
|
||||
LauncherProfileDocument source) =>
|
||||
new()
|
||||
{
|
||||
Version = source.Version,
|
||||
Servers = source.Servers.Select(server => new ServerProfile
|
||||
{
|
||||
Name = server.Name,
|
||||
Host = server.Host,
|
||||
Port = server.Port,
|
||||
Accounts = server.Accounts.Select(account => new AccountProfile
|
||||
{
|
||||
Account = account.Account,
|
||||
Password = account.Password,
|
||||
Characters = account.Characters.Select(character => new CharacterProfile
|
||||
{
|
||||
Name = character.Name,
|
||||
Id = character.Id,
|
||||
LaunchMode = character.LaunchMode,
|
||||
Plugins = [.. character.Plugins],
|
||||
LoginCommands = [.. character.LoginCommands],
|
||||
}).ToList(),
|
||||
}).ToList(),
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
private static void ValidateAndNormalizeDocument(LauncherProfileDocument document)
|
||||
{
|
||||
if (document.Servers is null)
|
||||
{
|
||||
throw new LauncherProfileException("The servers collection cannot be null.");
|
||||
}
|
||||
|
||||
var serverNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var normalizedIds = new List<(CharacterProfile Character, uint Id)>();
|
||||
foreach (ServerProfile? server in document.Servers)
|
||||
{
|
||||
if (server is null)
|
||||
{
|
||||
throw new LauncherProfileException("A server entry cannot be null.");
|
||||
}
|
||||
|
||||
RequireLoadedText(server.Name, "server name");
|
||||
RequireLoadedText(server.Host, $"host for server '{server.Name}'");
|
||||
RequireValidPort(server.Port);
|
||||
if (!serverNames.Add(server.Name))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"A server named '{server.Name}' appears more than once.");
|
||||
}
|
||||
|
||||
if (server.Accounts is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"The accounts collection for server '{server.Name}' cannot be null.");
|
||||
}
|
||||
|
||||
var accountNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (AccountProfile? account in server.Accounts)
|
||||
{
|
||||
if (account is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"A null account appears under server '{server.Name}'.");
|
||||
}
|
||||
|
||||
RequireLoadedText(account.Account, "account name");
|
||||
if (account.Password is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Password for account '{account.Account}' cannot be null.");
|
||||
}
|
||||
|
||||
if (!accountNames.Add(account.Account))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Account '{account.Account}' appears more than once on server '{server.Name}'.");
|
||||
}
|
||||
|
||||
if (account.Characters is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"The characters collection for account '{account.Account}' cannot be null.");
|
||||
}
|
||||
|
||||
var characterNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var characterIds = new HashSet<uint>();
|
||||
foreach (CharacterProfile? character in account.Characters)
|
||||
{
|
||||
if (character is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"A null character appears under account '{account.Account}'.");
|
||||
}
|
||||
|
||||
RequireLoadedText(character.Name, "character name");
|
||||
if (!characterNames.Add(character.Name))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{character.Name}' appears more than once on account '{account.Account}'.");
|
||||
}
|
||||
|
||||
RequireValidLaunchMode(character.LaunchMode);
|
||||
if (character.Id is not null)
|
||||
{
|
||||
if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{character.Name}' has an invalid id '{character.Id}'.");
|
||||
}
|
||||
|
||||
if (!characterIds.Add(id))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{character.Id}' appears more than once on account '{account.Account}'.");
|
||||
}
|
||||
|
||||
normalizedIds.Add((character, id));
|
||||
}
|
||||
|
||||
if (character.Plugins is null || character.LoginCommands is null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{character.Name}' has a null settings collection.");
|
||||
}
|
||||
|
||||
ValidateStringList(character.Plugins, "plugin", requireUnique: true);
|
||||
ValidateStringList(
|
||||
character.LoginCommands,
|
||||
"login command",
|
||||
requireUnique: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((CharacterProfile character, uint id) in normalizedIds)
|
||||
{
|
||||
character.Id = CharacterIdFormat.ToHexString(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateStringList(
|
||||
IReadOnlyList<string>? values,
|
||||
string valueName,
|
||||
bool requireUnique)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<string>? seen = requireUnique
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: null;
|
||||
foreach (string? value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"A {valueName} cannot be null or whitespace.");
|
||||
}
|
||||
|
||||
if (seen is not null && !seen.Add(value))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"The {valueName} '{value}' appears more than once.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireLoadedText(string? value, string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new LauncherProfileException($"The {field} cannot be null or whitespace.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireValidLaunchMode(LaunchMode mode)
|
||||
{
|
||||
if (!Enum.IsDefined(mode))
|
||||
{
|
||||
throw new LauncherProfileException($"Launch mode '{mode}' is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireValidPort(int port)
|
||||
{
|
||||
if (port is < 1 or > 65535)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
25
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
25
src/AcDream.Launcher/AcDream.Launcher.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>acdream-launcher</AssemblyName>
|
||||
<RootNamespace>AcDream.Launcher</RootNamespace>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
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;
|
||||
}
|
||||
}
|
||||
389
src/AcDream.Launcher/MainWindow.axaml
Normal file
389
src/AcDream.Launcher/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:AcDream.Launcher.ViewModels"
|
||||
x:Class="AcDream.Launcher.MainWindow"
|
||||
x:DataType="vm:LauncherWindowViewModel"
|
||||
Title="acdream launcher"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="900"
|
||||
MinHeight="620"
|
||||
Background="#10151D">
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="#18212D" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="#3C78D8" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="#8E3540" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="#A8B5C6" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Border Grid.Row="0" Background="#141C26" Padding="20,14">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="acdream" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="Servers, accounts, characters, and supervised sessions"
|
||||
Classes="muted" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="First-run setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
<Button Content="Check for updates"
|
||||
Command="{Binding UpdatePromptShell.OpenCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1" Margin="16,12,16,0" Spacing="8">
|
||||
<Border Classes="card"
|
||||
Padding="12"
|
||||
Background="#4B3820"
|
||||
IsVisible="{Binding IsFirstRunRequired}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Client setup required" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding InstallationStatus}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="Open setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Classes="card"
|
||||
Padding="12"
|
||||
Background="#24344B"
|
||||
IsVisible="{Binding ShowLinuxGraphicalNotice}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Linux graphical launch gate" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding LinuxGraphicalNotice}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2" Margin="16" ColumnDefinitions="330,12,*" RowDefinitions="*,12,220">
|
||||
<Border Grid.Column="0" Grid.RowSpan="3" Classes="card">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<TextBlock Text="Profiles" Classes="section" />
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="0,12,0,10">
|
||||
<Button Content="+ Server" Command="{Binding AddServerCommand}" />
|
||||
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
|
||||
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
|
||||
</StackPanel>
|
||||
<TreeView x:Name="ProfilesTree"
|
||||
Grid.Row="2"
|
||||
ItemsSource="{Binding Servers}"
|
||||
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
|
||||
<TreeView.DataTemplates>
|
||||
<TreeDataTemplate DataType="{x:Type vm:LauncherTreeNodeViewModel}"
|
||||
ItemsSource="{Binding Children}">
|
||||
<StackPanel Margin="2,4" Spacing="1">
|
||||
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding SecondaryText}"
|
||||
Classes="muted"
|
||||
FontSize="11"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.DataTemplates>
|
||||
</TreeView>
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="0,10,0,0">
|
||||
<Button Content="Edit" Command="{Binding EditSelectedCommand}" />
|
||||
<Button Content="Remove"
|
||||
Classes="danger"
|
||||
Command="{Binding RemoveSelectedCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Grid.Row="0" Classes="card">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding SelectionTitle}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding SelectionSubtitle}" Classes="muted" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsServerSelected}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Server profile" Classes="section" />
|
||||
<TextBlock Text="Add accounts beneath this server, or edit its host and port." TextWrapping="Wrap" />
|
||||
<Button Content="Add account"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding AddAccountCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsAccountSelected}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Account profile" Classes="section" />
|
||||
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Open character select"
|
||||
Classes="primary"
|
||||
AutomationProperties.Name="Open character select for account"
|
||||
Command="{Binding LaunchAccountGuiSelectCommand}" />
|
||||
<Button Content="Refresh characters"
|
||||
Command="{Binding RefreshCharactersCommand}" />
|
||||
<Button Content="Add cached character"
|
||||
Command="{Binding AddCharacterCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ProbeDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel IsVisible="{Binding IsCharacterSelected}" Spacing="14">
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Per-character launch settings" Classes="section" />
|
||||
<TextBlock Text="Default launch mode" Classes="muted" />
|
||||
<ComboBox ItemsSource="{Binding AvailableLaunchModes}"
|
||||
SelectedItem="{Binding CharacterLaunchMode, Mode=TwoWay}" />
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Plugins (one id per line)" Classes="muted" />
|
||||
<TextBox Text="{Binding CharacterPluginsText, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="96" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Login commands (ordered, one per line)" Classes="muted" />
|
||||
<TextBox Text="{Binding CharacterLoginCommandsText, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="96" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Button Content="Save settings"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding SaveCharacterSettingsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#213044" CornerRadius="6" Padding="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Launch" Classes="section" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="GUI — enter world"
|
||||
Classes="primary"
|
||||
Command="{Binding LaunchGuiCommand}" />
|
||||
<Button Content="GUI — character select"
|
||||
Command="{Binding LaunchGuiSelectCommand}" />
|
||||
<Button Content="Headless"
|
||||
Command="{Binding LaunchHeadlessCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
|
||||
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Grid.Row="2" Classes="card">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="Sessions" Classes="section" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Clear finished"
|
||||
Command="{Binding ClearFinishedSessionsCommand}" />
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" Margin="0,10,0,0">
|
||||
<ItemsControl ItemsSource="{Binding Sessions}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LauncherSessionRowViewModel">
|
||||
<Border BorderBrush="#344559"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="4,8">
|
||||
<Grid ColumnDefinitions="2*,90,90,3*,Auto">
|
||||
<TextBlock Text="{Binding Target}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Mode}" Classes="muted" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding State}" />
|
||||
<StackPanel Grid.Column="3" Spacing="2">
|
||||
<TextBlock Text="{Binding Status}"
|
||||
TextTrimming="CharacterEllipsis" Classes="muted" />
|
||||
<TextBlock Text="{Binding Error}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding HasError}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="4" Content="Stop" Command="{Binding StopCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="3" Background="#141C26" Padding="16,10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding OperationStatus}" />
|
||||
<TextBlock Text="{Binding LastError}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding HasError}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="Cancel operation"
|
||||
Command="{Binding CancelOperationCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="20"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="Profile editor modal dialog"
|
||||
IsVisible="{Binding EditorDialog.IsOpen}">
|
||||
<Border Classes="card"
|
||||
Width="480"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding EditorDialog.Title}" FontSize="22" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding EditorDialog.Message}" Classes="muted" TextWrapping="Wrap" />
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
|
||||
<TextBlock Text="Name" />
|
||||
<TextBox x:Name="ServerNameTextBox"
|
||||
AutomationProperties.Name="Server name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Host" />
|
||||
<TextBox AutomationProperties.Name="Server host"
|
||||
Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||
<TextBlock Text="Port" />
|
||||
<TextBox AutomationProperties.Name="Server port"
|
||||
Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
||||
<TextBlock Text="Account name" />
|
||||
<TextBox x:Name="AccountNameTextBox"
|
||||
AutomationProperties.Name="Account name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Password" />
|
||||
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Account password"
|
||||
PasswordChar="●" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
||||
<TextBlock Text="Character name" />
|
||||
<TextBox x:Name="CharacterNameTextBox"
|
||||
AutomationProperties.Name="Character name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
||||
<TextBox AutomationProperties.Name="Character id"
|
||||
Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding EditorDialog.Error}"
|
||||
Foreground="#FF9A9A"
|
||||
IsVisible="{Binding EditorDialog.HasError}"
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel"
|
||||
IsCancel="True"
|
||||
AutomationProperties.Name="Cancel profile change"
|
||||
Command="{Binding EditorDialog.CancelCommand}" />
|
||||
<Button x:Name="EditorSubmitButton"
|
||||
Content="{Binding EditorDialog.SubmitText}"
|
||||
Classes="primary"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Confirm profile change"
|
||||
Command="{Binding EditorDialog.SubmitCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="First-run setup modal dialog"
|
||||
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
|
||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button x:Name="FirstRunCloseButton"
|
||||
Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Close first-run setup"
|
||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="Update prompt modal dialog"
|
||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
|
||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button x:Name="UpdateCloseButton"
|
||||
Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Close update prompt"
|
||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
137
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
137
src/AcDream.Launcher/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.ComponentModel;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly DispatcherTimer _statusTimer;
|
||||
private LauncherWindowViewModel? _observedViewModel;
|
||||
private Control? _focusBeforeModal;
|
||||
private bool _wasModalOpen;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
_statusTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
_statusTimer.Tick += OnStatusTimerTick;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Opened += OnOpened;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
|
||||
private void OnOpened(object? sender, EventArgs e) => _statusTimer.Start();
|
||||
|
||||
private void OnClosed(object? sender, EventArgs e)
|
||||
{
|
||||
_statusTimer.Stop();
|
||||
_statusTimer.Tick -= OnStatusTimerTick;
|
||||
DataContextChanged -= OnDataContextChanged;
|
||||
ObserveViewModel(null);
|
||||
Opened -= OnOpened;
|
||||
Closed -= OnClosed;
|
||||
}
|
||||
|
||||
private void OnStatusTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (DataContext is LauncherWindowViewModel viewModel)
|
||||
{
|
||||
viewModel.PollStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e) =>
|
||||
ObserveViewModel(DataContext as LauncherWindowViewModel);
|
||||
|
||||
private void ObserveViewModel(LauncherWindowViewModel? viewModel)
|
||||
{
|
||||
if (ReferenceEquals(_observedViewModel, viewModel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_observedViewModel is not null)
|
||||
{
|
||||
_observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
_observedViewModel = viewModel;
|
||||
if (_observedViewModel is not null)
|
||||
{
|
||||
_observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
_wasModalOpen = viewModel?.IsModalOpen == true;
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
|
||||
|| sender is not LauncherWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isModalOpen = viewModel.IsModalOpen;
|
||||
if (isModalOpen && !_wasModalOpen)
|
||||
{
|
||||
_focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
|
||||
Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
|
||||
}
|
||||
else if (!isModalOpen && _wasModalOpen)
|
||||
{
|
||||
Control? focusToRestore = _focusBeforeModal;
|
||||
_focusBeforeModal = null;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (focusToRestore?.Focus() != true)
|
||||
{
|
||||
ProfilesTree.Focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_wasModalOpen = isModalOpen;
|
||||
}
|
||||
|
||||
private void FocusActiveModal(LauncherWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.EditorDialog.IsOpen)
|
||||
{
|
||||
Control target = viewModel.EditorDialog.Kind switch
|
||||
{
|
||||
ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
|
||||
ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
|
||||
ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
|
||||
_ => EditorSubmitButton,
|
||||
};
|
||||
target.Focus();
|
||||
}
|
||||
else if (viewModel.FirstRunWizardShell.IsOpen)
|
||||
{
|
||||
FirstRunCloseButton.Focus();
|
||||
}
|
||||
else if (viewModel.UpdatePromptShell.IsOpen)
|
||||
{
|
||||
UpdateCloseButton.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnModalKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
viewModel.CloseActiveModal();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
24
src/AcDream.Launcher/Program.cs
Normal file
24
src/AcDream.Launcher/Program.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using Avalonia;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (args is ["--verify-publish"])
|
||||
{
|
||||
// A display-free execution probe for the packaged artifact. CI
|
||||
// runs this with DOTNET_ROOT pointing at a missing directory; a
|
||||
// framework-dependent publish cannot reach this return statement.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect();
|
||||
}
|
||||
94
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
94
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System.Windows.Input;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object?> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public RelayCommand(
|
||||
Action<object?> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
if (CanExecute(parameter))
|
||||
{
|
||||
_execute(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public sealed class AsyncRelayCommand : ICommand
|
||||
{
|
||||
private readonly Func<object?, Task> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
private bool _isExecuting;
|
||||
|
||||
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand(
|
||||
Func<object?, Task> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) =>
|
||||
!_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
|
||||
|
||||
public async void Execute(object? parameter) =>
|
||||
await ExecuteAsync(parameter).ConfigureAwait(true);
|
||||
|
||||
public async Task ExecuteAsync(object? parameter = null)
|
||||
{
|
||||
if (!CanExecute(parameter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isExecuting = true;
|
||||
NotifyCanExecuteChanged();
|
||||
try
|
||||
{
|
||||
await _execute(parameter).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecuting = false;
|
||||
NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
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,50 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherSessionRowViewModel
|
||||
{
|
||||
public LauncherSessionRowViewModel(
|
||||
LauncherSessionSnapshot snapshot,
|
||||
Func<string, Task> stop,
|
||||
Func<bool>? canStop = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(stop);
|
||||
|
||||
SessionId = snapshot.SessionId;
|
||||
Target = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? $"{snapshot.ServerName} / {snapshot.AccountName} / character refresh"
|
||||
: $"{snapshot.ServerName} / {snapshot.AccountName} / {snapshot.CharacterName}";
|
||||
Mode = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? "Probe"
|
||||
: snapshot.LaunchMode?.ToString() ?? "Session";
|
||||
State = snapshot.State.ToString();
|
||||
Status = snapshot.Status;
|
||||
Error = snapshot.Error;
|
||||
IsActive = snapshot.IsActive;
|
||||
StopCommand = new AsyncRelayCommand(
|
||||
() => stop(SessionId),
|
||||
() => IsActive && (canStop?.Invoke() ?? true));
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Mode { get; }
|
||||
|
||||
public string State { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public bool IsActive { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
|
||||
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
41
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
41
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherShellViewModel : ObservableObject
|
||||
{
|
||||
private bool _isOpen;
|
||||
|
||||
public LauncherShellViewModel(
|
||||
string title,
|
||||
string body,
|
||||
string status,
|
||||
Func<bool>? canOpen = null)
|
||||
{
|
||||
Title = title;
|
||||
Body = body;
|
||||
Status = status;
|
||||
OpenCommand = new RelayCommand(() => IsOpen = true, canOpen);
|
||||
CloseCommand = new RelayCommand(() => IsOpen = false);
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set => SetProperty(ref _isOpen, value);
|
||||
}
|
||||
|
||||
public RelayCommand OpenCommand { get; }
|
||||
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
public void NotifyCommandStates()
|
||||
{
|
||||
OpenCommand.NotifyCanExecuteChanged();
|
||||
CloseCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
1005
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
1005
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
File diff suppressed because it is too large
Load diff
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