mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-24 10:34: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:
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user