Files
LanMountainDesktop/install/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs
2026-08-11 19:41:54 +08:00

354 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using LanDesktopPLONDS.Installer.Models;
using LanMountainDesktop.Shared.Contracts.Deployment;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class OnlineInstallService(
InstallerPlondsClient plondsClient,
FilesPackageInstaller packageInstaller,
IPrivacyDeviceIdentityProvider privacyIdentity,
InstalledProductInspector installedProductInspector,
IncrementalPlanBuilder incrementalPlanBuilder) : IOnlineInstallService
{
private InstallerPlondsCandidate? _latestCandidate;
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
{
// HttpClient 超时设置为无限:单次操作的超时控制由调用方通过 CancellationTokenSource 实现,
// HttpClient 级别仅作为兜底安全网,不应限制大型文件的下载时间。
var httpClient = new HttpClient
{
Timeout = Timeout.InfiniteTimeSpan
};
var stagingRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Installer",
"PLONDS");
return new OnlineInstallService(
new InstallerPlondsClient(httpClient, stagingRoot),
new FilesPackageInstaller(),
privacyIdentity,
new InstalledProductInspector(),
new IncrementalPlanBuilder());
}
public async Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
{
var candidate = await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
return new OnlineInstallPackageInfo(
candidate.Manifest.CurrentVersion,
candidate.Source.Id,
candidate.FilesZipUrl,
InstallerPlondsClient.EstimateInstallBytes(candidate.Manifest));
}
public async Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallFreshAsync(installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
await packageInstaller.InstallAsync(package, installPath, options, progress, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 修复安装:重新下载远程最新完整包并在新部署目录中重新部署。
/// 语义说明即使本地已安装的版本号与远程最新版本相同Repair 仍会执行完整重装。
/// 这确保本地文件的完整性(修复文件损坏、缺失或权限问题)。
/// 如果远程最新版本与本地已安装版本不同Repair 将安装远程最新版本(即同时完成升级)。
/// </summary>
public async Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
// 1. 检测已安装产品(用于日志记录和语义决策,但不阻止修复操作)
var installed = installedProductInspector.Detect(launcherRoot);
if (installed is not null)
{
progress?.Report(new InstallerDeployProgress(
"检测到已安装版本",
installed.Version.ToString(),
0,
0.02,
null,
0,
null));
}
// 2. 获取远程最新候选项
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
var remoteVersion = candidate.Manifest.CurrentVersion;
progress?.Report(new InstallerDeployProgress(
"准备修复包",
remoteVersion,
0,
0.04,
null,
0,
null));
// 3. 下载并准备完整包
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
// 4. 通过 FilesPackageInstaller 部署(自增目录命名避免冲突)
await packageInstaller.InstallAsync(package, launcherRoot, progress, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 增量更新:对比清单 FilesMap 与本地部署目录,仅替换变更文件。
/// 如果清单缺少逐文件哈希FilesMap 中所有条目的 Hash 为空),则回退到完整更新。
/// 如果可以获取 ChangedZipChangedZipUrl 非空且 ChangedFilesMap 有数据),
/// 则直接下载增量包并叠加到当前部署上;否则采用"计划-然后-应用"优化策略:
/// 下载完整 zip但从当前部署目录复制未变更文件仅从 zip 中提取变更文件,
/// 以减少磁盘 I/O 开销。激活步骤保持与 InstallAsync 相同的事务语义。
/// </summary>
public async Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
// 1. 检测已安装产品
var installed = installedProductInspector.Detect(launcherRoot);
if (installed is null)
{
// 未找到已安装产品,回退到全新安装
progress?.Report(new InstallerDeployProgress(
"未检测到已安装产品,执行全新安装",
null,
0,
0,
null,
0,
null));
await InstallFreshAsync(launcherRoot, progress, cancellationToken).ConfigureAwait(false);
return;
}
// 2. 获取远程最新候选项
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
var remoteVersion = candidate.Manifest.CurrentVersion;
progress?.Report(new InstallerDeployProgress(
"构建增量计划",
remoteVersion,
0,
0.02,
null,
0,
null));
// 3. 构建增量计划
var plan = incrementalPlanBuilder.Build(candidate.Manifest.FilesMap, installed.DeploymentPath);
if (plan.RequiresFullUpdate)
{
// 增量信息不可用,回退到完整更新
progress?.Report(new InstallerDeployProgress(
"增量信息不可用,回退到完整更新",
remoteVersion,
0,
0.04,
null,
0,
null));
await InstallFreshAsync(launcherRoot, progress, cancellationToken).ConfigureAwait(false);
return;
}
progress?.Report(new InstallerDeployProgress(
$"增量计划:{plan.FilesToReplace.Count} 个文件需更新,{plan.FilesUnchanged.Count} 个文件不变,{plan.FilesToDelete.Count} 个文件需删除",
remoteVersion,
0,
0.04,
null,
0,
null));
// 4. 下载完整包(当前不支持单文件下载,采用下载完整包后选择性提取的策略)
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
// 5. 创建新部署目录并执行增量部署
var targetDeployment = DeploymentLayout.BuildDeploymentDirectory(launcherRoot, package.Version);
InstallerElevation.EnsureCanInstall(launcherRoot);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, 0);
Directory.CreateDirectory(launcherRoot);
// 5a. 创建目标目录并标记为 .partial事务安全失败后标记残留可被清理
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, recursive: true);
}
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName), string.Empty);
progress?.Report(new InstallerDeployProgress(
"复制未变更文件",
remoteVersion,
0,
0.10,
null,
0,
null));
// 5b. 从当前部署目录复制未变更文件到新部署目录
var unchangedCount = 0;
foreach (var unchangedFile in plan.FilesUnchanged)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = Path.Combine(installed.DeploymentPath, unchangedFile.Replace('/', Path.DirectorySeparatorChar));
var targetPath = Path.Combine(targetDeployment, unchangedFile.Replace('/', Path.DirectorySeparatorChar));
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
File.Copy(sourcePath, targetPath, overwrite: false);
unchangedCount++;
if (plan.FilesToReplace.Count + plan.FilesUnchanged.Count > 0)
{
var fraction = 0.10 + (0.65 * unchangedCount / Math.Max(1, plan.FilesToReplace.Count + plan.FilesUnchanged.Count));
progress?.Report(new InstallerDeployProgress(
"复制未变更文件",
remoteVersion,
0,
Math.Clamp(fraction, 0.10, 0.75),
unchangedFile,
unchangedCount,
plan.FilesToReplace.Count + plan.FilesUnchanged.Count));
}
}
progress?.Report(new InstallerDeployProgress(
"提取变更文件",
remoteVersion,
0,
0.76,
null,
0,
null));
// 5c. 从下载的包中提取变更/缺失文件到新部署目录
var extractDir = package.ExtractDirectory;
var extractedCount = 0;
foreach (var fileAction in plan.FilesToReplace)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = Path.Combine(extractDir, fileAction.RelativePath.Replace('/', Path.DirectorySeparatorChar));
var targetPath = Path.Combine(targetDeployment, fileAction.RelativePath.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(sourcePath))
{
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
File.Copy(sourcePath, targetPath, overwrite: true);
}
extractedCount++;
if (plan.FilesToReplace.Count > 0)
{
var fraction = 0.76 + (0.16 * extractedCount / plan.FilesToReplace.Count);
progress?.Report(new InstallerDeployProgress(
"提取变更文件",
remoteVersion,
0,
Math.Clamp(fraction, 0.76, 0.92),
fileAction.RelativePath,
extractedCount,
plan.FilesToReplace.Count));
}
}
// 6. 激活部署(事务语义:先移除旧 .current再移除 .partial最后写入 .current
progress?.Report(new InstallerDeployProgress(
"激活部署",
remoteVersion,
0,
0.93,
null,
0,
null));
ActivateDeployment(launcherRoot, targetDeployment);
// 7. 清理旧的多余文件(如果在计划中指定)
// 注意:旧部署目录的清理由 Launcher 自身的 CleanupOldDeployments 负责
progress?.Report(new InstallerDeployProgress(
"完成",
remoteVersion,
1,
1,
null,
0,
null));
}
/// <summary>
/// 激活部署:移除所有旧的 .current 标记,移除新部署的 .partial 标记,写入 .current 标记。
/// 与 FilesPackageInstaller.ActivateInitialDeployment 相同的事务语义。
/// </summary>
private static void ActivateDeployment(string launcherRoot, string targetDeployment)
{
// 移除所有旧的 .current 标记
foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, DeploymentLayout.CurrentMarkerFileName, SearchOption.AllDirectories))
{
try
{
File.Delete(existingCurrent);
}
catch
{
// 忽略删除失败(文件可能被锁定)
}
}
// 移除新部署的 .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);
// 确保 .Launcher 状态目录存在
Directory.CreateDirectory(Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName));
}
}