using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Orchestration;
///
/// Resolves and validates the graphical/headless hosts. Production uses the
/// verified DataDirectory/app/current.json resolver; the explicit-path
/// constructor remains the injectable test seam.
///
public sealed class LauncherExecutableSet
{
private readonly Func _fileExists;
private readonly Func _hasUnixExecutePermission;
private readonly Func _resolve;
public LauncherExecutableSet(
string graphicalHostPath,
string headlessHostPath,
string? workingDirectory = null,
Func? fileExists = null,
Func? 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 resolve,
Func? fileExists = null,
Func? 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);
}
///
/// 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.
///
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);
}