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

354 lines
14 KiB
C#
Raw Normal View History

using LanDesktopPLONDS.Installer.Models;
2026-07-31 11:43:50 +09:00
using LanMountainDesktop.Shared.Contracts.Deployment;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class OnlineInstallService(
InstallerPlondsClient plondsClient,
FilesPackageInstaller packageInstaller,
2026-07-31 11:43:50 +09:00
IPrivacyDeviceIdentityProvider privacyIdentity,
InstalledProductInspector installedProductInspector,
IncrementalPlanBuilder incrementalPlanBuilder) : IOnlineInstallService
{
private InstallerPlondsCandidate? _latestCandidate;
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
{
2026-07-31 11:43:50 +09:00
// HttpClient 超时设置为无限:单次操作的超时控制由调用方通过 CancellationTokenSource 实现,
// HttpClient 级别仅作为兜底安全网,不应限制大型文件的下载时间。
var httpClient = new HttpClient
{
2026-07-31 11:43:50 +09:00
Timeout = Timeout.InfiniteTimeSpan
};
var stagingRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Installer",
"PLONDS");
return new OnlineInstallService(
new InstallerPlondsClient(httpClient, stagingRoot),
new FilesPackageInstaller(),
2026-07-31 11:43:50 +09:00
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);
}
2026-07-31 11:43:50 +09:00
/// <summary>
/// 修复安装:重新下载远程最新完整包并在新部署目录中重新部署。
/// 语义说明即使本地已安装的版本号与远程最新版本相同Repair 仍会执行完整重装。
/// 这确保本地文件的完整性(修复文件损坏、缺失或权限问题)。
/// 如果远程最新版本与本地已安装版本不同Repair 将安装远程最新版本(即同时完成升级)。
/// </summary>
public async Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
2026-07-31 11:43:50 +09:00
_ = 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);
}
2026-07-31 11:43:50 +09:00
/// <summary>
/// 增量更新:对比清单 FilesMap 与本地部署目录,仅替换变更文件。
/// 如果清单缺少逐文件哈希FilesMap 中所有条目的 Hash 为空),则回退到完整更新。
/// 如果可以获取 ChangedZipChangedZipUrl 非空且 ChangedFilesMap 有数据),
/// 则直接下载增量包并叠加到当前部署上;否则采用"计划-然后-应用"优化策略:
/// 下载完整 zip但从当前部署目录复制未变更文件仅从 zip 中提取变更文件,
/// 以减少磁盘 I/O 开销。激活步骤保持与 InstallAsync 相同的事务语义。
/// </summary>
public async Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
2026-07-31 11:43:50 +09:00
_ = 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));
}
}