mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 01:44:26 +08:00
refactor(launcher): reorganize into responsibility folders
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
67
LanMountainDesktop.Launcher/Oobe/DataLocationOobeStep.cs
Normal file
67
LanMountainDesktop.Launcher/Oobe/DataLocationOobeStep.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed class DataLocationOobeStep : IOobeStep
|
||||
{
|
||||
private readonly DataLocationResolver _resolver;
|
||||
|
||||
public DataLocationOobeStep(DataLocationResolver resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existingConfig = _resolver.LoadConfig();
|
||||
if (existingConfig is not null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step skipped: config already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
DataLocationPromptWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new DataLocationPromptWindow(_resolver);
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
|
||||
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
|
||||
Logger.Info(
|
||||
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
|
||||
$"Migrate={result.MigrateExistingData}; Success={success}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
184
LanMountainDesktop.Launcher/Oobe/HostAppSettingsOobeMerger.cs
Normal file
184
LanMountainDesktop.Launcher/Oobe/HostAppSettingsOobeMerger.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
/// <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 const string MultiInstanceLaunchBehaviorKey = "MultiInstanceLaunchBehavior";
|
||||
|
||||
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 MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(string settingsPath)
|
||||
{
|
||||
if (!File.Exists(settingsPath))
|
||||
{
|
||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
|
||||
if (root is null)
|
||||
{
|
||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
|
||||
return ReadMultiInstanceLaunchBehavior(root);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"HostAppSettingsOobeMerger: failed to read multi-instance behavior from '{settingsPath}'. {ex.Message}");
|
||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private static MultiInstanceLaunchBehavior ReadMultiInstanceLaunchBehavior(JsonObject root)
|
||||
{
|
||||
if (!root.TryGetPropertyValue(MultiInstanceLaunchBehaviorKey, out var node) || node is null)
|
||||
{
|
||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue<string>(out var text) &&
|
||||
Enum.TryParse<MultiInstanceLaunchBehavior>(text, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<int>(out var numeric) &&
|
||||
Enum.IsDefined(typeof(MultiInstanceLaunchBehavior), numeric))
|
||||
{
|
||||
return (MultiInstanceLaunchBehavior)numeric;
|
||||
}
|
||||
}
|
||||
|
||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
6
LanMountainDesktop.Launcher/Oobe/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Oobe/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
246
LanMountainDesktop.Launcher/Oobe/OobeStateService.cs
Normal file
246
LanMountainDesktop.Launcher/Oobe/OobeStateService.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyStatePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
public OobeStateService(
|
||||
string appRoot,
|
||||
string? stateRootOverride = null,
|
||||
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
_ = Path.GetFullPath(appRoot);
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? ResolveStateRoot(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
|
||||
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
|
||||
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 migratedLegacyMarker = false;
|
||||
if (File.Exists(_statePath))
|
||||
{
|
||||
return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyStatePath))
|
||||
{
|
||||
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return BuildUnavailableDecision(context, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryMigrateLegacyMarker(CommandContext context)
|
||||
{
|
||||
var result = MarkCompleted(context);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private OobeLaunchDecision EvaluateStateFile(CommandContext context, string statePath, bool migratedLegacyState)
|
||||
{
|
||||
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: migratedLegacyState);
|
||||
}
|
||||
|
||||
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 ResolveStateRoot(string appRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveDataRoot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
}
|
||||
245
LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs
Normal file
245
LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私协议同意状态管理服务(带防篡改保护)
|
||||
/// </summary>
|
||||
internal sealed class PrivacyAgreementService
|
||||
{
|
||||
private readonly string _storagePath;
|
||||
private readonly string _secretKey;
|
||||
private const string ConfigFileName = "privacy-agreement.state.json";
|
||||
private const string CurrentAgreementVersion = "1.0";
|
||||
|
||||
public PrivacyAgreementService(string launcherDataPath)
|
||||
{
|
||||
_storagePath = Path.Combine(launcherDataPath, ConfigFileName);
|
||||
// 使用机器特定信息生成密钥,增加篡改难度
|
||||
_secretKey = GenerateMachineSpecificKey();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查用户是否已同意隐私协议
|
||||
/// </summary>
|
||||
public bool HasUserAgreed()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_storagePath))
|
||||
{
|
||||
Logger.Info("[PrivacyAgreementService] 未找到隐私协议同意状态文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_storagePath);
|
||||
var state = JsonSerializer.Deserialize(json, AppJsonContext.Default.PrivacyAgreementState);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
Logger.Warn("[PrivacyAgreementService] 无法解析隐私协议状态文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证数据完整性
|
||||
if (!VerifyIntegrity(state))
|
||||
{
|
||||
Logger.Warn("[PrivacyAgreementService] 隐私协议状态文件已被篡改!");
|
||||
// 删除被篡改的文件
|
||||
try
|
||||
{
|
||||
File.Delete(_storagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 删除被篡改文件失败: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查协议版本是否匹配
|
||||
if (state.AgreementVersion != CurrentAgreementVersion)
|
||||
{
|
||||
Logger.Info($"[PrivacyAgreementService] 隐私协议版本已更新: {state.AgreementVersion} -> {CurrentAgreementVersion}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.Info($"[PrivacyAgreementService] 用户已于 {state.AgreedAtUtc:yyyy-MM-dd HH:mm:ss} UTC 同意隐私协议");
|
||||
return state.IsAgreed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 检查同意状态时出错: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存用户同意隐私协议的状态
|
||||
/// </summary>
|
||||
public bool SaveAgreement(bool isAgreed, string userId, string deviceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保目录存在
|
||||
var directory = Path.GetDirectoryName(_storagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// 生成随机盐值
|
||||
var salt = GenerateRandomSalt();
|
||||
|
||||
var state = new PrivacyAgreementState
|
||||
{
|
||||
IsAgreed = isAgreed,
|
||||
AgreedAtUtc = DateTime.UtcNow,
|
||||
AgreementVersion = CurrentAgreementVersion,
|
||||
UserId = userId,
|
||||
DeviceId = deviceId,
|
||||
Salt = salt
|
||||
};
|
||||
|
||||
// 计算完整性哈希
|
||||
state.IntegrityHash = CalculateIntegrityHash(state);
|
||||
|
||||
// 保存到文件
|
||||
var json = JsonSerializer.Serialize(state, AppJsonContext.Default.PrivacyAgreementState);
|
||||
File.WriteAllText(_storagePath, json);
|
||||
|
||||
Logger.Info($"[PrivacyAgreementService] 隐私协议同意状态已保存: IsAgreed={isAgreed}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 保存同意状态失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前的协议版本
|
||||
/// </summary>
|
||||
public string GetCurrentAgreementVersion() => CurrentAgreementVersion;
|
||||
|
||||
/// <summary>
|
||||
/// 清除同意状态(用于测试或重置)
|
||||
/// </summary>
|
||||
public bool ClearAgreement()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_storagePath))
|
||||
{
|
||||
File.Delete(_storagePath);
|
||||
Logger.Info("[PrivacyAgreementService] 隐私协议同意状态已清除");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 清除同意状态失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成机器特定的密钥
|
||||
/// </summary>
|
||||
private string GenerateMachineSpecificKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 组合多个机器特定信息生成密钥
|
||||
var machineName = Environment.MachineName;
|
||||
var userName = Environment.UserName;
|
||||
var osVersion = Environment.OSVersion.Version.ToString();
|
||||
var processorCount = Environment.ProcessorCount.ToString();
|
||||
|
||||
// 使用硬件信息(如果可用)
|
||||
var hardwareId = GetHardwareIdentifier();
|
||||
|
||||
var keyData = $"{machineName}:{userName}:{osVersion}:{processorCount}:{hardwareId}:LanMountainDesktop";
|
||||
|
||||
// 使用 SHA-256 生成固定长度的密钥
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyData));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果无法获取机器信息,使用备用密钥
|
||||
return "LanMountainDesktop-Privacy-Agreement-Fallback-Key-2026";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取硬件标识符
|
||||
/// </summary>
|
||||
private string GetHardwareIdentifier()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 尝试使用系统目录创建时间作为硬件标识的一部分
|
||||
var systemDir = Environment.SystemDirectory;
|
||||
var dirInfo = new DirectoryInfo(systemDir);
|
||||
return dirInfo.CreationTimeUtc.ToString("yyyyMMddHHmmss");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成随机盐值
|
||||
/// </summary>
|
||||
private string GenerateRandomSalt()
|
||||
{
|
||||
var saltBytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(saltBytes);
|
||||
return Convert.ToHexString(saltBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算完整性哈希(HMAC-SHA256)
|
||||
/// </summary>
|
||||
private string CalculateIntegrityHash(PrivacyAgreementState state)
|
||||
{
|
||||
// 构建需要哈希的数据字符串
|
||||
var dataToHash = $"{state.IsAgreed}:{state.AgreedAtUtc:o}:{state.AgreementVersion}:{state.UserId}:{state.DeviceId}:{state.Salt}";
|
||||
|
||||
// 使用 HMAC-SHA256 计算哈希
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataToHash));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数据完整性
|
||||
/// </summary>
|
||||
private bool VerifyIntegrity(PrivacyAgreementState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(state.IntegrityHash) || string.IsNullOrEmpty(state.Salt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedHash = CalculateIntegrityHash(state);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(state.IntegrityHash),
|
||||
Encoding.UTF8.GetBytes(expectedHash));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs
Normal file
50
LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||
{
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
OobeWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new OobeWindow();
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
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(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user