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

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