mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
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:
@@ -15,10 +15,12 @@ public partial class App : Application
|
||||
{
|
||||
Logger.Initialize();
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Info(
|
||||
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
|
||||
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
|
||||
$"ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
|
||||
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -30,9 +32,11 @@ public partial class App : Application
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Info(
|
||||
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
||||
$"IsDebugMode={context.IsDebugMode}.");
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
||||
|
||||
if (HandlePreviewCommand(context, desktop))
|
||||
{
|
||||
@@ -174,7 +178,8 @@ public partial class App : Application
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
@@ -323,7 +328,12 @@ public partial class App : Application
|
||||
Success = success,
|
||||
Stage = "apply-update",
|
||||
Code = success ? "ok" : "failed",
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = context.Command,
|
||||
["launchSource"] = context.LaunchSource
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(OobeStateFile))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
private const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
private static readonly string[] GuiCommands =
|
||||
[
|
||||
"launch",
|
||||
@@ -31,6 +33,8 @@ internal sealed class CommandContext
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
@@ -45,6 +49,12 @@ internal sealed class CommandContext
|
||||
public bool IsGuiCommand =>
|
||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsMaintenanceCommand =>
|
||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? ExplicitAppRoot => GetOption("app-root");
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||
@@ -81,6 +91,44 @@ internal sealed class CommandContext
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private string InferLaunchSource()
|
||||
{
|
||||
if (IsPreviewCommand)
|
||||
{
|
||||
return "debug-preview";
|
||||
}
|
||||
|
||||
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "apply-update";
|
||||
}
|
||||
|
||||
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "plugin-install";
|
||||
}
|
||||
|
||||
return "normal";
|
||||
}
|
||||
|
||||
private static string? NormalizeLaunchSource(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"normal" => "normal",
|
||||
"postinstall" => "postinstall",
|
||||
"apply-update" => "apply-update",
|
||||
"plugin-install" => "plugin-install",
|
||||
"debug-preview" => "debug-preview",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum OobeStateStatus
|
||||
{
|
||||
FirstRun,
|
||||
Completed,
|
||||
Unavailable,
|
||||
Suppressed
|
||||
}
|
||||
|
||||
internal sealed class OobeStateFile
|
||||
{
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
|
||||
public string CompletedAtUtc { get; init; } = string.Empty;
|
||||
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
|
||||
public string? UserSid { get; init; }
|
||||
|
||||
public string LaunchSource { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class OobeLaunchDecision
|
||||
{
|
||||
public OobeStateStatus Status { get; init; }
|
||||
|
||||
public bool ShouldShowOobe { get; init; }
|
||||
|
||||
public string StatePath { get; init; } = string.Empty;
|
||||
|
||||
public string LaunchSource { get; init; } = "normal";
|
||||
|
||||
public bool IsElevated { get; init; }
|
||||
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
|
||||
public string? UserSid { get; init; }
|
||||
|
||||
public string ResultCode { get; init; } = "ok";
|
||||
|
||||
public string SuppressionReason { get; init; } = string.Empty;
|
||||
|
||||
public string ErrorMessage { get; init; } = string.Empty;
|
||||
|
||||
public bool UsedLegacyMarker { get; init; }
|
||||
|
||||
public bool MigratedLegacyMarker { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class OobeCompletionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string ResultCode { get; init; } = "ok";
|
||||
|
||||
public string ErrorMessage { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record LauncherExecutionSnapshot(
|
||||
bool IsElevated,
|
||||
string UserName,
|
||||
string? UserSid);
|
||||
@@ -10,10 +10,13 @@ internal static class Program
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Initialize();
|
||||
Logger.Info(
|
||||
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
|
||||
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
|
||||
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
|
||||
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
|
||||
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
|
||||
|
||||
@@ -49,8 +52,11 @@ internal static class Program
|
||||
{
|
||||
["command"] = commandContext.Command,
|
||||
["subCommand"] = commandContext.SubCommand,
|
||||
["launchSource"] = commandContext.LaunchSource,
|
||||
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
||||
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
||||
["isElevated"] = execution.IsElevated.ToString(),
|
||||
["userSid"] = execution.UserSid ?? string.Empty,
|
||||
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Principal;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal static class LauncherExecutionContext
|
||||
{
|
||||
public static LauncherExecutionSnapshot Capture()
|
||||
{
|
||||
var userName = Environment.UserName ?? string.Empty;
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new LauncherExecutionSnapshot(false, userName, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return new LauncherExecutionSnapshot(
|
||||
principal.IsInRole(WindowsBuiltInRole.Administrator),
|
||||
userName,
|
||||
identity.User?.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherExecutionSnapshot(false, userName, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
"app-root", "launch-source",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
@@ -38,7 +38,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||
@@ -46,8 +46,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
try
|
||||
{
|
||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
var oobeDecision = _oobeStateService.Evaluate(_context);
|
||||
var launcherContextDetails = BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot());
|
||||
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
if (oobeDecision.ShouldShowOobe)
|
||||
{
|
||||
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||
if (legacyInfo is not null)
|
||||
@@ -127,7 +129,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
return WithAdditionalDetails(updateResult, launcherContextDetails);
|
||||
}
|
||||
|
||||
reporter.Report("plugins", "Applying plugin upgrades...");
|
||||
@@ -135,10 +137,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return queueResult;
|
||||
return WithAdditionalDetails(queueResult, launcherContextDetails);
|
||||
}
|
||||
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
if (oobeDecision.ShouldShowOobe)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||
foreach (var step in _oobeSteps)
|
||||
@@ -153,13 +155,13 @@ internal sealed class LauncherFlowCoordinator
|
||||
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
||||
if (!launchOutcome.Result.Success)
|
||||
{
|
||||
return launchOutcome.Result;
|
||||
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
|
||||
}
|
||||
|
||||
if (launchOutcome.ImmediateResult is not null)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return launchOutcome.ImmediateResult;
|
||||
return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails);
|
||||
}
|
||||
|
||||
if (launchOutcome.Process is null)
|
||||
@@ -169,7 +171,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
stage: "launch",
|
||||
code: "host_start_failed",
|
||||
message: "Host launch did not create a process.",
|
||||
details: launchOutcome.Details);
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||
@@ -190,7 +192,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
message: stage == StartupStage.ActivationRedirected
|
||||
? "Launcher activation was redirected to the existing desktop instance."
|
||||
: "Desktop is visible and ready.",
|
||||
details: launchOutcome.Details);
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
if (completedTask == activationFailedTcs.Task)
|
||||
@@ -200,7 +202,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return retryOutcome;
|
||||
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +217,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return retryOutcome;
|
||||
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +229,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
||||
? "Host redirected activation to the existing desktop instance."
|
||||
: $"Host exited before the desktop became visible. ExitCode={exitCode}.",
|
||||
details: MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
@@ -239,11 +241,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
stage: "launch",
|
||||
code: "desktop_not_visible",
|
||||
message: "Host process started, but the desktop never became visible within 30 seconds.",
|
||||
details: MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["ipcStage"] = lastStage.ToString(),
|
||||
["ipcMessage"] = lastStageMessage
|
||||
}));
|
||||
})));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -272,6 +274,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
stage: "launch",
|
||||
code: "exception",
|
||||
message: ex.Message,
|
||||
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
|
||||
errorMessage: ex.ToString());
|
||||
}
|
||||
}
|
||||
@@ -754,6 +757,50 @@ internal sealed class LauncherFlowCoordinator
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = result.Success,
|
||||
Stage = result.Stage,
|
||||
Code = result.Code,
|
||||
Message = result.Message,
|
||||
CurrentVersion = result.CurrentVersion,
|
||||
TargetVersion = result.TargetVersion,
|
||||
RolledBackTo = result.RolledBackTo,
|
||||
Details = MergeDetails(details, result.Details),
|
||||
InstalledPackagePath = result.InstalledPackagePath,
|
||||
ManifestId = result.ManifestId,
|
||||
ManifestName = result.ManifestName,
|
||||
ErrorMessage = result.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildLauncherContextDetails(
|
||||
CommandContext context,
|
||||
OobeLaunchDecision oobeDecision,
|
||||
string appRoot)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = context.Command,
|
||||
["launchSource"] = context.LaunchSource,
|
||||
["isGuiMode"] = context.IsGuiCommand.ToString(),
|
||||
["isDebugMode"] = context.IsDebugMode.ToString(),
|
||||
["isElevated"] = oobeDecision.IsElevated.ToString(),
|
||||
["resolvedAppRoot"] = appRoot,
|
||||
["oobeStatePath"] = oobeDecision.StatePath,
|
||||
["oobeStateStatus"] = oobeDecision.Status.ToString(),
|
||||
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
|
||||
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
|
||||
["oobeResultCode"] = oobeDecision.ResultCode,
|
||||
["userSid"] = oobeDecision.UserSid ?? string.Empty,
|
||||
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
|
||||
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
|
||||
["oobeStateError"] = oobeDecision.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildResolutionDetails(
|
||||
HostResolutionResult resolution,
|
||||
HostStartAttempt? firstAttempt,
|
||||
|
||||
@@ -262,6 +262,9 @@ internal sealed class LegacyVersionDetector
|
||||
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||
var fileName = parts[0].Trim('"');
|
||||
var arguments = parts.Length > 1 ? parts[1] : "";
|
||||
Logger.Info(
|
||||
$"Opening legacy uninstall interface with elevation reason 'legacy_uninstall'. " +
|
||||
$"InstallPath='{info.InstallPath}'; Version='{info.Version}'.");
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
|
||||
@@ -1,104 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
public OobeStateService(
|
||||
string appRoot,
|
||||
string? stateRootOverride = null,
|
||||
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
||||
string? stateDir = null;
|
||||
Exception? lastException = null;
|
||||
_ = Path.GetFullPath(appRoot);
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
||||
try
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
|
||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
stateDir = tempDir;
|
||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
||||
// 如果所有策略都失败,抛出异常让上层处理
|
||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
||||
}
|
||||
}
|
||||
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? GetDefaultStateRoot()
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
{
|
||||
var decision = EvaluateCore(context);
|
||||
Logger.Info(
|
||||
$"OOBE decision evaluated. LaunchSource='{decision.LaunchSource}'; Status='{decision.Status}'; " +
|
||||
$"ShouldShow={decision.ShouldShowOobe}; IsElevated={decision.IsElevated}; " +
|
||||
$"StatePath='{decision.StatePath}'; SuppressionReason='{decision.SuppressionReason}'; " +
|
||||
$"ResultCode='{decision.ResultCode}'; UserSid='{decision.UserSid ?? string.Empty}'.");
|
||||
return decision;
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var payload = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
LaunchSource = context.LaunchSource
|
||||
};
|
||||
|
||||
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
|
||||
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _statePath, overwrite: true);
|
||||
TryDeleteLegacyMarker();
|
||||
|
||||
Logger.Info(
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
|
||||
|
||||
return new OobeCompletionResult
|
||||
{
|
||||
Success = true,
|
||||
ResultCode = "ok"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
||||
return true;
|
||||
Logger.Warn(
|
||||
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"Error='{ex.Message}'.");
|
||||
return new OobeCompletionResult
|
||||
{
|
||||
Success = false,
|
||||
ResultCode = "oobe_state_unavailable",
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
private OobeLaunchDecision EvaluateCore(CommandContext context)
|
||||
{
|
||||
if (string.Equals(context.LaunchSource, "debug-preview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildSuppressedDecision(context, "debug_preview", "oobe_suppressed_debug_preview");
|
||||
}
|
||||
|
||||
if (context.IsMaintenanceCommand)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "maintenance", "oobe_suppressed_maintenance");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
var migratedLegacyMarker = false;
|
||||
if (File.Exists(_statePath))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
using var stream = File.OpenRead(_statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
|
||||
if (_executionSnapshot.IsElevated)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
|
||||
}
|
||||
|
||||
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
||||
return BuildUnavailableDecision(context, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryMigrateLegacyMarker(CommandContext context)
|
||||
{
|
||||
var result = MarkCompleted(context);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
{
|
||||
File.Delete(_legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildDecision(
|
||||
CommandContext context,
|
||||
OobeStateStatus status,
|
||||
bool shouldShowOobe,
|
||||
bool usedLegacyMarker = false,
|
||||
bool migratedLegacyMarker = false)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = status,
|
||||
ShouldShowOobe = shouldShowOobe,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
UsedLegacyMarker = usedLegacyMarker,
|
||||
MigratedLegacyMarker = migratedLegacyMarker,
|
||||
ResultCode = "ok"
|
||||
};
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildSuppressedDecision(CommandContext context, string reason, string resultCode)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = OobeStateStatus.Suppressed,
|
||||
ShouldShowOobe = false,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
SuppressionReason = reason,
|
||||
ResultCode = resultCode
|
||||
};
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = OobeStateStatus.Unavailable,
|
||||
ShouldShowOobe = false,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
ResultCode = "oobe_state_unavailable",
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultStateRoot()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ internal sealed class PluginInstallerService
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||
{
|
||||
return elevationRequiredResult;
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
@@ -51,6 +56,46 @@ internal sealed class PluginInstallerService
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
|
||||
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Logger.Warn(
|
||||
$"Plugin installation requires explicit elevation. Reason='plugin_requires_elevation'; " +
|
||||
$"PluginsDirectory='{pluginsDirectory}'; AllowedRoot='{allowedRoot}'.");
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin.install",
|
||||
Code = "plugin_elevation_required",
|
||||
Message = "Plugin installation outside the current user's LanMountainDesktop data directory requires explicit elevation.",
|
||||
ErrorMessage = "Plugin installation target is outside the current user's LanMountainDesktop data directory.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pluginsDirectory"] = pluginsDirectory,
|
||||
["allowedRoot"] = allowedRoot,
|
||||
["elevationReason"] = "outside_user_scope"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
|
||||
@@ -5,11 +5,13 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService)
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||
{
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
@@ -29,7 +31,13 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
}
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
_oobeStateService.MarkCompleted();
|
||||
var completion = _oobeStateService.MarkCompleted(_context);
|
||||
if (!completion.Success)
|
||||
{
|
||||
Logger.Warn(
|
||||
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"Error='{completion.ErrorMessage}'.");
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user