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

@@ -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;
}
}