The launcher spawned the headless host as
acdream-headless --config <path>
but HeadlessCommandLine.Parse reads arguments[0] as the COMMAND and accepts
only "validate" or "run". So every launcher-started headless session and every
"Refresh characters" died on its first instruction with
Invalid command. Run --help for usage. (exit 64)
The user's own cache shows it six times over two days. It was invisible because
the failure is an exit code in a status file, not something the UI says out
loud — which is how it survived a whole campaign whose gates exercised the
headless host through its CLI directly, never through the launcher's spec.
The graphical host takes a bare "--session-config" and has no command word;
this sibling call was written to match it. Both headless call sites now pass
"run" first. A probe is an ordinary "run" whose session config carries
mode: "probe" — the difference is in the document, not the command line, so
one fix repairs refresh and headless play together.
LauncherHeadlessCommandLineContractTests is the connection that was missing:
it takes the argument vector the launcher will really use and hands it to the
parser the host will really use, for probe and for headless play, and pins that
the graphical arguments are deliberately NOT a headless command line. The two
sides cannot drift again without failing here. Headless.Tests already
referenced both assemblies, so this needed no new coupling.
Also LU7, at the user's direction: a selected character now offers only Play
and Headless. Choosing a character means choosing to play AS that character, so
"Character select" — which deliberately picks no character — belongs to the
account page alone, where it already lives. The per-character GuiSelect command
and its capability are removed rather than left as dead surface.
Full solution 14,374 passed, 0 failed under the release-gate filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
8.5 KiB
C#
226 lines
8.5 KiB
C#
using AcDream.Launcher.Core.Launching;
|
|
using AcDream.Launcher.Core.Profiles;
|
|
using AcDream.Launcher.Core.Updates;
|
|
|
|
namespace AcDream.Launcher.Core.Orchestration;
|
|
|
|
/// <summary>
|
|
/// Resolves and validates the graphical/headless hosts. Production uses the
|
|
/// verified <c>DataDirectory/app/current.json</c> resolver; the explicit-path
|
|
/// constructor remains the injectable test seam.
|
|
/// </summary>
|
|
public sealed class LauncherExecutableSet
|
|
{
|
|
private readonly Func<string, bool> _fileExists;
|
|
private readonly Func<string, bool> _hasUnixExecutePermission;
|
|
private readonly Func<ExecutablePaths> _resolve;
|
|
|
|
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);
|
|
string graphical = graphicalHostPath;
|
|
string headless = headlessHostPath;
|
|
_resolve = () => new ExecutablePaths(graphical, headless, workingDirectory);
|
|
_fileExists = fileExists ?? File.Exists;
|
|
_hasUnixExecutePermission =
|
|
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
|
}
|
|
|
|
private LauncherExecutableSet(
|
|
Func<ExecutablePaths> resolve,
|
|
Func<string, bool>? fileExists = null,
|
|
Func<string, bool>? hasUnixExecutePermission = null)
|
|
{
|
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
_fileExists = fileExists ?? File.Exists;
|
|
_hasUnixExecutePermission =
|
|
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
|
}
|
|
|
|
public string GraphicalHostPath => _resolve().GraphicalHostPath;
|
|
|
|
public string HeadlessHostPath => _resolve().HeadlessHostPath;
|
|
|
|
public string? WorkingDirectory => _resolve().WorkingDirectory;
|
|
|
|
public LauncherCapability GetAvailability(LaunchMode mode)
|
|
{
|
|
ExecutablePaths paths;
|
|
try
|
|
{
|
|
paths = _resolve();
|
|
}
|
|
catch (Exception ex) when (ex is LauncherUpdateException
|
|
or InvalidOperationException
|
|
or IOException
|
|
or UnauthorizedAccessException)
|
|
{
|
|
return LauncherCapability.Unavailable(
|
|
$"The active versioned client is unavailable: {ex.Message}");
|
|
}
|
|
|
|
string path = mode == LaunchMode.Headless
|
|
? paths.HeadlessHostPath
|
|
: paths.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,
|
|
string? stderrLogPath = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
|
ExecutablePaths paths = RequireAvailable(mode);
|
|
|
|
return mode == LaunchMode.Headless
|
|
? new LauncherProcessSpec(
|
|
paths.HeadlessHostPath,
|
|
// "run" is REQUIRED and positional: HeadlessCommandLine.Parse
|
|
// reads arguments[0] as the command and accepts only
|
|
// "validate" or "run". Passing "--config" first made every
|
|
// launcher-started headless session die instantly with
|
|
// "Invalid command. Run --help for usage." (exit 64) — the
|
|
// graphical host takes a bare "--session-config", and this
|
|
// sibling call was written to match it.
|
|
["run", "--config", configFilePath],
|
|
paths.WorkingDirectory,
|
|
StderrLogPath: stderrLogPath)
|
|
: new LauncherProcessSpec(
|
|
paths.GraphicalHostPath,
|
|
["--session-config", configFilePath],
|
|
paths.WorkingDirectory,
|
|
SupportsConsoleGracefulStop: false,
|
|
StderrLogPath: stderrLogPath);
|
|
}
|
|
|
|
public LauncherProcessSpec CreateProbeSpec(
|
|
string configFilePath,
|
|
string? stderrLogPath = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
|
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
|
|
return new LauncherProcessSpec(
|
|
paths.HeadlessHostPath,
|
|
// Same grammar as CreatePlaySpec's headless arm — see its comment.
|
|
// A probe is an ordinary "run" whose session config carries
|
|
// mode: "probe"; the difference is in the document, not the
|
|
// command line.
|
|
["run", "--config", configFilePath],
|
|
paths.WorkingDirectory,
|
|
StderrLogPath: stderrLogPath);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dynamic production resolver. The store cache is admitted only after a
|
|
/// strict startup/update verification, and a pointer swap changes the
|
|
/// binaries selected for the next session without replacing LA9 content.
|
|
/// </summary>
|
|
public static LauncherExecutableSet FromCurrentVersionStore(
|
|
ClientVersionStore store)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(store);
|
|
return new LauncherExecutableSet(() =>
|
|
{
|
|
ClientVersionResolution resolution = store.CachedResolution;
|
|
if (!resolution.IsVerified || resolution.Directory is null)
|
|
{
|
|
throw new LauncherUpdateException(resolution.Status);
|
|
}
|
|
|
|
return FromDirectoryPaths(resolution.Directory);
|
|
});
|
|
}
|
|
|
|
public static LauncherExecutableSet Unavailable(string reason)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
|
return new LauncherExecutableSet(
|
|
() => throw new LauncherUpdateException(reason));
|
|
}
|
|
|
|
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
|
{
|
|
LauncherCapability capability = GetAvailability(mode);
|
|
if (!capability.IsAvailable)
|
|
{
|
|
throw new LauncherOperationException(
|
|
capability.Reason ?? "The selected launcher host is unavailable.");
|
|
}
|
|
|
|
return _resolve();
|
|
}
|
|
|
|
private static ExecutablePaths FromDirectoryPaths(string directory)
|
|
{
|
|
string fullDirectory = Path.GetFullPath(directory);
|
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
|
return new ExecutablePaths(
|
|
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
|
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
|
fullDirectory);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
private sealed record ExecutablePaths(
|
|
string GraphicalHostPath,
|
|
string HeadlessHostPath,
|
|
string? WorkingDirectory);
|
|
}
|