feat(launcher): Campaign LA add Avalonia desktop shell
This commit is contained in:
parent
6c4cd2bbc6
commit
d0a9c65d85
31 changed files with 4831 additions and 16 deletions
|
|
@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices;
|
|||
|
||||
namespace AcDream.Launcher.Core.Launching;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for one supervised launcher child. The Avalonia orchestration
|
||||
/// layer owns this interface through a factory and never constructs or drives
|
||||
/// <see cref="System.Diagnostics.Process"/> directly.
|
||||
/// </summary>
|
||||
public interface ILauncherProcessSupervisor : IDisposable
|
||||
{
|
||||
LauncherSessionState State { get; }
|
||||
|
||||
int? ExitCode { get; }
|
||||
|
||||
event EventHandler<LauncherSessionState>? StateChanged;
|
||||
|
||||
void Start(LauncherProcessSpec spec, string? password);
|
||||
|
||||
void Stop(TimeSpan timeout);
|
||||
}
|
||||
|
||||
public interface ILauncherProcessSupervisorFactory
|
||||
{
|
||||
ILauncherProcessSupervisor Create();
|
||||
}
|
||||
|
||||
public sealed class LauncherProcessSupervisorFactory(
|
||||
ILauncherChildProcessFactory? childProcessFactory = null)
|
||||
: ILauncherProcessSupervisorFactory
|
||||
{
|
||||
private readonly ILauncherChildProcessFactory _childProcessFactory =
|
||||
childProcessFactory ?? new SystemChildProcessFactory();
|
||||
|
||||
public ILauncherProcessSupervisor Create() =>
|
||||
new LauncherProcessSupervisor(_childProcessFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns a host process (App/Headless), feeds the account password to
|
||||
/// its stdin then closes it, and supervises its lifetime (Campaign LA
|
||||
/// spec §3/§6). One supervisor instance owns exactly one child process
|
||||
/// for its lifetime — start a new supervisor per launched session.
|
||||
/// </summary>
|
||||
public sealed class LauncherProcessSupervisor : IDisposable
|
||||
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||
{
|
||||
private readonly ILauncherChildProcessFactory _factory;
|
||||
private readonly object _gate = new();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig(
|
|||
string StatusFilePath,
|
||||
SessionConfigDocument Document);
|
||||
|
||||
/// <summary>
|
||||
/// Injectable composition/write seam used by the canonical launcher
|
||||
/// orchestrator. Production delegates to <see cref="SessionConfigComposer"/>;
|
||||
/// tests can capture the exact request without writing a file or starting a
|
||||
/// client process.
|
||||
/// </summary>
|
||||
public interface ILauncherSessionConfigService
|
||||
{
|
||||
ComposedSessionConfig ComposeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
CharacterProfile character,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId,
|
||||
int? loginCommandDelayMs = null);
|
||||
|
||||
ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId);
|
||||
}
|
||||
|
||||
public sealed class LauncherSessionConfigService : ILauncherSessionConfigService
|
||||
{
|
||||
public ComposedSessionConfig ComposeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
CharacterProfile character,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId,
|
||||
int? loginCommandDelayMs = null) =>
|
||||
SessionConfigComposer.ComposeAndWrite(
|
||||
server,
|
||||
account,
|
||||
character,
|
||||
install,
|
||||
paths,
|
||||
sessionId,
|
||||
loginCommandDelayMs);
|
||||
|
||||
public ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId) =>
|
||||
SessionConfigComposer.ComposeProbeAndWrite(
|
||||
server,
|
||||
account,
|
||||
install,
|
||||
paths,
|
||||
sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
|
||||
/// profile character + install record (Campaign LA spec §6). Passwords
|
||||
|
|
@ -187,6 +245,23 @@ public static class SessionConfigComposer
|
|||
sessionId,
|
||||
loginCommandDelayMs);
|
||||
|
||||
return Write(composed);
|
||||
}
|
||||
|
||||
/// <summary>Probe counterpart to <see cref="ComposeAndWrite"/>. It
|
||||
/// writes the pinned <c>mode: "probe"</c> document and never includes
|
||||
/// a character selector, policy, plugin set, login commands, or password.
|
||||
/// </summary>
|
||||
public static ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId) =>
|
||||
Write(ComposeProbe(server, account, install, paths, sessionId));
|
||||
|
||||
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical state/mutation surface projected by the Avalonia launcher. The UI
|
||||
/// never owns a second profile document, process map, status tail, or launch
|
||||
/// transaction; it asks for immutable snapshots and sends typed mutations here.
|
||||
/// </summary>
|
||||
public interface ILauncherOrchestrator : IDisposable
|
||||
{
|
||||
event EventHandler? StateChanged;
|
||||
|
||||
void LoadProfiles();
|
||||
|
||||
LauncherStateSnapshot GetSnapshot();
|
||||
|
||||
LauncherCapability GetLaunchCapability(LaunchMode mode);
|
||||
|
||||
LauncherCapability GetProbeCapability(string serverName, string accountName);
|
||||
|
||||
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||
|
||||
void AddServer(string name, string host, int port);
|
||||
|
||||
void EditServer(string name, string newName, string newHost, int newPort);
|
||||
|
||||
void RemoveServer(string name);
|
||||
|
||||
void AddAccount(string serverName, string accountName, string password);
|
||||
|
||||
void EditAccount(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string newAccountName,
|
||||
string? newPassword);
|
||||
|
||||
void RemoveAccount(string serverName, string accountName);
|
||||
|
||||
void AddCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string? characterId);
|
||||
|
||||
void EditCharacterIdentity(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string newCharacterName,
|
||||
string? newCharacterId);
|
||||
|
||||
void UpdateCharacterSettings(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode launchMode,
|
||||
IReadOnlyList<string> plugins,
|
||||
IReadOnlyList<string> loginCommands);
|
||||
|
||||
void RemoveCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName);
|
||||
|
||||
Task<LauncherSessionSnapshot> LaunchAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode mode,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LauncherSessionSnapshot> ProbeAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task StopSessionAsync(
|
||||
string sessionId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void PollStatus();
|
||||
|
||||
void ClearFinishedSessions();
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Host executable paths supplied by the current installation. LA10 will
|
||||
/// resolve these from the versioned <c>app/current</c> pointer; LA4 keeps the
|
||||
/// mapping injectable and host-agnostic.
|
||||
/// </summary>
|
||||
public sealed record LauncherExecutableSet(
|
||||
string GraphicalHostPath,
|
||||
string HeadlessHostPath,
|
||||
string? WorkingDirectory = null)
|
||||
{
|
||||
public LauncherProcessSpec CreatePlaySpec(
|
||||
LaunchMode mode,
|
||||
string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
|
||||
return mode == LaunchMode.Headless
|
||||
? new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory)
|
||||
: new LauncherProcessSpec(
|
||||
GraphicalHostPath,
|
||||
["--session-config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
return new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory);
|
||||
}
|
||||
|
||||
public static LauncherExecutableSet FromDirectory(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
string fullDirectory = Path.GetFullPath(directory);
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return new LauncherExecutableSet(
|
||||
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
||||
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||
fullDirectory);
|
||||
}
|
||||
}
|
||||
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
1184
src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,83 @@
|
|||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
public readonly record struct LauncherCapability(bool IsAvailable, string? Reason)
|
||||
{
|
||||
public static LauncherCapability Available { get; } = new(true, null);
|
||||
|
||||
public static LauncherCapability Unavailable(string reason) =>
|
||||
new(false, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable platform row selected once at launcher startup. Campaign LA
|
||||
/// ships the Avalonia launcher, profile editor, probes, and headless sessions
|
||||
/// on Windows and Linux. Graphical client launches remain Windows-only until
|
||||
/// Modern Runtime Slice L resumes from its parked L1 checkpoint.
|
||||
/// </summary>
|
||||
public sealed record LauncherPlatformCapabilities(
|
||||
bool IsWindows,
|
||||
bool IsLinux,
|
||||
bool CanRunHeadless,
|
||||
bool CanLaunchGraphicalClient,
|
||||
string PlatformName,
|
||||
string? GraphicalLaunchDisabledReason)
|
||||
{
|
||||
public const string LinuxGraphicalLaunchDisabledReason =
|
||||
"GUI launches require the Linux graphical client (Modern Runtime Slice L), "
|
||||
+ "which is parked at L1 and will resume later. The launcher, character "
|
||||
+ "probe, and headless sessions remain available on Linux.";
|
||||
|
||||
public static LauncherPlatformCapabilities Detect()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: true,
|
||||
IsLinux: false,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: true,
|
||||
PlatformName: "Windows",
|
||||
GraphicalLaunchDisabledReason: null);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: false,
|
||||
IsLinux: true,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: false,
|
||||
PlatformName: "Linux",
|
||||
GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason);
|
||||
}
|
||||
|
||||
return new LauncherPlatformCapabilities(
|
||||
IsWindows: false,
|
||||
IsLinux: false,
|
||||
CanRunHeadless: false,
|
||||
CanLaunchGraphicalClient: false,
|
||||
PlatformName: "Unsupported",
|
||||
GraphicalLaunchDisabledReason:
|
||||
"Graphical client launches are supported on Windows. Linux support "
|
||||
+ "requires Modern Runtime Slice L.");
|
||||
}
|
||||
|
||||
public LauncherCapability ForLaunchMode(LaunchMode mode)
|
||||
{
|
||||
if (mode == LaunchMode.Headless)
|
||||
{
|
||||
return CanRunHeadless
|
||||
? LauncherCapability.Available
|
||||
: LauncherCapability.Unavailable(
|
||||
"Headless launches are supported only on Windows and Linux.");
|
||||
}
|
||||
|
||||
return CanLaunchGraphicalClient
|
||||
? LauncherCapability.Available
|
||||
: LauncherCapability.Unavailable(
|
||||
GraphicalLaunchDisabledReason
|
||||
?? "The graphical client is unavailable on this platform.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
public sealed record LauncherCharacterSnapshot(
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
string Name,
|
||||
string? Id,
|
||||
LaunchMode LaunchMode,
|
||||
IReadOnlyList<string> Plugins,
|
||||
IReadOnlyList<string> LoginCommands,
|
||||
bool HasRunningSession,
|
||||
string SessionStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Password is deliberately absent. The account credential remains reachable
|
||||
/// only inside <see cref="Profiles.LauncherProfileStore"/> and the transient
|
||||
/// stdin handoff performed by <see cref="LauncherOrchestrator"/>.
|
||||
/// </summary>
|
||||
public sealed record LauncherAccountSnapshot(
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
IReadOnlyList<LauncherCharacterSnapshot> Characters,
|
||||
bool HasRunningActivity,
|
||||
string ActivityStatus);
|
||||
|
||||
public sealed record LauncherServerSnapshot(
|
||||
string Name,
|
||||
string Host,
|
||||
int Port,
|
||||
IReadOnlyList<LauncherAccountSnapshot> Accounts);
|
||||
|
||||
public enum LauncherActivityKind
|
||||
{
|
||||
Play,
|
||||
Probe,
|
||||
}
|
||||
|
||||
public enum LauncherActivityState
|
||||
{
|
||||
Starting,
|
||||
Running,
|
||||
Connected,
|
||||
InWorld,
|
||||
Disconnected,
|
||||
Stopping,
|
||||
Exited,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
public sealed record LauncherSessionSnapshot(
|
||||
string SessionId,
|
||||
LauncherActivityKind Kind,
|
||||
string ServerName,
|
||||
string AccountName,
|
||||
string? CharacterName,
|
||||
LaunchMode? LaunchMode,
|
||||
LauncherActivityState State,
|
||||
string Status,
|
||||
int? ExitCode,
|
||||
string? Error,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsActive => State is not (
|
||||
LauncherActivityState.Exited
|
||||
or LauncherActivityState.Failed
|
||||
or LauncherActivityState.Cancelled);
|
||||
}
|
||||
|
||||
public sealed record LauncherStateSnapshot(
|
||||
IReadOnlyList<LauncherServerSnapshot> Servers,
|
||||
IReadOnlyList<LauncherSessionSnapshot> Sessions,
|
||||
LauncherPlatformCapabilities Platform,
|
||||
bool IsInstallationReady,
|
||||
string InstallationStatus);
|
||||
|
||||
public sealed class LauncherOperationException : Exception
|
||||
{
|
||||
public LauncherOperationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -325,13 +325,59 @@ public sealed class LauncherProfileStore
|
|||
server.Accounts.Remove(profile);
|
||||
}
|
||||
|
||||
// --- Character settings (roster-driven add/remove; user-edited settings) ---
|
||||
// --- Character CRUD / user-owned settings --------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Edits the user-owned settings of an existing character row. There
|
||||
/// is no manual add/remove for characters — the roster (
|
||||
/// <see cref="MergeRoster"/>) is the only source of new rows, per
|
||||
/// spec §5/§6.
|
||||
/// Adds a manually configured character row. Normal operation discovers
|
||||
/// characters through <see cref="MergeRoster"/>, but LA4's full in-UI
|
||||
/// CRUD contract also lets a user create a cached row before a successful
|
||||
/// probe (for example, to launch by a known character name while a server
|
||||
/// is temporarily unavailable). A later roster merge remains
|
||||
/// authoritative for the id/name pair and preserves these user settings.
|
||||
/// </summary>
|
||||
public CharacterProfile AddCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName,
|
||||
string? id = null,
|
||||
LaunchMode launchMode = LaunchMode.GuiSelect,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
|
||||
if (FindCharacter(profile, characterName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{characterName}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
string? normalizedId = NormalizeCharacterId(id);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
var character = new CharacterProfile
|
||||
{
|
||||
Name = characterName,
|
||||
Id = normalizedId,
|
||||
LaunchMode = launchMode,
|
||||
Plugins = plugins is null ? [] : [.. plugins],
|
||||
LoginCommands = loginCommands is null ? [] : [.. loginCommands],
|
||||
};
|
||||
profile.Characters.Add(character);
|
||||
return character;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits the identity cache and/or user-owned settings of an existing
|
||||
/// character row. Passing an empty <paramref name="newId"/> clears a
|
||||
/// manually entered id so launches fall back to the character name.
|
||||
/// </summary>
|
||||
public void EditCharacter(
|
||||
string serverName,
|
||||
|
|
@ -339,12 +385,42 @@ public sealed class LauncherProfileStore
|
|||
string characterName,
|
||||
LaunchMode? launchMode = null,
|
||||
IReadOnlyList<string>? plugins = null,
|
||||
IReadOnlyList<string>? loginCommands = null)
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
string? newName = null,
|
||||
string? newId = null)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
|
||||
if (newName is not null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||
if (!string.Equals(newName, character.Name, StringComparison.Ordinal)
|
||||
&& FindCharacter(profile, newName) is not null)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character '{newName}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
character.Name = newName;
|
||||
}
|
||||
|
||||
if (newId is not null)
|
||||
{
|
||||
string? normalizedId = NormalizeCharacterId(newId);
|
||||
if (normalizedId is not null
|
||||
&& profile.Characters.Any(candidate =>
|
||||
!ReferenceEquals(candidate, character)
|
||||
&& CharacterIdsEqual(candidate.Id, normalizedId)))
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
$"Character id '{normalizedId}' already exists on account '{account}'.");
|
||||
}
|
||||
|
||||
character.Id = normalizedId;
|
||||
}
|
||||
|
||||
if (launchMode is not null)
|
||||
{
|
||||
character.LaunchMode = launchMode.Value;
|
||||
|
|
@ -361,6 +437,17 @@ public sealed class LauncherProfileStore
|
|||
}
|
||||
}
|
||||
|
||||
public void RemoveCharacter(
|
||||
string serverName,
|
||||
string account,
|
||||
string characterName)
|
||||
{
|
||||
ServerProfile server = FindServerOrThrow(serverName);
|
||||
AccountProfile profile = FindAccountOrThrow(server, account);
|
||||
CharacterProfile character = FindCharacterOrThrow(profile, characterName);
|
||||
profile.Characters.Remove(character);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds a reported character roster into an account's
|
||||
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
|
||||
|
|
@ -456,20 +543,46 @@ public sealed class LauncherProfileStore
|
|||
$"No account '{account}' on server '{server.Name}'.");
|
||||
}
|
||||
|
||||
private static CharacterProfile? FindCharacter(
|
||||
AccountProfile profile,
|
||||
string characterName) =>
|
||||
profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
private static CharacterProfile FindCharacterOrThrow(
|
||||
AccountProfile profile,
|
||||
string characterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
return profile.Characters.Find(
|
||||
character => string.Equals(
|
||||
character.Name,
|
||||
characterName,
|
||||
StringComparison.Ordinal))
|
||||
return FindCharacter(profile, characterName)
|
||||
?? throw new LauncherProfileException(
|
||||
$"No character '{characterName}' on account '{profile.Account}'.");
|
||||
}
|
||||
|
||||
private static string? NormalizeCharacterId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0)
|
||||
{
|
||||
throw new LauncherProfileException(
|
||||
"Character id must be a non-zero hexadecimal value with a 0x prefix.");
|
||||
}
|
||||
|
||||
return CharacterIdFormat.ToHexString(parsed);
|
||||
}
|
||||
|
||||
private static bool CharacterIdsEqual(string? left, string? right) =>
|
||||
CharacterIdFormat.TryParse(left, out uint leftId)
|
||||
&& CharacterIdFormat.TryParse(right, out uint rightId)
|
||||
&& leftId == rightId;
|
||||
|
||||
private static void RequireValidPort(int port)
|
||||
{
|
||||
if (port is < 1 or > 65535)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status;
|
|||
/// One tailer instance owns one file's read position; construct a new
|
||||
/// one per session.
|
||||
/// </summary>
|
||||
public sealed class StatusFileTailer
|
||||
public interface IStatusEventSource
|
||||
{
|
||||
IReadOnlyList<StatusEvent> ReadNewEvents();
|
||||
}
|
||||
|
||||
/// <summary>Creates one independent status source per launched session.</summary>
|
||||
public interface IStatusEventSourceFactory
|
||||
{
|
||||
IStatusEventSource Create(string path);
|
||||
}
|
||||
|
||||
public sealed class StatusFileTailerFactory : IStatusEventSourceFactory
|
||||
{
|
||||
public IStatusEventSource Create(string path) => new StatusFileTailer(path);
|
||||
}
|
||||
|
||||
public sealed class StatusFileTailer : IStatusEventSource
|
||||
{
|
||||
private readonly string _path;
|
||||
private long _position;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue