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:
81
src/PSLauncher.Core/Configuration/ConfigStore.cs
Normal file
81
src/PSLauncher.Core/Configuration/ConfigStore.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
10
src/PSLauncher.Core/Configuration/IConfigStore.cs
Normal file
10
src/PSLauncher.Core/Configuration/IConfigStore.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Configuration;
|
||||
|
||||
public interface IConfigStore
|
||||
{
|
||||
LocalConfig Load();
|
||||
void Save(LocalConfig config);
|
||||
string ConfigDirectory { get; }
|
||||
}
|
||||
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal file
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/PSLauncher.Core/Downloads/IDownloadManager.cs
Normal file
17
src/PSLauncher.Core/Downloads/IDownloadManager.cs
Normal 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);
|
||||
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal file
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal 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);
|
||||
}
|
||||
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal file
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal 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);
|
||||
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal file
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal file
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal 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}";
|
||||
}
|
||||
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal file
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/PSLauncher.Core/Integrity/IIntegrityService.cs
Normal file
6
src/PSLauncher.Core/Integrity/IIntegrityService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PSLauncher.Core.Integrity;
|
||||
|
||||
public interface IIntegrityService
|
||||
{
|
||||
Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct);
|
||||
}
|
||||
26
src/PSLauncher.Core/Integrity/IntegrityService.cs
Normal file
26
src/PSLauncher.Core/Integrity/IntegrityService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
src/PSLauncher.Core/Manifests/IManifestService.cs
Normal file
9
src/PSLauncher.Core/Manifests/IManifestService.cs
Normal 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);
|
||||
}
|
||||
46
src/PSLauncher.Core/Manifests/ManifestService.cs
Normal file
46
src/PSLauncher.Core/Manifests/ManifestService.cs
Normal 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('/');
|
||||
}
|
||||
18
src/PSLauncher.Core/PSLauncher.Core.csproj
Normal file
18
src/PSLauncher.Core/PSLauncher.Core.csproj
Normal 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>
|
||||
10
src/PSLauncher.Core/Process/IProcessLauncher.cs
Normal file
10
src/PSLauncher.Core/Process/IProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
44
src/PSLauncher.Core/Process/ProcessLauncher.cs
Normal file
44
src/PSLauncher.Core/Process/ProcessLauncher.cs
Normal 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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
16
src/PSLauncher.Core/Updates/IUpdateChecker.cs
Normal file
16
src/PSLauncher.Core/Updates/IUpdateChecker.cs
Normal 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);
|
||||
53
src/PSLauncher.Core/Updates/UpdateChecker.cs
Normal file
53
src/PSLauncher.Core/Updates/UpdateChecker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user