feat(launcher): prepare Campaign LA11 user gate

This commit is contained in:
Erik 2026-08-14 23:09:08 +02:00
parent 09d84387a8
commit f881e5b467
24 changed files with 3538 additions and 58 deletions

View file

@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable
/// documented project landmine; see CLAUDE.md
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
/// the headless host's SIGINT handler produces an ACE-confirmed
/// graceful logout). On Windows there is no reliable cross-console
/// mechanism for an arbitrary no-window child process today — see
/// <c>docs/ISSUES.md</c> for the tracked gap and fix direction; this
/// returns false there. Returns true only when the signal was
/// actually delivered; never throws.
/// graceful logout). On Windows, console-capable children are started
/// as distinct process-group leaders and receive a targeted
/// CTRL_BREAK_EVENT. Returns true only when the signal was actually
/// delivered; never throws.
/// </summary>
bool TryRequestGracefulStop();
@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
{
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
new SystemChildProcess(spec);
OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
? new WindowsSystemChildProcess(spec)
: new SystemChildProcess(spec);
}
internal sealed partial class SystemChildProcess : ILauncherChildProcess
@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
private static partial int kill(int pid, int sig);
private readonly Process _process;
private readonly bool _supportsConsoleGracefulStop;
private bool _raisingEnabled;
internal SystemChildProcess(LauncherProcessSpec spec)
{
ArgumentNullException.ThrowIfNull(spec);
_supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
var startInfo = new ProcessStartInfo
{
@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
public bool TryRequestGracefulStop()
{
if (!OperatingSystem.IsLinux())
if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
{
// No reliable cross-console mechanism exists for an
// arbitrary no-window Windows child process — tracked gap,
// see docs/ISSUES.md.
// Windows console-capable children use
// WindowsSystemChildProcess. Graphical/non-console children
// deliberately retain the Process/WM_CLOSE path.
return false;
}

View file

@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
/// Deliberately carries no credential field — the password is a separate
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
/// that flows only to the child's stdin, never into this spec, an
/// argument list, or a process environment.
/// argument list, or a process environment. Console-capable specs set
/// <paramref name="SupportsConsoleGracefulStop"/> so Windows starts them
/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and
/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE.
/// </summary>
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList<string> Arguments,
string? WorkingDirectory = null);
string? WorkingDirectory = null,
bool SupportsConsoleGracefulStop = true);

View file

@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
/// <summary>
/// Requests a graceful stop — first
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
/// on Linux; a no-op on Windows today, see
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
/// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
/// children),
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
/// not exited within <paramref name="timeout"/>. A no-op if

View file

@ -0,0 +1,843 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// Windows launcher child created without a shell as a true console process-
/// group leader. The native start is deliberately narrow: it exists only
/// because <see cref="ProcessStartInfo"/> does not expose
/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
/// </summary>
internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
{
private readonly LauncherProcessSpec _spec;
private readonly IWindowsConsoleControl _consoleControl;
private Process? _process;
private TextWriter? _standardInput;
private int _processGroupId;
private bool _raisingEnabled;
internal WindowsSystemChildProcess(
LauncherProcessSpec spec,
IWindowsConsoleControl? consoleControl = null)
{
_spec = spec ?? throw new ArgumentNullException(nameof(spec));
_consoleControl = consoleControl ?? WindowsConsoleControl.Instance;
}
public bool HasExited => RequireProcess().HasExited;
public int ExitCode => RequireProcess().ExitCode;
public TextWriter StandardInput => _standardInput
?? throw new InvalidOperationException("The child process has not started.");
public event EventHandler? Exited;
public void Start()
{
if (_process is not null)
{
throw new InvalidOperationException("The child process already started.");
}
WindowsProcessStartResult started = WindowsProcessNative.Start(_spec);
try
{
_process = Process.GetProcessById(started.ProcessId);
_process.EnableRaisingEvents = true;
_process.Exited += OnExited;
_raisingEnabled = true;
_standardInput = started.TakeStandardInput();
_processGroupId = started.ProcessId;
started.Resume();
}
catch
{
started.Terminate();
_standardInput?.Dispose();
_standardInput = null;
if (_process is not null)
{
if (_raisingEnabled)
{
_process.Exited -= OnExited;
}
_process.Dispose();
_process = null;
}
throw;
}
finally
{
started.Dispose();
}
}
public bool TryRequestGracefulStop()
{
try
{
if (!_spec.SupportsConsoleGracefulStop
|| _process is not { HasExited: false } process
|| _processGroupId <= 0)
{
return false;
}
return _consoleControl.TrySendBreak(process.Id, _processGroupId);
}
catch
{
// The process may have exited between the state check and the
// control request. Graceful-stop attempts never escape Stop().
return false;
}
}
public bool CloseMainWindow() => RequireProcess().CloseMainWindow();
public void Kill() => RequireProcess().Kill(entireProcessTree: true);
public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout);
public void Dispose()
{
_standardInput?.Dispose();
_standardInput = null;
if (_process is not null)
{
if (_raisingEnabled)
{
_process.Exited -= OnExited;
}
_process.Dispose();
_process = null;
}
}
private Process RequireProcess() => _process
?? throw new InvalidOperationException("The child process has not started.");
private void OnExited(object? sender, EventArgs e) =>
Exited?.Invoke(this, EventArgs.Empty);
}
internal interface IWindowsConsoleControl
{
bool TrySendBreak(int childProcessId, int childProcessGroupId);
}
internal sealed class WindowsConsoleControl : IWindowsConsoleControl
{
private const uint CtrlBreakEvent = 1;
internal static WindowsConsoleControl Instance { get; } = new();
private WindowsConsoleControl()
{
}
public bool TrySendBreak(int childProcessId, int childProcessGroupId)
{
if (!OperatingSystem.IsWindows()
|| childProcessId <= 0
|| childProcessGroupId <= 0)
{
return false;
}
lock (WindowsConsoleSynchronization.Gate)
{
bool attachedHere = false;
try
{
uint[] processes = new uint[1];
if (Native.GetConsoleProcessList(processes, 1) == 0)
{
if (!Native.AttachConsole((uint)childProcessId))
{
return false;
}
attachedHere = true;
}
return Native.GenerateConsoleCtrlEvent(
CtrlBreakEvent,
(uint)childProcessGroupId);
}
catch
{
return false;
}
finally
{
if (attachedHere)
{
_ = Native.FreeConsole();
}
}
}
}
private static class Native
{
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool AttachConsole(uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GenerateConsoleCtrlEvent(
uint controlEvent,
uint processGroupId);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern uint GetConsoleProcessList(
[Out] uint[] processList,
uint processCount);
}
}
/// <summary>
/// A process can be attached to only one console. Child creation and targeted
/// control-event attachment therefore share one process-wide gate.
/// </summary>
internal static class WindowsConsoleSynchronization
{
internal static object Gate { get; } = new();
}
internal sealed class WindowsProcessStartResult : IDisposable
{
private readonly SafeKernelHandle _processHandle;
private readonly SafeKernelHandle _threadHandle;
private SafeFileHandle? _standardInput;
private bool _resumed;
internal WindowsProcessStartResult(
int processId,
SafeKernelHandle processHandle,
SafeKernelHandle threadHandle,
SafeFileHandle standardInput)
{
ProcessId = processId;
_processHandle = processHandle;
_threadHandle = threadHandle;
_standardInput = standardInput;
}
internal int ProcessId { get; }
internal TextWriter TakeStandardInput()
{
SafeFileHandle handle = _standardInput
?? throw new InvalidOperationException("Standard input was already claimed.");
var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false);
_standardInput = null;
try
{
return new StreamWriter(
stream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true,
};
}
catch
{
stream.Dispose();
throw;
}
}
internal void Resume()
{
if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue)
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The Windows launcher child could not be resumed.");
}
_resumed = true;
}
internal void Terminate()
{
if (!_processHandle.IsInvalid)
{
_ = WindowsProcessNative.TerminateProcess(_processHandle, 74);
}
}
public void Dispose()
{
if (!_resumed)
{
Terminate();
}
_standardInput?.Dispose();
_threadHandle.Dispose();
_processHandle.Dispose();
}
}
internal static class WindowsProcessNative
{
private const uint CreateSuspended = 0x00000004;
private const uint CreateNewProcessGroup = 0x00000200;
private const uint ExtendedStartupInfoPresent = 0x00080000;
private const uint StartfUseStdHandles = 0x00000100;
private const short SwHide = 0;
private const uint HandleFlagInherit = 0x00000001;
private const uint DuplicateSameAccess = 0x00000002;
private const uint GenericWrite = 0x40000000;
private const uint FileShareRead = 0x00000001;
private const uint FileShareWrite = 0x00000002;
private const uint OpenExisting = 3;
private const uint FileAttributeNormal = 0x00000080;
private const int StdOutputHandle = -11;
private const int StdErrorHandle = -12;
private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002);
internal static WindowsProcessStartResult Start(LauncherProcessSpec spec)
{
ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath);
ArgumentNullException.ThrowIfNull(spec.Arguments);
lock (WindowsConsoleSynchronization.Gate)
{
bool allocatedConsole = false;
try
{
// An Avalonia launcher started from Explorer has no console.
// CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a
// console-less group cannot receive GenerateConsoleCtrlEvent.
// Allocate one only for the creation transaction, hide it,
// let the group leader inherit it, then detach the launcher.
// Each such child consequently owns a distinct console as
// well as a distinct process group.
if (!HasConsole())
{
if (!AllocConsole())
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The Windows launcher could not allocate the child console.");
}
allocatedConsole = true;
IntPtr consoleWindow = GetConsoleWindow();
if (consoleWindow != IntPtr.Zero)
{
_ = ShowWindow(consoleWindow, SwHide);
}
}
return StartCore(spec);
}
finally
{
if (allocatedConsole)
{
_ = FreeConsole();
}
}
}
}
private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec)
{
SafeFileHandle? parentInput = null;
try
{
using SafeFileHandle childInput = CreateChildInputPipe(
out SafeFileHandle createdParentInput);
parentInput = createdParentInput;
using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle);
using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle);
using var attributes = new ProcessThreadAttributeList(
childInput.DangerousGetHandle(),
childOutput.DangerousGetHandle(),
childError.DangerousGetHandle());
var startup = new StartupInfoEx
{
StartupInfo = new StartupInfo
{
Size = Marshal.SizeOf<StartupInfoEx>(),
Flags = StartfUseStdHandles,
StandardInput = childInput.DangerousGetHandle(),
StandardOutput = childOutput.DangerousGetHandle(),
StandardError = childError.DangerousGetHandle(),
},
AttributeList = attributes.Pointer,
};
string executable = ResolveExecutable(spec.ExecutablePath);
string commandLineText = BuildCommandLine(executable, spec.Arguments);
var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1);
string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory)
? null
: Path.GetFullPath(spec.WorkingDirectory);
if (!CreateProcessW(
executable,
commandLine,
IntPtr.Zero,
IntPtr.Zero,
inheritHandles: true,
CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent,
IntPtr.Zero,
workingDirectory,
ref startup,
out ProcessInformation information))
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The Windows launcher child could not be created.");
}
var processHandle = new SafeKernelHandle(
information.Process,
ownsHandle: true);
var threadHandle = new SafeKernelHandle(
information.Thread,
ownsHandle: true);
try
{
var result = new WindowsProcessStartResult(
checked((int)information.ProcessId),
processHandle,
threadHandle,
parentInput);
parentInput = null;
return result;
}
catch
{
_ = TerminateProcess(processHandle, 74);
threadHandle.Dispose();
processHandle.Dispose();
throw;
}
}
finally
{
parentInput?.Dispose();
}
}
private static bool HasConsole()
{
uint[] processes = new uint[1];
return GetConsoleProcessList(processes, 1) != 0;
}
internal static uint ResumeThread(SafeKernelHandle thread) =>
NativeResumeThread(thread);
internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) =>
NativeTerminateProcess(process, exitCode);
internal static string BuildCommandLine(
string executable,
IReadOnlyList<string> arguments)
{
var builder = new StringBuilder();
AppendQuotedArgument(builder, executable);
foreach (string argument in arguments)
{
ArgumentNullException.ThrowIfNull(argument);
builder.Append(' ');
AppendQuotedArgument(builder, argument);
}
return builder.ToString();
}
private static void AppendQuotedArgument(StringBuilder builder, string value)
{
builder.Append('"');
int backslashes = 0;
foreach (char character in value)
{
if (character == '\\')
{
backslashes++;
continue;
}
if (character == '"')
{
builder.Append('\\', backslashes * 2 + 1);
builder.Append('"');
backslashes = 0;
continue;
}
builder.Append('\\', backslashes);
backslashes = 0;
builder.Append(character);
}
builder.Append('\\', backslashes * 2);
builder.Append('"');
}
private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput)
{
var security = new SecurityAttributes
{
Length = Marshal.SizeOf<SecurityAttributes>(),
InheritHandle = true,
};
if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0))
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The launcher child stdin pipe could not be created.");
}
var child = new SafeFileHandle(read, ownsHandle: true);
parentInput = new SafeFileHandle(write, ownsHandle: true);
if (!SetHandleInformation(
parentInput,
HandleFlagInherit,
0))
{
int error = Marshal.GetLastWin32Error();
child.Dispose();
parentInput.Dispose();
throw new Win32Exception(error,
"The launcher child stdin pipe could not be isolated.");
}
return child;
}
private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle)
{
IntPtr source = GetStdHandle(standardHandle);
if (source != IntPtr.Zero && source != new IntPtr(-1))
{
IntPtr current = GetCurrentProcess();
if (DuplicateHandle(
current,
source,
current,
out IntPtr duplicate,
0,
inheritHandle: true,
DuplicateSameAccess))
{
return new SafeKernelHandle(duplicate, ownsHandle: true);
}
}
IntPtr nul = CreateFileW(
"NUL",
GenericWrite,
FileShareRead | FileShareWrite,
IntPtr.Zero,
OpenExisting,
FileAttributeNormal,
IntPtr.Zero);
if (nul == IntPtr.Zero || nul == new IntPtr(-1))
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The launcher child fallback output handle could not be opened.");
}
var handle = new SafeKernelHandle(nul, ownsHandle: true);
if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit))
{
int error = Marshal.GetLastWin32Error();
handle.Dispose();
throw new Win32Exception(error,
"The launcher child fallback output handle could not be inherited.");
}
return handle;
}
private static string ResolveExecutable(string executable)
{
if (Path.IsPathFullyQualified(executable))
{
return Path.GetFullPath(executable);
}
var buffer = new StringBuilder(32_768);
uint length = SearchPathW(
null,
executable,
null,
(uint)buffer.Capacity,
buffer,
IntPtr.Zero);
if (length == 0 || length >= buffer.Capacity)
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
$"Launcher child executable '{executable}' was not found.");
}
return Path.GetFullPath(buffer.ToString());
}
private sealed class ProcessThreadAttributeList : IDisposable
{
private IntPtr _pointer;
private IntPtr _handles;
private bool _initialized;
internal ProcessThreadAttributeList(params IntPtr[] handles)
{
nuint size = 0;
_ = InitializeProcThreadAttributeList(
IntPtr.Zero,
1,
0,
ref size);
_pointer = Marshal.AllocHGlobal(checked((nint)size));
if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size))
{
int error = Marshal.GetLastWin32Error();
Dispose();
throw new Win32Exception(error,
"The launcher child handle list could not be initialized.");
}
_initialized = true;
_handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size);
for (int index = 0; index < handles.Length; index++)
{
Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]);
}
if (!UpdateProcThreadAttribute(
_pointer,
0,
ProcThreadAttributeHandleList,
_handles,
checked((nuint)(handles.Length * IntPtr.Size)),
IntPtr.Zero,
IntPtr.Zero))
{
int error = Marshal.GetLastWin32Error();
Dispose();
throw new Win32Exception(error,
"The launcher child inherited-handle list could not be set.");
}
}
internal IntPtr Pointer => _pointer;
public void Dispose()
{
if (_pointer != IntPtr.Zero)
{
if (_initialized)
{
DeleteProcThreadAttributeList(_pointer);
_initialized = false;
}
Marshal.FreeHGlobal(_pointer);
_pointer = IntPtr.Zero;
}
if (_handles != IntPtr.Zero)
{
Marshal.FreeHGlobal(_handles);
_handles = IntPtr.Zero;
}
}
}
[StructLayout(LayoutKind.Sequential)]
private struct SecurityAttributes
{
internal int Length;
internal IntPtr SecurityDescriptor;
[MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
internal int Size;
internal string? Reserved;
internal string? Desktop;
internal string? Title;
internal int X;
internal int Y;
internal int XSize;
internal int YSize;
internal int XCountChars;
internal int YCountChars;
internal int FillAttribute;
internal uint Flags;
internal short ShowWindow;
internal short Reserved2Size;
internal IntPtr Reserved2;
internal IntPtr StandardInput;
internal IntPtr StandardOutput;
internal IntPtr StandardError;
}
[StructLayout(LayoutKind.Sequential)]
private struct StartupInfoEx
{
internal StartupInfo StartupInfo;
internal IntPtr AttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
internal IntPtr Process;
internal IntPtr Thread;
internal uint ProcessId;
internal uint ThreadId;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
StringBuilder commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
uint creationFlags,
IntPtr environment,
string? currentDirectory,
ref StartupInfoEx startupInfo,
out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreatePipe(
out IntPtr readPipe,
out IntPtr writePipe,
ref SecurityAttributes pipeAttributes,
uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetHandleInformation(
SafeHandle handle,
uint mask,
uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllocConsole();
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint GetConsoleProcessList(
[Out] uint[] processList,
uint processCount);
[DllImport("kernel32.dll")]
private static extern IntPtr GetConsoleWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr window, int commandShow);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(int standardHandle);
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DuplicateHandle(
IntPtr sourceProcess,
IntPtr sourceHandle,
IntPtr targetProcess,
out IntPtr targetHandle,
uint desiredAccess,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
uint options);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateFileW(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint SearchPathW(
string? path,
string fileName,
string? extension,
uint bufferLength,
StringBuilder buffer,
IntPtr filePart);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(
IntPtr attributeList,
int attributeCount,
int flags,
ref nuint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(
IntPtr attributeList,
uint flags,
IntPtr attribute,
IntPtr value,
nuint size,
IntPtr previousValue,
IntPtr returnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr attributeList);
[DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)]
private static extern uint NativeResumeThread(SafeKernelHandle thread);
[DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool NativeTerminateProcess(
SafeKernelHandle process,
uint exitCode);
}
internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid
{
internal SafeKernelHandle(IntPtr handle, bool ownsHandle)
: base(ownsHandle)
{
SetHandle(handle);
}
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}

View file

@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet
: new LauncherProcessSpec(
paths.GraphicalHostPath,
["--session-config", configFilePath],
paths.WorkingDirectory);
paths.WorkingDirectory,
SupportsConsoleGracefulStop: false);
}
public LauncherProcessSpec CreateProbeSpec(string configFilePath)

View file

@ -28,6 +28,10 @@
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free
CLI as its own self-contained single file into the same directory. -->

View file

@ -0,0 +1,248 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher;
internal enum LauncherStartupMode
{
Desktop,
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
}
/// <summary>
/// Immutable, process-local launcher inputs. Parsing happens before any
/// launcher owner is constructed so every owner receives the same exact path
/// set and the test-feed URI can reach only the updater composition.
/// </summary>
internal sealed class LauncherStartupOptions
{
private readonly IReadOnlyList<string> _publicArguments;
private LauncherStartupOptions(
LauncherStartupMode mode,
ApplicationPathSet paths,
Uri updateManifestUri,
IReadOnlyList<string> publicArguments)
{
Mode = mode;
Paths = paths;
UpdateManifestUri = updateManifestUri;
_publicArguments = Array.AsReadOnly(publicArguments.ToArray());
}
internal LauncherStartupMode Mode { get; }
internal ApplicationPathSet Paths { get; }
internal Uri UpdateManifestUri { get; }
/// <summary>
/// The validated public option suffix. LA10 passes this suffix through its
/// helper and confirmation processes so an isolated self-update cannot
/// fall back to canonical user roots or the production feed.
/// </summary>
internal IReadOnlyList<string> PublicArguments => _publicArguments;
internal static LauncherStartupOptions Parse(
IReadOnlyList<string> arguments,
Func<ApplicationPathSet>? resolveDefaultPaths = null)
{
ArgumentNullException.ThrowIfNull(arguments);
resolveDefaultPaths ??= () => ApplicationPathSet.Resolve();
(LauncherStartupMode mode, int publicStart) = ReadMode(arguments);
string[] publicArguments = arguments.Skip(publicStart).ToArray();
if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal))
{
if (mode != LauncherStartupMode.Desktop
|| publicArguments.Length != 1
|| !string.Equals(
publicArguments[0],
"--verify-publish",
StringComparison.Ordinal))
{
throw new LauncherStartupOptionsException(
"--verify-publish must be the only launcher argument.");
}
return new LauncherStartupOptions(
LauncherStartupMode.VerifyPublish,
// The publish probe returns before this value is observed. A
// non-resolving sentinel keeps the probe display- and
// user-profile-free even under a deliberately broken runtime.
new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null),
ReleaseManifestClient.ProductionManifestUri,
publicArguments);
}
string? configDirectory = null;
string? dataDirectory = null;
string? cacheDirectory = null;
Uri? updateManifestUri = null;
for (int index = 0; index < publicArguments.Length; index += 2)
{
string name = publicArguments[index];
if (index + 1 >= publicArguments.Length
|| publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal))
{
throw new LauncherStartupOptionsException(
$"Launcher option '{name}' requires a value.");
}
string value = publicArguments[index + 1];
if (string.IsNullOrWhiteSpace(value))
{
throw new LauncherStartupOptionsException(
$"Launcher option '{name}' requires a non-empty value.");
}
switch (name)
{
case "--config-dir":
SetDirectoryOnce(ref configDirectory, value, name);
break;
case "--data-dir":
SetDirectoryOnce(ref dataDirectory, value, name);
break;
case "--cache-dir":
SetDirectoryOnce(ref cacheDirectory, value, name);
break;
case "--update-manifest-uri":
if (updateManifestUri is not null)
{
throw new LauncherStartupOptionsException(
"Launcher options cannot be repeated.");
}
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed))
{
throw new LauncherStartupOptionsException(
"--update-manifest-uri must be an absolute URI.");
}
if (parsed.Scheme != Uri.UriSchemeHttps
&& !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback))
{
throw new LauncherStartupOptionsException(
"The update manifest URI must use HTTPS "
+ "(loopback HTTP is test-only).");
}
if (!string.IsNullOrEmpty(parsed.UserInfo))
{
throw new LauncherStartupOptionsException(
"The update manifest URI cannot contain user information.");
}
updateManifestUri = parsed;
break;
default:
throw new LauncherStartupOptionsException(
$"Unknown launcher option '{name}'.");
}
}
int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory }
.Count(path => path is not null);
if (suppliedRoots is > 0 and < 3)
{
throw new LauncherStartupOptionsException(
"--config-dir, --data-dir, and --cache-dir must be supplied together.");
}
ApplicationPathSet paths = suppliedRoots == 3
? new ApplicationPathSet(
configDirectory!,
dataDirectory!,
cacheDirectory!,
LegacyConfigDirectory: null)
: resolveDefaultPaths();
return new LauncherStartupOptions(
mode,
paths,
updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri,
publicArguments);
}
private static (LauncherStartupMode Mode, int PublicStart) ReadMode(
IReadOnlyList<string> arguments)
{
if (arguments.Count == 0)
{
return (LauncherStartupMode.Desktop, 0);
}
if (string.Equals(
arguments[0],
LauncherSelfUpdateBootstrap.HelperArgument,
StringComparison.Ordinal))
{
// Malformed internal invocations are rejected by the bootstrap
// with EX_USAGE. Do not reinterpret their operands as public
// options while resolving the manager they need to report that.
return (
LauncherStartupMode.SelfUpdateHelper,
arguments.Count >= 4 ? 4 : arguments.Count);
}
if (string.Equals(
arguments[0],
LauncherSelfUpdateBootstrap.ConfirmArgument,
StringComparison.Ordinal))
{
return (
LauncherStartupMode.SelfUpdateConfirmation,
arguments.Count >= 2 ? 2 : arguments.Count);
}
return (LauncherStartupMode.Desktop, 0);
}
private static void SetDirectoryOnce(
ref string? destination,
string value,
string option)
{
if (destination is not null)
{
throw new LauncherStartupOptionsException(
"Launcher options cannot be repeated.");
}
if (!Path.IsPathFullyQualified(value))
{
throw new LauncherStartupOptionsException(
$"Launcher option '{option}' must be an absolute path.");
}
try
{
destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
}
catch (Exception ex) when (ex is ArgumentException
or IOException
or NotSupportedException)
{
throw new LauncherStartupOptionsException(
$"Launcher option '{option}' is not a valid absolute path.",
ex);
}
}
}
internal sealed class LauncherStartupOptionsException : Exception
{
internal LauncherStartupOptionsException(string message)
: base(message)
{
}
internal LauncherStartupOptionsException(string message, Exception innerException)
: base(message, innerException)
{
}
}