Initial scaffolding: PS_Launcher v0.2

Client (C# / .NET 8 / WPF, MVVM via CommunityToolkit.Mvvm):
- PSLauncher.App: WPF UI dark theme (Epic-style sidebar + hero + big play button)
  with UpdateAvailableDialog rendering Markdown release notes via Markdig.Wpf.
- PSLauncher.Core: services for installation registry (scans Proserve v{X.Y.Z}/),
  process launcher, manifest fetch, SHA-256 integrity, HTTP download with
  progress, ZIP install via .tmp + atomic rename, update orchestrator.
- PSLauncher.Models: RemoteManifest, InstalledVersion, LocalConfig DTOs.

Server (PHP 8 for OVH mutualisé, deployed under www/PS_Launcher/):
- Front controller + routes /manifest and /releasenotes/{version}.
- Static signed-manifest workflow with tools/sign-manifest.php CLI to
  recompute SHA-256 and sizeBytes after each ZIP upload.
- .htaccess: HTTPS redirect, rewrite, security headers.
- config.example.php template; real config.php is gitignored.

Cohabiting versions: each release lives in its own Proserve v{version}/ folder
under installRoot. Old versions are never deleted automatically.

Roadmap: v0.3 = HTTP Range resume + Polly retry + state.json,
v0.4 = MySQL license + Ed25519 signatures + DPAPI, v0.5 = settings/UX polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 08:54:45 +02:00
commit 1c8c6803e8
48 changed files with 2269 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Configuration;
public sealed class ConfigStore : IConfigStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly ILogger<ConfigStore> _logger;
private readonly string _configFile;
public string ConfigDirectory { get; }
public ConfigStore(ILogger<ConfigStore> logger)
{
_logger = logger;
ConfigDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher");
Directory.CreateDirectory(ConfigDirectory);
_configFile = Path.Combine(ConfigDirectory, "config.json");
}
public LocalConfig Load()
{
if (!File.Exists(_configFile))
{
var fresh = new LocalConfig
{
InstallRoot = ResolveDefaultInstallRoot()
};
Save(fresh);
return fresh;
}
try
{
using var fs = File.OpenRead(_configFile);
var cfg = JsonSerializer.Deserialize<LocalConfig>(fs, JsonOptions) ?? new LocalConfig();
if (string.IsNullOrWhiteSpace(cfg.InstallRoot))
cfg.InstallRoot = ResolveDefaultInstallRoot();
return cfg;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load config; using defaults");
return new LocalConfig { InstallRoot = ResolveDefaultInstallRoot() };
}
}
public void Save(LocalConfig config)
{
var tmp = _configFile + ".tmp";
using (var fs = File.Create(tmp))
{
JsonSerializer.Serialize(fs, config, JsonOptions);
}
File.Move(tmp, _configFile, overwrite: true);
}
private static string ResolveDefaultInstallRoot()
{
var exeDir = AppContext.BaseDirectory;
var sibling = Path.Combine(Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!, "ASTERION_VR");
if (Directory.Exists(sibling)) return sibling;
var dev = @"C:\ASTERION\GIT\PS_Launcher\ASTERION_VR";
if (Directory.Exists(dev)) return dev;
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"ASTERION_VR");
}
}

View File

@@ -0,0 +1,10 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Configuration;
public interface IConfigStore
{
LocalConfig Load();
void Save(LocalConfig config);
string ConfigDirectory { get; }
}

View File

@@ -0,0 +1,126 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
public sealed class DownloadManager : IDownloadManager
{
private const int BufferSize = 1 << 20; // 1 MiB
private readonly HttpClient _http;
private readonly IConfigStore _configStore;
private readonly IIntegrityService _integrity;
private readonly ILogger<DownloadManager> _logger;
public DownloadManager(
HttpClient http,
IConfigStore configStore,
IIntegrityService integrity,
ILogger<DownloadManager> logger)
{
_http = http;
_configStore = configStore;
_integrity = integrity;
_logger = logger;
}
/// <summary>
/// Télécharge le ZIP et retourne le chemin du fichier final (SHA-256 vérifié).
/// v0.2 : pas de reprise — un échec impose de tout recommencer. Resume en v0.3.
/// </summary>
public async Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
Directory.CreateDirectory(dir);
var partial = Path.Combine(dir, $"proserve-{job.Version}.zip.partial");
var final = Path.Combine(dir, $"proserve-{job.Version}.zip");
// Vérif d'espace disque sur le volume du cache (× 1.05 pour marge)
if (job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(dir)!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
if (File.Exists(partial)) File.Delete(partial);
_logger.LogInformation("Downloading {Url} -> {Path}", job.Url, partial);
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? job.ExpectedSize;
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
await using (var dst = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true))
{
var buffer = new byte[BufferSize];
long read = 0;
var sw = Stopwatch.StartNew();
long lastReportRead = 0;
var lastReport = sw.Elapsed;
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
read += n;
var elapsed = sw.Elapsed;
if ((elapsed - lastReport).TotalMilliseconds >= 250)
{
var deltaBytes = read - lastReportRead;
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
TimeSpan? eta = null;
if (bps > 0 && total > 0) eta = TimeSpan.FromSeconds((total - read) / bps);
progress?.Report(new DownloadProgress(read, total, bps, eta));
lastReportRead = read;
lastReport = elapsed;
}
}
await dst.FlushAsync(ct).ConfigureAwait(false);
progress?.Report(new DownloadProgress(read, total, 0, TimeSpan.Zero));
}
// Vérif taille
var actualSize = new FileInfo(partial).Length;
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
{
File.Delete(partial);
throw new InvalidDataException(
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
}
// Vérif SHA-256
if (!string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Verifying SHA-256...");
var sha = await _integrity.ComputeSha256Async(partial, null, ct).ConfigureAwait(false);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(partial);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée (à corriger côté serveur)");
}
if (File.Exists(final)) File.Delete(final);
File.Move(partial, final);
return final;
}
}

View File

@@ -0,0 +1,17 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
public interface IDownloadManager
{
Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
CancellationToken ct);
}
public sealed record DownloadJob(
string Version,
Uri Url,
long ExpectedSize,
string ExpectedSha256);

View File

@@ -0,0 +1,11 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
public interface IInstallationRegistry
{
IReadOnlyList<InstalledVersion> Scan();
InstalledVersion? Get(string version);
bool IsInstalled(string version);
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
}

View File

@@ -0,0 +1,12 @@
namespace PSLauncher.Core.Installations;
public interface IZipInstaller
{
Task<string> InstallAsync(
string zipPath,
string targetFolder,
IProgress<InstallProgress>? progress,
CancellationToken ct);
}
public sealed record InstallProgress(long EntriesDone, long EntriesTotal, long BytesDone, long BytesTotal, string CurrentEntry);

View File

@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
public sealed partial class InstallationRegistry : IInstallationRegistry
{
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;
private readonly Func<string> _installRootProvider;
public InstallationRegistry(ILogger<InstallationRegistry> logger, Func<string> installRootProvider)
{
_logger = logger;
_installRootProvider = installRootProvider;
}
public IReadOnlyList<InstalledVersion> Scan()
{
var root = _installRootProvider();
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
_logger.LogWarning("Install root does not exist: {Root}", root);
return Array.Empty<InstalledVersion>();
}
var results = new List<InstalledVersion>();
foreach (var dir in Directory.EnumerateDirectories(root))
{
var name = Path.GetFileName(dir);
var match = VersionFolderRegex().Match(name);
if (!match.Success) continue;
var exe = Path.Combine(dir, ExecutableName);
if (!File.Exists(exe))
{
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
continue;
}
var info = new DirectoryInfo(dir);
results.Add(new InstalledVersion(
Version: match.Groups["v"].Value,
FolderPath: dir,
ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir)));
}
return results
.OrderByDescending(v => SemVer.Parse(v.Version))
.ToList();
}
public InstalledVersion? Get(string version) =>
Scan().FirstOrDefault(v => v.Version == version);
public bool IsInstalled(string version) => Get(version) is not null;
public Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct)
{
var existing = Get(version) ?? throw new InvalidOperationException($"Version {version} not installed");
return Task.Run(() =>
{
var trash = Path.Combine(_installRootProvider(), ".trash",
$"{Path.GetFileName(existing.FolderPath)}-{DateTime.UtcNow:yyyyMMddHHmmss}");
Directory.CreateDirectory(Path.GetDirectoryName(trash)!);
Directory.Move(existing.FolderPath, trash);
try
{
Directory.Delete(trash, recursive: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete trash {Path}", trash);
}
progress?.Report(1.0);
}, ct);
}
private long SafeDirectorySize(string dir)
{
try
{
return new DirectoryInfo(dir)
.EnumerateFiles("*", SearchOption.AllDirectories)
.Sum(f => f.Length);
}
catch
{
return 0;
}
}
}

View File

@@ -0,0 +1,28 @@
namespace PSLauncher.Core.Installations;
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
{
public static SemVer Parse(string s)
{
var parts = s.Split('.');
if (parts.Length != 3
|| !int.TryParse(parts[0], out var maj)
|| !int.TryParse(parts[1], out var min)
|| !int.TryParse(parts[2], out var pat))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat);
}
public int CompareTo(SemVer other)
{
var c = Major.CompareTo(other.Major);
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
if (c != 0) return c;
return Patch.CompareTo(other.Patch);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
}

View File

@@ -0,0 +1,99 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
namespace PSLauncher.Core.Installations;
public sealed class ZipInstaller : IZipInstaller
{
private readonly ILogger<ZipInstaller> _logger;
public ZipInstaller(ILogger<ZipInstaller> logger) => _logger = logger;
public async Task<string> InstallAsync(
string zipPath,
string targetFolder,
IProgress<InstallProgress>? progress,
CancellationToken ct)
{
if (!File.Exists(zipPath))
throw new FileNotFoundException("ZIP not found", zipPath);
var stagingFolder = targetFolder + ".tmp";
if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true);
Directory.CreateDirectory(stagingFolder);
try
{
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
if (Directory.Exists(targetFolder))
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
Directory.Move(stagingFolder, targetFolder);
_logger.LogInformation("Installed to {Target}", targetFolder);
return targetFolder;
}
catch
{
// Nettoie le staging si échec
try { if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true); } catch { }
throw;
}
}
private static void ExtractZip(string zipPath, string stagingFolder, IProgress<InstallProgress>? progress, CancellationToken ct)
{
using var archive = ZipFile.OpenRead(zipPath);
long totalBytes = 0;
foreach (var e in archive.Entries) totalBytes += e.Length;
long total = archive.Entries.Count;
long doneEntries = 0;
long doneBytes = 0;
// Détecte si toutes les entrées partagent un préfixe racine commun (ex: "Proserve v1.4.6/")
// → on strip ce préfixe pour aplatir l'arbo dans stagingFolder.
var rootPrefix = DetectCommonRootPrefix(archive);
foreach (var entry in archive.Entries)
{
ct.ThrowIfCancellationRequested();
var relative = entry.FullName;
if (rootPrefix is not null && relative.StartsWith(rootPrefix, StringComparison.Ordinal))
relative = relative[rootPrefix.Length..];
if (string.IsNullOrEmpty(relative)) { doneEntries++; continue; }
var target = Path.GetFullPath(Path.Combine(stagingFolder, relative));
if (!target.StartsWith(Path.GetFullPath(stagingFolder), StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"Entrée ZIP suspecte (zip-slip) : {entry.FullName}");
if (relative.EndsWith('/') || relative.EndsWith('\\'))
{
Directory.CreateDirectory(target);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target, overwrite: true);
doneBytes += entry.Length;
}
doneEntries++;
progress?.Report(new InstallProgress(doneEntries, total, doneBytes, totalBytes, relative));
}
}
private static string? DetectCommonRootPrefix(ZipArchive archive)
{
string? prefix = null;
foreach (var e in archive.Entries)
{
var slash = e.FullName.IndexOf('/');
if (slash <= 0) return null;
var first = e.FullName[..(slash + 1)];
if (prefix is null) prefix = first;
else if (prefix != first) return null;
}
return prefix;
}
}

View File

@@ -0,0 +1,6 @@
namespace PSLauncher.Core.Integrity;
public interface IIntegrityService
{
Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct);
}

View File

@@ -0,0 +1,26 @@
using System.Security.Cryptography;
namespace PSLauncher.Core.Integrity;
public sealed class IntegrityService : IIntegrityService
{
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
{
const int bufferSize = 1 << 20; // 1 MiB
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
using var sha = SHA256.Create();
var buffer = new byte[bufferSize];
long total = fs.Length;
long read = 0;
int n;
while ((n = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct).ConfigureAwait(false)) > 0)
{
sha.TransformBlock(buffer, 0, n, null, 0);
read += n;
if (total > 0) progress?.Report((double)read / total);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,9 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
public interface IManifestService
{
Task<RemoteManifest> FetchAsync(CancellationToken ct);
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
}

View File

@@ -0,0 +1,46 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
public sealed class ManifestService : IManifestService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _http;
private readonly Func<string> _serverBaseUrlProvider;
private readonly ILogger<ManifestService> _logger;
public ManifestService(HttpClient http, Func<string> serverBaseUrlProvider, ILogger<ManifestService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_logger = logger;
}
public async Task<RemoteManifest> FetchAsync(CancellationToken ct)
{
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
_logger.LogInformation("Fetching manifest: {Url}", url);
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var manifest = await resp.Content.ReadFromJsonAsync<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Empty manifest");
return manifest;
}
public async Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct)
{
_logger.LogInformation("Fetching release notes: {Url}", url);
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
private static string TrimSlash(string s) => s.TrimEnd('/');
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PSLauncher.Models\PSLauncher.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.Process;
public interface IProcessLauncher
{
SysProcess Launch(InstalledVersion version, string[]? args = null);
bool IsRunning(InstalledVersion version);
}

View File

@@ -0,0 +1,44 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.Process;
public sealed class ProcessLauncher : IProcessLauncher
{
private readonly ILogger<ProcessLauncher> _logger;
public ProcessLauncher(ILogger<ProcessLauncher> logger) => _logger = logger;
public SysProcess Launch(InstalledVersion version, string[]? args = null)
{
if (!File.Exists(version.ExecutablePath))
throw new FileNotFoundException("Executable not found", version.ExecutablePath);
var psi = new ProcessStartInfo
{
FileName = version.ExecutablePath,
WorkingDirectory = version.FolderPath,
UseShellExecute = true
};
if (args is not null)
{
foreach (var arg in args) psi.ArgumentList.Add(arg);
}
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
return SysProcess.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null");
}
public bool IsRunning(InstalledVersion version)
{
var name = Path.GetFileNameWithoutExtension(version.ExecutablePath);
return SysProcess.GetProcessesByName(name).Any(p =>
{
try { return string.Equals(p.MainModule?.FileName, version.ExecutablePath, StringComparison.OrdinalIgnoreCase); }
catch { return false; }
});
}
}

View File

@@ -0,0 +1,16 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Updates;
public interface IUpdateChecker
{
Task<UpdateCheckResult> CheckAsync(CancellationToken ct);
}
public sealed record UpdateCheckResult(
RemoteManifest? Manifest,
VersionManifest? LatestRemote,
InstalledVersion? CurrentInstalled,
bool IsLatestInstalled,
bool IsLatestNewerThanInstalled,
string? Error);

View File

@@ -0,0 +1,53 @@
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Manifests;
using PSLauncher.Models;
namespace PSLauncher.Core.Updates;
public sealed class UpdateChecker : IUpdateChecker
{
private readonly IManifestService _manifestService;
private readonly IInstallationRegistry _registry;
private readonly ILogger<UpdateChecker> _logger;
public UpdateChecker(
IManifestService manifestService,
IInstallationRegistry registry,
ILogger<UpdateChecker> logger)
{
_manifestService = manifestService;
_registry = registry;
_logger = logger;
}
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct)
{
try
{
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
var latest = manifest.Versions.FirstOrDefault(v => v.Version == manifest.Latest)
?? manifest.Versions
.OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault();
var installed = _registry.Scan();
var latestInstalled = installed
.OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault();
var isLatestInstalled = latest is not null && latestInstalled is not null
&& latestInstalled.Version == latest.Version;
var isNewer = latest is not null
&& (latestInstalled is null
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0);
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Update check failed");
return new UpdateCheckResult(null, null, null, false, false, ex.Message);
}
}
}