v0.25.10 — LAN P2P offline-friendly : manifest fallback + perf
Fonctionne entièrement offline si un PC du LAN a fetché OVH récemment :
les autres PCs récupèrent le manifest + les ZIPs depuis lui sans Internet.
Discovery des peers quasi-instantanée au cold start, fail-fast partout sur
les fetch OVH pour ne pas bloquer la UI.
== Manifest fallback offline ==
- IManifestCache + ManifestCache (Core/Manifests/) : cache disque du dernier
manifest réussi (JSON brut byte-for-byte pour préserver la signature Ed25519).
Path : %LocalAppData%\PSLauncher\manifest-cache.json
- IPeerManifestFetcher + PeerManifestFetcher (Core/Lan/) : récupère le
manifest brut depuis un peer LAN via GET /v1/manifest. Probe parallèle
avec timeout court par peer.
- LanCacheServer : nouvelle route GET /v1/manifest qui sert le cache disque
byte-for-byte aux clients sans Internet.
- ManifestService.FetchAsync : online check d'abord (ping ICMP 8.8.8.8 +
fallback TCP connect au serveur OVH si firewall bloque ICMP, timeout
800ms × 2). Si online → OVH only (canonique, voit toutes les versions).
Si offline → peer LAN puis cache disque local.
- ManifestSource enum exposé via IManifestService.LastSource → call sites
peuvent prendre des décisions éclairées (skip release notes, badge UI).
== Discovery active des peers (cold start instantané) ==
- LanCacheServer : nouvelle route GET /v1/info qui retourne {host, port,
versions} (même payload que les beacons UDP).
- LanDiscoveryService : bootstrap au démarrage qui probe en parallèle tous
les peers connus (cache disque) + manuels (config) via /v1/info, timeout
1s par peer. Les peers qui répondent sont injectés dans _peers avec
lastSeen=now → apparaissent immédiatement dans ListDiscovered() et donc
dans la sidebar + PeerSourceResolver. Plus besoin d'attendre les 15s du
prochain beacon UDP.
- ILanDiscoveryService.ListKnown() exposé pour que les fetchers puissent
utiliser le cache disque comme seed.
- LocalConfig.LanCache.ManualPeerUrls : default ["http://10.0.4.100:47623"]
(IP fixe du serveur ASTERION dans toutes les salles VR).
== Timeouts courts sur tous les fetch OVH (fail-fast offline) ==
- ManifestService : 3s pour le manifest fetch (vs 100s par défaut)
- LicenseService.ValidateAsync : 3s (résolvait le délai de ~15s pendant
CheckForUpdates en offline — HttpClient global a Timeout=Infinite,
sans CTS court ça hangait jusqu'à l'abandon SYN TCP par l'OS)
- LicenseService.GetSignedDownloadUrlAsync : 5s
- ManifestService.FetchReleaseNotesAsync : 5s
== UI ==
- StatusMessage post-Check Updates suffixé avec la source du manifest :
"🌐 OVH (en ligne)" / "📡 Peer LAN (hors ligne)" / "💾 Cache local (hors ligne)"
- InstallVersionAsync : skip le fetch release notes si manifest source
!= OVH → dialog s'ouvre instantanément avec "indisponible" au lieu
d'attendre 5s de timeout.
== Latences attendues ==
| Scénario | Avant | Après |
|---|---|---|
| Vérifier MAJ online | ~5s | ~500ms |
| Vérifier MAJ offline | ~15-20s | ~5s |
| Cold start discovery (1 peer connu) | jusqu'à 15s | ~200ms |
| Click Install offline | ~5s release notes timeout | instantané |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,10 +174,13 @@ public partial class App : Application
|
||||
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
||||
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
|
||||
|
||||
services.AddSingleton<IManifestCache, ManifestCache>();
|
||||
services.AddSingleton<IManifestService>(sp =>
|
||||
new ManifestService(
|
||||
sp.GetRequiredService<HttpClient>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||
sp.GetRequiredService<IManifestCache>(),
|
||||
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||
|
||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||
@@ -237,12 +240,15 @@ public partial class App : Application
|
||||
services.AddSingleton<LanDiscoveryService>(sp => new LanDiscoveryService(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||
configStore: sp.GetRequiredService<IConfigStore>(),
|
||||
http: sp.GetRequiredService<HttpClient>(),
|
||||
logger: sp.GetRequiredService<ILogger<LanDiscoveryService>>()));
|
||||
services.AddSingleton<ILanDiscoveryService>(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||
services.AddSingleton<LanCacheServer>(sp => new LanCacheServer(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||
manifestCache: sp.GetRequiredService<IManifestCache>(),
|
||||
logger: sp.GetRequiredService<ILogger<LanCacheServer>>()));
|
||||
services.AddSingleton<ILanCacheServer>(sp => sp.GetRequiredService<LanCacheServer>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LanCacheServer>());
|
||||
@@ -251,6 +257,11 @@ public partial class App : Application
|
||||
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
|
||||
http: sp.GetRequiredService<HttpClient>(),
|
||||
logger: sp.GetRequiredService<ILogger<PeerSourceResolver>>()));
|
||||
services.AddSingleton<IPeerManifestFetcher>(sp => new PeerManifestFetcher(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
|
||||
http: sp.GetRequiredService<HttpClient>(),
|
||||
logger: sp.GetRequiredService<ILogger<PeerManifestFetcher>>()));
|
||||
|
||||
services.AddSingleton<IOpenVrService, OpenVrService>();
|
||||
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.25.4</Version>
|
||||
<AssemblyVersion>0.25.4.0</AssemblyVersion>
|
||||
<FileVersion>0.25.4.0</FileVersion>
|
||||
<Version>0.25.10</Version>
|
||||
<AssemblyVersion>0.25.10.0</AssemblyVersion>
|
||||
<FileVersion>0.25.10.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -656,12 +656,23 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_ = Application.Current.Dispatcher.BeginInvoke(() => PromptLauncherUpdate(launcher));
|
||||
}
|
||||
|
||||
// Suffixe d'origine du manifest pour que l'utilisateur sache si la
|
||||
// liste des versions vient d'OVH (canonique, à jour) ou d'un peer/cache
|
||||
// local (potentiellement plus ancien).
|
||||
var sourceSuffix = _manifestService.LastSource switch
|
||||
{
|
||||
ManifestSource.Ovh => " · " + Strings.ManifestSourceOvh,
|
||||
ManifestSource.Peer => " · " + Strings.ManifestSourcePeer,
|
||||
ManifestSource.DiskCache => " · " + Strings.ManifestSourceDiskCache,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
||||
StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version);
|
||||
StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version) + sourceSuffix;
|
||||
else if (result.LatestRemote is not null)
|
||||
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version);
|
||||
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version) + sourceSuffix;
|
||||
else
|
||||
StatusMessage = Strings.StatusNoRemote;
|
||||
StatusMessage = Strings.StatusNoRemote + sourceSuffix;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -941,9 +952,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
if (!isResume)
|
||||
{
|
||||
// 1) Récupère release notes (best effort)
|
||||
// 1) Récupère release notes (best effort).
|
||||
// Skip si on a fetché le manifest depuis un peer ou cache (= probablement
|
||||
// offline) : l'URL release notes pointe sur OVH et le fetch va timeout
|
||||
// bêtement. Mieux vaut ouvrir le dialog tout de suite avec "indisponible".
|
||||
string notes = Strings.ReleaseNotesNone;
|
||||
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
|
||||
var manifestFromOvh = _manifestService.LastSource == ManifestSource.Ovh;
|
||||
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl) && manifestFromOvh)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -951,6 +966,11 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; }
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
|
||||
{
|
||||
_logger.LogInformation("Skipping release notes fetch (manifest source = {Source}, OVH probably unreachable)", _manifestService.LastSource);
|
||||
notes = Strings.ReleaseNotesUnavailable;
|
||||
}
|
||||
|
||||
// 2) Dialog de confirmation avec release notes
|
||||
var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow };
|
||||
|
||||
@@ -27,4 +27,11 @@ public interface ILanDiscoveryService
|
||||
{
|
||||
/// <summary>Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée.</summary>
|
||||
IReadOnlyList<DiscoveredPeer> ListDiscovered();
|
||||
|
||||
/// <summary>
|
||||
/// Peers connus de runs précédents, chargés depuis le cache disque au démarrage
|
||||
/// (max 1 semaine). Utile en cold start pour éviter d'attendre les 15 s du
|
||||
/// premier beacon UDP avant qu'un peer apparaisse.
|
||||
/// </summary>
|
||||
IReadOnlyList<DiscoveredPeer> ListKnown();
|
||||
}
|
||||
|
||||
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal file
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace PSLauncher.Core.Lan;
|
||||
|
||||
/// <summary>
|
||||
/// Récupère le manifest depuis un peer LAN. Sert quand le client n'a pas
|
||||
/// d'accès Internet : il interroge les peers découverts via UDP discovery
|
||||
/// (et les peers manuels en config) pour trouver un PC ayant déjà fetché le
|
||||
/// manifest depuis OVH récemment.
|
||||
///
|
||||
/// La sécurité reste celle du manifest signé Ed25519 — le peer pourrait
|
||||
/// servir n'importe quel JSON, mais le launcher rejettera tout manifest non
|
||||
/// signé correctement (ou tout ZIP dont le SHA-256 ne match pas).
|
||||
/// </summary>
|
||||
public interface IPeerManifestFetcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Tente de récupérer le manifest brut (JSON, byte-for-byte) depuis le
|
||||
/// premier peer qui répond. Null si aucun peer atteignable, no-op si
|
||||
/// ClientEnabled=false.
|
||||
/// </summary>
|
||||
Task<string?> TryFetchRawAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -7,6 +7,43 @@ namespace PSLauncher.Core.Lan;
|
||||
/// </summary>
|
||||
public sealed record PeerSource(Uri ZipUrl, string PeerHost);
|
||||
|
||||
/// <summary>
|
||||
/// Helper interne aux probers (PeerSourceResolver, PeerManifestFetcher) pour
|
||||
/// fusionner peers actifs (beacons UDP récents), peers connus (cache disque),
|
||||
/// peers manuels (config). Évite la duplication entre les 2 fetchers.
|
||||
/// </summary>
|
||||
internal static class PeerCandidates
|
||||
{
|
||||
public static List<string> Build(ILanDiscoveryService discovery, IEnumerable<string> manualUrls)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1) Peers actifs (UDP beacon < 60s) — les plus fiables
|
||||
foreach (var p in discovery.ListDiscovered())
|
||||
{
|
||||
var url = $"http://{p.Host}:{p.Port}";
|
||||
if (seen.Add(url)) candidates.Add(url);
|
||||
}
|
||||
// 2) Peers connus (cache disque, runs précédents) — essayés rapidement même
|
||||
// si pas encore reçu de beacon ce run (cold start, < 15s d'uptime)
|
||||
foreach (var p in discovery.ListKnown())
|
||||
{
|
||||
var url = $"http://{p.Host}:{p.Port}";
|
||||
if (seen.Add(url)) candidates.Add(url);
|
||||
}
|
||||
// 3) Peers manuels (config user) — fallback / override explicite
|
||||
foreach (var url in manualUrls)
|
||||
{
|
||||
var trimmed = url.Trim().TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
if (seen.Add(trimmed)) candidates.Add(trimmed);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trouve un peer LAN qui a la version demandée. Combine peers découverts auto
|
||||
/// (UDP discovery) et peers manuels (config), probe chacun avec un timeout court,
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
@@ -35,6 +36,7 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||
|
||||
private readonly Func<LanCacheConfig> _configProvider;
|
||||
private readonly IZipCacheStore _cache;
|
||||
private readonly IManifestCache _manifestCache;
|
||||
private readonly ILogger<LanCacheServer> _logger;
|
||||
private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);
|
||||
|
||||
@@ -45,10 +47,12 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||
public LanCacheServer(
|
||||
Func<LanCacheConfig> configProvider,
|
||||
IZipCacheStore cache,
|
||||
IManifestCache manifestCache,
|
||||
ILogger<LanCacheServer> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_cache = cache;
|
||||
_manifestCache = manifestCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -151,6 +155,8 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||
|
||||
const string hasPrefix = "/v1/has/";
|
||||
const string zipPrefix = "/v1/zip/";
|
||||
const string manifestPath = "/v1/manifest";
|
||||
const string infoPath = "/v1/info";
|
||||
if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var version = request.Path.Substring(hasPrefix.Length);
|
||||
@@ -161,6 +167,14 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||
var version = request.Path.Substring(zipPrefix.Length);
|
||||
await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (request.Path == manifestPath)
|
||||
{
|
||||
await HandleManifestAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (request.Path == infoPath)
|
||||
{
|
||||
await HandleInfoAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
|
||||
@@ -197,6 +211,54 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint léger d'auto-discovery active. Retourne la même structure que
|
||||
/// le beacon UDP : <c>{v, host, port, versions}</c>. Sert au cold-start
|
||||
/// d'un client qui veut probe ses peers connus du cache disque ou manuels
|
||||
/// SANS attendre les 15 s du prochain beacon UDP.
|
||||
/// </summary>
|
||||
private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct)
|
||||
{
|
||||
var versions = _cache.ListCached().Select(c => c.Version).ToList();
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
v = 1,
|
||||
host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}")
|
||||
.Replace("http://", "").Split(':')[0],
|
||||
port = _boundPort ?? 0,
|
||||
versions,
|
||||
});
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas
|
||||
/// d'accès Internet. Le client validera la signature Ed25519 du manifest
|
||||
/// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais
|
||||
/// fetché OVH avec succès).
|
||||
/// </summary>
|
||||
private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct)
|
||||
{
|
||||
var path = _manifestCache.GetCachedFilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||
await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to serve cached manifest");
|
||||
await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
|
||||
{
|
||||
if (!VersionRegex.IsMatch(version))
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
@@ -15,21 +16,35 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
|
||||
{
|
||||
private const int BeaconIntervalMs = 15_000;
|
||||
private const int PeerTtlSeconds = 60;
|
||||
/// <summary>TTL des peers persistés sur disque entre deux runs du launcher.</summary>
|
||||
private const int KnownPeerStaleHours = 168; // 1 semaine
|
||||
private const string KnownPeersFileName = "lan-peers.json";
|
||||
|
||||
private readonly Func<LanCacheConfig> _configProvider;
|
||||
private readonly IZipCacheStore _cache;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<LanDiscoveryService> _logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Peers pré-chargés depuis le disque au démarrage. Pas de TTL court — ils
|
||||
/// servent de "seed" pour ne pas attendre les 15s du premier beacon UDP.
|
||||
/// PeerSourceResolver / PeerManifestFetcher les utilisent en plus de ListDiscovered().</summary>
|
||||
private List<DiscoveredPeer> _knownPeers = new();
|
||||
private string? _ourLanIp;
|
||||
private DateTime _lastDiskFlush = DateTime.MinValue;
|
||||
|
||||
public LanDiscoveryService(
|
||||
Func<LanCacheConfig> configProvider,
|
||||
IZipCacheStore cache,
|
||||
IConfigStore configStore,
|
||||
HttpClient http,
|
||||
ILogger<LanDiscoveryService> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_cache = cache;
|
||||
_configStore = configStore;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -39,6 +54,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
|
||||
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peers connus de runs précédents (persistés sur disque, max 1 semaine).
|
||||
/// Sert de seed pour démarrer rapidement avant que le premier beacon arrive.
|
||||
/// Probés en HTTP par PeerSourceResolver — si le peer est offline on aura juste
|
||||
/// un timeout puis on essaiera le suivant.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DiscoveredPeer> ListKnown() => _knownPeers;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
@@ -50,6 +73,19 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
|
||||
|
||||
_ourLanIp = GetLocalLanIp();
|
||||
|
||||
// Charge les peers connus de la session précédente. Les probes pourront
|
||||
// ainsi essayer ces IPs immédiatement, sans attendre les 15s du premier
|
||||
// beacon UDP. Si un peer est offline, on aura juste un timeout HTTP rapide.
|
||||
_knownPeers = LoadKnownPeers();
|
||||
if (_knownPeers.Count > 0)
|
||||
_logger.LogInformation("Loaded {Count} known peers from disk cache", _knownPeers.Count);
|
||||
|
||||
// BOOTSTRAP ACTIF : kick off un probe HTTP /v1/info en parallèle sur tous les
|
||||
// peers connus (cache disque) + manuels (config). Découverte instantanée au
|
||||
// lieu d'attendre les 15 s du prochain beacon UDP. Fire-and-forget : le reste
|
||||
// du service démarre normalement pendant que le probe tourne en background.
|
||||
_ = Task.Run(() => BootstrapProbeKnownPeersAsync(cfg, stoppingToken), stoppingToken);
|
||||
|
||||
// Listener (toujours actif si Client OU Server activé)
|
||||
UdpClient? listener = null;
|
||||
try
|
||||
@@ -113,6 +149,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
|
||||
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
|
||||
LastSeenUtc: DateTime.UtcNow);
|
||||
_peers[beacon.Host] = peer;
|
||||
|
||||
// Persist to disk au plus toutes les 30s pour ne pas spammer le SSD
|
||||
// (un beacon arrive toutes les 15s par peer × N peers = beaucoup d'écritures).
|
||||
if ((DateTime.UtcNow - _lastDiskFlush).TotalSeconds >= 30)
|
||||
{
|
||||
_lastDiskFlush = DateTime.UtcNow;
|
||||
SaveKnownPeers(_peers.Values);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
@@ -182,4 +226,142 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
|
||||
[JsonPropertyName("port")] public int Port { get; set; }
|
||||
[JsonPropertyName("versions")] public List<string>? Versions { get; set; }
|
||||
}
|
||||
|
||||
private sealed class StoredPeer
|
||||
{
|
||||
[JsonPropertyName("host")] public string Host { get; set; } = "";
|
||||
[JsonPropertyName("port")] public int Port { get; set; }
|
||||
[JsonPropertyName("versions")] public List<string> Versions { get; set; } = new();
|
||||
[JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; }
|
||||
}
|
||||
|
||||
private string GetKnownPeersPath() =>
|
||||
Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName);
|
||||
|
||||
private List<DiscoveredPeer> LoadKnownPeers()
|
||||
{
|
||||
var path = GetKnownPeersPath();
|
||||
if (!File.Exists(path)) return new List<DiscoveredPeer>();
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllText(path);
|
||||
var stored = JsonSerializer.Deserialize<List<StoredPeer>>(raw);
|
||||
if (stored is null) return new List<DiscoveredPeer>();
|
||||
var staleAfter = DateTime.UtcNow.AddHours(-KnownPeerStaleHours);
|
||||
return stored
|
||||
.Where(s => s.LastSeenUtc >= staleAfter
|
||||
&& !string.IsNullOrEmpty(s.Host)
|
||||
&& s.Port > 0 && s.Port < 65536)
|
||||
.Select(s => new DiscoveredPeer(s.Host, s.Port, s.Versions, s.LastSeenUtc))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load known peers cache (non-fatal)");
|
||||
return new List<DiscoveredPeer>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe HTTP actif en parallèle de tous les peers connus (cache disque) et
|
||||
/// manuels (config). Pour chaque peer qui répond à /v1/info, on l'injecte dans
|
||||
/// _peers avec lastSeen=now → il apparaît immédiatement dans ListDiscovered()
|
||||
/// et donc dans la sidebar + PeerSourceResolver. Aucune attente d'un beacon UDP.
|
||||
/// Timeout court (1 s) par peer, parallel pour ne pas additionner.
|
||||
/// </summary>
|
||||
private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var urls = new List<string>();
|
||||
foreach (var p in _knownPeers)
|
||||
{
|
||||
var u = $"http://{p.Host}:{p.Port}";
|
||||
if (seen.Add(u)) urls.Add(u);
|
||||
}
|
||||
foreach (var raw in cfg.ManualPeerUrls ?? new List<string>())
|
||||
{
|
||||
var t = raw.Trim().TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(t)) continue;
|
||||
if (seen.Add(t)) urls.Add(t);
|
||||
}
|
||||
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Bootstrap probe: no known/manual peers to probe");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bootstrap probe: attempting {Count} known/manual peers in parallel", urls.Count);
|
||||
var tasks = urls.Select(u => ProbeOnePeerAsync(u, ct)).ToArray();
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
var alive = _peers.Count;
|
||||
_logger.LogInformation("Bootstrap probe done: {Alive} peer(s) responding", alive);
|
||||
if (alive > 0)
|
||||
{
|
||||
// Persist immediately — la session précédente avait peut-être des peers stale,
|
||||
// on remet à jour avec ceux qui répondent vraiment maintenant.
|
||||
SaveKnownPeers(_peers.Values);
|
||||
_lastDiskFlush = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProbeOnePeerAsync(string baseUrl, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(TimeSpan.FromSeconds(1));
|
||||
using var resp = await _http.GetAsync($"{baseUrl}/v1/info", probeCts.Token).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode) return;
|
||||
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
|
||||
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
|
||||
if (beacon is null || string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0) return;
|
||||
|
||||
// Ignore notre propre IP (au cas où une URL manuelle pointerait sur nous-mêmes)
|
||||
if (_ourLanIp is not null && string.Equals(beacon.Host, _ourLanIp, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var peer = new DiscoveredPeer(
|
||||
Host: beacon.Host,
|
||||
Port: beacon.Port,
|
||||
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
|
||||
LastSeenUtc: DateTime.UtcNow);
|
||||
_peers[beacon.Host] = peer;
|
||||
_logger.LogInformation("Bootstrap probe: peer alive at {Url} (versions: {V})", baseUrl,
|
||||
beacon.Versions is { Count: > 0 } ? string.Join(",", beacon.Versions) : "—");
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug("Bootstrap probe of {Url} failed: {Reason}", baseUrl, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveKnownPeers(IEnumerable<DiscoveredPeer> peers)
|
||||
{
|
||||
var path = GetKnownPeersPath();
|
||||
try
|
||||
{
|
||||
var stored = peers
|
||||
.Where(p => !string.IsNullOrEmpty(p.Host))
|
||||
.Select(p => new StoredPeer
|
||||
{
|
||||
Host = p.Host,
|
||||
Port = p.Port,
|
||||
Versions = p.Versions.ToList(),
|
||||
LastSeenUtc = p.LastSeenUtc,
|
||||
})
|
||||
.ToList();
|
||||
var json = JsonSerializer.Serialize(stored);
|
||||
// Atomic write : .tmp puis rename pour ne pas corrompre si crash mi-écriture.
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, json);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to save known peers cache (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal file
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
|
||||
public sealed class PeerManifestFetcher : IPeerManifestFetcher
|
||||
{
|
||||
private const int MaxPeersToProbe = 5;
|
||||
|
||||
private readonly Func<LanCacheConfig> _configProvider;
|
||||
private readonly ILanDiscoveryService _discovery;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<PeerManifestFetcher> _logger;
|
||||
|
||||
public PeerManifestFetcher(
|
||||
Func<LanCacheConfig> configProvider,
|
||||
ILanDiscoveryService discovery,
|
||||
HttpClient http,
|
||||
ILogger<PeerManifestFetcher> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_discovery = discovery;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> TryFetchRawAsync(CancellationToken ct)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
if (!cfg.ClientEnabled) return null;
|
||||
|
||||
// Liste fusionnée : actifs + connus (cache disque) + manuels.
|
||||
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No peers available for manifest fallback");
|
||||
return null;
|
||||
}
|
||||
|
||||
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
|
||||
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(probeMs);
|
||||
|
||||
var url = $"{baseUrl}/v1/manifest";
|
||||
using var resp = await _http.GetAsync(url, probeCts.Token).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Peer {Url} manifest fetch returned {Status}", baseUrl, (int)resp.StatusCode);
|
||||
continue;
|
||||
}
|
||||
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(json)) continue;
|
||||
_logger.LogInformation("Manifest fetched from peer {Url} ({Bytes} bytes)", baseUrl, json.Length);
|
||||
return json;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Peer {Url} manifest probe timed out", baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Peer {Url} manifest probe failed", baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("No peer responded with a manifest");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -38,25 +38,9 @@ public sealed class PeerSourceResolver : IPeerSourceResolver
|
||||
return null;
|
||||
}
|
||||
|
||||
// Construit la liste des base URLs à probe : auto-découverts d'abord
|
||||
// (qui annoncent déjà avoir la version), puis manuels (qu'on probe à l'aveugle).
|
||||
var candidates = new List<string>();
|
||||
foreach (var p in _discovery.ListDiscovered())
|
||||
{
|
||||
// Optim : si le peer annonce sa liste de versions et que la nôtre n'y est
|
||||
// pas, skip. Économise un round-trip HTTP. Mais on probe quand même si
|
||||
// la liste est vide (compat anciens peers / beacon partiel).
|
||||
if (p.Versions.Count > 0 && !p.Versions.Contains(version, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
candidates.Add($"http://{p.Host}:{p.Port}");
|
||||
}
|
||||
foreach (var url in cfg.ManualPeerUrls)
|
||||
{
|
||||
var trimmed = url.Trim().TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
if (!candidates.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
||||
candidates.Add(trimmed);
|
||||
}
|
||||
// Construit la liste fusionnée : actifs (beacons récents) + connus (cache
|
||||
// disque) + manuels. PeerCandidates dédoublonne et préserve l'ordre.
|
||||
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
|
||||
@@ -87,11 +87,19 @@ public sealed class LicenseService : ILicenseService
|
||||
|
||||
_logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);
|
||||
|
||||
// Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a
|
||||
// Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS
|
||||
// abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh
|
||||
// License pendant Check Updates) catch l'exception et garde le cache
|
||||
// local, donc fail-fast est sans danger.
|
||||
using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
validateCts.CancelAfter(3_000);
|
||||
|
||||
using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
httpReq.Content = JsonContent.Create(req);
|
||||
using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false);
|
||||
using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false);
|
||||
|
||||
var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false);
|
||||
LicenseValidationResponse? parsed;
|
||||
try
|
||||
{
|
||||
@@ -246,20 +254,27 @@ public sealed class LicenseService : ILicenseService
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
||||
try
|
||||
{
|
||||
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
||||
// en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer
|
||||
// la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on
|
||||
// est ici c'est qu'aucun peer n'avait la version.
|
||||
using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
sigCts.CancelAfter(5_000);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL");
|
||||
_logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +653,11 @@ public static class Strings
|
||||
"وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN."
|
||||
);
|
||||
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
|
||||
|
||||
// ---- Badge source du manifest (origine de la liste de versions affichée) ----
|
||||
public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH(在线)", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)");
|
||||
public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer(离线)", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");
|
||||
public static string ManifestSourceDiskCache => T("💾 Cache local (hors ligne)", "💾 Local cache (offline)", "💾 本地缓存(离线)", "💾 แคชในเครื่อง (ออฟไลน์)", "💾 ذاكرة محلية (غير متصل)");
|
||||
public static string MsgLanCacheRestart => T(
|
||||
"Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?",
|
||||
"Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?",
|
||||
|
||||
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal file
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
|
||||
/// <summary>
|
||||
/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages :
|
||||
/// 1. Permet au <c>LanCacheServer</c> de servir le manifest aux peers du LAN
|
||||
/// (route <c>GET /v1/manifest</c>).
|
||||
/// 2. Permet au launcher de fallback sur ce cache si OVH est injoignable et
|
||||
/// qu'aucun peer LAN ne répond non plus (mode offline complet).
|
||||
///
|
||||
/// Stocke le JSON brut (pas un objet désérialisé) pour préserver la signature
|
||||
/// Ed25519 byte-for-byte — la canonicalisation côté validation exige les bytes
|
||||
/// originaux. Path : <c>%LocalAppData%\PSLauncher\manifest-cache.json</c>.
|
||||
/// </summary>
|
||||
public interface IManifestCache
|
||||
{
|
||||
/// <summary>Persiste le JSON brut sur disque (atomic via .tmp + Move).</summary>
|
||||
Task SaveAsync(string rawJson, CancellationToken ct);
|
||||
|
||||
/// <summary>Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest).</summary>
|
||||
string GetCachedFilePath();
|
||||
|
||||
/// <summary>Lit + désérialise le manifest cached. Null si pas de cache.</summary>
|
||||
Task<RemoteManifest?> TryLoadAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -2,8 +2,26 @@ using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
|
||||
/// <summary>D'où provient le dernier manifest récupéré avec succès.</summary>
|
||||
public enum ManifestSource
|
||||
{
|
||||
/// <summary>Pas encore récupéré dans cette session.</summary>
|
||||
None,
|
||||
/// <summary>Source canonique : serveur OVH via Internet.</summary>
|
||||
Ovh,
|
||||
/// <summary>Peer LAN (offline ou OVH down). Manifest peut être plus ancien que celui d'OVH.</summary>
|
||||
Peer,
|
||||
/// <summary>Cache disque local (offline + aucun peer atteignable). Manifest figé à la dernière fetch.</summary>
|
||||
DiskCache,
|
||||
}
|
||||
|
||||
public interface IManifestService
|
||||
{
|
||||
Task<RemoteManifest> FetchAsync(CancellationToken ct);
|
||||
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
|
||||
|
||||
/// <summary>Source du dernier <see cref="FetchAsync"/> réussi. Sert au call site
|
||||
/// pour décider de skip les fetch dépendants d'OVH (release notes, signed URL)
|
||||
/// quand on sait qu'on est offline.</summary>
|
||||
ManifestSource LastSource { get; }
|
||||
}
|
||||
|
||||
61
src/PSLauncher.Core/Manifests/ManifestCache.cs
Normal file
61
src/PSLauncher.Core/Manifests/ManifestCache.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
|
||||
public sealed class ManifestCache : IManifestCache
|
||||
{
|
||||
private const string FileName = "manifest-cache.json";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly ILogger<ManifestCache> _logger;
|
||||
|
||||
public ManifestCache(IConfigStore configStore, ILogger<ManifestCache> logger)
|
||||
{
|
||||
_configStore = configStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string GetCachedFilePath() => Path.Combine(_configStore.ConfigDirectory, FileName);
|
||||
|
||||
public async Task SaveAsync(string rawJson, CancellationToken ct)
|
||||
{
|
||||
var path = GetCachedFilePath();
|
||||
var tmp = path + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tmp, rawJson, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
// Atomic swap : si on est interrompu pendant l'écriture, le cache reste valide.
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
_logger.LogDebug("Manifest cached to {Path} ({Bytes} bytes)", path, rawJson.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache manifest to disk (non-fatal)");
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RemoteManifest?> TryLoadAsync(CancellationToken ct)
|
||||
{
|
||||
var path = GetCachedFilePath();
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
var raw = await File.ReadAllTextAsync(path, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<RemoteManifest>(raw, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read manifest cache from {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
@@ -14,40 +17,180 @@ public sealed class ManifestService : IManifestService
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly Func<string> _serverBaseUrlProvider;
|
||||
private readonly IManifestCache _manifestCache;
|
||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
|
||||
public ManifestService(HttpClient http, Func<string> serverBaseUrlProvider, ILogger<ManifestService> logger)
|
||||
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
|
||||
|
||||
public ManifestService(
|
||||
HttpClient http,
|
||||
Func<string> serverBaseUrlProvider,
|
||||
IManifestCache manifestCache,
|
||||
IPeerManifestFetcher peerManifestFetcher,
|
||||
ILogger<ManifestService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||
_manifestCache = manifestCache;
|
||||
_peerManifestFetcher = peerManifestFetcher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout court sur la tentative OVH : si le serveur Internet ne répond pas
|
||||
/// rapidement, on bascule sur les fallbacks LAN/cache plutôt que d'attendre les
|
||||
/// 100s du HttpClient.Timeout par défaut. Avec la race OVH+peer, ce timeout
|
||||
/// devient juste une borne haute — en pratique le peer répond en <200ms si
|
||||
/// dispo, OVH gagne la course en online normal.
|
||||
/// </summary>
|
||||
private const int OvhFetchTimeoutMs = 3_000;
|
||||
|
||||
/// <summary>
|
||||
/// Stratégie : on probe la connectivité Internet d'abord (~10ms si online).
|
||||
/// - Online → OVH seul (source canonique, garantit de voir la version la plus
|
||||
/// récente même si nos peers ont un manifest plus ancien). Fallback cache
|
||||
/// disque si OVH plante.
|
||||
/// - Offline → peer LAN seul (pas la peine de payer le timeout OVH puisqu'on
|
||||
/// sait que ça va échouer). Fallback cache disque si pas de peer.
|
||||
///
|
||||
/// Le test de connectivité combine ICMP ping (rapide) + TCP connect au serveur
|
||||
/// (au cas où le firewall corporate bloque ICMP) pour minimiser les faux négatifs.
|
||||
/// </summary>
|
||||
public async Task<RemoteManifest> FetchAsync(CancellationToken ct)
|
||||
{
|
||||
var isOnline = await IsOnlineAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Fetching manifest (online={Online})", isOnline);
|
||||
|
||||
if (isOnline)
|
||||
{
|
||||
// En ligne : OVH canonique, jamais de peer (qui pourrait avoir un manifest
|
||||
// plus ancien et masquer la dernière version publiée).
|
||||
try
|
||||
{
|
||||
var rawJson = await FetchFromOvhAsync(ct).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<RemoteManifest>(rawJson, JsonOptions)
|
||||
?? throw new InvalidOperationException("Empty manifest");
|
||||
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
|
||||
LastSource = ManifestSource.Ovh;
|
||||
return manifest;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("OVH manifest fetch failed despite online connectivity ({Reason}), falling back to disk cache", ex.Message);
|
||||
var cached = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false);
|
||||
if (cached is not null)
|
||||
{
|
||||
LastSource = ManifestSource.DiskCache;
|
||||
return cached;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Offline : peer LAN direct, pas d'attente du timeout OVH.
|
||||
var peerJson = await _peerManifestFetcher.TryFetchRawAsync(ct).ConfigureAwait(false);
|
||||
if (peerJson is not null)
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<RemoteManifest>(peerJson, JsonOptions)
|
||||
?? throw new InvalidOperationException("Empty peer manifest");
|
||||
_logger.LogInformation("Manifest récupéré via peer LAN (offline)");
|
||||
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
|
||||
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
|
||||
LastSource = ManifestSource.Peer;
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// Aucun peer atteignable : on tente le cache disque local
|
||||
var localCache = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false);
|
||||
if (localCache is not null)
|
||||
{
|
||||
_logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)");
|
||||
LastSource = ManifestSource.DiskCache;
|
||||
return localCache;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Manifest unavailable: offline, no LAN peer responded, no local cache.");
|
||||
}
|
||||
|
||||
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
|
||||
{
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
_logger.LogInformation("Fetching manifest: {Url}", url);
|
||||
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
// Bypass des caches intermédiaires — l'utilisateur a explicitement cliqué « Vérifier ».
|
||||
req.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue
|
||||
{
|
||||
NoCache = true,
|
||||
NoStore = true,
|
||||
};
|
||||
req.Headers.Pragma.ParseAdd("no-cache");
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
using var resp = await _http.SendAsync(req, ovhCts.Token).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var manifest = await resp.Content.ReadFromJsonAsync<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Empty manifest");
|
||||
return manifest;
|
||||
return await resp.Content.ReadAsStringAsync(ovhCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détection rapide de connectivité Internet : 1) ping ICMP 8.8.8.8 (DNS Google,
|
||||
/// 99% reachable depuis n'importe quel LAN), 2) si ping ne marche pas (firewall
|
||||
/// corporate qui bloque ICMP), TCP connect direct sur le port HTTPS du serveur OVH.
|
||||
/// Online normal : ~10-30ms (premier ping suffit). Offline : ~1.5s pire cas
|
||||
/// (les deux probes timeout). Bon compromis vs un OVH HTTP fetch qui prend 3s.
|
||||
/// </summary>
|
||||
private async Task<bool> IsOnlineAsync(CancellationToken ct)
|
||||
{
|
||||
// Test 1 : ping ICMP (rapide ~10ms si online, fail-fast si offline)
|
||||
try
|
||||
{
|
||||
using var ping = new Ping();
|
||||
var reply = await ping.SendPingAsync(IPAddress.Parse("8.8.8.8"), TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
|
||||
if (reply.Status == IPStatus.Success)
|
||||
{
|
||||
_logger.LogDebug("Online: ping 8.8.8.8 OK ({Ms} ms)", reply.RoundtripTime);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Ping 8.8.8.8 raised exception (firewall blocks ICMP ?)");
|
||||
}
|
||||
|
||||
// Test 2 : TCP connect direct sur le port HTTPS du serveur OVH. Cas du
|
||||
// firewall corporate qui bloque ICMP mais laisse passer HTTPS.
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(_serverBaseUrlProvider(), UriKind.Absolute, out var serverUri))
|
||||
{
|
||||
using var tcp = new TcpClient();
|
||||
using var tcpCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
tcpCts.CancelAfter(TimeSpan.FromMilliseconds(800));
|
||||
await tcp.ConnectAsync(serverUri.Host, serverUri.Port == -1 ? 443 : serverUri.Port, tcpCts.Token).ConfigureAwait(false);
|
||||
_logger.LogDebug("Online: TCP connect to {Host}:443 OK", serverUri.Host);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "TCP connect to server failed");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Offline detected (ping + TCP both failed)");
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
// Timeout court : les release notes sont du markdown léger, et si OVH ne
|
||||
// répond pas, on aime mieux ouvrir le dialog d'install rapidement avec
|
||||
// "release notes indisponibles" plutôt que de bloquer la UI 100s.
|
||||
using var rnCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
rnCts.CancelAfter(OvhFetchTimeoutMs);
|
||||
using var resp = await _http.GetAsync(url, rnCts.Token).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return await resp.Content.ReadAsStringAsync(rnCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string TrimSlash(string s) => s.TrimEnd('/');
|
||||
|
||||
@@ -204,8 +204,12 @@ public sealed class LanCacheConfig
|
||||
/// URLs peers ajoutées manuellement (override / fallback de l'auto-discovery).
|
||||
/// Format : <c>http://10.0.0.5:47623</c> (sans trailing slash). Probées EN PLUS
|
||||
/// des peers découverts auto, après ceux-ci.
|
||||
///
|
||||
/// Default <c>http://10.0.4.100:47623</c> = IP fixe du PC serveur ASTERION
|
||||
/// dans toutes les salles VR PROSERVE. Permet la découverte instantanée au
|
||||
/// tout premier démarrage du launcher (avant que le 1er beacon UDP n'arrive).
|
||||
/// </summary>
|
||||
public List<string> ManualPeerUrls { get; set; } = new();
|
||||
public List<string> ManualPeerUrls { get; set; } = new() { "http://10.0.4.100:47623" };
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de ZIPs à conserver en cache local (les N plus récents par mtime).
|
||||
|
||||
Reference in New Issue
Block a user