diff --git a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md new file mode 100644 index 0000000..1afaff9 --- /dev/null +++ b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md @@ -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. diff --git a/.trae/specs/launcher-oobe-elevation-hardening/spec.md b/.trae/specs/launcher-oobe-elevation-hardening/spec.md new file mode 100644 index 0000000..2ec14a7 --- /dev/null +++ b/.trae/specs/launcher-oobe-elevation-hardening/spec.md @@ -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. diff --git a/.trae/specs/launcher-oobe-elevation-hardening/tasks.md b/.trae/specs/launcher-oobe-elevation-hardening/tasks.md new file mode 100644 index 0000000..15e57ca --- /dev/null +++ b/.trae/specs/launcher-oobe-elevation-hardening/tasks.md @@ -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. diff --git a/.trae/specs/launcher-upgrade/checklist.md b/.trae/specs/launcher-upgrade/checklist.md index d496572..284a50c 100644 --- a/.trae/specs/launcher-upgrade/checklist.md +++ b/.trae/specs/launcher-upgrade/checklist.md @@ -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`. diff --git a/.trae/specs/launcher-upgrade/spec.md b/.trae/specs/launcher-upgrade/spec.md index 524f535..e4836c7 100644 --- a/.trae/specs/launcher-upgrade/spec.md +++ b/.trae/specs/launcher-upgrade/spec.md @@ -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. diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index 78b4219..d18571e 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -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 ?? ""}'."); + $"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " + + $"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? ""}'."); 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") ?? ""}'."); + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"ResultPath='{context.GetOption("result") ?? ""}'."); 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(StringComparer.OrdinalIgnoreCase) + { + ["command"] = context.Command, + ["launchSource"] = context.LaunchSource + } }).ConfigureAwait(false); Environment.ExitCode = success ? 0 : 1; diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 6d2380d..7a92b0b 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -25,6 +25,7 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(PluginManifest))] [JsonSerializable(typeof(PendingUpgrade))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(OobeStateFile))] [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(List))] diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs index 81db418..f57c05a 100644 --- a/LanMountainDesktop.Launcher/CommandContext.cs +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -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(); + /// /// 是否处于调试模式(从 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 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 ParseOptions(string[] args) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop.Launcher/Models/OobeStateModels.cs b/LanMountainDesktop.Launcher/Models/OobeStateModels.cs new file mode 100644 index 0000000..6f025a7 --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/OobeStateModels.cs @@ -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); diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs index fa18a83..a5b089b 100644 --- a/LanMountainDesktop.Launcher/Program.cs +++ b/LanMountainDesktop.Launcher/Program.cs @@ -10,10 +10,13 @@ internal static class Program private static async Task 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 ?? ""}'."); @@ -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 } }; diff --git a/LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs b/LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..944b2df --- /dev/null +++ b/LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")] diff --git a/LanMountainDesktop.Launcher/Services/LauncherExecutionContext.cs b/LanMountainDesktop.Launcher/Services/LauncherExecutionContext.cs new file mode 100644 index 0000000..8845da8 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherExecutionContext.cs @@ -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); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 6fa700b..f39bcd0 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -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 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 + details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary { ["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 + details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary { ["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 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 BuildLauncherContextDetails( + CommandContext context, + OobeLaunchDecision oobeDecision, + string appRoot) + { + return new Dictionary(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 BuildResolutionDetails( HostResolutionResult resolution, HostStartAttempt? firstAttempt, diff --git a/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs b/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs index 010edf3..19e25e4 100644 --- a/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs +++ b/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs @@ -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 { diff --git a/LanMountainDesktop.Launcher/Services/OobeStateService.cs b/LanMountainDesktop.Launcher/Services/OobeStateService.cs index ba712c4..6903ba2 100644 --- a/LanMountainDesktop.Launcher/Services/OobeStateService.cs +++ b/LanMountainDesktop.Launcher/Services/OobeStateService.cs @@ -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"); + } } diff --git a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs index e6dc390..905fe97 100644 --- a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs +++ b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs @@ -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(StringComparer.OrdinalIgnoreCase) + { + ["pluginsDirectory"] = pluginsDirectory, + ["allowedRoot"] = allowedRoot, + ["elevationReason"] = "outside_user_scope" + } + }; + } + public PluginManifest ReadManifestFromPackage(string packagePath) { using var archive = ZipFile.OpenRead(packagePath); diff --git a/LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs b/LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs index 810d83b..241b5e6 100644 --- a/LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs +++ b/LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs @@ -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(() => { diff --git a/LanMountainDesktop.Tests/CommandContextTests.cs b/LanMountainDesktop.Tests/CommandContextTests.cs new file mode 100644 index 0000000..1a728c8 --- /dev/null +++ b/LanMountainDesktop.Tests/CommandContextTests.cs @@ -0,0 +1,25 @@ +using LanMountainDesktop.Launcher; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class CommandContextTests +{ + public static TheoryData 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); + } +} diff --git a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj index d44230f..5b8f8ef 100644 --- a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj +++ b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj @@ -18,5 +18,6 @@ + diff --git a/LanMountainDesktop.Tests/OobeStateServiceTests.cs b/LanMountainDesktop.Tests/OobeStateServiceTests.cs new file mode 100644 index 0000000..99b3be3 --- /dev/null +++ b/LanMountainDesktop.Tests/OobeStateServiceTests.cs @@ -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"); +} diff --git a/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs new file mode 100644 index 0000000..e477da8 --- /dev/null +++ b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs @@ -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 + { + } + } +} diff --git a/LanMountainDesktop/Services/LauncherClient.cs b/LanMountainDesktop/Services/LauncherClient.cs index 96210f1..63fdea5 100644 --- a/LanMountainDesktop/Services/LauncherClient.cs +++ b/LanMountainDesktop/Services/LauncherClient.cs @@ -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); diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 59d3a9a..461fb72 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -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, diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index 4b7ba8d..50f1d42 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -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 diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index f37971c..772a8e2 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -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); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d26113..36b0744 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. +- Default plugin install should stay inside the user's LocalAppData scope and should not ask for UAC. diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index a1738ec..f884854 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index fbee68e..ad24615 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 + +--- + +### : װⵯԱȨ + +**ԭ:** ijЩ·ʽ `runas`̰ĬûĿ¼гҪȨ + +**ǰȨİ:** +- װ +- ȫװӦ +- ûʽȷϵ legacy uninstall + +**Ӧ UAC ij:** +- ͨ +- OOBE +- +- +- Ĭϲװû LocalAppData · + +**Խ:** +- ־е `launchSource``isElevated``oobeStateStatus``oobeSuppressionReason` +- װĿǷ `%LOCALAPPDATA%\LanMountainDesktop` +- ȷûж `Verb = "runas"` Ĭ·