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:
@@ -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('/');
|
||||
|
||||
Reference in New Issue
Block a user