feat(launcher): Campaign LA add Avalonia desktop shell

This commit is contained in:
Erik 2026-08-14 18:15:14 +02:00
parent 6c4cd2bbc6
commit d0a9c65d85
31 changed files with 4831 additions and 16 deletions

View file

@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// Test seam for one supervised launcher child. The Avalonia orchestration
/// layer owns this interface through a factory and never constructs or drives
/// <see cref="System.Diagnostics.Process"/> directly.
/// </summary>
public interface ILauncherProcessSupervisor : IDisposable
{
LauncherSessionState State { get; }
int? ExitCode { get; }
event EventHandler<LauncherSessionState>? StateChanged;
void Start(LauncherProcessSpec spec, string? password);
void Stop(TimeSpan timeout);
}
public interface ILauncherProcessSupervisorFactory
{
ILauncherProcessSupervisor Create();
}
public sealed class LauncherProcessSupervisorFactory(
ILauncherChildProcessFactory? childProcessFactory = null)
: ILauncherProcessSupervisorFactory
{
private readonly ILauncherChildProcessFactory _childProcessFactory =
childProcessFactory ?? new SystemChildProcessFactory();
public ILauncherProcessSupervisor Create() =>
new LauncherProcessSupervisor(_childProcessFactory);
}
/// <summary>
/// Spawns a host process (App/Headless), feeds the account password to
/// its stdin then closes it, and supervises its lifetime (Campaign LA
/// spec §3/§6). One supervisor instance owns exactly one child process
/// for its lifetime — start a new supervisor per launched session.
/// </summary>
public sealed class LauncherProcessSupervisor : IDisposable
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
{
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();

View file

@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig(
string StatusFilePath,
SessionConfigDocument Document);
/// <summary>
/// Injectable composition/write seam used by the canonical launcher
/// orchestrator. Production delegates to <see cref="SessionConfigComposer"/>;
/// tests can capture the exact request without writing a file or starting a
/// client process.
/// </summary>
public interface ILauncherSessionConfigService
{
ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null);
ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId);
}
public sealed class LauncherSessionConfigService : ILauncherSessionConfigService
{
public ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null) =>
SessionConfigComposer.ComposeAndWrite(
server,
account,
character,
install,
paths,
sessionId,
loginCommandDelayMs);
public ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId) =>
SessionConfigComposer.ComposeProbeAndWrite(
server,
account,
install,
paths,
sessionId);
}
/// <summary>
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
/// profile character + install record (Campaign LA spec §6). Passwords
@ -187,6 +245,23 @@ public static class SessionConfigComposer
sessionId,
loginCommandDelayMs);
return Write(composed);
}
/// <summary>Probe counterpart to <see cref="ComposeAndWrite"/>. It
/// writes the pinned <c>mode: "probe"</c> document and never includes
/// a character selector, policy, plugin set, login commands, or password.
/// </summary>
public static ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId) =>
Write(ComposeProbe(server, account, install, paths, sessionId));
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
{
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
if (!string.IsNullOrEmpty(directory))
{

View file

@ -0,0 +1,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();
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
public readonly record struct LauncherCapability(bool IsAvailable, string? Reason)
{
public static LauncherCapability Available { get; } = new(true, null);
public static LauncherCapability Unavailable(string reason) =>
new(false, reason);
}
/// <summary>
/// Immutable platform row selected once at launcher startup. Campaign LA
/// ships the Avalonia launcher, profile editor, probes, and headless sessions
/// on Windows and Linux. Graphical client launches remain Windows-only until
/// Modern Runtime Slice L resumes from its parked L1 checkpoint.
/// </summary>
public sealed record LauncherPlatformCapabilities(
bool IsWindows,
bool IsLinux,
bool CanRunHeadless,
bool CanLaunchGraphicalClient,
string PlatformName,
string? GraphicalLaunchDisabledReason)
{
public const string LinuxGraphicalLaunchDisabledReason =
"GUI launches require the Linux graphical client (Modern Runtime Slice L), "
+ "which is parked at L1 and will resume later. The launcher, character "
+ "probe, and headless sessions remain available on Linux.";
public static LauncherPlatformCapabilities Detect()
{
if (OperatingSystem.IsWindows())
{
return new LauncherPlatformCapabilities(
IsWindows: true,
IsLinux: false,
CanRunHeadless: true,
CanLaunchGraphicalClient: true,
PlatformName: "Windows",
GraphicalLaunchDisabledReason: null);
}
if (OperatingSystem.IsLinux())
{
return new LauncherPlatformCapabilities(
IsWindows: false,
IsLinux: true,
CanRunHeadless: true,
CanLaunchGraphicalClient: false,
PlatformName: "Linux",
GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason);
}
return new LauncherPlatformCapabilities(
IsWindows: false,
IsLinux: false,
CanRunHeadless: false,
CanLaunchGraphicalClient: false,
PlatformName: "Unsupported",
GraphicalLaunchDisabledReason:
"Graphical client launches are supported on Windows. Linux support "
+ "requires Modern Runtime Slice L.");
}
public LauncherCapability ForLaunchMode(LaunchMode mode)
{
if (mode == LaunchMode.Headless)
{
return CanRunHeadless
? LauncherCapability.Available
: LauncherCapability.Unavailable(
"Headless launches are supported only on Windows and Linux.");
}
return CanLaunchGraphicalClient
? LauncherCapability.Available
: LauncherCapability.Unavailable(
GraphicalLaunchDisabledReason
?? "The graphical client is unavailable on this platform.");
}
}

View file

@ -0,0 +1,85 @@
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Orchestration;
public sealed record LauncherCharacterSnapshot(
string ServerName,
string AccountName,
string Name,
string? Id,
LaunchMode LaunchMode,
IReadOnlyList<string> Plugins,
IReadOnlyList<string> LoginCommands,
bool HasRunningSession,
string SessionStatus);
/// <summary>
/// Password is deliberately absent. The account credential remains reachable
/// only inside <see cref="Profiles.LauncherProfileStore"/> and the transient
/// stdin handoff performed by <see cref="LauncherOrchestrator"/>.
/// </summary>
public sealed record LauncherAccountSnapshot(
string ServerName,
string AccountName,
IReadOnlyList<LauncherCharacterSnapshot> Characters,
bool HasRunningActivity,
string ActivityStatus);
public sealed record LauncherServerSnapshot(
string Name,
string Host,
int Port,
IReadOnlyList<LauncherAccountSnapshot> Accounts);
public enum LauncherActivityKind
{
Play,
Probe,
}
public enum LauncherActivityState
{
Starting,
Running,
Connected,
InWorld,
Disconnected,
Stopping,
Exited,
Failed,
Cancelled,
}
public sealed record LauncherSessionSnapshot(
string SessionId,
LauncherActivityKind Kind,
string ServerName,
string AccountName,
string? CharacterName,
LaunchMode? LaunchMode,
LauncherActivityState State,
string Status,
int? ExitCode,
string? Error,
DateTimeOffset CreatedAt)
{
public bool IsActive => State is not (
LauncherActivityState.Exited
or LauncherActivityState.Failed
or LauncherActivityState.Cancelled);
}
public sealed record LauncherStateSnapshot(
IReadOnlyList<LauncherServerSnapshot> Servers,
IReadOnlyList<LauncherSessionSnapshot> Sessions,
LauncherPlatformCapabilities Platform,
bool IsInstallationReady,
string InstallationStatus);
public sealed class LauncherOperationException : Exception
{
public LauncherOperationException(string message)
: base(message)
{
}
}

View file

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

View file

@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status;
/// One tailer instance owns one file's read position; construct a new
/// one per session.
/// </summary>
public sealed class StatusFileTailer
public interface IStatusEventSource
{
IReadOnlyList<StatusEvent> ReadNewEvents();
}
/// <summary>Creates one independent status source per launched session.</summary>
public interface IStatusEventSourceFactory
{
IStatusEventSource Create(string path);
}
public sealed class StatusFileTailerFactory : IStatusEventSourceFactory
{
public IStatusEventSource Create(string path) => new StatusFileTailer(path);
}
public sealed class StatusFileTailer : IStatusEventSource
{
private readonly string _path;
private long _position;

View file

@ -0,0 +1,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>

View file

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

View file

@ -0,0 +1,66 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.ViewModels;
using AcDream.Platform;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace AcDream.Launcher;
public sealed partial class App : Application
{
private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
LauncherInstallRecord? install = ResolveDevelopmentInstallRecord();
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
install);
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher());
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
{
DataContext = _viewModel,
};
desktop.Exit += OnDesktopExit;
}
base.OnFrameworkInitializationCompleted();
}
private static LauncherInstallRecord? ResolveDevelopmentInstallRecord()
{
// LA9 owns persisted install discovery. LA4 accepts the existing
// developer environment pair at this one composition root so the
// launch/probe UI can be exercised before the first-run body lands.
string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
return !string.IsNullOrWhiteSpace(datDirectory)
&& !string.IsNullOrWhiteSpace(preparedAssetPath)
? new LauncherInstallRecord(datDirectory, preparedAssetPath)
: null;
}
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
{
_viewModel?.Dispose();
_orchestrator?.Dispose();
_viewModel = null;
_orchestrator = null;
}
}

View file

@ -0,0 +1,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>

View 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();
}
}
}

View 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();
}

View 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);
}

View file

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

View file

@ -0,0 +1,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; }
}

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

View file

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

View 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);
}
}

View file

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

View file

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