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 };
|
||||
|
||||
Reference in New Issue
Block a user