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

@@ -1,90 +0,0 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
internal sealed class AirAppHostLocator
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
public string Resolve(string? packageRoot, string? hostPath = null)
{
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
{
if (File.Exists(candidate))
{
return candidate;
}
}
throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output.");
}
private static IEnumerable<string> EnumerateCandidates(string? packageRoot, string? hostPath)
{
foreach (var root in EnumerateRoots(packageRoot, hostPath))
{
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
yield return Path.Combine(root, "AirAppHost", DllName);
yield return Path.Combine(root, WindowsExecutableName);
yield return Path.Combine(root, DllName);
if (Directory.Exists(root))
{
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
{
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
yield return Path.Combine(deploymentDirectory, DllName);
}
}
}
var current = new DirectoryInfo(AppContext.BaseDirectory);
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
{
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppHost",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
WindowsExecutableName);
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppHost",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
DllName);
}
}
private static IEnumerable<string> EnumerateRoots(string? packageRoot, string? hostPath)
{
if (!string.IsNullOrWhiteSpace(packageRoot))
{
yield return Path.GetFullPath(packageRoot);
}
if (!string.IsNullOrWhiteSpace(hostPath))
{
var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath));
if (!string.IsNullOrWhiteSpace(hostDirectory))
{
yield return hostDirectory;
}
}
yield return AppContext.BaseDirectory;
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
}
}

View File

@@ -1,24 +0,0 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
internal static class AirAppInstanceKey
{
public static string Build(string appId, string? sourceComponentId, string? sourcePlacementId)
{
var normalizedAppId = Normalize(appId, "unknown");
if (string.Equals(normalizedAppId, "world-clock", StringComparison.OrdinalIgnoreCase))
{
return $"{normalizedAppId}:clock-suite:global";
}
var normalizedComponentId = Normalize(sourceComponentId, "none");
var normalizedPlacementId = Normalize(sourcePlacementId, "none");
return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";
}
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
}
}

View File

@@ -1,139 +0,0 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
internal interface IAirAppProcessStarter
{
Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId);
}
internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{
private readonly AirAppHostLocator _locator;
private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func<string?> packageRootProvider,
Func<string?> hostPathProvider,
Func<string?> dataRootProvider,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{
_locator = locator;
_packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider;
_runtimeProbeOptions = runtimeProbeOptions;
}
public Process? Start(
string appId,
string sessionId,
string instanceKey,
string? sourceComponentId,
string? sourcePlacementId)
{
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
var dataRoot = _dataRootProvider();
if (!string.IsNullOrWhiteSpace(dataRoot))
{
AddArgument(startInfo, "--data-root", Path.GetFullPath(dataRoot));
}
if (!string.IsNullOrWhiteSpace(sourceComponentId))
{
AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim());
}
if (!string.IsNullOrWhiteSpace(sourcePlacementId))
{
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
}
LanMountainDesktop.Launcher.Services.Logger.Info(
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
var process = Process.Start(startInfo);
if (process is not null)
{
process.EnableRaisingEvents = true;
process.Exited += (_, _) =>
{
try
{
LanMountainDesktop.Launcher.Services.Logger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
catch (Exception ex)
{
LanMountainDesktop.Launcher.Services.Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
}
};
}
return process;
}
internal static ProcessStartInfo CreateStartInfo(
string hostPath,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows())
{
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
{
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
{
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!executableRuntime.IsAvailable)
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
executableRuntime.Message);
}
}
startInfo.FileName = hostPath;
return startInfo;
}
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
runtime.Message);
}
startInfo.FileName = runtime.DotNetHostPath;
startInfo.ArgumentList.Add(hostPath);
return startInfo;
}
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Add(hostPath);
return startInfo;
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(value);
}
}

View File

@@ -1,29 +0,0 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
{
private readonly PublicIpcHostService _host;
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
{
LifecycleService = lifecycleService;
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
}
public LauncherAirAppLifecycleService LifecycleService { get; }
public void Start()
{
_host.Start();
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
}
public void Dispose()
{
_host.Dispose();
}
}

View File

@@ -1,332 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
{
private readonly object _gate = new();
private readonly IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
{
_processStarter = processStarter;
}
public Task<AirAppOperationResult> OpenAsync(AirAppOpenRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
Logger.Info(
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
lock (_gate)
{
CleanupExitedInstances();
if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId))
{
TryActivateProcess(existing.ProcessId);
existing.Touch();
return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing));
}
var sessionId = Guid.NewGuid().ToString("N");
try
{
var process = _processStarter.Start(
appId,
sessionId,
instanceKey,
request.SourceComponentId,
request.SourcePlacementId);
if (process is null)
{
return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null));
}
var instance = new ManagedAirAppInstance(
instanceKey,
appId,
sessionId,
process.Id,
$"{appId} - Air APP",
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
}
catch (Exception ex)
{
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
}
}
}
public Task<AirAppOperationResult> ActivateAsync(string instanceKey)
{
lock (_gate)
{
CleanupExitedInstances();
if (!_instances.TryGetValue(instanceKey, out var instance))
{
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
var accepted = TryActivateProcess(instance.ProcessId);
instance.Touch();
return Task.FromResult(BuildResult(
accepted,
accepted ? "activated" : "activation_failed",
accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.",
instance));
}
}
public Task<AirAppOperationResult> CloseAsync(string instanceKey)
{
lock (_gate)
{
CleanupExitedInstances();
if (!_instances.TryGetValue(instanceKey, out var instance))
{
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
var accepted = TryCloseProcess(instance.ProcessId);
instance.Touch();
return Task.FromResult(BuildResult(
accepted,
accepted ? "close_requested" : "close_failed",
accepted ? "Air APP close requested." : "Failed to request Air APP close.",
instance));
}
}
public Task<AirAppInstanceInfo[]> GetInstancesAsync()
{
lock (_gate)
{
CleanupExitedInstances();
return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
}
}
public Task<AirAppOperationResult> RegisterAsync(AirAppRegistrationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
lock (_gate)
{
var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey)
? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId)
: request.InstanceKey.Trim();
var instance = new ManagedAirAppInstance(
instanceKey,
Normalize(request.AppId, "unknown"),
Normalize(request.SessionId, Guid.NewGuid().ToString("N")),
request.ProcessId,
Normalize(request.WindowTitle, $"{request.AppId} - Air APP"),
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
}
}
public Task<AirAppOperationResult> UnregisterAsync(string instanceKey, int processId)
{
lock (_gate)
{
if (_instances.TryGetValue(instanceKey, out var instance) &&
(processId <= 0 || instance.ProcessId == processId))
{
_instances.Remove(instanceKey);
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
}
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
}
public bool HasLiveAirApps()
{
lock (_gate)
{
CleanupExitedInstances();
return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId));
}
}
private void CleanupExitedInstances()
{
var exitedKeys = _instances
.Where(static pair => !IsProcessAlive(pair.Value.ProcessId))
.Select(static pair => pair.Key)
.ToList();
foreach (var key in exitedKeys)
{
_instances.Remove(key);
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
}
}
private static AirAppOperationResult BuildResult(
bool accepted,
string code,
string message,
ManagedAirAppInstance? instance)
{
return new AirAppOperationResult(accepted, code, message, instance?.ToInfo());
}
private static bool TryActivateProcess(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
if (process.HasExited)
{
return false;
}
if (!OperatingSystem.IsWindows())
{
return true;
}
process.Refresh();
var handle = process.MainWindowHandle;
if (handle == IntPtr.Zero)
{
return true;
}
_ = ShowWindow(handle, SW_SHOWNORMAL);
_ = SetForegroundWindow(handle);
return true;
}
catch
{
return false;
}
}
private static bool TryCloseProcess(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
if (process.HasExited)
{
return false;
}
return process.CloseMainWindow();
}
catch
{
return false;
}
}
private static bool IsProcessAlive(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
return false;
}
}
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
}
private const int SW_SHOWNORMAL = 1;
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private sealed class ManagedAirAppInstance
{
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
public ManagedAirAppInstance(
string instanceKey,
string appId,
string sessionId,
int processId,
string windowTitle,
string? sourceComponentId,
string? sourcePlacementId)
{
InstanceKey = instanceKey;
AppId = appId;
SessionId = sessionId;
ProcessId = processId;
WindowTitle = windowTitle;
SourceComponentId = sourceComponentId;
SourcePlacementId = sourcePlacementId;
UpdatedAtUtc = _startedAtUtc;
}
public string InstanceKey { get; }
public string AppId { get; }
public string SessionId { get; }
public int ProcessId { get; }
public string WindowTitle { get; }
public string? SourceComponentId { get; }
public string? SourcePlacementId { get; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
public void Touch()
{
UpdatedAtUtc = DateTimeOffset.UtcNow;
}
public AirAppInstanceInfo ToInfo()
{
return new AirAppInstanceInfo(
InstanceKey,
AppId,
SessionId,
ProcessId,
WindowTitle,
SourceComponentId,
SourcePlacementId,
IsProcessAlive(ProcessId),
_startedAtUtc,
UpdatedAtUtc);
}
}
}

View File

@@ -1,196 +0,0 @@
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal static class Commands
{
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
{
var resultPath = context.GetOption("result");
LauncherResult result;
try
{
var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
result = installer.InstallPackage(source, pluginsDir);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "failed",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "command",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
default:
return new LauncherResult
{
Success = false,
Stage = "command",
Code = "unsupported_command",
Message = $"Unsupported command '{context.Command}'."
};
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.SubCommand.ToLowerInvariant())
{
case "install":
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginInstaller.InstallPackage(source, pluginsDir);
}
case "update":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
}
default:
return new LauncherResult
{
Success = false,
Stage = "plugin",
Code = "unsupported_subcommand",
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
};
}
}
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
{
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
var fullPath = Path.GetFullPath(resultPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}
public static string ResolveAppRoot(CommandContext context)
{
var configured = context.GetOption("app-root");
if (!string.IsNullOrWhiteSpace(configured))
{
return Path.GetFullPath(configured);
}
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
? launcherDir
: AppContext.BaseDirectory);
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
if (File.Exists(parentHost))
{
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -1,67 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
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

@@ -1,295 +0,0 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 解析应用数据目录位置。
/// </summary>
/// <remarks>
/// 安装后的目录结构:
/// <code>
/// {AppRoot}/ ← 应用安装根目录
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
/// app-{version}/ ← Host 部署目录
/// LanMountainDesktop.exe
/// ...
/// </code>
///
/// Launcher 数据目录固定位于应用安装根目录下的 <c>.Launcher</c> 文件夹中,
/// 与 app-* 部署目录同级。此目录不随数据位置模式改变。
///
/// DesktopHost数据目录则根据用户选择可位于系统目录或便携目录。
/// </remarks>
internal sealed class DataLocationResolver
{
private const string ConfigFileName = "data-location.config.json";
private const string DesktopFolderName = "Desktop";
private const string LauncherDataFolderName = ".Launcher";
private readonly string _appRoot;
private readonly string _defaultSystemDataPath;
public DataLocationResolver(string appRoot)
{
_appRoot = Path.GetFullPath(appRoot);
_defaultSystemDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
}
public string AppRoot => _appRoot;
/// <summary>
/// 默认系统数据路径(用户目录)
/// </summary>
public string DefaultSystemDataPath => _defaultSystemDataPath;
/// <summary>
/// 默认便携模式数据路径(应用目录下的 Desktop 文件夹)
/// </summary>
public string DefaultPortableDataPath => Path.Combine(_appRoot, DesktopFolderName);
/// <summary>
/// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。
/// 该目录与 app-* 部署目录同级,不随数据位置模式改变。
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(_appRoot, LauncherDataFolderName);
}
/// <summary>
/// 桌面应用数据目录(组件、设置、插件等)
/// </summary>
public string ResolveDesktopDataPath()
{
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
}
/// <summary>
/// 数据位置配置文件路径(保存在 Launcher 数据目录下)
/// </summary>
public string ResolveConfigPath()
{
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
}
/// <summary>
/// 启动器日志目录
/// </summary>
public string ResolveLauncherLogsPath()
{
return Path.Combine(ResolveLauncherDataPath(), "logs");
}
/// <summary>
/// 启动器状态目录
/// </summary>
public string ResolveLauncherStatePath()
{
return Path.Combine(ResolveLauncherDataPath(), "state");
}
/// <summary>
/// 检查是否允许便携模式(应用目录是否可写)
/// </summary>
public bool IsPortableModeAllowed()
{
try
{
var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(testFile, string.Empty);
File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
public DataLocationMode ResolveMode()
{
var config = LoadConfig();
if (config is null)
{
return DataLocationMode.System;
}
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
? DataLocationMode.Portable
: DataLocationMode.System;
}
/// <summary>
/// 解析数据根目录(用户选择的位置)
/// </summary>
public string ResolveDataRoot()
{
var config = LoadConfig();
return ResolveDataRoot(config);
}
private string ResolveDataRoot(DataLocationConfig? config)
{
if (config is null)
{
return _defaultSystemDataPath;
}
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
{
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
? config.PortableDataPath
: DefaultPortableDataPath;
return Path.GetFullPath(portablePath);
}
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
? Path.GetFullPath(config.SystemDataPath)
: _defaultSystemDataPath;
}
public DataLocationConfig? LoadConfig()
{
try
{
var configPath = ResolveConfigPath();
if (!File.Exists(configPath))
{
return null;
}
var json = File.ReadAllText(configPath);
return JsonSerializer.Deserialize(json, AppJsonContext.Default.DataLocationConfig);
}
catch (Exception ex)
{
Logger.Warn($"Failed to load data location config. Error='{ex.Message}'.");
return null;
}
}
public bool SaveConfig(DataLocationConfig config)
{
try
{
var launcherDataPath = ResolveLauncherDataPath();
Directory.CreateDirectory(launcherDataPath);
var configPath = ResolveConfigPath();
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
File.WriteAllText(configPath, json);
return true;
}
catch (Exception ex)
{
Logger.Warn($"Failed to save data location config. Error='{ex.Message}'.");
return false;
}
}
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
? Path.GetFullPath(customPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
// 先创建目录结构
try
{
Directory.CreateDirectory(ResolveLauncherDataPath());
Directory.CreateDirectory(Path.Combine(ResolveDataRoot(config), DesktopFolderName));
}
catch (Exception ex)
{
Logger.Warn($"Failed to create data directories. Error='{ex.Message}'.");
return false;
}
// 保存配置
if (!SaveConfig(config))
{
return false;
}
if (migrateExistingData && mode == DataLocationMode.Portable)
{
MigrateSystemDataToPortable(targetDataRoot);
}
return true;
}
public bool HasExistingSystemData()
{
var desktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
if (!Directory.Exists(desktopPath))
{
return false;
}
var markerFiles = new[]
{
Path.Combine(desktopPath, "settings.json"),
Path.Combine(desktopPath, "component-state.db"),
Path.Combine(desktopPath, "app.db")
};
return markerFiles.Any(File.Exists);
}
private void MigrateSystemDataToPortable(string targetDataRoot)
{
if (!HasExistingSystemData())
{
return;
}
var sourceDesktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
var targetDesktopPath = Path.Combine(targetDataRoot, DesktopFolderName);
try
{
Directory.CreateDirectory(targetDesktopPath);
// 迁移桌面数据
if (Directory.Exists(sourceDesktopPath))
{
CopyDirectory(sourceDesktopPath, targetDesktopPath);
}
Logger.Info($"Data migration completed. Target='{targetDataRoot}'.");
}
catch (Exception ex)
{
Logger.Warn($"Data migration failed. Target='{targetDataRoot}'. Error='{ex.Message}'.");
}
}
private static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
var destFile = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, destFile, overwrite: true);
}
foreach (var subDir in Directory.GetDirectories(sourceDir))
{
var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir));
CopyDirectory(subDir, destSubDir);
}
}
}

View File

@@ -1,40 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{
private ISplashStageReporter? _inner;
private readonly List<(string Stage, string Message)> _pending = [];
public void SetInner(ISplashStageReporter inner)
{
_inner = inner;
foreach (var (stage, message) in _pending)
{
_inner.Report(stage, message);
}
_pending.Clear();
}
public void Report(string stage, string message)
{
if (_inner is not null)
{
_inner.Report(stage, message);
}
else
{
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -1,637 +0,0 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeploymentLocator
{
private readonly string _appRoot;
public DeploymentLocator(string appRoot)
{
_appRoot = appRoot;
}
public string GetAppRoot() => _appRoot;
public string? FindCurrentDeploymentDirectory()
{
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
if (!Directory.Exists(_appRoot))
{
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
}
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
try
{
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
var validInstallations = candidates
.Where(path =>
{
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
var hasExe = File.Exists(Path.Combine(path, executable));
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
var version = ParseVersionFromDirectory(path);
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
$"Version={version} | " +
$"Current={hasCurrent} | " +
$"Destroy={hasDestroy} | " +
$"Partial={hasPartial} | " +
$"HasExe={hasExe}");
return !hasDestroy && !hasPartial && hasExe;
})
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 鏍囪鐨勬帓鍓嶉潰
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
.ToList();
if (validInstallations.Count == 0)
{
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
return null;
}
var best = validInstallations[0];
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
return best.Path;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
return null;
}
}
public HostResolutionResult ResolveHostExecutable(CommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var searchedPaths = new List<string>();
var explicitAppRoot = context.ExplicitAppRoot;
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
string? resolvedPath;
string? source;
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
{
var explicitRoot = Path.GetFullPath(explicitAppRoot);
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
}
else
{
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
}
if (resolvedPath is null && context.IsDebugMode)
{
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
}
if (resolvedPath is null)
{
resolvedPath = ResolveHostExecutablePathLegacy();
if (!string.IsNullOrWhiteSpace(resolvedPath))
{
searchedPaths.Add(Path.GetFullPath(resolvedPath));
source = "legacy_fallback";
}
}
return new HostResolutionResult
{
Success = !string.IsNullOrWhiteSpace(resolvedPath),
ResolvedHostPath = resolvedPath,
ResolutionSource = source,
AppRoot = _appRoot,
ExplicitAppRoot = explicitAppRoot,
DevModeConfigIgnored = devModeConfigIgnored,
SearchedPaths = searchedPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
public string? ResolveHostExecutablePath()
{
return ResolveHostExecutablePathLegacy();
}
private string? TryResolveExplicitAppRoot(
string explicitRoot,
string executable,
List<string> searchedPaths,
out string? source)
{
var directPath = Path.Combine(explicitRoot, executable);
searchedPaths.Add(directPath);
if (File.Exists(directPath))
{
source = "explicit_app_root_direct";
return directPath;
}
var deployment = FindBestDeploymentHost(explicitRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "explicit_app_root_deployment";
return deployment;
}
source = null;
return null;
}
private string? TryResolvePublishedOrPortableHost(
string executable,
List<string> searchedPaths,
out string? source)
{
var deployment = FindBestDeploymentHost(_appRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "published_deployment";
return deployment;
}
var portableCandidates = new[]
{
Path.Combine(_appRoot, executable),
Path.Combine(AppContext.BaseDirectory, executable)
};
foreach (var candidate in portableCandidates
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
searchedPaths.Add(candidate);
if (File.Exists(candidate))
{
source = "portable_host";
return candidate;
}
}
source = null;
return null;
}
private string? TryResolveDebugHost(
string executable,
List<string> searchedPaths,
out string? source)
{
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
{
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
}
foreach (var devPath in GetDevelopmentPaths(executable))
{
var fullPath = Path.GetFullPath(devPath);
searchedPaths.Add(fullPath);
if (File.Exists(fullPath))
{
source = "debug_build_output";
return fullPath;
}
}
source = null;
return null;
}
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
{
try
{
fullSavedPath = Path.GetFullPath(savedPath);
return true;
}
catch (Exception ex)
{
fullSavedPath = string.Empty;
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
return false;
}
}
private static string? FindBestDeploymentHost(
string root,
string executable,
List<string> searchedPaths)
{
if (!Directory.Exists(root))
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
return null;
}
var appDirs = Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
HostPath = Path.Combine(path, executable),
HasCurrent = File.Exists(Path.Combine(path, ".current")),
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.HasCurrent)
.ThenByDescending(item => item.Version)
.ToList();
foreach (var candidate in appDirs)
{
searchedPaths.Add(candidate.HostPath);
if (File.Exists(candidate.HostPath))
{
return candidate.HostPath;
}
}
if (appDirs.Count == 0)
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
}
return null;
}
private string? ResolveHostExecutablePathLegacy()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 棣栧厛鏌ユ壘 app-{version} 鐩綍锛堢敓浜х幆澧冿級
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent))
{
return inParent;
}
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
}
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
if (File.Exists(devPath))
{
return devPath;
}
}
return null;
}
/// <summary>
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var solutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var possiblePaths = new[]
{
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 向后兼容
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// dev-test 目录
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
Logger.Info($"Scanning development path: {path}");
if (File.Exists(path))
{
Logger.Info($"Found host at: {path}");
return path;
}
}
return null;
}
/// <summary>
/// 鑾峰彇寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忚経
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
var launcherDir = AppContext.BaseDirectory;
// 计算解决方案根目录:从 LanMountainDesktop.Launcher\bin\Debug\net10.0\ 向上4级
var solutionRoot = Path.GetFullPath(Path.Combine(launcherDir, "..", "..", "..", ".."));
var possiblePaths = new[]
{
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 向后兼容:如果 Launcher 在特殊目录结构中
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// dev-test 目录
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(deployment))
{
return "0.0.0";
}
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
/// <summary>
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
{
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
if (!Directory.Exists(_appRoot))
{
return;
}
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
IsCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderByDescending(item => item.Version)
.ToList();
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 纭畾瑕佷繚鐣欑殑鐗堟湰
var versionsToKeep = new HashSet<string>();
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
versionsToKeep.Add(currentVersion.Path);
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
.ToList();
foreach (var ver in activeVersions)
{
versionsToKeep.Add(ver.Path);
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var resolver = new DataLocationResolver(_appRoot);
var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots");
if (Directory.Exists(snapshotDir))
{
try
{
var snapshotFiles = Directory
.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(File.GetCreationTimeUtc)
.Take(Math.Max(1, minVersionsToKeep))
.ToArray();
foreach (var snapshotFile in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
{
versionsToKeep.Add(snapshot.SourceDirectory);
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
}
}
}
catch
{
// 蹇界暐蹇収瑙f瀽閿欒
}
}
}
catch
{
// 蹇界暐蹇収鐩綍璁块棶閿欒
}
}
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
{
if (deployment.IsDestroyed)
{
try
{
File.Delete(Path.Combine(deployment.Path, ".destroy"));
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
}
catch
{
// 蹇界暐鍙栨秷鏍囪澶辫触
}
}
continue;
}
if (!deployment.IsDestroyed)
{
try
{
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
}
catch
{
// 蹇界暐鏍囪澶辫触
}
}
// 灏濊瘯鍒犻櫎
try
{
Directory.Delete(deployment.Path, recursive: true);
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
}
catch
{
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 蹇界暐娓呯悊澶辫触
}
}
/// <summary>
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
{
CleanupOldDeployments(3);
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return null;
}
return segments[1];
}
/// <summary>
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
public AppVersionInfo GetVersionInfo()
{
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
return string.IsNullOrWhiteSpace(resolved.Version)
? new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate"
}
: resolved;
}
}

View File

@@ -1,401 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
internal enum DotNetRuntimeArchitecture
{
X64,
X86
}
internal sealed record DotNetRuntimeInfo(
string Name,
string Version,
string Source,
string? Location);
internal sealed record DotNetRuntimeProbeOptions
{
public int RequiredMajorVersion { get; init; } = 10;
public DotNetRuntimeArchitecture Architecture { get; init; } = DotNetRuntimeProbe.GetCurrentArchitecture();
public string? ProgramFilesPath { get; init; }
public string? ProgramFilesX86Path { get; init; }
public string? LocalAppDataPath { get; init; }
public IReadOnlyList<string>? DotNetHostCandidates { get; init; }
public bool IncludeRegistry { get; init; } = true;
public bool IncludeDotNetCli { get; init; } = true;
}
internal sealed record DotNetRuntimeProbeResult(
bool IsAvailable,
int RequiredMajorVersion,
DotNetRuntimeArchitecture Architecture,
string? DotNetHostPath,
IReadOnlyList<string> SearchedPaths,
IReadOnlyList<DotNetRuntimeInfo> DetectedRuntimes,
string Message)
{
public Dictionary<string, string> ToDetails(string prefix = "dotnetRuntime")
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[$"{prefix}Available"] = IsAvailable.ToString(),
[$"{prefix}RequiredMajorVersion"] = RequiredMajorVersion.ToString(),
[$"{prefix}Architecture"] = Architecture.ToString(),
[$"{prefix}DotNetHostPath"] = DotNetHostPath ?? string.Empty,
[$"{prefix}SearchedPaths"] = string.Join(" | ", SearchedPaths),
[$"{prefix}DetectedRuntimes"] = string.Join(
" | ",
DetectedRuntimes.Select(runtime =>
$"{runtime.Name} {runtime.Version} [{runtime.Source}{(string.IsNullOrWhiteSpace(runtime.Location) ? string.Empty : $": {runtime.Location}")}]")),
[$"{prefix}Message"] = Message
};
}
}
internal static class DotNetRuntimeProbe
{
public const string RequiredSharedFrameworkName = "Microsoft.NETCore.App";
public const string WindowsDesktopSharedFrameworkName = "Microsoft.WindowsDesktop.App";
private static readonly string[] RequiredSharedFrameworkNames =
[
RequiredSharedFrameworkName,
WindowsDesktopSharedFrameworkName
];
public static DotNetRuntimeProbeResult Probe(DotNetRuntimeProbeOptions? options = null)
{
options ??= new DotNetRuntimeProbeOptions();
var searchedPaths = new List<string>();
var detected = new List<DotNetRuntimeInfo>();
var requiredMajor = options.RequiredMajorVersion;
var localAppDataRoot = GetLocalAppDataPath(options);
var perUserDotnetRoot = !string.IsNullOrWhiteSpace(localAppDataRoot)
? Path.Combine(localAppDataRoot, "dotnet")
: null;
foreach (var frameworkName in RequiredSharedFrameworkNames)
{
foreach (var basePath in EnumerateDotNetInstallRoots(options))
{
var sharedFrameworkDirectory = Path.Combine(basePath, "shared", frameworkName);
searchedPaths.Add(sharedFrameworkDirectory);
var isPerUser = perUserDotnetRoot is not null &&
string.Equals(basePath, perUserDotnetRoot, StringComparison.OrdinalIgnoreCase);
AddDirectoryRuntimes(sharedFrameworkDirectory, frameworkName,
isPerUser ? "shared-framework-directory-per-user" : "shared-framework-directory",
detected);
}
}
string? dotNetHostPath = null;
foreach (var candidate in EnumerateDotNetHostCandidates(options))
{
searchedPaths.Add(candidate);
if (dotNetHostPath is null && File.Exists(candidate))
{
dotNetHostPath = candidate;
}
}
if (OperatingSystem.IsWindows() && options.IncludeRegistry)
{
foreach (var frameworkName in RequiredSharedFrameworkNames)
{
AddRegistryRuntimes(options.Architecture, frameworkName, detected);
}
}
if (options.IncludeDotNetCli)
{
AddDotNetCliRuntimes(dotNetHostPath, detected);
}
var isAvailable = detected.Any(runtime =>
string.Equals(runtime.Name, RequiredSharedFrameworkName, StringComparison.OrdinalIgnoreCase) &&
IsRequiredMajor(runtime.Version, requiredMajor));
var message = isAvailable
? $".NET {requiredMajor} runtime found for {options.Architecture}."
: $".NET {requiredMajor} runtime was not found for {options.Architecture}.";
return new DotNetRuntimeProbeResult(
isAvailable,
requiredMajor,
options.Architecture,
dotNetHostPath,
searchedPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
detected
.DistinctBy(runtime => $"{runtime.Name}|{runtime.Version}|{runtime.Source}|{runtime.Location}", StringComparer.OrdinalIgnoreCase)
.OrderBy(runtime => runtime.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(runtime => runtime.Version, StringComparer.OrdinalIgnoreCase)
.ToList(),
message);
}
public static DotNetRuntimeArchitecture GetCurrentArchitecture()
{
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X86 => DotNetRuntimeArchitecture.X86,
_ => DotNetRuntimeArchitecture.X64
};
}
public static string? FindDotNetHostPath(DotNetRuntimeProbeOptions? options = null)
{
options ??= new DotNetRuntimeProbeOptions();
return EnumerateDotNetHostCandidates(options).FirstOrDefault(File.Exists);
}
public static bool IsFrameworkDependentWindowsApp(string executablePath)
{
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(executablePath))
{
return false;
}
var directory = Path.GetDirectoryName(Path.GetFullPath(executablePath));
if (string.IsNullOrWhiteSpace(directory))
{
return false;
}
var appName = Path.GetFileNameWithoutExtension(executablePath);
var runtimeConfigPath = Path.Combine(directory, $"{appName}.runtimeconfig.json");
if (!File.Exists(runtimeConfigPath))
{
return false;
}
return !File.Exists(Path.Combine(directory, "coreclr.dll")) &&
!File.Exists(Path.Combine(directory, "hostfxr.dll")) &&
!File.Exists(Path.Combine(directory, "hostpolicy.dll")) &&
!File.Exists(Path.Combine(directory, "System.Private.CoreLib.dll"));
}
private static IEnumerable<string> EnumerateDotNetInstallRoots(DotNetRuntimeProbeOptions options)
{
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
? GetProgramFilesX86Path(options)
: GetProgramFilesPath(options);
yield return Path.Combine(programFilesRoot, "dotnet");
var localAppData = GetLocalAppDataPath(options);
if (!string.IsNullOrWhiteSpace(localAppData))
{
var perUserDotnet = Path.Combine(localAppData, "dotnet");
if (!string.Equals(perUserDotnet, Path.Combine(programFilesRoot, "dotnet"), StringComparison.OrdinalIgnoreCase))
{
yield return perUserDotnet;
}
}
}
private static IEnumerable<string> EnumerateDotNetHostCandidates(DotNetRuntimeProbeOptions options)
{
if (options.DotNetHostCandidates is not null)
{
foreach (var candidate in options.DotNetHostCandidates)
{
if (!string.IsNullOrWhiteSpace(candidate))
{
yield return Path.GetFullPath(candidate);
}
}
yield break;
}
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
? GetProgramFilesX86Path(options)
: GetProgramFilesPath(options);
yield return Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
var localAppData = GetLocalAppDataPath(options);
if (!string.IsNullOrWhiteSpace(localAppData))
{
var perUserHost = Path.Combine(localAppData, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
if (!string.Equals(perUserHost, Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"), StringComparison.OrdinalIgnoreCase))
{
yield return perUserHost;
}
}
}
private static string GetProgramFilesPath(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.ProgramFilesPath))
{
return Path.GetFullPath(options.ProgramFilesPath);
}
return Environment.GetEnvironmentVariable("ProgramW6432") ??
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
}
private static string GetProgramFilesX86Path(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.ProgramFilesX86Path))
{
return Path.GetFullPath(options.ProgramFilesX86Path);
}
return Environment.GetEnvironmentVariable("ProgramFiles(x86)") ??
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
}
private static string GetLocalAppDataPath(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.LocalAppDataPath))
{
return Path.GetFullPath(options.LocalAppDataPath);
}
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
private static void AddDirectoryRuntimes(
string sharedFrameworkDirectory,
string sharedFrameworkName,
string source,
List<DotNetRuntimeInfo> detected)
{
if (!Directory.Exists(sharedFrameworkDirectory))
{
return;
}
foreach (var directory in Directory.GetDirectories(sharedFrameworkDirectory))
{
var version = Path.GetFileName(directory);
if (!string.IsNullOrWhiteSpace(version))
{
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, version, source, directory));
}
}
}
private static void AddRegistryRuntimes(
DotNetRuntimeArchitecture architecture,
string sharedFrameworkName,
List<DotNetRuntimeInfo> detected)
{
try
{
var registryView = architecture == DotNetRuntimeArchitecture.X86
? RegistryView.Registry32
: RegistryView.Registry64;
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, registryView);
using var key = baseKey.OpenSubKey(
$@"SOFTWARE\dotnet\Setup\InstalledVersions\{(architecture == DotNetRuntimeArchitecture.X86 ? "x86" : "x64")}\sharedfx\{sharedFrameworkName}");
if (key is null)
{
return;
}
foreach (var valueName in key.GetValueNames())
{
if (key.GetValue(valueName) is not null)
{
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, valueName, "registry", key.Name));
}
}
}
catch (Exception ex)
{
Logger.Warn($"Failed to inspect .NET runtime registry keys: {ex.Message}");
}
}
private static void AddDotNetCliRuntimes(
string? dotNetHostPath,
List<DotNetRuntimeInfo> detected)
{
if (string.IsNullOrWhiteSpace(dotNetHostPath) || !File.Exists(dotNetHostPath))
{
return;
}
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = dotNetHostPath,
Arguments = "--list-runtimes",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(3000);
foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
{
var parsed = ParseListRuntimeLine(line);
if (parsed is not null &&
RequiredSharedFrameworkNames.Contains(parsed.Value.Name, StringComparer.OrdinalIgnoreCase))
{
detected.Add(new DotNetRuntimeInfo(
parsed.Value.Name,
parsed.Value.Version,
"dotnet-cli",
parsed.Value.Location));
}
}
}
catch (Exception ex)
{
Logger.Warn($"Failed to inspect .NET runtimes via dotnet CLI: {ex.Message}");
}
}
private static (string Name, string Version, string? Location)? ParseListRuntimeLine(string line)
{
var firstSpace = line.IndexOf(' ');
if (firstSpace <= 0 || firstSpace + 1 >= line.Length)
{
return null;
}
var secondSpace = line.IndexOf(' ', firstSpace + 1);
if (secondSpace <= firstSpace)
{
return null;
}
var name = line[..firstSpace].Trim();
var version = line[(firstSpace + 1)..secondSpace].Trim();
var location = line[(secondSpace + 1)..].Trim().Trim('[', ']');
return (name, version, string.IsNullOrWhiteSpace(location) ? null : location);
}
private static bool IsRequiredMajor(string version, int requiredMajor)
{
var dotIndex = version.IndexOf('.');
var majorText = dotIndex < 0 ? version : version[..dotIndex];
return int.TryParse(majorText, out var major) && major == requiredMajor;
}
}

View File

@@ -1,184 +0,0 @@
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 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

@@ -1,47 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主程序发现选项
/// </summary>
public sealed class HostDiscoveryOptions
{
/// <summary>
/// 可执行文件名Windows 下自动添加 .exe
/// </summary>
public string ExecutableName { get; set; } = "LanMountainDesktop";
/// <summary>
/// 额外的搜索路径(支持通配符)
/// </summary>
public List<string> AdditionalSearchPaths { get; set; } = new();
/// <summary>
/// 是否递归搜索子目录
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// 递归搜索的最大深度
/// </summary>
public int MaxRecursionDepth { get; set; } = 3;
/// <summary>
/// 环境变量名称,用于指定自定义路径
/// </summary>
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
/// <summary>
/// 配置文件路径(相对于 app root
/// </summary>
public string? ConfigFileName { get; set; } = "host-discovery.json";
/// <summary>
/// 是否优先使用开发模式配置
/// </summary>
public bool PreferDevModeConfig { get; set; } = true;
/// <summary>
/// 搜索超时(毫秒)
/// </summary>
public int SearchTimeoutMs { get; set; } = 5000;
}

View File

@@ -1,213 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed record HostLaunchPlan(
string HostPath,
string PackageRoot,
string WorkingDirectory,
IReadOnlyList<string> Arguments,
IReadOnlyDictionary<string, string> EnvironmentVariables,
AppVersionInfo VersionInfo);
internal static class HostLaunchPlanBuilder
{
public const string DataRootOptionName = "data-root";
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
"app-root", DataRootOptionName,
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
public static HostLaunchPlan Build(
CommandContext context,
DeploymentLocator deploymentLocator,
HostResolutionResult resolution,
string? dataRoot = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(deploymentLocator);
ArgumentNullException.ThrowIfNull(resolution);
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
{
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
}
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
var versionInfo = deploymentLocator.GetVersionInfo();
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo, dataRoot);
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
};
if (!string.IsNullOrWhiteSpace(dataRoot))
{
environment["LMD_DATA_ROOT"] = dataRoot;
}
return new HostLaunchPlan(
hostPath,
packageRoot,
Directory.Exists(packageRoot)
? packageRoot
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
arguments,
environment,
versionInfo);
}
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
{
return string.Join(" ", arguments.Select(QuoteArgument));
}
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
{
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
? AppContext.BaseDirectory
: Path.GetFullPath(appRoot);
var hostDirectory = Path.GetDirectoryName(hostPath);
if (hostDirectory is not null &&
Directory.Exists(fullAppRoot) &&
IsAppDeploymentDirectory(hostDirectory) &&
IsParentOf(fullAppRoot, hostDirectory))
{
return fullAppRoot;
}
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
{
return fullAppRoot;
}
return hostDirectory ?? fullAppRoot;
}
private static IReadOnlyList<string> BuildForwardedArguments(
CommandContext context,
string packageRoot,
AppVersionInfo versionInfo,
string? dataRoot = null)
{
var arguments = new List<string>();
for (var index = 0; index < context.RawArgs.Count; index++)
{
var arg = context.RawArgs[index];
if (index == 0 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (index == 1 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (arg.StartsWith("--", StringComparison.Ordinal))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
key = key[..equalsIndex];
}
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
{
if (equalsIndex < 0 &&
index + 1 < context.RawArgs.Count &&
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
{
index++;
}
continue;
}
}
arguments.Add(arg);
}
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
if (!string.IsNullOrWhiteSpace(dataRoot))
{
arguments.Add($"--{DataRootOptionName}={dataRoot}");
}
return arguments;
}
private static bool IsAppDeploymentDirectory(string path)
{
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
}
private static bool IsParentOf(string parent, string child)
{
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return childPath.StartsWith(
parentPath + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
}

View File

@@ -1,18 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class HostResolutionResult
{
public bool Success { get; init; }
public string? ResolvedHostPath { get; init; }
public string? ResolutionSource { get; init; }
public string AppRoot { get; init; } = string.Empty;
public string? ExplicitAppRoot { get; init; }
public bool DevModeConfigIgnored { get; init; }
public List<string> SearchedPaths { get; init; } = [];
}

View File

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

View File

@@ -1,11 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
void ReportStage(string stage, int progress);
}

View File

@@ -1,9 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

@@ -1,111 +0,0 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
public async Task<LauncherCoordinatorResponse?> SendAsync(
string pipeName,
LauncherCoordinatorRequest request,
TimeSpan timeout)
{
if (string.IsNullOrWhiteSpace(pipeName))
{
return null;
}
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
await using var client = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false);
return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (TimeoutException)
{
return null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}");
return null;
}
}
private static async Task WriteRequestAsync(
Stream stream,
LauncherCoordinatorRequest request,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<LauncherCoordinatorResponse?> ReadResponseAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorResponse);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -1,251 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private const int BackoffBaseMs = 250;
private const int BackoffMaxMs = 8000;
private const int BackoffJitterMs = 150;
private readonly string _pipeName;
private readonly Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
private readonly Action<LauncherCoordinatorStatus> _heartbeatHandler;
private readonly CancellationTokenSource _cts = new();
private readonly object _statusGate = new();
private LauncherCoordinatorStatus _status;
private Task? _listenTask;
private Task? _heartbeatTask;
public LauncherCoordinatorIpcServer(
string pipeName,
LauncherCoordinatorStatus initialStatus,
Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
Action<LauncherCoordinatorStatus> heartbeatHandler)
{
_pipeName = pipeName;
_status = initialStatus;
_requestHandler = requestHandler;
_heartbeatHandler = heartbeatHandler;
}
public static string CreatePipeName()
{
var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant()));
return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}";
}
public void Start()
{
_listenTask ??= Task.Run(ListenLoopAsync);
_heartbeatTask ??= Task.Run(HeartbeatLoopAsync);
}
public LauncherCoordinatorStatus GetStatus()
{
lock (_statusGate)
{
return _status;
}
}
public void UpdateStatus(LauncherCoordinatorStatus status)
{
lock (_statusGate)
{
_status = status;
}
}
public void Dispose()
{
_cts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
_heartbeatTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
}
_cts.Dispose();
}
private async Task ListenLoopAsync()
{
var consecutiveErrors = 0;
while (!_cts.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
try
{
server = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
8,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
var connectedServer = server;
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
server = null;
consecutiveErrors = 0;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
consecutiveErrors++;
var delay = ComputeBackoff(consecutiveErrors);
Logger.Warn($"Launcher coordinator IPC listener failed (attempt {consecutiveErrors}), retrying in {delay}ms: {ex.Message}");
try
{
await Task.Delay(delay, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
server?.Dispose();
}
}
}
private int ComputeBackoff(int attempt)
{
var exponential = BackoffBaseMs * (1 << Math.Min(attempt - 1, 5));
var capped = Math.Min(exponential, BackoffMaxMs);
var jitter = Random.Shared.Next(0, BackoffJitterMs);
return capped + jitter;
}
private async Task HeartbeatLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
_heartbeatHandler(GetStatus());
await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}");
}
}
}
private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken)
{
try
{
var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false);
var status = GetStatus();
var response = request is null
? new LauncherCoordinatorResponse
{
Accepted = false,
Code = "invalid_request",
Message = "Launcher coordinator request was invalid.",
Status = status
}
: await _requestHandler(request, status).ConfigureAwait(false);
await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}");
}
finally
{
try
{
server.Dispose();
}
catch
{
}
}
}
private static async Task<LauncherCoordinatorRequest?> ReadRequestAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorRequest);
}
private static async Task WriteResponseAsync(
Stream stream,
LauncherCoordinatorResponse response,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -1,132 +0,0 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{
private const int LengthPrefixSize = 4;
private readonly string _pipeName;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipe;
private Task? _listenTask;
private volatile bool _clientConnected;
public LauncherUpdateProgressIpcServer(int launcherPid)
{
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
}
public string PipeName => _pipeName;
public void Start()
{
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
}
private async Task AcceptConnectionAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
_clientConnected = true;
return;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
public void ReportProgress(InstallProgressReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
}
}
public void ReportComplete(InstallCompleteReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
}
}
private static void WriteMessage(Stream stream, string json)
{
var payload = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(payload.Length);
stream.Write(lengthPrefix, 0, LengthPrefixSize);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
public void Dispose()
{
_cts.Cancel();
try
{
_pipe?.Dispose();
}
catch
{
}
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
_cts.Dispose();
}
}

View File

@@ -1,56 +0,0 @@
using System.Globalization;
using System.Text.Json.Nodes;
namespace LanMountainDesktop.Launcher.Services;
internal static class LanguagePreferenceService
{
public static string ResolveLanguageCode(string appRoot)
{
try
{
var dataLocationResolver = new DataLocationResolver(appRoot);
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot());
if (!File.Exists(settingsPath))
{
return "zh-CN";
}
var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
if (root is not null &&
root.TryGetPropertyValue("LanguageCode", out var node) &&
node is JsonValue value &&
value.TryGetValue<string>(out var code) &&
!string.IsNullOrWhiteSpace(code))
{
return NormalizeLanguageCode(code);
}
}
catch
{
}
return "zh-CN";
}
public static void ApplyLanguage(string languageCode)
{
var normalized = NormalizeLanguageCode(languageCode);
var culture = CultureInfo.GetCultureInfo(normalized);
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
}
private static string NormalizeLanguageCode(string code)
{
return code.ToLowerInvariant() switch
{
"en-us" or "en" => "en-US",
"ja-jp" or "ja" => "ja-JP",
"ko-kr" or "ko" => "ko-KR",
_ => "zh-CN"
};
}
}

View File

@@ -1,174 +0,0 @@
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 启动器背景图片服务
/// </summary>
internal static class LauncherBackgroundService
{
private const string PictureFileName = "Launcher Picture";
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
private const double AspectRatioTolerance = 0.15; // 15% 误差
private static Bitmap? _cachedBitmap;
private static string? _cachedPath;
/// <summary>
/// 背景图片信息
/// </summary>
public record BackgroundImageInfo
{
public required bool Exists { get; init; }
public required bool IsValid { get; init; }
public string? FilePath { get; init; }
public Bitmap? Bitmap { get; init; }
public int Width { get; init; }
public int Height { get; init; }
public double AspectRatio { get; init; }
public string? ErrorMessage { get; init; }
}
/// <summary>
/// 加载背景图片
/// </summary>
public static BackgroundImageInfo LoadBackgroundImage()
{
try
{
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
var launcherPath = resolver.ResolveLauncherDataPath();
// 查找图片文件
var imagePath = FindImageFile(launcherPath);
if (imagePath == null)
{
return new BackgroundImageInfo
{
Exists = false,
IsValid = false,
ErrorMessage = "未找到背景图片文件"
};
}
// 检查文件大小
var fileInfo = new FileInfo(imagePath);
if (fileInfo.Length > MaxFileSize)
{
return new BackgroundImageInfo
{
Exists = true,
IsValid = false,
FilePath = imagePath,
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
};
}
// 使用缓存
if (_cachedBitmap != null && _cachedPath == imagePath)
{
return new BackgroundImageInfo
{
Exists = true,
IsValid = true,
FilePath = imagePath,
Bitmap = _cachedBitmap,
Width = _cachedBitmap.PixelSize.Width,
Height = _cachedBitmap.PixelSize.Height,
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
};
}
// 加载图片
var bitmap = new Bitmap(imagePath);
var width = bitmap.PixelSize.Width;
var height = bitmap.PixelSize.Height;
var aspectRatio = (double)width / height;
// 校验比例
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
if (ratioDiff > AspectRatioTolerance)
{
bitmap.Dispose();
return new BackgroundImageInfo
{
Exists = true,
IsValid = false,
FilePath = imagePath,
Width = width,
Height = height,
AspectRatio = aspectRatio,
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
};
}
// 缓存图片
_cachedBitmap = bitmap;
_cachedPath = imagePath;
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
return new BackgroundImageInfo
{
Exists = true,
IsValid = true,
FilePath = imagePath,
Bitmap = bitmap,
Width = width,
Height = height,
AspectRatio = aspectRatio
};
}
catch (Exception ex)
{
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
return new BackgroundImageInfo
{
Exists = false,
IsValid = false,
ErrorMessage = $"加载失败: {ex.Message}"
};
}
}
/// <summary>
/// 查找图片文件
/// </summary>
private static string? FindImageFile(string directory)
{
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
foreach (var ext in extensions)
{
var path = Path.Combine(directory, PictureFileName + ext);
if (File.Exists(path))
{
return path;
}
}
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
var files = Directory.GetFiles(directory, PictureFileName + ".*");
foreach (var file in files)
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (extensions.Contains(ext))
{
return file;
}
}
return null;
}
/// <summary>
/// 清除缓存
/// </summary>
public static void ClearCache()
{
_cachedBitmap?.Dispose();
_cachedBitmap = null;
_cachedPath = null;
}
}

View File

@@ -1,134 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
internal static class LauncherDebugSettingsStore
{
private const string DevModeFileName = "dev-mode.flag";
private const string CustomHostPathFileName = "custom-host-path.txt";
private const string LegacyDevModeFileName = "devmode.config";
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
internal static string? ConfigBaseDirectoryOverride { get; set; }
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
public static LauncherDebugSettings Load()
{
return new LauncherDebugSettings(
LoadDevModeState(),
LoadCustomHostPath());
}
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
public static void Save(LauncherDebugSettings settings)
{
try
{
Directory.CreateDirectory(ConfigBaseDirectory);
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
}
catch (Exception ex)
{
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
}
}
public static void SaveDevModeState(bool enabled)
{
var current = Load();
Save(current with { DevModeEnabled = enabled });
}
public static void SaveCustomHostPath(string? customHostPath)
{
var current = Load();
Save(current with { CustomHostPath = customHostPath });
}
private static bool LoadDevModeState()
{
var newValue = TryReadText(GetPath(DevModeFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return TryParseDevMode(newValue);
}
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
}
private static string? LoadCustomHostPath()
{
var newValue = TryReadText(GetPath(CustomHostPathFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return newValue.Trim();
}
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
}
private static bool TryParseDevMode(string value)
{
var normalized = value.Trim();
return normalized == "1" ||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
}
private static string? TryReadText(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
return null;
}
}
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
private static string ResolveConfigBaseDirectory()
{
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return resolver.ResolveLauncherDataPath();
}
catch
{
}
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", "Launcher");
}
}
catch
{
}
try
{
return Path.Combine(AppContext.BaseDirectory, "Launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), "Launcher");
}
}
}

View File

@@ -1,30 +0,0 @@
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);
}
}
}

View File

@@ -2,7 +2,6 @@ using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Startup;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -185,115 +184,18 @@ internal sealed partial class LauncherFlowCoordinator
}
}
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient) =>
await HostStartupMonitor.TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
private static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
LanMountainDesktopIpcClient ipcClient,
StartupSuccessTracker startupSuccessTracker,
TimeSpan timeout)
{
var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false);
if (activation is null)
{
return null;
}
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
{
return shellSuccess;
}
if (activation.Accepted)
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
return HostActivationPolicy.IsRecoverableActivationFailure(activation)
? new StartupSuccessState(
StartupStage.Ready,
"startup_pending",
activation.Message)
: null;
}
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to query public shell status: {ex.Message}");
return null;
}
}
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
LanMountainDesktopIpcClient ipcClient,
Process hostProcess,
Task<StartupSuccessState> successTask,
StartupSuccessTracker startupSuccessTracker)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
StartupDiagnostics.TraceShellStatus("recovery_activation", activation.Status);
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
{
return shellSuccess;
}
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
if (completedTask == successTask)
{
return await successTask.ConfigureAwait(false);
}
if (!hostProcess.HasExited && (activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation)))
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
}
catch (Exception ex)
{
Logger.Warn($"Public activation recovery failed: {ex.Message}");
}
return null;
}
private static LoadingStateMessage BuildDelayedLoadingState(
LoadingStateMessage loadingState,
string summaryMessage,
string detailMessage,
DateTimeOffset startedAtUtc)
{
var delayedItems = loadingState.ActiveItems
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
.ToList();
delayedItems.Insert(0, new LoadingItem
{
Id = "launcher-soft-timeout",
Type = LoadingItemType.System,
Name = "Startup still in progress",
Description = detailMessage,
State = LoadingState.Delayed,
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
Message = detailMessage,
StartTime = startedAtUtc
});
return loadingState with
{
ActiveItems = delayedItems,
Message = summaryMessage,
Timestamp = DateTimeOffset.UtcNow,
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
};
}
TimeSpan timeout) =>
await HostStartupMonitor.TryRecoverActivationThroughExistingHostAsync(
ipcClient,
startupSuccessTracker,
timeout).ConfigureAwait(false);
private static Dictionary<string, string> BuildAttemptDetails(
StartupAttemptRecord? trackedAttempt,

View File

@@ -2,7 +2,6 @@ using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;

View File

@@ -2,7 +2,6 @@ using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Startup;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -340,7 +339,7 @@ internal sealed partial class LauncherFlowCoordinator
{
softTimeoutShown = true;
reporter.Report("delayed", SoftTimeoutStatusMessage);
loadingState = BuildDelayedLoadingState(
loadingState = HostStartupMonitor.BuildDelayedLoadingState(
loadingState,
SoftTimeoutStatusMessage,
SoftTimeoutDetailsMessage,

View File

@@ -1,82 +0,0 @@
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

@@ -1,344 +0,0 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
/// </summary>
internal sealed class LegacyVersionDetector
{
private const string LegacyAppName = "LanMountainDesktop";
private const string LegacyExeName = "LanMountainDesktop.exe";
/// <summary>
/// 检测是否存在老版本安装
/// </summary>
public static LegacyVersionInfo? DetectLegacyInstallation()
{
// 1. 检查注册表(安装版)
var registryInfo = DetectFromRegistry();
if (registryInfo != null)
{
return registryInfo;
}
// 2. 检查常见安装目录
var commonPaths = DetectFromCommonPaths();
if (commonPaths != null)
{
return commonPaths;
}
// 3. 检查便携版位置
var portableInfo = DetectPortableInstallation();
if (portableInfo != null)
{
return portableInfo;
}
return null;
}
/// <summary>
/// 从注册表检测安装信息
/// </summary>
private static LegacyVersionInfo? DetectFromRegistry()
{
try
{
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
using var key = Registry.LocalMachine.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (key != null)
{
var installLocation = key.GetValue("InstallLocation") as string;
var displayVersion = key.GetValue("DisplayVersion") as string;
var uninstallString = key.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
// 检查 HKCU用户级安装
using var userKey = Registry.CurrentUser.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (userKey != null)
{
var installLocation = userKey.GetValue("InstallLocation") as string;
var displayVersion = userKey.GetValue("DisplayVersion") as string;
var uninstallString = userKey.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 从常见安装路径检测
/// </summary>
private static LegacyVersionInfo? DetectFromCommonPaths()
{
var commonPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
};
foreach (var path in commonPaths)
{
try
{
if (Directory.Exists(path))
{
// 检查是否存在老版本的特征文件(没有 app-* 目录)
var exePath = Path.Combine(path, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
if (File.Exists(exePath) && !hasAppDirs)
{
// 尝试读取版本信息
var version = TryGetFileVersion(exePath);
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = path,
UninstallCommand = FindUninstaller(path),
InstallType = LegacyInstallType.CommonPath
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
}
}
return null;
}
/// <summary>
/// 检测便携版安装
/// </summary>
private static LegacyVersionInfo? DetectPortableInstallation()
{
try
{
// 检查启动器所在目录的父目录(便携版常见布局)
var launcherDir = AppContext.BaseDirectory;
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
var exePath = Path.Combine(parentDir, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
// 如果存在 exe 且没有 app-* 目录,可能是老版本
if (File.Exists(exePath) && !hasAppDirs)
{
var version = TryGetFileVersion(exePath);
// 检查是否真的是老版本(通过文件版本或特定标记)
if (IsLegacyVersion(version))
{
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = parentDir,
UninstallCommand = null, // 便携版没有卸载程序
InstallType = LegacyInstallType.Portable
};
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 查找卸载程序
/// </summary>
private static string? FindUninstaller(string installPath)
{
try
{
// 常见的卸载程序命名
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
foreach (var name in uninstallerNames)
{
var path = Path.Combine(installPath, name);
if (File.Exists(path))
{
return path;
}
}
}
catch { }
return null;
}
/// <summary>
/// 获取文件版本
/// </summary>
private static string? TryGetFileVersion(string filePath)
{
try
{
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
return versionInfo.FileVersion;
}
catch
{
return null;
}
}
/// <summary>
/// 判断是否为老版本(版本号 < 1.0.0
/// </summary>
private static bool IsLegacyVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return true; // 无法确定版本时,保守认为是老版本
}
if (Version.TryParse(version.Split(' ')[0], out var v))
{
return v.Major < 1;
}
return true;
}
/// <summary>
/// 打开卸载界面
/// </summary>
public static void OpenUninstallInterface(LegacyVersionInfo info)
{
try
{
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
{
// 有卸载命令,直接执行
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
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = true,
Verb = "runas" // 请求管理员权限
});
}
else
{
// 没有卸载命令,打开系统卸载面板
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
// 兜底:打开系统卸载面板
try
{
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
catch { }
}
}
/// <summary>
/// 在资源管理器中显示老版本位置
/// </summary>
public static void ShowInExplorer(string path)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{path}\"",
UseShellExecute = false
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
}
}
}
/// <summary>
/// 老版本信息
/// </summary>
public class LegacyVersionInfo
{
public string Version { get; set; } = "0.8.x";
public string InstallPath { get; set; } = "";
public string? UninstallCommand { get; set; }
public LegacyInstallType InstallType { get; set; }
}
/// <summary>
/// 老版本安装类型
/// </summary>
public enum LegacyInstallType
{
Registry, // 注册表安装版
CommonPath, // 常见路径安装
Portable // 便携版
}

View File

@@ -1,148 +0,0 @@
using System.Text;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 简单的日志记录器 - 同时输出到控制台和文件
/// </summary>
internal static class Logger
{
private static readonly object _lock = new();
private static string? _logFilePath;
private static bool _initialized;
/// <summary>
/// 初始化日志记录器
/// </summary>
public static void Initialize()
{
if (_initialized)
{
return;
}
try
{
var logDir = GetLogDirectory();
if (!string.IsNullOrEmpty(logDir))
{
Directory.CreateDirectory(logDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
}
_initialized = true;
}
/// <summary>
/// 获取日志文件路径
/// </summary>
public static string? GetLogFilePath()
{
return _logFilePath;
}
/// <summary>
/// 获取日志目录
/// </summary>
private static string? GetLogDirectory()
{
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return resolver.ResolveLauncherLogsPath();
}
catch
{
}
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
return Path.Combine(appData, "LanMountainDesktop", "Launcher", "logs");
}
}
catch
{
}
try
{
var launcherDir = AppContext.BaseDirectory;
return Path.Combine(launcherDir, "Launcher", "logs");
}
catch
{
}
return null;
}
/// <summary>
/// 记录信息日志
/// </summary>
public static void Info(string message)
{
WriteLog("INFO", message);
}
/// <summary>
/// 记录警告日志
/// </summary>
public static void Warn(string message)
{
WriteLog("WARN", message);
}
/// <summary>
/// 记录错误日志
/// </summary>
public static void Error(string message)
{
WriteLog("ERROR", message);
}
/// <summary>
/// 记录错误日志(带异常)
/// </summary>
public static void Error(string message, Exception exception)
{
WriteLog("ERROR", $"{message}\n{exception}");
}
/// <summary>
/// 写入日志
/// </summary>
private static void WriteLog(string level, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logLine = $"[{timestamp}] [{level}] {message}";
Console.WriteLine(logLine);
if (string.IsNullOrEmpty(_logFilePath))
{
return;
}
try
{
lock (_lock)
{
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
}
}
}

View File

@@ -1,9 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
{
public void ReportProgress(InstallProgressReport report) { }
public void ReportComplete(InstallCompleteReport report) { }
}

View File

@@ -1,246 +0,0 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
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

@@ -1,300 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 插件安装服务 - 简化版,不依赖 PluginSdk
/// </summary>
internal sealed class PluginInstallerService
{
private const string ManifestFileName = "plugin.json";
private const string LegacyManifestFileName = "manifest.json";
private const string PackageFileExtension = ".laapp";
private const string LegacyPackageFileExtension = ".lmdp";
private const string RuntimeDirectoryName = "runtime";
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
{
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
if (!File.Exists(fullSourcePath))
{
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));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
return new LauncherResult
{
Success = true,
Stage = "plugin.install",
Code = "ok",
Message = "Plugin installed.",
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
{
if (!OperatingSystem.IsWindows())
{
return null;
}
string? allowedRoot = null;
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
}
catch
{
}
if (string.IsNullOrWhiteSpace(allowedRoot))
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
return null;
}
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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["pluginsDirectory"] = pluginsDirectory,
["allowedRoot"] = allowedRoot,
["elevationReason"] = "outside_user_scope"
}
};
}
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = FindManifestEntries(archive, ManifestFileName);
if (entries.Length == 0)
{
entries = FindManifestEntries(archive, LegacyManifestFileName);
}
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}' or '{LegacyManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
}
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
}
return manifest;
}
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
{
return archive.Entries
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path =>
path.EndsWith(PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase))
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
File.Move(existingPackagePath, pendingPath);
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
}
/// <summary>
/// 简化的插件清单模型
/// </summary>
public class PluginManifest
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Version { get; set; } = "";
public string? Description { get; set; }
public string? Author { get; set; }
}

View File

@@ -1,94 +0,0 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class PluginUpgradeQueueService
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private readonly PluginInstallerService _installerService;
public PluginUpgradeQueueService(PluginInstallerService installerService)
{
_installerService = installerService;
}
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
{
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
if (!File.Exists(pendingPath))
{
return new LauncherResult
{
Success = true,
Stage = "plugin.update",
Code = "noop",
Message = "No pending plugin upgrades."
};
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
foreach (var item in pending)
{
if (!item.IsValid())
{
failures.Add(item.PluginId);
continue;
}
try
{
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
succeeded.Add(item);
}
catch
{
failures.Add(item.PluginId);
}
}
var remaining = pending
.Except(succeeded)
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
.ToList();
if (remaining.Count == 0)
{
File.Delete(pendingPath);
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
}
return new LauncherResult
{
Success = failures.Count == 0,
Stage = "plugin.update",
Code = failures.Count == 0 ? "ok" : "partial_failed",
Message = failures.Count == 0
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -1,245 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <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

@@ -1,550 +0,0 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class StartupAttemptRegistry
{
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
private readonly string _statePath;
private readonly string _mutexName;
private string? _ownedAttemptId;
public StartupAttemptRegistry()
: this(ResolveDefaultStatePath())
{
}
private static string ResolveDefaultStatePath()
{
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return Path.Combine(resolver.ResolveLauncherStatePath(), "startup-attempt.json");
}
catch
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Launcher",
"state",
"startup-attempt.json");
}
}
internal StartupAttemptRegistry(string statePath)
{
_statePath = statePath;
_mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}";
}
public StartupAttemptRecord StartOwnedAttempt(
int hostPid,
string launchSource,
string successPolicy,
StartupStage stage,
string? message)
{
var record = new StartupAttemptRecord
{
AttemptId = Guid.NewGuid().ToString("N"),
HostPid = hostPid,
CoordinatorPid = Environment.ProcessId,
LaunchSource = launchSource,
SuccessPolicy = successPolicy,
LastObservedStage = stage,
LastObservedMessage = message ?? string.Empty,
StartedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
HeartbeatAtUtc = DateTimeOffset.UtcNow,
State = StartupAttemptState.Pending
};
ExecuteWithLock(() =>
{
SaveUnsafe(record);
_ownedAttemptId = record.AttemptId;
});
return Clone(record);
}
public bool TryReserveCoordinator(
string launchSource,
string successPolicy,
string coordinatorPipeName,
out StartupAttemptRecord reservedAttempt,
out StartupAttemptRecord? activeCoordinatorAttempt)
{
StartupAttemptRecord? reserved = null;
StartupAttemptRecord? active = null;
ExecuteWithLock(() =>
{
var existing = LoadUnsafe();
if (existing is not null && IsCoordinatorLive(existing))
{
active = Clone(existing);
return;
}
if (existing is not null && IsRecoverableCoordinatorAttempt(existing))
{
existing.CoordinatorPid = Environment.ProcessId;
existing.CoordinatorPipeName = coordinatorPipeName;
existing.HeartbeatAtUtc = DateTimeOffset.UtcNow;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
if (existing.HostPid <= 0)
{
existing.ReservedBeforeHostStart = true;
}
if (existing.State == StartupAttemptState.DetachedWaiting)
{
existing.State = StartupAttemptState.SoftTimeout;
}
_ownedAttemptId = existing.AttemptId;
SaveUnsafe(existing);
reserved = Clone(existing);
return;
}
var now = DateTimeOffset.UtcNow;
var record = new StartupAttemptRecord
{
AttemptId = Guid.NewGuid().ToString("N"),
HostPid = 0,
CoordinatorPid = Environment.ProcessId,
CoordinatorPipeName = coordinatorPipeName,
LaunchSource = launchSource,
SuccessPolicy = successPolicy,
LastObservedStage = StartupStage.Initializing,
LastObservedMessage = "Launcher coordinator reserved startup ownership.",
StartedAtUtc = now,
UpdatedAtUtc = now,
HeartbeatAtUtc = now,
ReservedBeforeHostStart = true,
State = StartupAttemptState.Pending
};
_ownedAttemptId = record.AttemptId;
SaveUnsafe(record);
reserved = Clone(record);
});
reservedAttempt = reserved ?? new StartupAttemptRecord();
activeCoordinatorAttempt = active;
return reserved is not null;
}
public StartupAttemptRecord? GetOwnedAttempt()
{
StartupAttemptRecord? result = null;
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
{
return null;
}
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord? TryGetLiveCoordinatorAttempt()
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null && IsCoordinatorLive(record))
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord? TryGetLatestAttempt()
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null)
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord AssignOwnedHostProcess(
int hostPid,
StartupStage stage,
string? message)
{
StartupAttemptRecord? result = null;
UpdateOwned(record =>
{
record.HostPid = hostPid;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
record.ReservedBeforeHostStart = false;
result = Clone(record);
});
return result ?? StartOwnedAttempt(
hostPid,
string.Empty,
string.Empty,
stage,
message);
}
public bool AdoptAttempt(string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId))
{
return false;
}
var adopted = false;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal))
{
return;
}
if (!IsAttachable(record))
{
return;
}
_ownedAttemptId = record.AttemptId;
if (record.State == StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.SoftTimeout;
}
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(record);
adopted = true;
});
return adopted;
}
public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy)
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null ||
!IsAttachable(record) ||
!string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase))
{
return;
}
result = Clone(record);
});
return result;
}
public void MarkOwnedIpcConnected()
{
UpdateOwned(record =>
{
record.IpcConnected = true;
record.PublicIpcConnected = true;
});
}
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
{
UpdateOwned(record =>
{
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? string.Empty;
if (ipcConnected)
{
record.IpcConnected = true;
record.PublicIpcConnected = true;
}
});
}
public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status)
{
UpdateOwned(record =>
{
record.CoordinatorPid = Environment.ProcessId;
record.HeartbeatAtUtc = DateTimeOffset.UtcNow;
record.LastObservedStage = status.LastObservedStage;
record.LastObservedMessage = status.LastObservedMessage;
record.IpcConnected = status.PublicIpcConnected;
record.PublicIpcConnected = status.PublicIpcConnected;
record.ShellStatus = status.ShellStatus?.ShellState ?? status.State;
});
}
public void MarkOwnedSoftTimeout(string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.SoftTimeout;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedWaitingForShell(string? message)
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.WaitingForShell;
}
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedDetachedWaiting()
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
{
record.State = StartupAttemptState.DetachedWaiting;
}
});
}
public void MarkOwnedSucceeded(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Succeeded;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedFailed(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Failed;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
private void UpdateOwned(Action<StartupAttemptRecord> update)
{
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
{
return;
}
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
{
return;
}
update(record);
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(record);
});
}
private void ExecuteWithLock(Action action)
{
using var mutex = new Mutex(false, _mutexName);
var hasHandle = false;
try
{
try
{
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
}
catch (AbandonedMutexException)
{
hasHandle = true;
}
if (!hasHandle)
{
return;
}
action();
}
finally
{
if (hasHandle)
{
mutex.ReleaseMutex();
}
}
}
private StartupAttemptRecord? LoadUnsafe()
{
if (!File.Exists(_statePath))
{
return null;
}
try
{
var json = File.ReadAllText(_statePath);
return JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupAttemptRecord);
}
catch
{
return null;
}
}
private void SaveUnsafe(StartupAttemptRecord record)
{
var directory = Path.GetDirectoryName(_statePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, AppJsonContext.Default.StartupAttemptRecord));
}
private static bool IsAttachable(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
return TryGetLiveProcess(record.HostPid, out _);
}
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
if (record.HostPid <= 0)
{
return true;
}
return TryGetLiveProcess(record.HostPid, out _);
}
private static bool IsCoordinatorLive(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
if (record.CoordinatorPid <= 0 ||
string.IsNullOrWhiteSpace(record.CoordinatorPipeName) ||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout)
{
return false;
}
return TryGetLiveProcess(record.CoordinatorPid, out _);
}
private static bool TryGetLiveProcess(int processId, out Process? process)
{
process = null;
if (processId <= 0)
{
return false;
}
try
{
process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
process?.Dispose();
process = null;
return false;
}
}
private static string ComputePathHash(string statePath)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant()));
return Convert.ToHexString(bytes[..8]);
}
private static StartupAttemptRecord Clone(StartupAttemptRecord record)
{
return new StartupAttemptRecord
{
AttemptId = record.AttemptId,
HostPid = record.HostPid,
CoordinatorPid = record.CoordinatorPid,
CoordinatorPipeName = record.CoordinatorPipeName,
StartedAtUtc = record.StartedAtUtc,
UpdatedAtUtc = record.UpdatedAtUtc,
HeartbeatAtUtc = record.HeartbeatAtUtc,
LaunchSource = record.LaunchSource,
SuccessPolicy = record.SuccessPolicy,
LastObservedStage = record.LastObservedStage,
LastObservedMessage = record.LastObservedMessage,
IpcConnected = record.IpcConnected,
PublicIpcConnected = record.PublicIpcConnected,
ShellStatus = record.ShellStatus,
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
State = record.State
};
}
}

View File

@@ -1,68 +0,0 @@
using Avalonia;
using Avalonia.Styling;
using FluentAvalonia.Styling;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主题服务,管理启动器的主题设置
/// </summary>
public static class ThemeService
{
private static ThemeVariant _currentTheme = ThemeVariant.Light;
private static string _accentColor = "#0078D4";
/// <summary>
/// 获取当前主题
/// </summary>
public static ThemeVariant CurrentTheme => _currentTheme;
/// <summary>
/// 获取当前主题色
/// </summary>
public static string AccentColor => _accentColor;
/// <summary>
/// 应用主题设置
/// </summary>
public static void ApplyTheme(ThemeMode mode, string accentColor)
{
_currentTheme = mode switch
{
ThemeMode.Dark => ThemeVariant.Dark,
_ => ThemeVariant.Light
};
_accentColor = accentColor;
// 应用到当前应用程序
if (Application.Current is { } app)
{
app.RequestedThemeVariant = _currentTheme;
}
}
/// <summary>
/// 应用浅色主题
/// </summary>
public static void ApplyLightTheme(string accentColor)
{
ApplyTheme(ThemeMode.Light, accentColor);
}
/// <summary>
/// 应用深色主题
/// </summary>
public static void ApplyDarkTheme(string accentColor)
{
ApplyTheme(ThemeMode.Dark, accentColor);
}
}
/// <summary>
/// 主题模式
/// </summary>
public enum ThemeMode
{
Light,
Dark
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
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();
}
});
}
}