mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-27 04:34:26 +08:00
Launcher (#4)
* 激进的更新 * 试试 * fix.可爱的我一直在修CI( * fix.启动器一定要能够启动 * feat.尝试弄了AOT的启动器。 * fix.修CI,好像是因为Linux那边有个问题,反正修就对了。 * fix.ci难修,为什么liunx跑不起来呢? * Update build.yml * Update LanMountainDesktop.csproj * changed.调整了启动逻辑,优化了更新页面。 * changed.优化了更新体验 * feat.依旧试增量更新这一块,看看velopack * fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 * fix.继续修ci,ci怎么天天炸 * changed.velopack,试试rust * fix.修ci,修融合桌面,修启动器 * fix.GitHub Action工作流怎么天天出问题 * feat.引入velopack,不好,是rust(至少内存很安全了。 * chore: migrate release pipeline to signed filemap and wire rainyun s3 * fix: make optional s3 upload step workflow-parse safe * fix: make delta pack generation robust for empty diffs and linux paths * chore: rotate launcher update public key for pdc signing * fix: restore stable launcher update public key * fix: sync launcher public key with update signing secret * fix: normalize PEM line endings in signing key validation * fix: rotate launcher public key to match ci signing secret * fix: compare signing keys by SPKI instead of PEM text * refactor update backend to host-managed PDC pipeline * fix release workflow env key collisions * relax publish-pdc precheck to require S3 only * set GH_TOKEN for PDCC installer step * ci: add local pdc mock fallback for release publish * ci: fix pdc mock process log redirection * ci: fallback pdcc signing key to update private key * ci: ensure pdcc signing passphrase env is always set * ci: create pdcc publish root before invoking client * ci: set pdcc version variable from release version * ci: decouple pdcc installer version from publish config version * ci: package pdcc subchannels with generated filemap and changelog * ci: make local pdc mock diff return empty for fast fallback * ci: fix pdcc variable mapping and pdc signing prechecks * Update App.axaml.cs * ci: wire aws cli credentials for rainyun s3 * ci: pin pdcc client version separately from app version * ci: harden local pdc mock transport handling * ci: publish pdcc subchannels in one pass * ci: add pdcc publish heartbeat and timeout * ci: fix pdcc publish workdir bootstrap * feat.Penguin Logistics Online Network Distribution System * ci: fix plonds s3 probe and signing fallback * ci: validate signing key and quiet missing baselines * ci: relax aws checksum mode for rainyun s3 * ci: avoid multipart uploads to rainyun s3 * ci: handle empty plonds baselines safely * ci.plonds * Rebuild release pipeline around PLONDS and DDSS * Fix Windows installer script path in release workflow
This commit is contained in:
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
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 baseDir = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
468
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
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");
|
||||
|
||||
// ClassIsland 风格的查询:先筛选,后排序
|
||||
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 string? ResolveHostExecutablePath()
|
||||
{
|
||||
// 使用新的灵活定位器
|
||||
var options = new HostDiscoveryOptions
|
||||
{
|
||||
ExecutableName = "LanMountainDesktop",
|
||||
PreferDevModeConfig = true,
|
||||
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
|
||||
AdditionalSearchPaths = new List<string>
|
||||
{
|
||||
// 可以通过配置文件或环境变量添加更多路径
|
||||
"${AppRoot}",
|
||||
"${AppRoot}/..",
|
||||
"${BaseDirectory}/../..",
|
||||
}
|
||||
};
|
||||
|
||||
var locator = new FlexibleHostLocator(_appRoot, options);
|
||||
var result = locator.ResolveHostExecutablePath();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑(作为备选)
|
||||
return ResolveHostExecutablePathLegacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传统的主程序路径解析(作为备选)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (File.Exists(inParent))
|
||||
{
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
// 4.1 首先检查保存的自定义路径
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
{
|
||||
return savedCustomPath;
|
||||
}
|
||||
|
||||
// 4.2 扫描开发路径
|
||||
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()
|
||||
{
|
||||
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">最少保留版本数,默认3个</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);
|
||||
|
||||
// 过滤掉无效部署目录(排除partial),按版本排序
|
||||
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 snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
|
||||
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
|
||||
{
|
||||
// 忽略快照解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
}
|
||||
}
|
||||
|
||||
// 清理不需要的版本
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
{
|
||||
// 保留此版本,如果之前标记了destroy则取消标记
|
||||
if (deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(Path.Combine(deployment.Path, ".destroy"));
|
||||
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略取消标记失败
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果还没标记destroy的,先标记
|
||||
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 deploymentDir = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||
if (File.Exists(versionFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略读取失败,回退到默认值
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从目录名解析版本,使用默认开发代号
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate" // 默认开发代号
|
||||
};
|
||||
}
|
||||
}
|
||||
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
@@ -0,0 +1,629 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 灵活的主程序定位器
|
||||
/// </summary>
|
||||
internal sealed class FlexibleHostLocator
|
||||
{
|
||||
private readonly HostDiscoveryOptions _options;
|
||||
private readonly string _appRoot;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
_deploymentLocator = new DeploymentLocator(appRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
ExecutableName = executable,
|
||||
AppRoot = _appRoot,
|
||||
Options = _options
|
||||
};
|
||||
|
||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||
|
||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||
var envPath = GetPathFromEnvironment();
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
||||
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
||||
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
||||
if (File.Exists(deploymentExePath))
|
||||
{
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
||||
return deploymentExePath;
|
||||
}
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
||||
}
|
||||
|
||||
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
||||
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
{
|
||||
return deploymentPath;
|
||||
}
|
||||
|
||||
// 4. 检查 Launcher 同级目录(便携模式)
|
||||
var portablePath = SearchPortableLocation(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(portablePath))
|
||||
{
|
||||
return portablePath;
|
||||
}
|
||||
|
||||
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
||||
|
||||
// 5. 检查配置文件中的路径 - 用户自定义配置
|
||||
var configPath = GetPathFromConfigFile();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(configPath, "config file");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 5. 搜索附近目录(向上、向下各一层)
|
||||
var nearbyPath = SearchNearbyDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(nearbyPath))
|
||||
{
|
||||
return nearbyPath;
|
||||
}
|
||||
|
||||
// 7. 开发模式:检查保存的自定义路径
|
||||
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 搜索标准开发路径
|
||||
var devPath = SearchDevelopmentPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// 9. 搜索额外的配置路径
|
||||
var additionalPath = SearchAdditionalPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(additionalPath))
|
||||
{
|
||||
return additionalPath;
|
||||
}
|
||||
|
||||
// 10. 递归搜索(如果启用)
|
||||
if (_options.RecursiveSearch)
|
||||
{
|
||||
var recursivePath = SearchRecursively(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(recursivePath))
|
||||
{
|
||||
return recursivePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从环境变量获取路径
|
||||
/// </summary>
|
||||
private string? GetPathFromEnvironment()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从配置文件获取路径
|
||||
/// </summary>
|
||||
private string? GetPathFromConfigFile()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||
{
|
||||
return config.HostPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略配置文件读取错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索部署目录
|
||||
/// </summary>
|
||||
private string? SearchDeploymentDirectories(SearchContext context)
|
||||
{
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 查找 app-* 目录
|
||||
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
|
||||
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
|
||||
.ToList();
|
||||
|
||||
// 优先选择带 .current 标记的
|
||||
var currentMarked = appDirs
|
||||
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
|
||||
.Select(dir => Path.Combine(dir, context.ExecutableName))
|
||||
.FirstOrDefault(File.Exists);
|
||||
|
||||
if (currentMarked != null)
|
||||
{
|
||||
return currentMarked;
|
||||
}
|
||||
|
||||
// 选择版本号最高的
|
||||
var latest = appDirs
|
||||
.Select(dir => new
|
||||
{
|
||||
Dir = dir,
|
||||
Version = ParseVersionFromDirectoryName(dir)
|
||||
})
|
||||
.OrderByDescending(x => x.Version)
|
||||
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
|
||||
.FirstOrDefault(File.Exists);
|
||||
|
||||
return latest;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索便携模式位置(Launcher 同级目录)
|
||||
/// </summary>
|
||||
private string? SearchPortableLocation(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
|
||||
|
||||
if (File.Exists(portablePath))
|
||||
{
|
||||
return portablePath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索附近目录(灵活查找,适用于各种部署场景)
|
||||
/// </summary>
|
||||
private string? SearchNearbyDirectories(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchDirs = new List<string>();
|
||||
|
||||
// Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
searchDirs.Add(launcherDir);
|
||||
|
||||
// 上级目录
|
||||
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||
if (Directory.Exists(parentDir))
|
||||
{
|
||||
searchDirs.Add(parentDir);
|
||||
}
|
||||
|
||||
// 上上级目录
|
||||
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
|
||||
if (Directory.Exists(grandparentDir))
|
||||
{
|
||||
searchDirs.Add(grandparentDir);
|
||||
}
|
||||
|
||||
// AppRoot 及其上级
|
||||
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
|
||||
{
|
||||
searchDirs.Add(_appRoot);
|
||||
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
if (Directory.Exists(appParent))
|
||||
{
|
||||
searchDirs.Add(appParent);
|
||||
}
|
||||
}
|
||||
|
||||
// 去重后搜索
|
||||
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// 直接搜索
|
||||
var directPath = Path.Combine(dir, context.ExecutableName);
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// 搜索子目录(一层)
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(dir))
|
||||
{
|
||||
var subPath = Path.Combine(subDir, context.ExecutableName);
|
||||
if (File.Exists(subPath))
|
||||
{
|
||||
return subPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索开发路径
|
||||
/// </summary>
|
||||
private string? SearchDevelopmentPaths(SearchContext context)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 动态构建可能的开发路径(支持不同的项目结构)
|
||||
var possiblePaths = new List<string>();
|
||||
|
||||
// 从解决方案根目录搜索(支持不同的解决方案结构)
|
||||
var solutionRoot = FindSolutionRoot(launcherDir);
|
||||
if (!string.IsNullOrWhiteSpace(solutionRoot))
|
||||
{
|
||||
// 搜索所有可能的 bin 目录
|
||||
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
|
||||
}
|
||||
|
||||
// 添加硬编码的备用路径
|
||||
possiblePaths.AddRange(new[]
|
||||
{
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||
});
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索额外的配置路径
|
||||
/// </summary>
|
||||
private string? SearchAdditionalPaths(SearchContext context)
|
||||
{
|
||||
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pattern in _options.AdditionalSearchPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 替换变量
|
||||
var expandedPattern = ExpandVariables(pattern);
|
||||
|
||||
// 支持通配符
|
||||
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
|
||||
var filePattern = Path.GetFileName(expandedPattern);
|
||||
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
|
||||
var validMatch = matches.FirstOrDefault(File.Exists);
|
||||
if (validMatch != null)
|
||||
{
|
||||
return validMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (File.Exists(expandedPattern))
|
||||
{
|
||||
return expandedPattern;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归搜索
|
||||
/// </summary>
|
||||
private string? SearchRecursively(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
|
||||
|
||||
foreach (var searchDir in searchDirs.Where(Directory.Exists))
|
||||
{
|
||||
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略递归搜索错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归搜索目录
|
||||
/// </summary>
|
||||
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
|
||||
{
|
||||
if (depth > _options.MaxRecursionDepth)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 检查当前目录
|
||||
var directPath = Path.Combine(dir, executableName);
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// 检查子目录
|
||||
foreach (var subDir in Directory.GetDirectories(dir))
|
||||
{
|
||||
// 跳过某些目录
|
||||
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
|
||||
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略访问错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找解决方案根目录
|
||||
/// </summary>
|
||||
private string? FindSolutionRoot(string startDir)
|
||||
{
|
||||
var current = new DirectoryInfo(startDir);
|
||||
while (current != null)
|
||||
{
|
||||
// 查找 .sln 文件
|
||||
if (current.GetFiles("*.sln").Any())
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
// 查找 .git 目录作为备选
|
||||
if (current.GetDirectories(".git").Any())
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索 bin 目录
|
||||
/// </summary>
|
||||
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// 查找所有 bin 目录
|
||||
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var binDir in binDirs)
|
||||
{
|
||||
// 检查 Debug 和 Release 子目录
|
||||
var configDirs = new[] { "Debug", "Release" };
|
||||
foreach (var config in configDirs)
|
||||
{
|
||||
var configPath = Path.Combine(binDir, config);
|
||||
if (Directory.Exists(configPath))
|
||||
{
|
||||
// 检查所有 net* 子目录
|
||||
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
|
||||
foreach (var fwDir in frameworkDirs)
|
||||
{
|
||||
var exePath = Path.Combine(fwDir, executableName);
|
||||
if (File.Exists(exePath))
|
||||
{
|
||||
results.Add(exePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证路径并返回
|
||||
/// </summary>
|
||||
private string? ValidateAndReturn(string path, string source)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Debug.WriteLine($"Found host executable from {source}: {path}");
|
||||
return path;
|
||||
}
|
||||
|
||||
// 尝试添加 .exe(Windows)
|
||||
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var withExe = path + ".exe";
|
||||
if (File.Exists(withExe))
|
||||
{
|
||||
Debug.WriteLine($"Found host executable from {source}: {withExe}");
|
||||
return withExe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取可执行文件名
|
||||
/// </summary>
|
||||
private string GetExecutableName()
|
||||
{
|
||||
var name = _options.ExecutableName;
|
||||
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name += ".exe";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 展开路径变量
|
||||
/// </summary>
|
||||
private string ExpandVariables(string path)
|
||||
{
|
||||
return path
|
||||
.Replace("${AppRoot}", _appRoot)
|
||||
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
|
||||
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从目录名解析版本
|
||||
/// </summary>
|
||||
private static Version ParseVersionFromDirectoryName(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-');
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索上下文
|
||||
/// </summary>
|
||||
private class SearchContext
|
||||
{
|
||||
public required string ExecutableName { get; set; }
|
||||
public required string AppRoot { get; set; }
|
||||
public required HostDiscoveryOptions Options { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
internal class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface ISplashStageReporter
|
||||
{
|
||||
void Report(string stage, string message);
|
||||
|
||||
/// <summary>
|
||||
/// 报告阶段和进度(0-100)
|
||||
/// </summary>
|
||||
void ReportStage(string stage, int progress);
|
||||
}
|
||||
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
|
||||
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// </summary>
|
||||
public class LauncherIpcServer : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Action<StartupProgressMessage> _onProgress;
|
||||
private Task? _listenTask;
|
||||
private NamedPipeServerStream? _currentPipe;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
|
||||
/// </summary>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||
{
|
||||
_onProgress = onProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 IPC 服务端监听
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
|
||||
}
|
||||
|
||||
private async Task ListenLoopAsync()
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
NamedPipeServerStream? pipe = null;
|
||||
try
|
||||
{
|
||||
pipe = new NamedPipeServerStream(
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Byte);
|
||||
|
||||
_currentPipe = pipe;
|
||||
await pipe.WaitForConnectionAsync(_cts.Token);
|
||||
|
||||
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
|
||||
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 客户端断开连接,继续等待新连接
|
||||
continue;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
|
||||
try
|
||||
{
|
||||
await Task.Delay(200, _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe?.Dispose();
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (ReferenceEquals(_currentPipe, pipe))
|
||||
{
|
||||
_currentPipe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已连接的管道中持续读取消息,直到连接断开或取消
|
||||
/// </summary>
|
||||
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
|
||||
{
|
||||
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
|
||||
try
|
||||
{
|
||||
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// 1. 读取 4 字节长度前缀
|
||||
var totalRead = 0;
|
||||
while (totalRead < LengthPrefixSize)
|
||||
{
|
||||
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
// 连接已关闭
|
||||
return;
|
||||
}
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
|
||||
{
|
||||
// 无效长度,跳过此连接
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 读取消息正文
|
||||
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
|
||||
try
|
||||
{
|
||||
totalRead = 0;
|
||||
while (totalRead < payloadLength)
|
||||
{
|
||||
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
// 3. 反序列化并回调
|
||||
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||
if (message is not null)
|
||||
{
|
||||
_onProgress(message);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略解析错误,继续读取下一条消息
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(payloadBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(lengthBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 IPC 服务端
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_currentPipe?.Dispose();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
783
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
783
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
@@ -0,0 +1,783 @@
|
||||
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;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 清理旧版本,保留至少3个版本
|
||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
|
||||
// 检测老版本安装(首次运行时)
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||
if (legacyInfo != null)
|
||||
{
|
||||
var migrationResult = await ShowMigrationPromptAsync(legacyInfo);
|
||||
// 无论用户选择什么,都继续启动流程
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}");
|
||||
}
|
||||
}
|
||||
|
||||
// 使用传入的 Splash 窗口或创建新的
|
||||
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
// 创建加载详情窗口(可选,用于显示详细加载状态)
|
||||
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
loadingDetailsWindow = new LoadingDetailsWindow();
|
||||
loadingDetailsWindow.Show();
|
||||
});
|
||||
}
|
||||
|
||||
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
|
||||
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// 加载状态管理
|
||||
var loadingState = new LoadingStateMessage();
|
||||
|
||||
// 启动 IPC 服务端监听主程序进度
|
||||
using var ipcServer = new LauncherIpcServer(msg =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 更新加载状态
|
||||
loadingState = loadingState with
|
||||
{
|
||||
Stage = msg.Stage,
|
||||
OverallProgressPercent = msg.ProgressPercent,
|
||||
Message = msg.Message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 报告到 Splash 窗口
|
||||
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
|
||||
|
||||
// 更新加载详情窗口
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
|
||||
// 主程序报告就绪后,关闭 Splash 窗口和加载详情窗口
|
||||
if (msg.Stage == StartupStage.Ready)
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
loadingDetailsWindow?.Close();
|
||||
hostReadyTcs.TrySetResult();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error in IPC callback: {ex.Message}");
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcServer.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// 检查并安装待处理的更新(主程序下载的)
|
||||
reporter.Report("update", "检查更新...");
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
// 检查并安装待处理的插件更新
|
||||
reporter.Report("plugins", "检查插件更新...");
|
||||
var pluginsDir = _context.GetOption("plugins-dir")
|
||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return queueResult;
|
||||
}
|
||||
|
||||
// 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, hostProcess) = await LaunchHostWithIpcAsync(splashWindow);
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
// 等待主程序进程退出。Launcher 作为后台守护进程保持运行,
|
||||
// 维持 IPC 管道服务端供主程序报告启动进度。
|
||||
if (hostProcess is not null)
|
||||
{
|
||||
var processExitTask = hostProcess.WaitForExitAsync();
|
||||
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
|
||||
var readyOrTimeoutOrExit = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
var completedTask = await readyOrTimeoutOrExit;
|
||||
|
||||
// Host process exited before reporting Ready.
|
||||
if (completedTask == processExitTask)
|
||||
{
|
||||
var exitCode = hostProcess.ExitCode;
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||
|
||||
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
|
||||
exitCode,
|
||||
hostReadyTcs,
|
||||
splashWindow,
|
||||
loadingDetailsWindow).ConfigureAwait(false);
|
||||
if (recoveryResult is not null)
|
||||
{
|
||||
return recoveryResult;
|
||||
}
|
||||
|
||||
// Close Splash window for unrecoverable early exits.
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "host_crashed",
|
||||
Message = $"主程序异常退出,退出代码: {exitCode}"
|
||||
};
|
||||
}
|
||||
|
||||
// 如果 Splash 窗口仍然打开(超时情况),关闭它
|
||||
if (splashWindow.IsVisible)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Timeout waiting for Ready signal, closing splash window...");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window on timeout: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 继续等待主程序进程退出(如果它还在运行)
|
||||
if (!hostProcess.HasExited)
|
||||
{
|
||||
await processExitTask;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果无法获取进程引用,退回到有限等待
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "exit",
|
||||
Code = "ok",
|
||||
Message = "Launcher completed successfully."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Splash 窗口可能已由 IPC Ready 回调关闭,这里做安全清理
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Splash window closed in finally block");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window in finally: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
|
||||
int exitCode,
|
||||
TaskCompletionSource hostReadyTcs,
|
||||
SplashWindow splashWindow,
|
||||
LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activated_existing_instance",
|
||||
Message = "Detected existing running instance and activation was acknowledged."
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
|
||||
|
||||
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
|
||||
if (!retryLaunchResult.Success)
|
||||
{
|
||||
return retryLaunchResult;
|
||||
}
|
||||
|
||||
if (retryProcess is null)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_start_failed",
|
||||
Message = "Explicit activation retry failed because no host process was created."
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
|
||||
var retryExitTask = retryProcess.WaitForExitAsync();
|
||||
var retryCompleted = await Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
retryExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
|
||||
|
||||
if (retryCompleted == hostReadyTcs.Task)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_ready",
|
||||
Message = "Explicit activation retry succeeded and host reported Ready."
|
||||
};
|
||||
}
|
||||
|
||||
if (retryCompleted == retryExitTask)
|
||||
{
|
||||
var retryExitCode = retryProcess.ExitCode;
|
||||
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_redirected",
|
||||
Message = "Explicit activation retry redirected to the existing primary instance."
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_failed",
|
||||
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_timeout",
|
||||
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
|
||||
{
|
||||
// 优先使用自定义路径(调试模式选择的路径)
|
||||
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(splashWindow, selectedPath);
|
||||
}
|
||||
return await LaunchHostWithIpcAsync(splashWindow);
|
||||
}
|
||||
|
||||
// 用户选择退出
|
||||
return (new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launchHost",
|
||||
Code = "host_not_found",
|
||||
Message = "LanMountainDesktop host executable not found."
|
||||
}, null);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
|
||||
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
|
||||
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0),
|
||||
// 避免子进程窗口创建成功但不可见的问题。
|
||||
var arguments = new System.Text.StringBuilder();
|
||||
|
||||
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
|
||||
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid)
|
||||
foreach (var arg in _context.RawArgs)
|
||||
{
|
||||
if (arg == _context.Command || arg == _context.SubCommand)
|
||||
continue;
|
||||
|
||||
if (arg.StartsWith("--"))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0) key = key[..equalsIndex];
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
// 通过命令行参数传递 IPC 连接信息(UseShellExecute=true 时不支持 EnvironmentVariables)
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
|
||||
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = hostWorkingDir,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
|
||||
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承)
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||
Environment.ProcessId.ToString();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||
_deploymentLocator.GetAppRoot();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||
|
||||
var hostProcess = Process.Start(processStartInfo);
|
||||
Console.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
|
||||
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
|
||||
return (new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launchHost",
|
||||
Code = "ok",
|
||||
Message = "Host launched."
|
||||
}, hostProcess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示找不到主程序的错误窗口
|
||||
/// </summary>
|
||||
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
// 在 UI 线程创建并显示错误窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
|
||||
errorWindow.Show();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow shown for host not found");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show ErrorWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
Console.Error.WriteLine("[LauncherFlowCoordinator] ErrorWindow is null, cannot wait for choice");
|
||||
return (ErrorWindowResult.Exit, null);
|
||||
}
|
||||
|
||||
// 等待用户选择
|
||||
ErrorWindowResult result;
|
||||
string? customPath;
|
||||
|
||||
try
|
||||
{
|
||||
result = await errorWindow.WaitForChoiceAsync();
|
||||
customPath = errorWindow.GetCustomHostPath();
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] ErrorWindow result: {result}, customPath: {customPath != null}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for choice: {ex.Message}");
|
||||
result = ErrorWindowResult.Exit;
|
||||
customPath = null;
|
||||
}
|
||||
|
||||
// 安全关闭错误窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
||||
{
|
||||
errorWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow closed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing ErrorWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return (result, customPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示迁移提示窗口
|
||||
/// </summary>
|
||||
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||
{
|
||||
MigrationPromptWindow? migrationWindow = null;
|
||||
|
||||
// 在 UI 线程创建并显示迁移提示窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
migrationWindow = new MigrationPromptWindow();
|
||||
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||
migrationWindow.Show();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow shown");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
if (migrationWindow is null)
|
||||
{
|
||||
Console.Error.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow is null, skipping migration prompt");
|
||||
return MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 等待用户选择
|
||||
MigrationResult result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await migrationWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] MigrationPromptWindow result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for migration choice: {ex.Message}");
|
||||
result = MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 安全关闭窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||
{
|
||||
migrationWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow closed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||
File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly OobeStateService _stateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService stateService)
|
||||
{
|
||||
_stateService = stateService;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
OobeWindow? window = null;
|
||||
|
||||
try
|
||||
{
|
||||
window = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE window shown");
|
||||
return oobeWindow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Failed to show OOBE window: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Console.Error.WriteLine("[WelcomeOobeStep] OOBE window is null, skipping OOBE");
|
||||
_stateService.MarkCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
using var _ = cancellationToken.Register(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (window.IsVisible && window.IsLoaded)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window on cancel: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE completed by user");
|
||||
_stateService.MarkCompleted();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (window is not null)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (window.IsVisible && window.IsLoaded)
|
||||
{
|
||||
window.Close();
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE window closed in finally");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window in finally: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
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] : "";
|
||||
|
||||
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 // 便携版
|
||||
}
|
||||
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
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 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
104
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
104
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
||||
string? stateDir = null;
|
||||
Exception? lastException = null;
|
||||
|
||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
||||
try
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
|
||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
stateDir = tempDir;
|
||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
||||
// 如果所有策略都失败,抛出异常让上层处理
|
||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
||||
}
|
||||
}
|
||||
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
try
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
||||
}
|
||||
}
|
||||
}
|
||||
226
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
226
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
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 = "manifest.json";
|
||||
private const string PackageFileExtension = ".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);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
}
|
||||
|
||||
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 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, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查服务 - 基于 GitHub Release API
|
||||
/// </summary>
|
||||
internal sealed class UpdateCheckService
|
||||
{
|
||||
private const string GitHubApiBase = "https://api.github.com";
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
_repoOwner = repoOwner;
|
||||
_repoName = repoName;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查更新
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await FetchReleasesAsync(cancellationToken);
|
||||
|
||||
// 根据频道过滤版本
|
||||
var filteredReleases = channel == UpdateChannel.Stable
|
||||
? releases.Where(r => !r.Prerelease).ToList()
|
||||
: releases;
|
||||
|
||||
// 找到最新版本
|
||||
var latestRelease = filteredReleases
|
||||
.OrderByDescending(r => ParseVersion(r.TagName))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latestRelease == null)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = "No releases found"
|
||||
};
|
||||
}
|
||||
|
||||
var latestVersion = ParseVersionString(latestRelease.TagName);
|
||||
var current = ParseVersion(currentVersion);
|
||||
var latest = ParseVersion(latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = latest > current,
|
||||
LatestVersion = latestVersion,
|
||||
CurrentVersion = currentVersion,
|
||||
Release = latestRelease
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Release
|
||||
/// </summary>
|
||||
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
TagName = r.TagName ?? "",
|
||||
Name = r.Name ?? "",
|
||||
Prerelease = r.Prerelease,
|
||||
PublishedAt = r.PublishedAt,
|
||||
Body = r.Body,
|
||||
Assets = r.Assets?.Select(a => new ReleaseAsset
|
||||
{
|
||||
Name = a.Name ?? "",
|
||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||
Size = a.Size
|
||||
}).ToList() ?? []
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
||||
/// </summary>
|
||||
private static string ParseVersionString(string tag)
|
||||
{
|
||||
return tag.TrimStart('v', 'V');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析版本号
|
||||
/// </summary>
|
||||
private static Version ParseVersion(string versionString)
|
||||
{
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
internal sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
1590
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
1590
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user