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:
2026-05-04 17:51:50 +02:00
parent 65ff8541b7
commit 3d691c3e38
18 changed files with 715 additions and 44 deletions

View File

@@ -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 &lt;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('/');