Add OOBE startup presentation and settings merge

Introduce a new OOBE step for "Startup & Presentation" that exposes startup and UI preferences in OobeWindow (toggles for taskbar, slide/fade transitions, fused popup, and autostart). Add HostAppSettingsOobeMerger to read/write Host settings.json (PascalCase fields) and MergeStartupPresentation behavior, plus LauncherWindowsStartupService to sync the current Launcher into the Windows Run key on Windows. Wire UI handlers, persist choices on Next, and load defaults when entering the step. Include unit tests for the merger, adjust SettingsWindow navigation pane/toggle handling, and update docs/LAUNCHER.md to describe the new OOBE step and implementation files.
This commit is contained in:
lincube
2026-05-04 11:22:21 +08:00
parent 574b798092
commit 60e7f31ba7
9 changed files with 717 additions and 48 deletions

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host
/// AppSettingsSnapshot 的 JSON 序列化一致PascalCase
/// </summary>
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<bool>(out var b) => b,
JsonValue v when v.TryGetValue<string>(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);

View File

@@ -0,0 +1,82 @@
using System;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 将当前 Windows 用户登录时自启动项指向<strong>本 Launcher 进程</strong>(与正式入口一致)。
/// Host 内 WindowsStartupService 使用 Host 进程路径;
/// OOBE 在 Launcher 内执行时应使用本类型,以便开机后仍走更新/版本协调流程。
/// </summary>
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;
}
}
}

View File

@@ -596,7 +596,142 @@
</StackPanel>
</Grid>
<!-- 步骤 4: 信息与隐私页面 -->
<!-- 步骤 4: 启动与展示(紧接数据保存位置) -->
<Grid x:Name="StartupPresentationStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,16">
<TextBlock Text="启动与展示"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="这些选项可随时在桌面应用的「设置」中更改。主窗口滑动入场仅在 Windows 上可用。"
FontSize="13"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="14">
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="在任务栏显示主桌面窗口"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="开启后最小化时可在任务栏保留条目;关闭则更多依赖托盘图标。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ToggleSwitch x:Name="OobeShowInTaskbarToggle"
Grid.Column="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="OobeSlideTransitionSection"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="以滑动方式显示主窗口"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="自屏幕边缘滑入;与「淡入」二选一。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ToggleSwitch x:Name="OobeSlideTransitionToggle"
Grid.Column="1"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="启动时使用淡入过渡"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="在未启用滑动入场时建议使用。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ToggleSwitch x:Name="OobeFadeTransitionToggle"
Grid.Column="1"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="SlideLayout"
FontSize="20"
Foreground="{DynamicResource AccentFillColorDefaultBrush}" />
<TextBlock Text="融合桌面与弹入手势"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
</StackPanel>
<TextBlock Text="同时启用融合桌面与三指滑动手势,以便使用边缘弹入与相关实验特性(与设置中开发者选项一致)。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ToggleSwitch x:Name="OobeFusedPopupToggle"
Grid.Column="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="登录 Windows 时自动启动阑山桌面"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock x:Name="OobeAutoStartDescriptionText"
Text="通过当前用户的启动项注册本启动器(与安装程序可选任务使用同一注册表项)。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ToggleSwitch x:Name="OobeAutoStartToggle"
Grid.Column="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,24,0,0">
<Button x:Name="StartupPresentationBackButton"
Content="返回"
Theme="{DynamicResource ButtonTheme}" />
<Button x:Name="StartupPresentationNextButton"
Content="下一步"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
<!-- 步骤 5: 信息与隐私页面 -->
<Grid x:Name="PrivacyStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
<TextBlock Text="信息与隐私"
@@ -731,7 +866,7 @@
</StackPanel>
</Grid>
<!-- 步骤 5: 欢迎完成页面 -->
<!-- 步骤 6: 欢迎完成页面 -->
<Grid x:Name="WelcomeStep" Margin="48" RowDefinitions="*,Auto" IsVisible="False">
<StackPanel Grid.Row="0"
VerticalAlignment="Center"

View File

@@ -31,6 +31,9 @@ public partial class OobeWindow : Window
private string _selectedAccentColor = "#0078D4";
private MonetSource _selectedMonetSource = MonetSource.Wallpaper;
private readonly bool _startupSlideUiAvailable;
private bool _suppressOobeStartupTransitionHandlers;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
@@ -39,6 +42,7 @@ public partial class OobeWindow : Window
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
_resolver = new DataLocationResolver(appRoot);
_startupSlideUiAvailable = OperatingSystem.IsWindows();
}
public void SetDebugMode(bool isDebugMode)
@@ -181,7 +185,27 @@ public partial class OobeWindow : Window
};
}
// 步骤 4: 隐私设置页面
if (this.FindControl<Button>("StartupPresentationBackButton") is { } startupPresentationBack)
{
startupPresentationBack.Click += OnStartupPresentationBackClick;
}
if (this.FindControl<Button>("StartupPresentationNextButton") is { } startupPresentationNext)
{
startupPresentationNext.Click += OnStartupPresentationNextClick;
}
if (this.FindControl<ToggleSwitch>("OobeSlideTransitionToggle") is { } oobeSlideTransition)
{
oobeSlideTransition.IsCheckedChanged += OnOobeStartupSlideTransitionChanged;
}
if (this.FindControl<ToggleSwitch>("OobeFadeTransitionToggle") is { } oobeFadeTransition)
{
oobeFadeTransition.IsCheckedChanged += OnOobeStartupFadeTransitionChanged;
}
// 步骤 5: 隐私设置页面
if (this.FindControl<Button>("PrivacyBackButton") is { } privacyBackButton)
{
privacyBackButton.Click += OnPrivacyBackClick;
@@ -203,7 +227,7 @@ public partial class OobeWindow : Window
privacyCheckBox.IsCheckedChanged += OnPrivacyAgreementChanged;
}
// 步骤 5: 欢迎完成页面
// 步骤 6: 欢迎完成页面
if (this.FindControl<Button>("EnterButton") is { } enterButton)
{
enterButton.Click += OnEnterClick;
@@ -460,11 +484,189 @@ public partial class OobeWindow : Window
await NavigateToStep(4);
}
// 启动与展示OOBE 步骤 4
private async void OnStartupPresentationBackClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(3);
}
private async void OnStartupPresentationNextClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
SaveOobeStartupPresentation();
await NavigateToStep(5);
}
private void SaveOobeStartupPresentation()
{
try
{
var choices = CollectOobeStartupChoices();
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(_resolver.ResolveDataRoot());
HostAppSettingsOobeMerger.MergeStartupPresentation(path, choices);
if (OperatingSystem.IsWindows())
{
_ = new LauncherWindowsStartupService().SetEnabled(choices.AutoStartWithWindows);
}
Logger.Info($"[OobeWindow] 启动与展示已写入 '{path}'.");
}
catch (Exception ex)
{
Logger.Warn($"[OobeWindow] 启动与展示保存失败: {ex.Message}");
}
}
private void RefreshOobeStartupPresentationFromDisk()
{
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(_resolver.ResolveDataRoot());
var defaults = HostAppSettingsOobeMerger.LoadStartupDefaults(path);
if (this.FindControl<Border>("OobeSlideTransitionSection") is { } slideSection)
{
slideSection.IsVisible = _startupSlideUiAvailable;
}
if (this.FindControl<TextBlock>("OobeAutoStartDescriptionText") is { } autoStartDesc)
{
autoStartDesc.Text = OperatingSystem.IsWindows()
? "通过当前用户的启动项注册本启动器(与安装程序可选任务使用同一注册表项)。"
: "当前平台仅保存偏好;是否随系统自启动请使用系统提供的应用自启动设置。";
}
_suppressOobeStartupTransitionHandlers = true;
try
{
if (this.FindControl<ToggleSwitch>("OobeShowInTaskbarToggle") is { } taskbar)
{
taskbar.IsChecked = defaults.ShowInTaskbar;
}
if (_startupSlideUiAvailable)
{
if (this.FindControl<ToggleSwitch>("OobeSlideTransitionToggle") is { } slide)
{
slide.IsChecked = defaults.EnableSlideTransition;
}
if (this.FindControl<ToggleSwitch>("OobeFadeTransitionToggle") is { } fade)
{
fade.IsChecked = defaults.EnableFadeTransition;
fade.IsEnabled = !defaults.EnableSlideTransition;
}
}
if (this.FindControl<ToggleSwitch>("OobeFusedPopupToggle") is { } fused)
{
fused.IsChecked = defaults.FusedPopupExperience;
}
if (this.FindControl<ToggleSwitch>("OobeAutoStartToggle") is { } autoStart)
{
autoStart.IsChecked = defaults.AutoStartWithWindows;
autoStart.IsEnabled = OperatingSystem.IsWindows();
}
}
finally
{
_suppressOobeStartupTransitionHandlers = false;
}
}
private HostAppSettingsStartupChoices CollectOobeStartupChoices()
{
var showTaskbar = this.FindControl<ToggleSwitch>("OobeShowInTaskbarToggle")?.IsChecked == true;
var fused = this.FindControl<ToggleSwitch>("OobeFusedPopupToggle")?.IsChecked == true;
var autoStart = OperatingSystem.IsWindows() &&
this.FindControl<ToggleSwitch>("OobeAutoStartToggle")?.IsChecked == true;
bool fade;
bool slide;
if (_startupSlideUiAvailable)
{
slide = this.FindControl<ToggleSwitch>("OobeSlideTransitionToggle")?.IsChecked == true;
fade = this.FindControl<ToggleSwitch>("OobeFadeTransitionToggle")?.IsChecked == true;
}
else
{
slide = false;
fade = true;
}
return new HostAppSettingsStartupChoices(
ShowInTaskbar: showTaskbar,
EnableFadeTransition: fade,
EnableSlideTransition: slide,
FusedPopupExperience: fused,
AutoStartWithWindows: autoStart);
}
private void OnOobeStartupSlideTransitionChanged(object? sender, RoutedEventArgs e)
{
if (!_startupSlideUiAvailable || _suppressOobeStartupTransitionHandlers)
{
return;
}
if (sender is not ToggleSwitch slide || slide.IsChecked != true)
{
if (this.FindControl<ToggleSwitch>("OobeFadeTransitionToggle") is { } fade)
{
fade.IsEnabled = true;
}
return;
}
_suppressOobeStartupTransitionHandlers = true;
try
{
if (this.FindControl<ToggleSwitch>("OobeFadeTransitionToggle") is { } fade)
{
fade.IsChecked = false;
fade.IsEnabled = false;
}
}
finally
{
_suppressOobeStartupTransitionHandlers = false;
}
}
private void OnOobeStartupFadeTransitionChanged(object? sender, RoutedEventArgs e)
{
if (!_startupSlideUiAvailable || _suppressOobeStartupTransitionHandlers)
{
return;
}
if (sender is not ToggleSwitch fade || fade.IsChecked != true)
{
return;
}
_suppressOobeStartupTransitionHandlers = true;
try
{
if (this.FindControl<ToggleSwitch>("OobeSlideTransitionToggle") is { } slide)
{
slide.IsChecked = false;
}
fade.IsEnabled = true;
}
finally
{
_suppressOobeStartupTransitionHandlers = false;
}
}
// 隐私设置页面按钮
private async void OnPrivacyBackClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(3);
await NavigateToStep(4);
}
private async void OnPrivacyNextClick(object? sender, RoutedEventArgs e)
@@ -474,7 +676,7 @@ public partial class OobeWindow : Window
// 保存隐私设置
SavePrivacySettings();
await NavigateToStep(5);
await NavigateToStep(6);
}
private void OnViewPrivacyPolicyClick(object? sender, RoutedEventArgs e)
@@ -712,8 +914,9 @@ public partial class OobeWindow : Window
1 => this.FindControl<Grid>("TypingStep"),
2 => this.FindControl<Grid>("ThemeStep"),
3 => this.FindControl<Grid>("DataLocationStep"),
4 => this.FindControl<Grid>("PrivacyStep"),
5 => this.FindControl<Grid>("WelcomeStep"),
4 => this.FindControl<Grid>("StartupPresentationStep"),
5 => this.FindControl<Grid>("PrivacyStep"),
6 => this.FindControl<Grid>("WelcomeStep"),
_ => null
};
@@ -723,8 +926,9 @@ public partial class OobeWindow : Window
1 => this.FindControl<Grid>("TypingStep"),
2 => this.FindControl<Grid>("ThemeStep"),
3 => this.FindControl<Grid>("DataLocationStep"),
4 => this.FindControl<Grid>("PrivacyStep"),
5 => this.FindControl<Grid>("WelcomeStep"),
4 => this.FindControl<Grid>("StartupPresentationStep"),
5 => this.FindControl<Grid>("PrivacyStep"),
6 => this.FindControl<Grid>("WelcomeStep"),
_ => null
};
@@ -737,6 +941,11 @@ public partial class OobeWindow : Window
await AnimateOpacityAsync(currentStepControl, 1, 0, AnimationDurationMs);
currentStepControl.IsVisible = false;
if (step == 4)
{
RefreshOobeStartupPresentationFromDisk();
}
nextStepControl.IsVisible = true;
nextStepControl.Opacity = 0;
await AnimateOpacityAsync(nextStepControl, 0, 1, AnimationDurationMs);