Harden OOBE, launch-source and elevation flow

Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
This commit is contained in:
lincube
2026-04-22 09:25:22 +08:00
parent 703ed7b48a
commit 9224c9a33a
28 changed files with 843 additions and 109 deletions

View File

@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -28,7 +29,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
"Elevated helper install is only supported on Windows.");
"Elevated helper install is only supported on Windows.",
"failed");
}
var launcherPath = ResolveLauncherPath();
@@ -37,7 +39,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
$"Launcher executable was not found at '{launcherPath}'.");
$"Launcher executable was not found at '{launcherPath}'.",
"failed");
}
var resultPath = Path.Combine(
@@ -53,14 +56,18 @@ internal sealed class LauncherClient
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
if (process is null)
{
return new LauncherInstallResult(false, null, "Failed to start launcher process.");
return new LauncherInstallResult(false, null, "Failed to start launcher process.", "failed");
}
await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null)
{
return new LauncherInstallResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
return new LauncherInstallResult(
result.Success,
result.InstalledPackagePath,
result.ErrorMessage ?? result.Message,
MapResultCode(result.Code));
}
if (process.ExitCode == 0)
@@ -68,7 +75,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
"Launcher exited without producing a result file.");
"Launcher exited without producing a result file.",
"failed");
}
return new LauncherInstallResult(
@@ -77,11 +85,12 @@ internal sealed class LauncherClient
string.Format(
CultureInfo.InvariantCulture,
"Launcher exited with code {0}.",
process.ExitCode));
process.ExitCode),
"failed");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.");
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.", "elevation_cancelled");
}
finally
{
@@ -98,12 +107,11 @@ internal sealed class LauncherClient
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Verb = "runas",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
CultureInfo.InvariantCulture,
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
};
return Process.Start(startInfo);
@@ -170,12 +178,32 @@ internal sealed class LauncherClient
}
}
private static string MapResultCode(string? launcherCode)
{
return launcherCode switch
{
"plugin_elevation_required" => "requires_elevation",
"elevation_cancelled" => "elevation_cancelled",
"ok" => "ok",
_ => "failed"
};
}
private sealed class HelperResultFile
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
}
@@ -183,4 +211,5 @@ internal sealed class LauncherClient
internal sealed record LauncherInstallResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage);
string? ErrorMessage,
string Code);

View File

@@ -1454,7 +1454,7 @@ public sealed class UpdateWorkflowService
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{launcherRoot}\"",
Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update",
UseShellExecute = false,
WorkingDirectory = launcherRoot
};
@@ -1493,6 +1493,7 @@ public sealed class UpdateWorkflowService
try
{
AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'.");
var startInfo = new ProcessStartInfo
{
FileName = pending.InstallerPath,