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:
@@ -0,0 +1,8 @@
|
||||
# Launcher OOBE and Elevation Hardening Checklist
|
||||
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] Default plugin install does not request UAC.
|
||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Launcher OOBE and Elevation Hardening Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Stabilize the launcher startup path so that:
|
||||
|
||||
- OOBE does not reappear for the same Windows user after reinstall/upgrade.
|
||||
- Normal startup, OOBE, update checks, incremental downloads, and default plugin installs do not trigger unexpected UAC prompts.
|
||||
- Only the approved elevation paths remain allowed.
|
||||
|
||||
## Scope
|
||||
|
||||
- Launcher OOBE state handling
|
||||
- launch source classification
|
||||
- elevation boundary cleanup
|
||||
- plugin install default behavior
|
||||
- diagnostic logging and troubleshooting guidance
|
||||
|
||||
## Behavior
|
||||
|
||||
- OOBE state is stored as a per-user truth source at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- `first_run_completed` is treated as a legacy compatibility marker only.
|
||||
- `launchSource` values are treated as:
|
||||
- `normal`
|
||||
- `postinstall`
|
||||
- `apply-update`
|
||||
- `plugin-install`
|
||||
- `debug-preview`
|
||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
- user-confirmed legacy uninstall
|
||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Same-user reinstall does not re-enter OOBE.
|
||||
- Missing or damaged OOBE state does not silently bounce the user back into OOBE loops.
|
||||
- Default plugin installation path never triggers surprise UAC.
|
||||
- Logs can explain why OOBE was shown or suppressed and why elevation was or was not requested.
|
||||
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Launcher OOBE and Elevation Hardening Tasks
|
||||
|
||||
- [ ] Move OOBE state to a single per-user JSON source.
|
||||
- [ ] Treat `first_run_completed` as legacy migration-only state.
|
||||
- [ ] Add explicit `launchSource` handling for startup and maintenance flows.
|
||||
- [ ] Suppress auto-OOBE for maintenance and elevated launch contexts.
|
||||
- [ ] Remove default elevation from plugin installation into the user data scope.
|
||||
- [ ] Add structured diagnostics for OOBE decisions and elevation reasons.
|
||||
- [ ] Update launcher docs and troubleshooting guidance.
|
||||
@@ -6,3 +6,6 @@
|
||||
- [x] Legacy plugin install arguments still execute.
|
||||
- [x] OOBE and splash are implemented as separate windows.
|
||||
- [x] Update and rollback logic use version directory markers.
|
||||
|
||||
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
|
||||
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
|
||||
@@ -52,3 +52,9 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
|
||||
|
||||
- `IOobeStep` for future multi-step OOBE
|
||||
- `ISplashStageReporter` for future startup progress visualization
|
||||
|
||||
## Compatibility Addendum
|
||||
|
||||
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- `first_run_completed` remains legacy compatibility data only.
|
||||
- Same-user reinstall or upgrade should not re-enter OOBE.
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
25
LanMountainDesktop.Tests/CommandContextTests.cs
Normal file
25
LanMountainDesktop.Tests/CommandContextTests.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CommandContextTests
|
||||
{
|
||||
public static TheoryData<string[], string> LaunchSourceCases => new()
|
||||
{
|
||||
{ [], "normal" },
|
||||
{ ["preview-oobe"], "debug-preview" },
|
||||
{ ["apply-update"], "apply-update" },
|
||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LaunchSourceCases))]
|
||||
public void FromArgs_InfersExpectedLaunchSource(string[] args, string expectedLaunchSource)
|
||||
{
|
||||
var context = CommandContext.FromArgs(args);
|
||||
|
||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
124
LanMountainDesktop.Tests/OobeStateServiceTests.cs
Normal file
124
LanMountainDesktop.Tests/OobeStateServiceTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class OobeStateServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", nameof(OobeStateServiceTests), Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsFirstRun_ForNormalLaunch_WhenStateIsMissing()
|
||||
{
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
|
||||
Assert.True(decision.ShouldShowOobe);
|
||||
Assert.Equal("normal", decision.LaunchSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsCompleted_WhenStateFileExists()
|
||||
{
|
||||
var statePath = GetStatePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(statePath)!);
|
||||
var state = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
UserName = "tester",
|
||||
UserSid = "S-1-5-test",
|
||||
LaunchSource = "normal"
|
||||
};
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Completed, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MigratesLegacyMarker_AndTreatsItAsCompleted()
|
||||
{
|
||||
var legacyMarkerPath = GetLegacyMarkerPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyMarkerPath)!);
|
||||
File.WriteAllText(legacyMarkerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Completed, decision.Status);
|
||||
Assert.True(decision.UsedLegacyMarker);
|
||||
Assert.True(decision.MigratedLegacyMarker);
|
||||
Assert.True(File.Exists(GetStatePath()));
|
||||
Assert.False(File.Exists(legacyMarkerPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SuppressesOobe_ForElevatedFirstRun()
|
||||
{
|
||||
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
Assert.Equal("oobe_suppressed_elevated", decision.ResultCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsUnavailable_ForInvalidStateFile()
|
||||
{
|
||||
var statePath = GetStatePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(statePath)!);
|
||||
File.WriteAllText(statePath, "{ this is not valid json }");
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Unavailable, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
Assert.Equal("oobe_state_unavailable", decision.ResultCode);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private OobeStateService CreateService(LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
return new OobeStateService(
|
||||
appRoot: _tempRoot,
|
||||
stateRootOverride: _tempRoot,
|
||||
executionSnapshot: executionSnapshot ?? new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
|
||||
}
|
||||
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
|
||||
|
||||
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
|
||||
}
|
||||
42
LanMountainDesktop.Tests/PluginInstallerServiceTests.cs
Normal file
42
LanMountainDesktop.Tests/PluginInstallerServiceTests.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginInstallerServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", nameof(PluginInstallerServiceTests), Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReturnsElevationRequired_ForOutsideUserScope_OnWindows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var packagePath = Path.Combine(_tempRoot, "sample.lmdp");
|
||||
File.WriteAllText(packagePath, "placeholder");
|
||||
|
||||
var service = new PluginInstallerService();
|
||||
var result = service.InstallPackage(packagePath, Path.Combine(_tempRoot, "Plugins"));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("plugin_elevation_required", result.Code);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,7 +138,7 @@ Name: "{autodesktop}\{cm:AppShortcutName}"; Filename: "{app}\{#MyAppExeName}"; T
|
||||
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--launch-source postinstall"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
const
|
||||
|
||||
@@ -243,7 +243,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. " +
|
||||
$"Code='{helperResult.Code}'; Message='{helperMessage}'.");
|
||||
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -200,3 +200,15 @@ The runtime flow starts with the Launcher selecting the best version, then proce
|
||||
- Incremental package build/publish has moved to VeloPack native assets (
|
||||
eleases.win.json + *.nupkg).
|
||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
||||
|
||||
|
||||
## Launcher OOBE / Elevation Contract
|
||||
|
||||
- Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- Same-user reinstall or upgrade should keep OOBE completed.
|
||||
- `first_run_completed` is legacy migration-only data.
|
||||
- The recognized launch sources are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
|
||||
- Auto-OOBE is only allowed for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-open OOBE.
|
||||
- Elevation is allowed only for the installer, full installer update application, and user-confirmed legacy uninstall.
|
||||
|
||||
@@ -547,3 +547,15 @@ var updateCheckService = new UpdateCheckService(
|
||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||
- [架构文档](ARCHITECTURE.md)
|
||||
- [开发文档](DEVELOPMENT.md)
|
||||
|
||||
## Current OOBE and Elevation Contract
|
||||
|
||||
- OOBE state is a per-user truth source stored at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- Same-user reinstall or upgrade must not re-enter OOBE.
|
||||
- `first_run_completed` is legacy compatibility data only and should not remain the long-term primary format.
|
||||
- Launch source values are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
|
||||
- Auto-OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may open OOBE only when the launcher is not elevated and the user state path is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
|
||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||
|
||||
@@ -642,3 +642,40 @@ xattr -cr /Applications/LanMountainDesktop.app
|
||||
- [Launcher 鏋舵瀯](LAUNCHER.md)
|
||||
- [鏇存柊绯荤粺](UPDATE_SYSTEM.md)
|
||||
- [鏋勫缓鍜岄儴缃瞉(BUILD_AND_DEPLOY.md)
|
||||
|
||||
### 问题: OOBE 窗口重复出现
|
||||
|
||||
**原因:** OOBE 完成标记丢失、损坏,或者旧版标记文件只作为迁移兼容而不是主状态源。
|
||||
|
||||
**当前权威状态路径:**
|
||||
```bash
|
||||
Windows: %LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json
|
||||
```
|
||||
|
||||
**处理原则:**
|
||||
- 同一 Windows 用户重装或升级后,默认不应该再次进入 OOBE。
|
||||
- `first_run_completed` 只保留为兼容迁移数据。
|
||||
- 如果状态文件不可读,Launcher 应优先保证稳定启动并记录 `oobe_state_unavailable`,不要反复把用户拉回 OOBE。
|
||||
|
||||
---
|
||||
|
||||
### 问题: 启动或插件安装意外弹出管理员权限
|
||||
|
||||
**原因:** 某些路径显式请求了 `runas`,或者流程把默认用户目录误判成需要提权。
|
||||
|
||||
**当前允许提权的白名单:**
|
||||
- 安装器本体
|
||||
- 全量安装包更新应用
|
||||
- 用户显式确认的 legacy uninstall
|
||||
|
||||
**不应弹 UAC 的场景:**
|
||||
- 普通冷启动
|
||||
- OOBE
|
||||
- 检查更新
|
||||
- 增量下载
|
||||
- 默认插件安装到用户 LocalAppData 路径
|
||||
|
||||
**调试建议:**
|
||||
- 检查日志中的 `launchSource`、`isElevated`、`oobeStateStatus`、`oobeSuppressionReason`
|
||||
- 检查插件安装目标是否仍在 `%LOCALAPPDATA%\LanMountainDesktop` 下
|
||||
- 确认没有额外的 `Verb = "runas"` 被引入默认路径
|
||||
|
||||
Reference in New Issue
Block a user