diff --git a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md index 1afaff9..b9003ac 100644 --- a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md +++ b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md @@ -6,3 +6,4 @@ - [ ] `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. +- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable). diff --git a/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs new file mode 100644 index 0000000..04a4b9b --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host +/// AppSettingsSnapshot 的 JSON 序列化一致(PascalCase)。 +/// +public static class HostAppSettingsOobeMerger +{ + public const string ShowInTaskbarKey = "ShowInTaskbar"; + public const string EnableFadeTransitionKey = "EnableFadeTransition"; + public const string EnableSlideTransitionKey = "EnableSlideTransition"; + public const string EnableFusedDesktopKey = "EnableFusedDesktop"; + public const string EnableThreeFingerSwipeKey = "EnableThreeFingerSwipe"; + public const string AutoStartWithWindowsKey = "AutoStartWithWindows"; + + public static string GetSettingsFilePath(string dataRoot) => + Path.Combine(Path.GetFullPath(dataRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), "settings.json"); + + public static HostAppSettingsStartupDefaults LoadStartupDefaults(string settingsPath) + { + if (!File.Exists(settingsPath)) + { + return HostAppSettingsStartupDefaults.Fallback; + } + + try + { + var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject(); + if (root is null) + { + return HostAppSettingsStartupDefaults.Fallback; + } + + var fade = ReadBool(root, EnableFadeTransitionKey, defaultValue: true); + var slide = ReadBool(root, EnableSlideTransitionKey, defaultValue: false); + var normalized = StartupVisualPreferencesResolver.FromFlags(fade, slide); + + return new HostAppSettingsStartupDefaults( + ShowInTaskbar: ReadBool(root, ShowInTaskbarKey, defaultValue: false), + EnableFadeTransition: normalized.EnableFadeTransition, + EnableSlideTransition: normalized.EnableSlideTransition, + FusedPopupExperience: ReadBool(root, EnableFusedDesktopKey, defaultValue: false) && + ReadBool(root, EnableThreeFingerSwipeKey, defaultValue: false), + AutoStartWithWindows: ReadBool(root, AutoStartWithWindowsKey, defaultValue: false)); + } + catch (Exception ex) + { + Logger.Warn($"HostAppSettingsOobeMerger: failed to read '{settingsPath}'. {ex.Message}"); + return HostAppSettingsStartupDefaults.Fallback; + } + } + + public static void MergeStartupPresentation(string settingsPath, HostAppSettingsStartupChoices choices) + { + var directory = Path.GetDirectoryName(settingsPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + JsonObject root; + if (File.Exists(settingsPath)) + { + try + { + root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject() ?? new JsonObject(); + } + catch (Exception ex) + { + Logger.Warn($"HostAppSettingsOobeMerger: replacing invalid JSON at '{settingsPath}'. {ex.Message}"); + root = new JsonObject(); + } + } + else + { + root = new JsonObject(); + } + + var normalized = StartupVisualPreferencesResolver.FromFlags( + choices.EnableFadeTransition, + choices.EnableSlideTransition); + + root[ShowInTaskbarKey] = choices.ShowInTaskbar; + root[EnableFadeTransitionKey] = normalized.EnableFadeTransition; + root[EnableSlideTransitionKey] = normalized.EnableSlideTransition; + root[EnableFusedDesktopKey] = choices.FusedPopupExperience; + root[EnableThreeFingerSwipeKey] = choices.FusedPopupExperience; + root[AutoStartWithWindowsKey] = choices.AutoStartWithWindows; + + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(settingsPath, root.ToJsonString(options)); + } + + private static bool ReadBool(JsonObject root, string key, bool defaultValue) + { + if (!root.TryGetPropertyValue(key, out var node) || node is null) + { + return defaultValue; + } + + return node switch + { + JsonValue v when v.TryGetValue(out var b) => b, + JsonValue v when v.TryGetValue(out var s) => bool.TryParse(s, out var p) && p, + _ => defaultValue + }; + } +} + +public readonly record struct HostAppSettingsStartupDefaults( + bool ShowInTaskbar, + bool EnableFadeTransition, + bool EnableSlideTransition, + bool FusedPopupExperience, + bool AutoStartWithWindows) +{ + public static HostAppSettingsStartupDefaults Fallback { get; } = new( + ShowInTaskbar: false, + EnableFadeTransition: true, + EnableSlideTransition: false, + FusedPopupExperience: false, + AutoStartWithWindows: false); +} + +public readonly record struct HostAppSettingsStartupChoices( + bool ShowInTaskbar, + bool EnableFadeTransition, + bool EnableSlideTransition, + bool FusedPopupExperience, + bool AutoStartWithWindows); diff --git a/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs b/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs new file mode 100644 index 0000000..d94a912 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.Win32; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 将当前 Windows 用户登录时自启动项指向本 Launcher 进程(与正式入口一致)。 +/// Host 内 WindowsStartupService 使用 Host 进程路径; +/// OOBE 在 Launcher 内执行时应使用本类型,以便开机后仍走更新/版本协调流程。 +/// +public sealed class LauncherWindowsStartupService +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "LanMountainDesktop"; + private readonly string _startupCommand; + + public LauncherWindowsStartupService() + { + var processPath = Environment.ProcessPath; + _startupCommand = string.IsNullOrWhiteSpace(processPath) + ? string.Empty + : $"\"{processPath}\""; + } + + public bool IsEnabled() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false); + return runKey?.GetValue(ValueName) is string value && + !string.IsNullOrWhiteSpace(value); + } + catch (Exception ex) + { + Logger.Warn($"LauncherWindowsStartup: failed to read Run key. {ex.Message}"); + return false; + } + } + + public bool SetEnabled(bool enabled) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + if (enabled && string.IsNullOrWhiteSpace(_startupCommand)) + { + return false; + } + + try + { + using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath); + if (runKey is null) + { + return false; + } + + if (enabled) + { + runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String); + } + else + { + runKey.DeleteValue(ValueName, throwOnMissingValue: false); + } + + return IsEnabled() == enabled; + } + catch (Exception ex) + { + Logger.Warn($"LauncherWindowsStartup: failed to set Run key. Enabled={enabled}. {ex.Message}"); + return false; + } + } +} diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml index eb55cfa..e098665 100644 --- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml @@ -596,7 +596,142 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +