2026-06-03 00:50:52 +08:00
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
|
using LanDesktopPLONDS.Installer.Models;
|
2026-07-31 11:43:50 +09:00
|
|
|
|
using LanMountainDesktop.Shared.Contracts.Deployment;
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
|
|
|
|
|
namespace LanDesktopPLONDS.Installer.Services;
|
|
|
|
|
|
|
|
|
|
|
|
internal sealed class FilesPackageInstaller
|
|
|
|
|
|
{
|
|
|
|
|
|
public async Task InstallAsync(
|
|
|
|
|
|
PreparedFilesPackage package,
|
|
|
|
|
|
string installPath,
|
|
|
|
|
|
IProgress<InstallerDeployProgress>? progress,
|
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken)
|
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task InstallAsync(
|
|
|
|
|
|
PreparedFilesPackage package,
|
|
|
|
|
|
string installPath,
|
|
|
|
|
|
OnlineInstallOptions options,
|
|
|
|
|
|
IProgress<InstallerDeployProgress>? progress,
|
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(package);
|
|
|
|
|
|
|
|
|
|
|
|
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
|
|
|
|
|
|
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
|
2026-07-31 11:43:50 +09:00
|
|
|
|
var targetDeployment = DeploymentLayout.BuildDeploymentDirectory(launcherRoot, package.Version);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-06-09 22:18:27 +08:00
|
|
|
|
InstallerElevation.EnsureCanInstall(launcherRoot);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
|
|
|
|
|
|
Directory.CreateDirectory(launcherRoot);
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 在复制 launcherRoot 文件之前,检测是否有进程正在运行
|
|
|
|
|
|
RunningProcessGuard.EnsureNoRunningProcesses(launcherRoot);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 事务性安装:失败时回滚,不破坏已有状态
|
|
|
|
|
|
try
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// (a) 创建部署目录并写入 .partial 标记
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"创建部署目录",
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
0.12,
|
|
|
|
|
|
null,
|
|
|
|
|
|
0,
|
|
|
|
|
|
null));
|
|
|
|
|
|
|
|
|
|
|
|
PrepareTargetDirectory(targetDeployment);
|
|
|
|
|
|
|
|
|
|
|
|
// (b) 复制所有应用文件到部署目录
|
|
|
|
|
|
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
|
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
|
|
// (c) 验证文件数量匹配
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"验证文件完整性",
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
0.85,
|
|
|
|
|
|
null,
|
|
|
|
|
|
0,
|
|
|
|
|
|
null));
|
|
|
|
|
|
|
|
|
|
|
|
VerifyFileCount(sourceAppDirectory, targetDeployment);
|
|
|
|
|
|
|
|
|
|
|
|
// (d) 复制 launcherRoot 载荷:先写入临时兄弟目录,再逐文件原子移入
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"复制启动器文件",
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
0.88,
|
|
|
|
|
|
null,
|
|
|
|
|
|
0,
|
|
|
|
|
|
null));
|
|
|
|
|
|
|
|
|
|
|
|
await CopyLauncherRootPayloadAtomicAsync(
|
|
|
|
|
|
package.ExtractDirectory,
|
|
|
|
|
|
sourceAppDirectory,
|
|
|
|
|
|
launcherRoot,
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
progress,
|
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
|
|
// (e) 激活部署:删除 .partial → 写 .current → 清除其他 .current
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"激活部署",
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
0.92,
|
|
|
|
|
|
null,
|
|
|
|
|
|
0,
|
|
|
|
|
|
null));
|
|
|
|
|
|
|
|
|
|
|
|
ActivateDeployment(launcherRoot, targetDeployment);
|
|
|
|
|
|
CreateWindowsShortcutsIfAvailable(launcherRoot, options);
|
|
|
|
|
|
|
|
|
|
|
|
// ARP 注册(仅 Windows)
|
|
|
|
|
|
ArpRegistration.Register(launcherRoot, package.Version);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 清理过时部署目录,保留最新一个作为回滚
|
|
|
|
|
|
CleanupStaleDeployments(launcherRoot);
|
|
|
|
|
|
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"已完成",
|
|
|
|
|
|
package.Version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
1,
|
|
|
|
|
|
null,
|
|
|
|
|
|
0,
|
|
|
|
|
|
null));
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
// (f) 任何失败:删除不完整的部署目录,保留预先存在的状态,重新抛出带中文上下文的异常
|
|
|
|
|
|
CleanupPartialDeployment(targetDeployment);
|
|
|
|
|
|
throw new InvalidOperationException($"安装失败,已回滚更改:{ex.Message}", ex);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 从包目录中解析出包含主程序可执行文件的应用目录。
|
|
|
|
|
|
/// </summary>
|
2026-06-03 00:50:52 +08:00
|
|
|
|
public static string ResolveFullPackageAppDirectory(string filesDirectory, string version)
|
|
|
|
|
|
{
|
|
|
|
|
|
var root = Path.GetFullPath(filesDirectory);
|
|
|
|
|
|
if (!Directory.Exists(root))
|
|
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
throw new DirectoryNotFoundException($"PLONDS Files 包目录不存在:{root}");
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
var executableName = DeploymentLayout.GetHostExecutableName();
|
2026-06-03 00:50:52 +08:00
|
|
|
|
var directExecutable = Path.Combine(root, executableName);
|
|
|
|
|
|
if (File.Exists(directExecutable))
|
|
|
|
|
|
{
|
|
|
|
|
|
return root;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var versionDirectory = Directory
|
|
|
|
|
|
.EnumerateDirectories(root, $"app-{version}*", SearchOption.TopDirectoryOnly)
|
|
|
|
|
|
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(versionDirectory))
|
|
|
|
|
|
{
|
|
|
|
|
|
return versionDirectory;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var nested = Directory
|
|
|
|
|
|
.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
|
|
|
|
|
|
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(nested))
|
|
|
|
|
|
{
|
|
|
|
|
|
return nested;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
throw new FileNotFoundException($"PLONDS Files 包中未找到 {executableName}。");
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 准备目标部署目录:创建目录并写入 .partial 标记。
|
|
|
|
|
|
/// </summary>
|
2026-06-03 00:50:52 +08:00
|
|
|
|
private static void PrepareTargetDirectory(string targetDeployment)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Directory.Exists(targetDeployment))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.Delete(targetDeployment, recursive: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Directory.CreateDirectory(targetDeployment);
|
2026-07-31 11:43:50 +09:00
|
|
|
|
File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName), string.Empty);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 逐文件复制源目录到目标目录,跳过标记文件。
|
|
|
|
|
|
/// </summary>
|
2026-06-03 00:50:52 +08:00
|
|
|
|
private static async Task CopyDirectoryAsync(
|
|
|
|
|
|
string sourceDirectory,
|
|
|
|
|
|
string targetDirectory,
|
|
|
|
|
|
string version,
|
|
|
|
|
|
IProgress<InstallerDeployProgress>? progress,
|
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).ToArray();
|
|
|
|
|
|
var total = Math.Max(1, sourceFiles.Length);
|
|
|
|
|
|
for (var index = 0; index < sourceFiles.Length; index++)
|
|
|
|
|
|
{
|
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
var sourcePath = sourceFiles[index];
|
|
|
|
|
|
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, sourcePath));
|
2026-07-31 11:43:50 +09:00
|
|
|
|
if (DeploymentLayout.IsDeploymentMarker(relativePath))
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath));
|
|
|
|
|
|
InstallerPathGuard.EnsureChildPath(targetDirectory, targetPath);
|
|
|
|
|
|
var targetParent = Path.GetDirectoryName(targetPath);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(targetParent))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.CreateDirectory(targetParent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await using (var source = File.OpenRead(sourcePath))
|
|
|
|
|
|
await using (var target = File.Create(targetPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
2026-07-31 11:43:50 +09:00
|
|
|
|
"复制文件",
|
2026-06-03 00:50:52 +08:00
|
|
|
|
version,
|
|
|
|
|
|
1,
|
2026-07-31 11:43:50 +09:00
|
|
|
|
0.15 + ((index + 1) * 0.70 / total),
|
2026-06-03 00:50:52 +08:00
|
|
|
|
relativePath,
|
|
|
|
|
|
index + 1,
|
|
|
|
|
|
total));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 验证源目录与目标目录的文件数量匹配。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static void VerifyFileCount(string sourceDirectory, string targetDirectory)
|
|
|
|
|
|
{
|
|
|
|
|
|
var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
|
|
|
|
|
|
.Count(p => !DeploymentLayout.IsDeploymentMarker(
|
|
|
|
|
|
InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, p))));
|
|
|
|
|
|
var targetFiles = Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories)
|
|
|
|
|
|
.Count(p => !DeploymentLayout.IsDeploymentMarker(
|
|
|
|
|
|
InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(targetDirectory, p))));
|
|
|
|
|
|
|
|
|
|
|
|
if (sourceFiles != targetFiles)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
|
$"文件数量验证失败:源目录 {sourceFiles} 个文件,目标目录 {targetFiles} 个文件。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 原子性地将 launcher-root 载荷复制到安装根目录。
|
|
|
|
|
|
/// 先复制到临时兄弟目录,然后逐文件用 File.Move(overwrite) 移入目标。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static async Task CopyLauncherRootPayloadAtomicAsync(
|
2026-06-03 00:50:52 +08:00
|
|
|
|
string packageRoot,
|
|
|
|
|
|
string sourceAppDirectory,
|
|
|
|
|
|
string launcherRoot,
|
|
|
|
|
|
string version,
|
|
|
|
|
|
IProgress<InstallerDeployProgress>? progress,
|
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
var resolvedPackageRoot = Path.GetFullPath(packageRoot);
|
|
|
|
|
|
var resolvedAppDirectory = Path.GetFullPath(sourceAppDirectory);
|
|
|
|
|
|
if (string.Equals(
|
|
|
|
|
|
resolvedPackageRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
|
|
|
|
|
resolvedAppDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
|
|
|
|
|
StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var files = Directory
|
|
|
|
|
|
.EnumerateFiles(resolvedPackageRoot, "*", SearchOption.AllDirectories)
|
|
|
|
|
|
.Where(path => !InstallerPathGuard.IsSameOrChildPath(resolvedAppDirectory, path))
|
|
|
|
|
|
.Where(path =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var relative = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, path));
|
2026-07-31 11:43:50 +09:00
|
|
|
|
return !relative.StartsWith(DeploymentLayout.DeploymentDirectoryPrefix, StringComparison.OrdinalIgnoreCase);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
})
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 先复制到临时目录
|
|
|
|
|
|
var tempSibling = launcherRoot + ".tmp-staging-" + Guid.NewGuid().ToString("N")[..8];
|
|
|
|
|
|
try
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
Directory.CreateDirectory(tempSibling);
|
|
|
|
|
|
|
|
|
|
|
|
var total = Math.Max(1, files.Length);
|
|
|
|
|
|
for (var index = 0; index < files.Length; index++)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
var sourcePath = files[index];
|
|
|
|
|
|
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath));
|
|
|
|
|
|
if (DeploymentLayout.IsDeploymentMarker(relativePath))
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var tempPath = Path.GetFullPath(Path.Combine(tempSibling, relativePath));
|
|
|
|
|
|
InstallerPathGuard.EnsureChildPath(tempSibling, tempPath);
|
|
|
|
|
|
var tempParent = Path.GetDirectoryName(tempPath);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(tempParent))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.CreateDirectory(tempParent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await using (var source = File.OpenRead(sourcePath))
|
|
|
|
|
|
await using (var target = File.Create(tempPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
progress?.Report(new InstallerDeployProgress(
|
|
|
|
|
|
"复制启动器文件",
|
|
|
|
|
|
version,
|
|
|
|
|
|
1,
|
|
|
|
|
|
0.88 + ((index + 1) * 0.03 / total),
|
|
|
|
|
|
relativePath,
|
|
|
|
|
|
index + 1,
|
|
|
|
|
|
total));
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 逐文件原子移入目标(File.Move overwrite + 回退 copy+delete)
|
|
|
|
|
|
var tempFiles = Directory.EnumerateFiles(tempSibling, "*", SearchOption.AllDirectories).ToArray();
|
|
|
|
|
|
foreach (var tempFile in tempFiles)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(tempSibling, tempFile));
|
|
|
|
|
|
var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath));
|
|
|
|
|
|
InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath);
|
|
|
|
|
|
var targetParent = Path.GetDirectoryName(targetPath);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(targetParent))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.CreateDirectory(targetParent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AtomicMoveOrCopyDelete(tempFile, targetPath);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
2026-07-31 11:43:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
// 清理临时目录
|
|
|
|
|
|
TryDeleteDirectory(tempSibling);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 原子移动文件:优先 File.Move(overwrite),失败时回退到复制+删除。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static void AtomicMoveOrCopyDelete(string source, string destination)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
File.Move(source, destination, overwrite: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (IOException)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 回退:复制后删除源文件
|
|
|
|
|
|
File.Copy(source, destination, overwrite: true);
|
|
|
|
|
|
File.Delete(source);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 激活部署:删除 .partial → 写 .current → 移除其他部署的 .current → 创建 .Launcher 目录。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static void ActivateDeployment(string launcherRoot, string targetDeployment)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 删除 .partial 标记
|
|
|
|
|
|
var partialMarker = Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName);
|
|
|
|
|
|
if (File.Exists(partialMarker))
|
|
|
|
|
|
{
|
|
|
|
|
|
File.Delete(partialMarker);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写入 .current 标记
|
|
|
|
|
|
File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.CurrentMarkerFileName), string.Empty);
|
|
|
|
|
|
|
|
|
|
|
|
// 移除其他部署目录中的 .current 标记
|
|
|
|
|
|
foreach (var dir in Directory.EnumerateDirectories(launcherRoot))
|
|
|
|
|
|
{
|
|
|
|
|
|
var dirName = Path.GetFileName(dir);
|
|
|
|
|
|
if (!DeploymentLayout.IsDeploymentDirectoryName(dirName))
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
continue;
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
if (string.Equals(dir, targetDeployment, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var otherCurrent = Path.Combine(dir, DeploymentLayout.CurrentMarkerFileName);
|
|
|
|
|
|
if (File.Exists(otherCurrent))
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
File.Delete(otherCurrent);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// 忽略无法删除的标记文件
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
2026-07-31 11:43:50 +09:00
|
|
|
|
|
|
|
|
|
|
Directory.CreateDirectory(Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName));
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 清理不完整的部署目录(回滚用)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static void CleanupPartialDeployment(string targetDeployment)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Directory.Exists(targetDeployment))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.Delete(targetDeployment, recursive: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 回滚失败时尝试标记 .destroy
|
2026-06-03 00:50:52 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
if (Directory.Exists(targetDeployment))
|
|
|
|
|
|
{
|
|
|
|
|
|
File.WriteAllText(
|
|
|
|
|
|
Path.Combine(targetDeployment, DeploymentLayout.DestroyMarkerFileName),
|
|
|
|
|
|
string.Empty);
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 最终放弃,无法清理
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-31 11:43:50 +09:00
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 清理过时的部署目录:保留最新一个作为回滚,删除更旧的;锁定的目录写入 .destroy 标记。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static void CleanupStaleDeployments(string launcherRoot)
|
|
|
|
|
|
{
|
|
|
|
|
|
var deployments = Directory.EnumerateDirectories(launcherRoot)
|
|
|
|
|
|
.Where(dir =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var name = Path.GetFileName(dir);
|
|
|
|
|
|
return DeploymentLayout.IsDeploymentDirectoryName(name);
|
|
|
|
|
|
})
|
|
|
|
|
|
.OrderByDescending(dir => Directory.GetLastWriteTimeUtc(dir))
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
var keptRollback = false;
|
|
|
|
|
|
foreach (var deployment in deployments)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
var hasCurrent = File.Exists(Path.Combine(deployment, DeploymentLayout.CurrentMarkerFileName));
|
|
|
|
|
|
if (hasCurrent)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 活动部署不处理
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!keptRollback)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 保留最新一个作为回滚
|
|
|
|
|
|
keptRollback = true;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 尝试删除更旧的部署
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.Delete(deployment, recursive: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// 目录被锁定时写入 .destroy 标记
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
File.WriteAllText(
|
|
|
|
|
|
Path.Combine(deployment, DeploymentLayout.DestroyMarkerFileName),
|
|
|
|
|
|
string.Empty);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// 无法标记也放弃
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static long EstimateRequiredBytes(string sourceDirectory)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Directory
|
|
|
|
|
|
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
|
|
|
|
|
|
.Sum(path => new FileInfo(path).Length);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 12:32:56 +08:00
|
|
|
|
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:43:50 +09:00
|
|
|
|
var launcherExeName = DeploymentLayout.GetLauncherExecutableName();
|
|
|
|
|
|
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
if (!File.Exists(launcherPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
var deployedLauncher = Directory
|
2026-07-31 11:43:50 +09:00
|
|
|
|
.EnumerateFiles(launcherRoot, launcherExeName, SearchOption.AllDirectories)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
.FirstOrDefault();
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(deployedLauncher))
|
|
|
|
|
|
{
|
|
|
|
|
|
File.Copy(deployedLauncher, launcherPath, overwrite: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!File.Exists(launcherPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 22:18:27 +08:00
|
|
|
|
var startMenu = InstallerElevation.IsRunningElevated()
|
|
|
|
|
|
? Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu)
|
|
|
|
|
|
: Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
2026-06-03 00:50:52 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(startMenu))
|
|
|
|
|
|
{
|
|
|
|
|
|
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(startMenu))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var programs = Path.Combine(startMenu, "Programs");
|
|
|
|
|
|
Directory.CreateDirectory(programs);
|
|
|
|
|
|
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
|
|
|
|
|
|
WriteUrlShortcut(shortcutPath, launcherPath);
|
|
|
|
|
|
|
2026-06-03 12:32:56 +08:00
|
|
|
|
if (options.CreateDesktopShortcut)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-06-03 12:32:56 +08:00
|
|
|
|
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(desktop))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.CreateDirectory(desktop);
|
|
|
|
|
|
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 12:32:56 +08:00
|
|
|
|
if (options.CreateStartupShortcut)
|
2026-06-03 00:50:52 +08:00
|
|
|
|
{
|
2026-06-03 12:32:56 +08:00
|
|
|
|
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(startup))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.CreateDirectory(startup);
|
|
|
|
|
|
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
2026-07-31 11:43:50 +09:00
|
|
|
|
// 快捷方式创建是尽力而为;部署本身必须在没有 shell 集成的情况下可用。
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static void WriteUrlShortcut(string shortcutPath, string targetPath)
|
|
|
|
|
|
{
|
|
|
|
|
|
File.WriteAllText(
|
|
|
|
|
|
shortcutPath,
|
|
|
|
|
|
$"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}");
|
|
|
|
|
|
}
|
2026-07-31 11:43:50 +09:00
|
|
|
|
|
|
|
|
|
|
private static void TryDeleteDirectory(string path)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Directory.Exists(path))
|
|
|
|
|
|
{
|
|
|
|
|
|
Directory.Delete(path, recursive: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// 临时目录清理失败时忽略
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 00:50:52 +08:00
|
|
|
|
}
|