fix.启动器一定要能够启动

This commit is contained in:
lincube
2026-04-16 19:28:58 +08:00
parent e9ff590d79
commit 59c4824425
22 changed files with 2991 additions and 115 deletions

View File

@@ -58,6 +58,8 @@ internal sealed class DeploymentLocator
public string? ResolveHostExecutablePath()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 首先查找 app-{version} 目录(生产环境)
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
@@ -68,15 +70,98 @@ internal sealed class DeploymentLocator
}
}
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
return File.Exists(inParent) ? inParent : null;
if (File.Exists(inParent))
{
return inParent;
}
// 4. 开发模式:如果启用了开发模式,优先扫描开发路径
if (Views.ErrorWindow.CheckDevModeEnabled())
{
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 possiblePaths = new[]
{
// 从 Launcher 项目运行
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "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(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 获取开发环境可能的主程序路径
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 可能的开发目录结构
var possiblePaths = new[]
{
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从解决方案根目录运行LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从 dev-test 目录运行
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
}
public string GetCurrentVersion()

View File

@@ -0,0 +1,109 @@
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipeServer;
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
public void Start()
{
_listenTask = Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipeServer = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Message);
await _pipeServer.WaitForConnectionAsync(_cts.Token);
using var reader = new StreamReader(_pipeServer);
var json = await reader.ReadToEndAsync(_cts.Token);
if (!string.IsNullOrEmpty(json))
{
try
{
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
if (message != null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误
}
}
try
{
_pipeServer.Disconnect();
}
catch { }
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
{
// 管道断开,继续监听
continue;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC error: {ex.Message}");
await Task.Delay(100, _cts.Token);
}
}
}, _cts.Token);
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_pipeServer?.Disconnect();
}
catch { }
}
public void Dispose()
{
Stop();
_pipeServer?.Dispose();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -1,7 +1,9 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
@@ -39,14 +41,7 @@ internal sealed class LauncherFlowCoordinator
// 清理待删除的旧版本
_deploymentLocator.CleanupDestroyedDeployments();
if (_oobeStateService.IsFirstRun())
{
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
}
// 显示 Splash 窗口
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
@@ -55,17 +50,29 @@ internal sealed class LauncherFlowCoordinator
});
var reporter = (ISplashStageReporter)splashWindow;
// 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
Dispatcher.UIThread.Post(() =>
{
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
});
});
ipcServer.Start();
try
{
reporter.Report("silentUpdate", "update");
// 检查并安装待处理的更新(主程序下载的)
reporter.Report("update", "检查更新...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
return updateResult;
}
reporter.Report("pluginTasks", "plugins");
// 检查并安装待处理的插件更新
reporter.Report("plugins", "检查插件更新...");
var pluginsDir = _context.GetOption("plugins-dir")
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
@@ -74,13 +81,28 @@ internal sealed class LauncherFlowCoordinator
return queueResult;
}
reporter.Report("launchHost", "launch");
var hostResult = LaunchHost();
// OOBE首次运行引导
if (_oobeStateService.IsFirstRun())
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
}
// 启动主程序
reporter.Report("launch", "正在启动...");
var hostResult = await LaunchHostWithIpcAsync();
if (!hostResult.Success)
{
return hostResult;
}
// 等待主程序就绪或超时
await Task.Delay(TimeSpan.FromSeconds(30));
return new LauncherResult
{
Success = true,
@@ -107,11 +129,28 @@ internal sealed class LauncherFlowCoordinator
}
}
private LauncherResult LaunchHost()
private async Task<LauncherResult> LaunchHostWithIpcAsync(string? customHostPath = null)
{
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
// 优先使用自定义路径(调试模式选择的路径)
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
if (string.IsNullOrWhiteSpace(hostPath))
{
// 关闭 Splash 窗口
// 显示错误窗口而不是直接退出
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
if (errorResult == ErrorWindowResult.Retry)
{
// 用户选择重试,如果有选择路径则使用,否则重新尝试
if (!string.IsNullOrWhiteSpace(selectedPath))
{
return await LaunchHostWithIpcAsync(selectedPath);
}
return await LaunchHostWithIpcAsync();
}
// 用户选择退出
return new LauncherResult
{
Success = false,
@@ -133,6 +172,12 @@ internal sealed class LauncherFlowCoordinator
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
};
// 传递环境变量供 IPC 使用
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
Process.Start(processStartInfo);
return new LauncherResult
{
@@ -143,6 +188,26 @@ internal sealed class LauncherFlowCoordinator
};
}
/// <summary>
/// 显示找不到主程序的错误窗口
/// </summary>
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
{
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
errorWindow.Show();
var result = await errorWindow.WaitForChoiceAsync();
var customPath = errorWindow.GetCustomHostPath();
await Dispatcher.UIThread.InvokeAsync(() => errorWindow.Close());
return (result, customPath);
});
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())

View File

@@ -1,11 +1,18 @@
using System.IO.Compression;
using LanMountainDesktop.PluginSdk;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 插件安装服务 - 简化版,不依赖 PluginSdk
/// </summary>
internal sealed class PluginInstallerService
{
private const string ManifestFileName = "manifest.json";
private const string PackageFileExtension = ".lmdp";
private const string RuntimeDirectoryName = "runtime";
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
@@ -48,33 +55,40 @@ internal sealed class PluginInstallerService
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
}
return manifest;
}
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
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, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
@@ -188,7 +202,7 @@ internal sealed class PluginInstallerService
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PluginSdkInfo.PackageFileExtension;
return fileName + PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
@@ -198,3 +212,15 @@ internal sealed class PluginInstallerService
: 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

@@ -146,7 +146,9 @@ internal sealed class UpdateEngineService
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
// 全新安装场景:没有当前部署目录,但有更新包
// 这种情况下应该直接应用更新作为首次安装
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
}
var currentVersion = _deploymentLocator.GetCurrentVersion();
@@ -258,6 +260,167 @@ internal sealed class UpdateEngineService
}
}
/// <summary>
/// 全新安装场景:直接应用更新包作为首次部署
/// </summary>
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
SignedFileMap fileMap,
string archivePath,
string fileMapPath,
string signaturePath)
{
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!;
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json");
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = "0.0.0",
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = "",
TargetDirectory = targetDeployment,
Status = "pending"
};
SaveSnapshot(snapshotPath, snapshot);
// 清理并解压更新包
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
// 创建目标部署目录
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
// 应用所有文件(全新安装时,所有文件都是新增或替换)
foreach (var file in fileMap.Files)
{
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
}
// 验证文件哈希
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
{
continue;
}
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
// 激活部署(创建 .current 标记,删除 .partial 标记)
var currentMarker = Path.Combine(targetDeployment, ".current");
File.WriteAllText(currentMarker, string.Empty);
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
// 清理更新包
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Initial deployment to {targetVersion}.",
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
// 清理失败的目标目录
try
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, true);
}
}
catch
{
}
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = "initial_deploy_failed",
Message = "Failed to apply initial deployment.",
ErrorMessage = ex.Message,
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
};
}
finally
{
try
{
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
}
catch
{
}
}
}
/// <summary>
/// 应用初始部署文件(全新安装场景,不需要源目录)
/// </summary>
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
{
var normalizedPath = NormalizeRelativePath(file.Path);
// 删除操作在全新安装时忽略
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
var targetPath = Path.Combine(targetDeployment, normalizedPath);
EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
// 无论是 add 还是 replace都从压缩包复制
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(extractRoot, archiveRelative);
EnsurePathWithinRoot(extractedPath, extractRoot);
if (!File.Exists(extractedPath))
{
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
}
File.Copy(extractedPath, targetPath, overwrite: true);
}
public LauncherResult RollbackLatest()
{
if (!Directory.Exists(_snapshotsRoot))