refactor(launcher): reorganize into responsibility folders

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lincube
2026-05-28 10:43:30 +08:00
parent 1ee6e68f33
commit b219f109ec
57 changed files with 92 additions and 197 deletions

View 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();
}
});
}
}
}

View 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);

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher.Oobe;
internal interface IOobeStep
{
Task RunAsync(CancellationToken cancellationToken);
}

View File

@@ -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;
}
}
}

View 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");
}
}
}

View 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;
}
}
}

View 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();
}
});
}
}