merge: Campaign LA LA11 - automated closeout review-closed
# Conflicts: # docs/plans/2026-08-14-launcher-campaign.md
This commit is contained in:
commit
d39f3098d5
39 changed files with 6097 additions and 204 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap
|
|||
{
|
||||
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
|
||||
internal const int DeferredLeaseExitCode = 73;
|
||||
internal const int UpdateLeaseBusyExitCode = 73;
|
||||
private const string InternalArgumentPrefix = "--acdream-self-update-";
|
||||
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||
string[] args,
|
||||
|
|
@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap
|
|||
Path.GetFullPath(launcherBaseDirectory));
|
||||
string executable = Path.GetFullPath(currentExecutablePath);
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
|
||||
{
|
||||
return new SelfUpdateStartupResult(false, 0, args[1..]);
|
||||
}
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||
{
|
||||
|
|
@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap
|
|||
|
||||
int exitCode = await RunHelperAsync(
|
||||
manager,
|
||||
baseDirectory,
|
||||
executable,
|
||||
parentPid,
|
||||
args[2],
|
||||
args[3],
|
||||
|
|
@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap
|
|||
return new SelfUpdateStartupResult(true, 64, []);
|
||||
}
|
||||
|
||||
if (manager.Barrier.TryAcquireSession(
|
||||
out UpdateSessionBarrier.SessionLease? unexpectedSharedLease))
|
||||
{
|
||||
unexpectedSharedLease?.Dispose();
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update confirmation is trusted only while its helper owns "
|
||||
+ "the exclusive update lease.");
|
||||
}
|
||||
|
||||
await manager.ConfirmAsync(
|
||||
args[1],
|
||||
baseDirectory,
|
||||
executable,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await FinishConfirmedCleanupAsync(
|
||||
manager,
|
||||
baseDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
// The helper that owns the exclusive lease observes this durable
|
||||
// receipt and performs authoritative completion. A later ordinary
|
||||
// startup also completes it if that helper crashes after receipt.
|
||||
return new SelfUpdateStartupResult(false, 0, args[2..]);
|
||||
}
|
||||
|
||||
if (args.Length > 0
|
||||
&& args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
// Internal modes are an exact vocabulary. In particular, an old
|
||||
// deferred-restart marker must never become an authorization to
|
||||
// skip a pending recovery state.
|
||||
return new SelfUpdateStartupResult(true, 64, []);
|
||||
}
|
||||
|
||||
// Load first: an invalid/ambiguous journal must fail closed even when
|
||||
// another process currently owns the update barrier.
|
||||
_ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!manager.Barrier.TryAcquireExclusive(
|
||||
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
||||
{
|
||||
// A running session or another launcher is staging. Reading the
|
||||
// plan is safe, but cleanup or starting a competing helper is not.
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
if (!manager.Barrier.TryAcquireSession(
|
||||
out UpdateSessionBarrier.SessionLease? sharedLease))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Launcher startup is blocked by an active update or recovery transaction.");
|
||||
}
|
||||
|
||||
using (sharedLease
|
||||
?? throw new InvalidOperationException("Shared startup lease is missing."))
|
||||
{
|
||||
SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (blockedPlan is null)
|
||||
{
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
ValidateCanonicalStartup(blockedPlan, baseDirectory, executable);
|
||||
if (blockedPlan.State != SelfUpdatePlanState.Staged)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update state '{blockedPlan.State}' requires exclusive recovery.");
|
||||
}
|
||||
|
||||
// A verified staged update may wait while an already-running
|
||||
// session holds the shared lease. No helper is spawned, so a
|
||||
// late session lease cannot create a restart loop.
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
}
|
||||
|
||||
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
|
||||
|
|
@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap
|
|||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update targets a different launcher directory.");
|
||||
}
|
||||
ValidateCanonicalStartup(plan, baseDirectory, executable);
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
|
|
@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap
|
|||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
baseDirectory,
|
||||
GetLauncherFileName(plan.Rid));
|
||||
if (!PathsEqual(executable, expectedExecutable))
|
||||
if (plan.State is SelfUpdatePlanState.Applying
|
||||
or SelfUpdatePlanState.RolledBack)
|
||||
{
|
||||
if (plan.State == SelfUpdatePlanState.Applying)
|
||||
{
|
||||
plan = await manager.RecoverApplyingAsync(
|
||||
baseDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (plan.State != SelfUpdatePlanState.RolledBack)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The interrupted self-update did not produce a rollback receipt.");
|
||||
}
|
||||
|
||||
await manager.CompleteRolledBackAsync(
|
||||
plan.TransactionId,
|
||||
baseDirectory,
|
||||
lease,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_ = manager.CleanupOwnedResidueUnderLease(
|
||||
pending: null,
|
||||
baseDirectory,
|
||||
lease);
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
if (plan.State != SelfUpdatePlanState.Staged)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update can start only from the published acdream-launcher executable.");
|
||||
$"Self-update state '{plan.State}' cannot start a helper.");
|
||||
}
|
||||
|
||||
string helperPath = manager.GetStagedLauncherPath(plan);
|
||||
|
|
@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap
|
|||
|
||||
private static async Task<int> RunHelperAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
string helperBaseDirectory,
|
||||
string currentExecutablePath,
|
||||
int parentPid,
|
||||
string targetDirectory,
|
||||
string transactionId,
|
||||
|
|
@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap
|
|||
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("The helper found no pending self-update.");
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
|
||||
if (plan.State != SelfUpdatePlanState.Staged
|
||||
|| !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The helper transaction does not match the pending self-update.");
|
||||
"The helper mode does not match a staged self-update transaction.");
|
||||
}
|
||||
|
||||
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||
|
|
@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap
|
|||
"The helper target does not match the pending self-update.");
|
||||
}
|
||||
|
||||
string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId);
|
||||
string expectedHelperPath = manager.GetStagedLauncherPath(plan);
|
||||
if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory)
|
||||
|| !PathsEqual(currentExecutablePath, expectedHelperPath))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update helper mode is trusted only from the staged launcher payload.");
|
||||
}
|
||||
|
||||
string launcherPath = ClientVersionStore.ResolveContained(
|
||||
targetDirectory,
|
||||
GetLauncherFileName(plan.Rid));
|
||||
|
|
@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap
|
|||
{
|
||||
// Do not restart the canonical launcher: it would immediately see
|
||||
// the same staged plan and create an unbounded helper loop.
|
||||
return DeferredLeaseExitCode;
|
||||
return UpdateLeaseBusyExitCode;
|
||||
}
|
||||
|
||||
ProcessStartInfo? restoredStart = null;
|
||||
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
||||
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
||||
{
|
||||
|
|
@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap
|
|||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The helper found no pending self-update after acquiring the lease.");
|
||||
if (!string.Equals(
|
||||
if (plan.State != SelfUpdatePlanState.Staged
|
||||
|| !string.Equals(
|
||||
plan.TransactionId,
|
||||
transactionId,
|
||||
StringComparison.Ordinal)
|
||||
|
|
@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap
|
|||
return 75;
|
||||
}
|
||||
|
||||
var restored = new ProcessStartInfo(launcherPath)
|
||||
restoredStart = new ProcessStartInfo(launcherPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||
};
|
||||
restored.ArgumentList.Add(DeferredArgument);
|
||||
foreach (string argument in publicArguments)
|
||||
{
|
||||
restored.ArgumentList.Add(argument);
|
||||
restoredStart.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
_ = Process.Start(restored);
|
||||
return 74;
|
||||
}
|
||||
finally
|
||||
{
|
||||
replacement?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task FinishConfirmedCleanupAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
|
||||
do
|
||||
// Release the helper's exclusive barrier before restarting the
|
||||
// restored canonical launcher. It will observe the durable RolledBack
|
||||
// receipt through the ordinary startup path, re-verify it, finalize
|
||||
// recovery, and continue with no privileged bypass argument.
|
||||
if (restoredStart is null || Process.Start(restoredStart) is null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (manager.Barrier.TryAcquireExclusive(
|
||||
out UpdateSessionBarrier.ExclusiveLease? lease))
|
||||
{
|
||||
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
|
||||
?? throw new InvalidOperationException(
|
||||
"Exclusive cleanup lease is missing."))
|
||||
{
|
||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (pending is
|
||||
{
|
||||
State: SelfUpdatePlanState.AwaitingConfirmation,
|
||||
}
|
||||
&& manager.IsConfirmed(pending.TransactionId))
|
||||
{
|
||||
await manager.CompleteConfirmedAsync(
|
||||
pending.TransactionId,
|
||||
targetDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
pending = null;
|
||||
}
|
||||
|
||||
if (manager.CleanupOwnedResidueUnderLease(
|
||||
pending,
|
||||
targetDirectory,
|
||||
acquiredLease))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||
return 75;
|
||||
}
|
||||
while (DateTimeOffset.UtcNow < deadline);
|
||||
|
||||
return 74;
|
||||
}
|
||||
|
||||
private static string GetLauncherFileName(string rid) =>
|
||||
"acdream-launcher"
|
||||
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||
|
||||
private static void ValidateCanonicalStartup(
|
||||
SelfUpdatePlan plan,
|
||||
string baseDirectory,
|
||||
string executable)
|
||||
{
|
||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update targets a different launcher directory.");
|
||||
}
|
||||
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
baseDirectory,
|
||||
GetLauncherFileName(plan.Rid));
|
||||
if (!PathsEqual(executable, expectedExecutable))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update can run only from the published acdream-launcher executable.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForParentExitAsync(
|
||||
int parentPid,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager
|
|||
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes a durable rollback only after the prior owned launcher set
|
||||
/// has been freshly re-verified while the caller holds the update
|
||||
/// barrier. A failed self-update is abandoned rather than silently
|
||||
/// re-staged, so an ordinary restart cannot enter an automatic retry
|
||||
/// loop.
|
||||
/// </summary>
|
||||
internal async Task CompleteRolledBackAsync(
|
||||
string transactionId,
|
||||
string expectedTargetDirectory,
|
||||
UpdateSessionBarrier.ExclusiveLease lease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Barrier.RequireOwned(lease);
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no rolled-back self-update.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||
|| plan.State != SelfUpdatePlanState.RolledBack)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update does not have the expected rollback receipt.");
|
||||
}
|
||||
|
||||
await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
File.Delete(PendingPlanPath);
|
||||
SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan));
|
||||
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ public interface IReleaseManifestClient
|
|||
|
||||
/// <summary>
|
||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
||||
/// construction is HTTPS-only. The loopback HTTP allowance is available only
|
||||
/// through an internal fixture factory and is never inferred from a URI.
|
||||
/// construction is pinned and HTTPS-only. The explicitly named process-local
|
||||
/// feed factory independently revalidates its URI and can admit HTTP only for
|
||||
/// the loopback operator fixture; it cannot change the production constructor.
|
||||
/// Redirects are followed manually so every hop is checked before any bytes
|
||||
/// cross that hop.
|
||||
/// </summary>
|
||||
|
|
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
CreateRedirectDisabledHandler(),
|
||||
timeout);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the explicit process-local feed seam used by the Campaign LA
|
||||
/// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted
|
||||
/// only for a loopback manifest, and never by the pinned production
|
||||
/// constructor. Credential-bearing or mutable URI suffixes are rejected.
|
||||
/// </summary>
|
||||
public static ReleaseManifestClient CreateLocalUpdateFeedOverride(
|
||||
Uri manifestUri,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifestUri);
|
||||
if (!string.IsNullOrEmpty(manifestUri.UserInfo)
|
||||
|| !string.IsNullOrEmpty(manifestUri.Query)
|
||||
|| !string.IsNullOrEmpty(manifestUri.Fragment))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"A process-local manifest URI cannot contain user information, "
|
||||
+ "a query, or a fragment.");
|
||||
}
|
||||
|
||||
bool allowLoopbackHttp = string.Equals(
|
||||
manifestUri.Scheme,
|
||||
Uri.UriSchemeHttp,
|
||||
StringComparison.Ordinal)
|
||||
&& manifestUri.IsLoopback;
|
||||
if (!string.Equals(
|
||||
manifestUri.Scheme,
|
||||
Uri.UriSchemeHttps,
|
||||
StringComparison.Ordinal)
|
||||
&& !allowLoopbackHttp)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"A process-local manifest URI must use HTTPS "
|
||||
+ "(loopback HTTP is fixture-only).");
|
||||
}
|
||||
|
||||
return new ReleaseManifestClient(
|
||||
manifestUri,
|
||||
allowLoopbackHttp,
|
||||
CreateRedirectDisabledHandler(),
|
||||
timeout);
|
||||
}
|
||||
|
||||
internal static ReleaseManifestClient CreateForTransportTest(
|
||||
Uri manifestUri,
|
||||
bool allowLoopbackHttp,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier
|
|||
return new SessionLease(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking shared-lease probe used only by launcher startup after an
|
||||
/// exclusive probe observed contention. Success proves that no updater
|
||||
/// owns the exclusive lease at that instant; permission and path failures
|
||||
/// remain hard errors.
|
||||
/// </summary>
|
||||
public bool TryAcquireSession(out SessionLease? lease)
|
||||
{
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(_lockPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The update/session lock path has no parent directory."));
|
||||
try
|
||||
{
|
||||
lease = new SessionLease(
|
||||
new FileStream(
|
||||
_lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.ReadWrite,
|
||||
bufferSize: 1,
|
||||
FileOptions.None));
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The update/session lease could not be opened: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ExclusiveLease AcquireExclusive()
|
||||
{
|
||||
FileStream stream = Open(
|
||||
|
|
|
|||
|
|
@ -14,17 +14,33 @@ namespace AcDream.Launcher;
|
|||
|
||||
public sealed partial class App : Application
|
||||
{
|
||||
private readonly LauncherStartupOptions? _startupOptions;
|
||||
private LauncherOrchestrator? _orchestrator;
|
||||
private LauncherWindowViewModel? _viewModel;
|
||||
private LauncherUpdateComposition? _updateComposition;
|
||||
|
||||
public App()
|
||||
{
|
||||
}
|
||||
|
||||
internal App(LauncherStartupOptions startupOptions)
|
||||
{
|
||||
_startupOptions = startupOptions
|
||||
?? throw new ArgumentNullException(nameof(startupOptions));
|
||||
}
|
||||
|
||||
internal LauncherStartupOptions StartupOptions => _startupOptions
|
||||
?? throw new InvalidOperationException(
|
||||
"Launcher startup options were not supplied by the composition root.");
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
LauncherStartupOptions startupOptions = StartupOptions;
|
||||
ApplicationPathSet paths = startupOptions.Paths;
|
||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
|
|
@ -57,7 +73,8 @@ public sealed partial class App : Application
|
|||
GetLauncherVersion(),
|
||||
AppContext.BaseDirectory,
|
||||
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
||||
== true);
|
||||
== true,
|
||||
updateManifestUri: startupOptions.UpdateManifestUri);
|
||||
_updateComposition = updates;
|
||||
|
||||
_orchestrator = new LauncherOrchestrator(
|
||||
|
|
|
|||
255
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
255
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(parsed.Query)
|
||||
|| !string.IsNullOrEmpty(parsed.Fragment))
|
||||
{
|
||||
throw new LauncherStartupOptionsException(
|
||||
"The update manifest URI cannot contain a query or fragment.");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
ClientVersionStore versions,
|
||||
LauncherExecutableSet executables,
|
||||
ILauncherUpdater updater,
|
||||
Uri updateManifestUri,
|
||||
HttpClient? artifactClient,
|
||||
ReleaseManifestClient? manifestClient)
|
||||
{
|
||||
Versions = versions;
|
||||
Executables = executables;
|
||||
Updater = updater;
|
||||
UpdateManifestUri = updateManifestUri;
|
||||
_artifactClient = artifactClient;
|
||||
_manifestClient = manifestClient;
|
||||
}
|
||||
|
|
@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
|
||||
public ILauncherUpdater Updater { get; }
|
||||
|
||||
internal Uri UpdateManifestUri { get; }
|
||||
|
||||
public static LauncherUpdateComposition Create(
|
||||
ApplicationPathSet paths,
|
||||
string rid,
|
||||
LauncherVersion launcherVersion,
|
||||
string launcherTargetDirectory,
|
||||
Func<bool> hasRunningSessions,
|
||||
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
|
||||
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null,
|
||||
Uri? updateManifestUri = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(launcherVersion);
|
||||
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
||||
Uri manifestUri = updateManifestUri
|
||||
?? ReleaseManifestClient.ProductionManifestUri;
|
||||
var versions = new ClientVersionStore(paths);
|
||||
HttpClient? artifactClient = null;
|
||||
ReleaseManifestClient? manifestClient = null;
|
||||
|
|
@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
Timeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
|
||||
manifestClient = CreateManifestClient(manifestUri);
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
||||
var updater = new LauncherUpdater(
|
||||
manifestClient,
|
||||
|
|
@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
versions,
|
||||
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
||||
updater,
|
||||
manifestUri,
|
||||
artifactClient,
|
||||
manifestClient);
|
||||
}
|
||||
|
|
@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
versions,
|
||||
LauncherExecutableSet.Unavailable(status),
|
||||
new UnavailableLauncherUpdater(status, resolution),
|
||||
manifestUri,
|
||||
artifactClient: null,
|
||||
manifestClient: null);
|
||||
}
|
||||
|
|
@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
_artifactClient?.Dispose();
|
||||
}
|
||||
|
||||
private static ReleaseManifestClient CreateManifestClient(Uri manifestUri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifestUri);
|
||||
return manifestUri == ReleaseManifestClient.ProductionManifestUri
|
||||
? new ReleaseManifestClient(TimeSpan.FromSeconds(15))
|
||||
: ReleaseManifestClient.CreateLocalUpdateFeedOverride(
|
||||
manifestUri,
|
||||
TimeSpan.FromSeconds(15));
|
||||
}
|
||||
|
||||
private static bool IsStorageFailure(Exception exception) => exception is
|
||||
IOException
|
||||
or UnauthorizedAccessException
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
using Avalonia;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
|
@ -9,19 +8,18 @@ internal static class Program
|
|||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (args is ["--verify-publish"])
|
||||
{
|
||||
// A display-free execution probe for the packaged artifact. CI
|
||||
// runs this with DOTNET_ROOT pointing at a missing directory; a
|
||||
// framework-dependent publish cannot reach this return statement.
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(args);
|
||||
if (options.Mode == LauncherStartupMode.VerifyPublish)
|
||||
{
|
||||
// A display-free execution probe for the packaged artifact.
|
||||
// Parsing above deliberately never resolves user paths.
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
|
||||
var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
|
||||
string executable = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException(
|
||||
"The launcher executable path is unavailable.");
|
||||
|
|
@ -37,8 +35,9 @@ internal static class Program
|
|||
return startup.ExitCode;
|
||||
}
|
||||
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
|
||||
startup.RemainingArguments);
|
||||
RequireUnchangedPublicArguments(options, startup);
|
||||
|
||||
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -47,7 +46,25 @@ internal static class Program
|
|||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return AppBuilder.Configure(() => new App(options))
|
||||
.UsePlatformDetect();
|
||||
}
|
||||
|
||||
internal static void RequireUnchangedPublicArguments(
|
||||
LauncherStartupOptions options,
|
||||
SelfUpdateStartupResult startup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(startup);
|
||||
if (!startup.RemainingArguments.SequenceEqual(
|
||||
options.PublicArguments,
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The self-update bootstrap changed validated launcher arguments.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue