Files
LanMountainDesktop/install/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs

584 lines
21 KiB
C#
Raw Normal View History

using System.Diagnostics;
using LanDesktopPLONDS.Installer.Models;
2026-07-31 11:43:50 +09:00
using LanMountainDesktop.Shared.Contracts.Deployment;
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-09 22:18:27 +08:00
InstallerElevation.EnsureCanInstall(launcherRoot);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
Directory.CreateDirectory(launcherRoot);
2026-07-31 11:43:50 +09:00
// 在复制 launcherRoot 文件之前,检测是否有进程正在运行
RunningProcessGuard.EnsureNoRunningProcesses(launcherRoot);
2026-07-31 11:43:50 +09:00
// 事务性安装:失败时回滚,不破坏已有状态
try
{
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-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-07-31 11:43:50 +09:00
/// <summary>
/// 从包目录中解析出包含主程序可执行文件的应用目录。
/// </summary>
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-07-31 11:43:50 +09:00
var executableName = DeploymentLayout.GetHostExecutableName();
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-07-31 11:43:50 +09:00
/// <summary>
/// 准备目标部署目录:创建目录并写入 .partial 标记。
/// </summary>
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-07-31 11:43:50 +09:00
/// <summary>
/// 逐文件复制源目录到目标目录,跳过标记文件。
/// </summary>
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))
{
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
"复制文件",
version,
1,
2026-07-31 11:43:50 +09:00
0.15 + ((index + 1) * 0.70 / total),
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(
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);
})
.ToArray();
2026-07-31 11:43:50 +09:00
// 先复制到临时目录
var tempSibling = launcherRoot + ".tmp-staging-" + Guid.NewGuid().ToString("N")[..8];
try
{
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-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-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-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-07-31 11:43:50 +09:00
}
finally
{
// 清理临时目录
TryDeleteDirectory(tempSibling);
}
}
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-07-31 11:43:50 +09:00
continue;
}
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-07-31 11:43:50 +09:00
Directory.CreateDirectory(Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName));
}
2026-07-31 11:43:50 +09:00
/// <summary>
/// 清理不完整的部署目录(回滚用)。
/// </summary>
private static void CleanupPartialDeployment(string targetDeployment)
{
2026-07-31 11:43:50 +09:00
try
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, recursive: true);
}
}
catch
{
2026-07-31 11:43:50 +09:00
// 回滚失败时尝试标记 .destroy
try
{
2026-07-31 11:43:50 +09:00
if (Directory.Exists(targetDeployment))
{
File.WriteAllText(
Path.Combine(targetDeployment, DeploymentLayout.DestroyMarkerFileName),
string.Empty);
}
}
catch
{
2026-07-31 11:43:50 +09:00
// 最终放弃,无法清理
}
}
2026-07-31 11:43:50 +09: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-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-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
{
// 无法标记也放弃
}
}
}
}
private static long EstimateRequiredBytes(string sourceDirectory)
{
return Directory
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
.Sum(path => new FileInfo(path).Length);
}
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options)
{
try
{
if (!OperatingSystem.IsWindows())
{
return;
}
2026-07-31 11:43:50 +09:00
var launcherExeName = DeploymentLayout.GetLauncherExecutableName();
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
if (!File.Exists(launcherPath))
{
var deployedLauncher = Directory
2026-07-31 11:43:50 +09:00
.EnumerateFiles(launcherRoot, launcherExeName, SearchOption.AllDirectories)
.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);
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);
if (options.CreateDesktopShortcut)
{
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (!string.IsNullOrWhiteSpace(desktop))
{
Directory.CreateDirectory(desktop);
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
}
}
if (options.CreateStartupShortcut)
{
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
if (!string.IsNullOrWhiteSpace(startup))
{
Directory.CreateDirectory(startup);
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
}
}
}
catch
{
2026-07-31 11:43:50 +09:00
// 快捷方式创建是尽力而为;部署本身必须在没有 shell 集成的情况下可用。
}
}
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
{
// 临时目录清理失败时忽略
}
}
}