mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
feat.在线安装器,更好的Issue与pull request模板。
This commit is contained in:
344
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
344
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using System.Diagnostics;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
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);
|
||||
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
|
||||
|
||||
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
|
||||
Directory.CreateDirectory(launcherRoot);
|
||||
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Creating deployment",
|
||||
package.Version,
|
||||
1,
|
||||
0.15,
|
||||
null,
|
||||
0,
|
||||
null));
|
||||
|
||||
PrepareTargetDirectory(targetDeployment);
|
||||
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Activating deployment",
|
||||
package.Version,
|
||||
1,
|
||||
0.92,
|
||||
null,
|
||||
0,
|
||||
null));
|
||||
|
||||
ActivateInitialDeployment(launcherRoot, targetDeployment);
|
||||
CreateWindowsShortcutsIfAvailable(launcherRoot, options.CreateDesktopShortcut);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Completed",
|
||||
package.Version,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
0,
|
||||
null));
|
||||
}
|
||||
|
||||
public static string BuildDeploymentDirectory(string launcherRoot, string version)
|
||||
{
|
||||
var sanitized = string.IsNullOrWhiteSpace(version) ? "0.0.0" : version.Trim();
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}");
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ResolveFullPackageAppDirectory(string filesDirectory, string version)
|
||||
{
|
||||
var root = Path.GetFullPath(filesDirectory);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"PLONDS Files package directory is missing: {root}");
|
||||
}
|
||||
|
||||
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
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;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"PLONDS Files package does not contain {executableName}.");
|
||||
}
|
||||
|
||||
private static void PrepareTargetDirectory(string targetDeployment)
|
||||
{
|
||||
if (Directory.Exists(targetDeployment))
|
||||
{
|
||||
Directory.Delete(targetDeployment, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
}
|
||||
|
||||
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));
|
||||
if (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(
|
||||
"Copying files",
|
||||
version,
|
||||
1,
|
||||
0.18 + ((index + 1) * 0.70 / total),
|
||||
relativePath,
|
||||
index + 1,
|
||||
total));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CopyLauncherRootPayloadAsync(
|
||||
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));
|
||||
return !relative.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var total = Math.Max(1, files.Length);
|
||||
for (var index = 0; index < files.Length; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var sourcePath = files[index];
|
||||
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath));
|
||||
if (IsDeploymentMarker(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath));
|
||||
InstallerPathGuard.EnsureChildPath(launcherRoot, 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(
|
||||
"Copying launcher files",
|
||||
version,
|
||||
1,
|
||||
0.10 + ((index + 1) * 0.05 / total),
|
||||
relativePath,
|
||||
index + 1,
|
||||
total));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ActivateInitialDeployment(string launcherRoot, string targetDeployment)
|
||||
{
|
||||
foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, ".current", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(existingCurrent);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
if (File.Exists(partialMarker))
|
||||
{
|
||||
File.Delete(partialMarker);
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
|
||||
Directory.CreateDirectory(Path.Combine(launcherRoot, ".Launcher"));
|
||||
}
|
||||
|
||||
private static long EstimateRequiredBytes(string sourceDirectory)
|
||||
{
|
||||
return Directory
|
||||
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
|
||||
.Sum(path => new FileInfo(path).Length);
|
||||
}
|
||||
|
||||
private static bool IsDeploymentMarker(string relativePath)
|
||||
{
|
||||
var name = Path.GetFileName(relativePath);
|
||||
return name is ".current" or ".partial" or ".destroy";
|
||||
}
|
||||
|
||||
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, bool createDesktopShortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe");
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
var deployedLauncher = Directory
|
||||
.EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(deployedLauncher))
|
||||
{
|
||||
File.Copy(deployedLauncher, launcherPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
|
||||
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 (!createDesktopShortcut)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||
if (string.IsNullOrWhiteSpace(desktop))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(desktop);
|
||||
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Shortcut creation is best-effort; deployment itself must remain usable without shell integration.
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteUrlShortcut(string shortcutPath, string targetPath)
|
||||
{
|
||||
File.WriteAllText(
|
||||
shortcutPath,
|
||||
$"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
29
LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs
Normal file
29
LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
public interface IOnlineInstallService
|
||||
{
|
||||
Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task InstallFreshAsync(
|
||||
string installPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task InstallFreshAsync(
|
||||
string installPath,
|
||||
OnlineInstallOptions options,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task RepairAsync(
|
||||
string installPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateIncrementalAsync(
|
||||
string installPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
[JsonSerializable(typeof(InstallerPlondsManifest))]
|
||||
internal sealed partial class InstallerJsonContext : JsonSerializerContext;
|
||||
129
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
129
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
public static class InstallerPathGuard
|
||||
{
|
||||
public static string GetDefaultInstallPath()
|
||||
{
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
if (string.IsNullOrWhiteSpace(programFiles))
|
||||
{
|
||||
programFiles = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Programs");
|
||||
}
|
||||
|
||||
return Path.Combine(programFiles, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
public static string NormalizeInstallPath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("Installation path is required.", nameof(path));
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path.Trim());
|
||||
ValidateInstallPath(fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
public static void ValidateInstallPath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Installation path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var root = Path.GetPathRoot(fullPath);
|
||||
if (string.Equals(
|
||||
fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||
root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Choose a folder instead of a drive root.");
|
||||
}
|
||||
|
||||
var blockedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Windows",
|
||||
"System32",
|
||||
"SysWOW64",
|
||||
"Program Files",
|
||||
"Program Files (x86)",
|
||||
"Users"
|
||||
};
|
||||
var name = Path.GetFileName(fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (blockedNames.Contains(name))
|
||||
{
|
||||
throw new InvalidOperationException("Choose a dedicated application folder.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureUsableInstallPath(string path, long requiredBytes)
|
||||
{
|
||||
var fullPath = NormalizeInstallPath(path);
|
||||
var directory = Directory.Exists(fullPath)
|
||||
? new DirectoryInfo(fullPath)
|
||||
: Directory.CreateDirectory(fullPath);
|
||||
|
||||
var testPath = Path.Combine(directory.FullName, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(testPath, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(testPath))
|
||||
{
|
||||
File.Delete(testPath);
|
||||
}
|
||||
}
|
||||
|
||||
var drive = new DriveInfo(directory.Root.FullName);
|
||||
if (drive.AvailableFreeSpace > 0 && drive.AvailableFreeSpace < requiredBytes)
|
||||
{
|
||||
throw new InvalidOperationException("The selected drive does not have enough free space.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureChildPath(string parent, string child)
|
||||
{
|
||||
if (!IsSameOrChildPath(parent, child))
|
||||
{
|
||||
throw new InvalidDataException($"Path escapes the expected root: {child}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSameOrChildPath(string parent, string child)
|
||||
{
|
||||
var resolvedParent = Path.GetFullPath(parent)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var resolvedChild = Path.GetFullPath(child);
|
||||
return string.Equals(
|
||||
resolvedParent,
|
||||
resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string NormalizeRelativePath(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new InvalidDataException("Package entry path is empty.");
|
||||
}
|
||||
|
||||
var normalized = relativePath
|
||||
.Replace('\\', Path.DirectorySeparatorChar)
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (Path.IsPathRooted(normalized) || normalized.Split(Path.DirectorySeparatorChar).Contains(".."))
|
||||
{
|
||||
throw new InvalidDataException($"Package entry path is invalid: {relativePath}");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
357
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal file
357
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal file
@@ -0,0 +1,357 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagingRoot)
|
||||
{
|
||||
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
|
||||
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
|
||||
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
|
||||
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static IReadOnlyList<InstallerPlondsSource> CreateBuiltInSources()
|
||||
{
|
||||
return
|
||||
[
|
||||
new("s3", "s3", ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), 100),
|
||||
new("github", "github", ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), 50)
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<InstallerPlondsCandidate> FindLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = CreateBuiltInSources().ToList();
|
||||
var candidates = new List<InstallerPlondsCandidate>();
|
||||
|
||||
for (var index = 0; index < sources.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var source = sources[index];
|
||||
InstallerPlondsManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = await GetManifestAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddManifestSources(sources, manifest.Sources);
|
||||
var filesUrl = InstallerPlondsUrlResolver.ResolveFilesZipUrls(manifest, source).FirstOrDefault();
|
||||
if (filesUrl is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(new InstallerPlondsCandidate(source, manifest, filesUrl));
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
|
||||
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
|
||||
.ThenByDescending(candidate => candidate.Source.Priority)
|
||||
.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No usable PLONDS full package source was found.");
|
||||
}
|
||||
|
||||
public async Task<PreparedFilesPackage> DownloadAndPrepareFullPackageAsync(
|
||||
InstallerPlondsCandidate candidate,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
|
||||
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
|
||||
if (Directory.Exists(packageRoot))
|
||||
{
|
||||
Directory.Delete(packageRoot, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(packageRoot);
|
||||
var zipPath = Path.Combine(packageRoot, "Files.zip");
|
||||
var extractDirectory = Path.Combine(packageRoot, "Files");
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
|
||||
await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.FilesZipUrl, cancellationToken).ConfigureAwait(false);
|
||||
ExtractZip(zipPath, extractDirectory);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Files package prepared",
|
||||
version,
|
||||
1,
|
||||
0.10,
|
||||
"Files.zip",
|
||||
new FileInfo(zipPath).Length,
|
||||
new FileInfo(zipPath).Length));
|
||||
|
||||
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
|
||||
}
|
||||
|
||||
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
|
||||
{
|
||||
var filesBytes = manifest.FilesMap?.Values.Sum(file => Math.Max(0, file.Size)) ?? 0;
|
||||
var packageBytes = FindChecksumSizeHint(manifest.Checksums);
|
||||
return Math.Max(filesBytes, packageBytes);
|
||||
}
|
||||
|
||||
private async Task<InstallerPlondsManifest?> GetManifestAsync(
|
||||
InstallerPlondsSource source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DownloadToFileAsync(
|
||||
InstallerPlondsCandidate candidate,
|
||||
string destinationPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(candidate.FilesZipUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var totalBytes = response.Content.Headers.ContentLength;
|
||||
var partialPath = $"{destinationPath}.partial";
|
||||
long downloaded = 0;
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var target = File.Create(partialPath))
|
||||
{
|
||||
var buffer = new byte[128 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
downloaded += read;
|
||||
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Downloading Files.zip",
|
||||
candidate.Manifest.CurrentVersion,
|
||||
fraction,
|
||||
0,
|
||||
"Files.zip",
|
||||
downloaded,
|
||||
totalBytes));
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(partialPath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
private static async Task VerifyPackageAsync(
|
||||
string zipPath,
|
||||
InstallerPlondsManifest manifest,
|
||||
Uri filesZipUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl));
|
||||
if (checksum is null)
|
||||
{
|
||||
throw new InvalidDataException("PLONDS manifest does not declare a checksum for Files.zip.");
|
||||
}
|
||||
|
||||
var (algorithm, expectedHash) = ParseChecksum(checksum);
|
||||
var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"PLONDS Files.zip checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractZip(string zipPath, string destinationDirectory)
|
||||
{
|
||||
if (Directory.Exists(destinationDirectory))
|
||||
{
|
||||
Directory.Delete(destinationDirectory, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var normalizedName = InstallerPathGuard.NormalizeRelativePath(entry.FullName);
|
||||
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, normalizedName));
|
||||
InstallerPathGuard.EnsureChildPath(destinationDirectory, destinationPath);
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var parent = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(parent))
|
||||
{
|
||||
Directory.CreateDirectory(parent);
|
||||
}
|
||||
|
||||
entry.ExtractToFile(destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddManifestSources(List<InstallerPlondsSource> sources, IEnumerable<InstallerPlondsSource>? manifestSources)
|
||||
{
|
||||
if (manifestSources is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var source in manifestSources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sources.Any(existing => string.Equals(existing.Id, source.Id, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existing.ManifestUrl, source.ManifestUrl, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.Add(source with
|
||||
{
|
||||
Id = source.Id.Trim(),
|
||||
Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(),
|
||||
ManifestUrl = source.ManifestUrl.Trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetChecksumKeys(Uri url)
|
||||
{
|
||||
var urlFileName = Path.GetFileName(url.LocalPath);
|
||||
return new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName }
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? FindChecksum(IReadOnlyDictionary<string, string>? checksums, IEnumerable<string> keys)
|
||||
{
|
||||
if (checksums is null || checksums.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var match = checksums.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string Algorithm, string Hash) ParseChecksum(string checksum)
|
||||
{
|
||||
var normalized = checksum.Trim();
|
||||
var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant();
|
||||
var hash = NormalizeHash(normalized[(separatorIndex + 1)..]);
|
||||
if (algorithm is "md5" or "sha256" && hash.Length > 0)
|
||||
{
|
||||
return (algorithm, hash);
|
||||
}
|
||||
}
|
||||
|
||||
var inferred = NormalizeHash(normalized);
|
||||
return inferred.Length switch
|
||||
{
|
||||
32 => ("md5", inferred),
|
||||
64 => ("sha256", inferred),
|
||||
_ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(string filePath, string algorithm, CancellationToken cancellationToken)
|
||||
{
|
||||
using HashAlgorithm hasher = algorithm switch
|
||||
{
|
||||
"md5" => MD5.Create(),
|
||||
"sha256" => SHA256.Create(),
|
||||
_ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}")
|
||||
};
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static long FindChecksumSizeHint(IReadOnlyDictionary<string, string>? checksums)
|
||||
{
|
||||
_ = checksums;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string version)
|
||||
{
|
||||
var normalized = version.Trim().TrimStart('v', 'V');
|
||||
return Version.Parse(normalized);
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string version, out Version parsed)
|
||||
{
|
||||
return Version.TryParse(version.Trim().TrimStart('v', 'V'), out parsed!);
|
||||
}
|
||||
|
||||
private static string NormalizeHash(string value)
|
||||
{
|
||||
return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ResolveManifestUrl(string environmentVariable, string fallback)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(environmentVariable);
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private static string SanitizePathSegment(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
|
||||
var sanitized = new string(chars).Trim();
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal static class InstallerPlondsUrlResolver
|
||||
{
|
||||
public static IReadOnlyList<Uri> ResolveFilesZipUrls(
|
||||
InstallerPlondsManifest manifest,
|
||||
InstallerPlondsSource source)
|
||||
{
|
||||
var urls = new List<string?>();
|
||||
var sourceKind = source.Kind.Trim().ToLowerInvariant();
|
||||
|
||||
if (sourceKind is "s3")
|
||||
{
|
||||
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
|
||||
}
|
||||
else if (sourceKind is "github")
|
||||
{
|
||||
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
|
||||
}
|
||||
|
||||
urls.Add(DerivePackageUrl(source.ManifestUrl));
|
||||
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
|
||||
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
|
||||
|
||||
return urls
|
||||
.Where(url => !string.IsNullOrWhiteSpace(url))
|
||||
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.OfType<Uri>()
|
||||
.Where(uri => uri.Scheme is "http" or "https")
|
||||
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? DerivePackageUrl(string manifestUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(uri);
|
||||
var lastSlash = builder.Path.LastIndexOf('/');
|
||||
builder.Path = lastSlash >= 0
|
||||
? $"{builder.Path[..(lastSlash + 1)]}Files.zip"
|
||||
: "Files.zip";
|
||||
builder.Query = string.Empty;
|
||||
builder.Fragment = string.Empty;
|
||||
return builder.Uri.AbsoluteUri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
public sealed partial class InstallerPrivacyConsentStore
|
||||
{
|
||||
private const string ConsentFileName = "privacy-consent.json";
|
||||
|
||||
private readonly string _consentPath;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public InstallerPrivacyConsentStore(string? consentPath = null)
|
||||
{
|
||||
_consentPath = string.IsNullOrWhiteSpace(consentPath)
|
||||
? GetDefaultConsentPath()
|
||||
: Path.GetFullPath(consentPath);
|
||||
}
|
||||
|
||||
public bool HasConfirmed(string deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var document = TryLoad();
|
||||
return document is not null
|
||||
&& string.Equals(document.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)
|
||||
&& document.ConfirmedAtUtc <= DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveConfirmed(string deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
throw new ArgumentException("Device ID is required.", nameof(deviceId));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
Save(new InstallerPrivacyConsentDocument(
|
||||
SchemaVersion: 1,
|
||||
DeviceId: deviceId,
|
||||
ConfirmedAtUtc: DateTimeOffset.UtcNow,
|
||||
Categories:
|
||||
[
|
||||
"anonymousDeviceId",
|
||||
"systemAndArchitecture",
|
||||
"targetVersion",
|
||||
"serverReceivedIpAddress"
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetDefaultConsentPath()
|
||||
{
|
||||
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
root = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(root, "LanMountainDesktop", "Installer", ConsentFileName);
|
||||
}
|
||||
|
||||
private InstallerPrivacyConsentDocument? TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_consentPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_consentPath);
|
||||
return JsonSerializer.Deserialize(
|
||||
json,
|
||||
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(InstallerPrivacyConsentDocument document)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_consentPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var tempPath = $"{_consentPath}.{Guid.NewGuid():N}.tmp";
|
||||
var json = JsonSerializer.Serialize(
|
||||
document,
|
||||
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _consentPath, overwrite: true);
|
||||
}
|
||||
|
||||
private sealed record InstallerPrivacyConsentDocument(
|
||||
int SchemaVersion,
|
||||
string DeviceId,
|
||||
DateTimeOffset ConfirmedAtUtc,
|
||||
IReadOnlyList<string> Categories);
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(InstallerPrivacyConsentDocument))]
|
||||
private sealed partial class InstallerPrivacyConsentJsonContext : JsonSerializerContext;
|
||||
}
|
||||
83
LanDesktopPLONDS.installer/Services/OnlineInstallService.cs
Normal file
83
LanDesktopPLONDS.installer/Services/OnlineInstallService.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Privacy;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal sealed class OnlineInstallService(
|
||||
InstallerPlondsClient plondsClient,
|
||||
FilesPackageInstaller packageInstaller,
|
||||
IPrivacyDeviceIdentityProvider privacyIdentity) : IOnlineInstallService
|
||||
{
|
||||
private InstallerPlondsCandidate? _latestCandidate;
|
||||
|
||||
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
|
||||
{
|
||||
var httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(20)
|
||||
};
|
||||
var stagingRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Installer",
|
||||
"PLONDS");
|
||||
return new OnlineInstallService(
|
||||
new InstallerPlondsClient(httpClient, stagingRoot),
|
||||
new FilesPackageInstaller(),
|
||||
privacyIdentity);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public Task RepairAsync(
|
||||
string installPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = installPath;
|
||||
_ = progress;
|
||||
_ = cancellationToken;
|
||||
throw new NotSupportedException("Repair is reserved for a later installer version.");
|
||||
}
|
||||
|
||||
public Task UpdateIncrementalAsync(
|
||||
string installPath,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = installPath;
|
||||
_ = progress;
|
||||
_ = cancellationToken;
|
||||
throw new NotSupportedException("Incremental update is reserved for a later installer version.");
|
||||
}
|
||||
}
|
||||
81
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal file
81
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal sealed record InstallerPlondsSource(
|
||||
string Id,
|
||||
string Kind,
|
||||
string ManifestUrl,
|
||||
int Priority = 0);
|
||||
|
||||
internal sealed record InstallerPlondsManifest(
|
||||
string FormatVersion,
|
||||
string CurrentVersion,
|
||||
string PreviousVersion,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyDictionary<string, InstallerPlondsFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, InstallerPlondsChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums,
|
||||
InstallerPlondsDownloads? Downloads,
|
||||
IReadOnlyList<InstallerPlondsSource>? Sources);
|
||||
|
||||
internal sealed record InstallerPlondsFileEntry(
|
||||
string Action,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
internal sealed record InstallerPlondsChangedFileEntry(
|
||||
string ArchivePath,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
internal sealed record InstallerPlondsDownloads(
|
||||
InstallerPlondsGitHubDownloads? GitHub,
|
||||
InstallerPlondsS3Downloads? S3);
|
||||
|
||||
internal sealed record InstallerPlondsGitHubDownloads(
|
||||
string? ReleaseUrl,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipUrl,
|
||||
string? FilesZipUrl);
|
||||
|
||||
internal sealed record InstallerPlondsS3Downloads(
|
||||
string? Bucket,
|
||||
string? Prefix,
|
||||
string? ManifestKey,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipKey,
|
||||
string? ChangedZipUrl,
|
||||
string? ChangedFolderKey,
|
||||
string? ChangedFolderUrl,
|
||||
string? FilesZipKey,
|
||||
string? FilesZipUrl,
|
||||
string? FilesFolderKey,
|
||||
string? FilesFolderUrl);
|
||||
|
||||
public sealed record OnlineInstallPackageInfo(
|
||||
string Version,
|
||||
string SourceId,
|
||||
Uri FilesZipUrl,
|
||||
long EstimatedBytes);
|
||||
|
||||
public sealed record OnlineInstallOptions(bool CreateDesktopShortcut)
|
||||
{
|
||||
public static OnlineInstallOptions Default { get; } = new(CreateDesktopShortcut: false);
|
||||
}
|
||||
|
||||
internal sealed record InstallerPlondsCandidate(
|
||||
InstallerPlondsSource Source,
|
||||
InstallerPlondsManifest Manifest,
|
||||
Uri FilesZipUrl);
|
||||
|
||||
internal sealed record PreparedFilesPackage(
|
||||
string Version,
|
||||
string SourceId,
|
||||
string ZipPath,
|
||||
string ExtractDirectory,
|
||||
InstallerPlondsManifest Manifest);
|
||||
Reference in New Issue
Block a user