mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 01:44: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] Legacy plugin install arguments still execute.
|
||||||
- [x] OOBE and splash are implemented as separate windows.
|
- [x] OOBE and splash are implemented as separate windows.
|
||||||
- [x] Update and rollback logic use version directory markers.
|
- [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
|
- `IOobeStep` for future multi-step OOBE
|
||||||
- `ISplashStageReporter` for future startup progress visualization
|
- `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();
|
Logger.Initialize();
|
||||||
var context = LauncherRuntimeContext.Current;
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
Logger.Info(
|
Logger.Info(
|
||||||
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
|
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
|
||||||
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
|
$"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);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
@@ -30,9 +32,11 @@ public partial class App : Application
|
|||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
var context = LauncherRuntimeContext.Current;
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
Logger.Info(
|
Logger.Info(
|
||||||
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
$"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))
|
if (HandlePreviewCommand(context, desktop))
|
||||||
{
|
{
|
||||||
@@ -174,7 +178,8 @@ public partial class App : Application
|
|||||||
var appRoot = Commands.ResolveAppRoot(context);
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
Logger.Info(
|
Logger.Info(
|
||||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
$"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 deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
var coordinator = new LauncherFlowCoordinator(
|
var coordinator = new LauncherFlowCoordinator(
|
||||||
@@ -323,7 +328,12 @@ public partial class App : Application
|
|||||||
Success = success,
|
Success = success,
|
||||||
Stage = "apply-update",
|
Stage = "apply-update",
|
||||||
Code = success ? "ok" : "failed",
|
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);
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
Environment.ExitCode = success ? 0 : 1;
|
Environment.ExitCode = success ? 0 : 1;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PluginManifest))]
|
[JsonSerializable(typeof(PluginManifest))]
|
||||||
[JsonSerializable(typeof(PendingUpgrade))]
|
[JsonSerializable(typeof(PendingUpgrade))]
|
||||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||||
|
[JsonSerializable(typeof(OobeStateFile))]
|
||||||
[JsonSerializable(typeof(GitHubRelease))]
|
[JsonSerializable(typeof(GitHubRelease))]
|
||||||
[JsonSerializable(typeof(GitHubAsset))]
|
[JsonSerializable(typeof(GitHubAsset))]
|
||||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
|
|
||||||
internal sealed class CommandContext
|
internal sealed class CommandContext
|
||||||
{
|
{
|
||||||
|
private const string LaunchSourceOptionName = "launch-source";
|
||||||
|
|
||||||
private static readonly string[] GuiCommands =
|
private static readonly string[] GuiCommands =
|
||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
@@ -31,6 +33,8 @@ internal sealed class CommandContext
|
|||||||
Options.ContainsKey("plugins-dir") &&
|
Options.ContainsKey("plugins-dir") &&
|
||||||
Options.ContainsKey("result");
|
Options.ContainsKey("result");
|
||||||
|
|
||||||
|
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||||
@@ -45,6 +49,12 @@ internal sealed class CommandContext
|
|||||||
public bool IsGuiCommand =>
|
public bool IsGuiCommand =>
|
||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
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");
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
|
|
||||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||||
@@ -81,6 +91,44 @@ internal sealed class CommandContext
|
|||||||
: fallback;
|
: 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)
|
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
{
|
{
|
||||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
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)
|
private static async Task<int> Main(string[] args)
|
||||||
{
|
{
|
||||||
var commandContext = CommandContext.FromArgs(args);
|
var commandContext = CommandContext.FromArgs(args);
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
Logger.Initialize();
|
Logger.Initialize();
|
||||||
Logger.Info(
|
Logger.Info(
|
||||||
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
|
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
|
||||||
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
|
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
|
||||||
|
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||||
|
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
|
||||||
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
|
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
|
||||||
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
|
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
|
||||||
|
|
||||||
@@ -49,8 +52,11 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
["command"] = commandContext.Command,
|
["command"] = commandContext.Command,
|
||||||
["subCommand"] = commandContext.SubCommand,
|
["subCommand"] = commandContext.SubCommand,
|
||||||
|
["launchSource"] = commandContext.LaunchSource,
|
||||||
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
||||||
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
||||||
|
["isElevated"] = execution.IsElevated.ToString(),
|
||||||
|
["userSid"] = execution.UserSid ?? string.Empty,
|
||||||
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? 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 =
|
private static readonly string[] LauncherOnlyOptions =
|
||||||
[
|
[
|
||||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||||
"app-root",
|
"app-root", "launch-source",
|
||||||
LauncherIpcConstants.LauncherPidEnvVar,
|
LauncherIpcConstants.LauncherPidEnvVar,
|
||||||
LauncherIpcConstants.PackageRootEnvVar,
|
LauncherIpcConstants.PackageRootEnvVar,
|
||||||
LauncherIpcConstants.VersionEnvVar,
|
LauncherIpcConstants.VersionEnvVar,
|
||||||
@@ -38,7 +38,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
_oobeStateService = oobeStateService;
|
_oobeStateService = oobeStateService;
|
||||||
_updateEngine = updateEngine;
|
_updateEngine = updateEngine;
|
||||||
_pluginInstallerService = pluginInstallerService;
|
_pluginInstallerService = pluginInstallerService;
|
||||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||||
@@ -46,8 +46,10 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
_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();
|
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||||
if (legacyInfo is not null)
|
if (legacyInfo is not null)
|
||||||
@@ -127,7 +129,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||||
if (!updateResult.Success)
|
if (!updateResult.Success)
|
||||||
{
|
{
|
||||||
return updateResult;
|
return WithAdditionalDetails(updateResult, launcherContextDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter.Report("plugins", "Applying plugin upgrades...");
|
reporter.Report("plugins", "Applying plugin upgrades...");
|
||||||
@@ -135,10 +137,10 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||||
if (!queueResult.Success)
|
if (!queueResult.Success)
|
||||||
{
|
{
|
||||||
return queueResult;
|
return WithAdditionalDetails(queueResult, launcherContextDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_oobeStateService.IsFirstRun())
|
if (oobeDecision.ShouldShowOobe)
|
||||||
{
|
{
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||||
foreach (var step in _oobeSteps)
|
foreach (var step in _oobeSteps)
|
||||||
@@ -153,13 +155,13 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
||||||
if (!launchOutcome.Result.Success)
|
if (!launchOutcome.Result.Success)
|
||||||
{
|
{
|
||||||
return launchOutcome.Result;
|
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (launchOutcome.ImmediateResult is not null)
|
if (launchOutcome.ImmediateResult is not null)
|
||||||
{
|
{
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return launchOutcome.ImmediateResult;
|
return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (launchOutcome.Process is null)
|
if (launchOutcome.Process is null)
|
||||||
@@ -169,7 +171,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
stage: "launch",
|
stage: "launch",
|
||||||
code: "host_start_failed",
|
code: "host_start_failed",
|
||||||
message: "Host launch did not create a process.",
|
message: "Host launch did not create a process.",
|
||||||
details: launchOutcome.Details);
|
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||||
}
|
}
|
||||||
|
|
||||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||||
@@ -190,7 +192,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
message: stage == StartupStage.ActivationRedirected
|
message: stage == StartupStage.ActivationRedirected
|
||||||
? "Launcher activation was redirected to the existing desktop instance."
|
? "Launcher activation was redirected to the existing desktop instance."
|
||||||
: "Desktop is visible and ready.",
|
: "Desktop is visible and ready.",
|
||||||
details: launchOutcome.Details);
|
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completedTask == activationFailedTcs.Task)
|
if (completedTask == activationFailedTcs.Task)
|
||||||
@@ -200,7 +202,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
if (retryOutcome is not null)
|
if (retryOutcome is not null)
|
||||||
{
|
{
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
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)
|
if (retryOutcome is not null)
|
||||||
{
|
{
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return retryOutcome;
|
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +229,10 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
||||||
? "Host redirected activation to the existing desktop instance."
|
? "Host redirected activation to the existing desktop instance."
|
||||||
: $"Host exited before the desktop became visible. ExitCode={exitCode}.",
|
: $"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()
|
["exitCode"] = exitCode.ToString()
|
||||||
}));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
@@ -239,11 +241,11 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
stage: "launch",
|
stage: "launch",
|
||||||
code: "desktop_not_visible",
|
code: "desktop_not_visible",
|
||||||
message: "Host process started, but the desktop never became visible within 30 seconds.",
|
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(),
|
["ipcStage"] = lastStage.ToString(),
|
||||||
["ipcMessage"] = lastStageMessage
|
["ipcMessage"] = lastStageMessage
|
||||||
}));
|
})));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -272,6 +274,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
stage: "launch",
|
stage: "launch",
|
||||||
code: "exception",
|
code: "exception",
|
||||||
message: ex.Message,
|
message: ex.Message,
|
||||||
|
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
|
||||||
errorMessage: ex.ToString());
|
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(
|
private static Dictionary<string, string> BuildResolutionDetails(
|
||||||
HostResolutionResult resolution,
|
HostResolutionResult resolution,
|
||||||
HostStartAttempt? firstAttempt,
|
HostStartAttempt? firstAttempt,
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ internal sealed class LegacyVersionDetector
|
|||||||
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||||
var fileName = parts[0].Trim('"');
|
var fileName = parts[0].Trim('"');
|
||||||
var arguments = parts.Length > 1 ? parts[1] : "";
|
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
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,104 +1,221 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
internal sealed class OobeStateService
|
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(用户目录,普通用户一定有权限)
|
_ = Path.GetFullPath(appRoot);
|
||||||
string? stateDir = null;
|
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||||
Exception? lastException = null;
|
|
||||||
|
|
||||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||||
try
|
? GetDefaultStateRoot()
|
||||||
{
|
: Path.GetFullPath(stateRootOverride);
|
||||||
var appDataDir = Path.Combine(
|
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||||
"LanMountainDesktop");
|
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
Logger.Warn(
|
||||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||||
return true;
|
$"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
|
try
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(_markerPath);
|
var migratedLegacyMarker = false;
|
||||||
if (!string.IsNullOrWhiteSpace(dir))
|
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"));
|
if (File.Exists(_legacyMarkerPath))
|
||||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
{
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
return BuildUnavailableDecision(context, ex.Message);
|
||||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||||
|
{
|
||||||
|
return elevationRequiredResult;
|
||||||
|
}
|
||||||
|
|
||||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||||
Directory.CreateDirectory(fullPluginsDirectory);
|
Directory.CreateDirectory(fullPluginsDirectory);
|
||||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
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)
|
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(packagePath);
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ namespace LanMountainDesktop.Launcher.Services;
|
|||||||
|
|
||||||
internal sealed class WelcomeOobeStep : IOobeStep
|
internal sealed class WelcomeOobeStep : IOobeStep
|
||||||
{
|
{
|
||||||
|
private readonly CommandContext _context;
|
||||||
private readonly OobeStateService _oobeStateService;
|
private readonly OobeStateService _oobeStateService;
|
||||||
|
|
||||||
public WelcomeOobeStep(OobeStateService oobeStateService)
|
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||||
{
|
{
|
||||||
_oobeStateService = oobeStateService;
|
_oobeStateService = oobeStateService;
|
||||||
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken cancellationToken)
|
public async Task RunAsync(CancellationToken cancellationToken)
|
||||||
@@ -29,7 +31,13 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
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(() =>
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</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.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -28,7 +29,8 @@ internal sealed class LauncherClient
|
|||||||
return new LauncherInstallResult(
|
return new LauncherInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
"Elevated helper install is only supported on Windows.");
|
"Elevated helper install is only supported on Windows.",
|
||||||
|
"failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherPath = ResolveLauncherPath();
|
var launcherPath = ResolveLauncherPath();
|
||||||
@@ -37,7 +39,8 @@ internal sealed class LauncherClient
|
|||||||
return new LauncherInstallResult(
|
return new LauncherInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
$"Launcher executable was not found at '{launcherPath}'.");
|
$"Launcher executable was not found at '{launcherPath}'.",
|
||||||
|
"failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
var resultPath = Path.Combine(
|
var resultPath = Path.Combine(
|
||||||
@@ -53,14 +56,18 @@ internal sealed class LauncherClient
|
|||||||
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
|
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
|
||||||
if (process is null)
|
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);
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
var result = await ReadResultAsync(resultPath, cancellationToken);
|
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||||
if (result is not null)
|
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)
|
if (process.ExitCode == 0)
|
||||||
@@ -68,7 +75,8 @@ internal sealed class LauncherClient
|
|||||||
return new LauncherInstallResult(
|
return new LauncherInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
"Launcher exited without producing a result file.");
|
"Launcher exited without producing a result file.",
|
||||||
|
"failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LauncherInstallResult(
|
return new LauncherInstallResult(
|
||||||
@@ -77,11 +85,12 @@ internal sealed class LauncherClient
|
|||||||
string.Format(
|
string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"Launcher exited with code {0}.",
|
"Launcher exited with code {0}.",
|
||||||
process.ExitCode));
|
process.ExitCode),
|
||||||
|
"failed");
|
||||||
}
|
}
|
||||||
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -98,12 +107,11 @@ internal sealed class LauncherClient
|
|||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = launcherPath,
|
FileName = launcherPath,
|
||||||
Verb = "runas",
|
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||||
Arguments = string.Create(
|
Arguments = string.Create(
|
||||||
CultureInfo.InvariantCulture,
|
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);
|
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
|
private sealed class HelperResultFile
|
||||||
{
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
public bool Success { get; init; }
|
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; }
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errorMessage")]
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,4 +211,5 @@ internal sealed class LauncherClient
|
|||||||
internal sealed record LauncherInstallResult(
|
internal sealed record LauncherInstallResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? InstalledPackagePath,
|
string? InstalledPackagePath,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage,
|
||||||
|
string Code);
|
||||||
|
|||||||
@@ -1454,7 +1454,7 @@ public sealed class UpdateWorkflowService
|
|||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = launcherPath,
|
FileName = launcherPath,
|
||||||
Arguments = $"apply-update --app-root \"{launcherRoot}\"",
|
Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update",
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
WorkingDirectory = launcherRoot
|
WorkingDirectory = launcherRoot
|
||||||
};
|
};
|
||||||
@@ -1493,6 +1493,7 @@ public sealed class UpdateWorkflowService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'.");
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = pending.InstallerPath,
|
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
|
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||||
|
|
||||||
[Run]
|
[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]
|
[Code]
|
||||||
const
|
const
|
||||||
|
|||||||
@@ -243,7 +243,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
|
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
|
||||||
AppLogger.Error(
|
AppLogger.Error(
|
||||||
"PluginMarket",
|
"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);
|
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 (
|
- Incremental package build/publish has moved to VeloPack native assets (
|
||||||
eleases.win.json + *.nupkg).
|
eleases.win.json + *.nupkg).
|
||||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
- 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)
|
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||||
- [架构文档](ARCHITECTURE.md)
|
- [架构文档](ARCHITECTURE.md)
|
||||||
- [开发文档](DEVELOPMENT.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)
|
- [Launcher 鏋舵瀯](LAUNCHER.md)
|
||||||
- [鏇存柊绯荤粺](UPDATE_SYSTEM.md)
|
- [鏇存柊绯荤粺](UPDATE_SYSTEM.md)
|
||||||
- [鏋勫缓鍜岄儴缃瞉(BUILD_AND_DEPLOY.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