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

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.25.4"
#define MyAppVersion "0.25.10"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"

View File

@@ -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>();

View File

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

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

View File

@@ -27,4 +27,11 @@ public interface ILanDiscoveryService
{
/// <summary>Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée.</summary>
IReadOnlyList<DiscoveredPeer> ListDiscovered();
/// <summary>
/// Peers connus de runs précédents, chargés depuis le cache disque au démarrage
/// (max 1 semaine). Utile en cold start pour éviter d'attendre les 15 s du
/// premier beacon UDP avant qu'un peer apparaisse.
/// </summary>
IReadOnlyList<DiscoveredPeer> ListKnown();
}

View File

@@ -0,0 +1,21 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Récupère le manifest depuis un peer LAN. Sert quand le client n'a pas
/// d'accès Internet : il interroge les peers découverts via UDP discovery
/// (et les peers manuels en config) pour trouver un PC ayant déjà fetché le
/// manifest depuis OVH récemment.
///
/// La sécurité reste celle du manifest signé Ed25519 — le peer pourrait
/// servir n'importe quel JSON, mais le launcher rejettera tout manifest non
/// signé correctement (ou tout ZIP dont le SHA-256 ne match pas).
/// </summary>
public interface IPeerManifestFetcher
{
/// <summary>
/// Tente de récupérer le manifest brut (JSON, byte-for-byte) depuis le
/// premier peer qui répond. Null si aucun peer atteignable, no-op si
/// ClientEnabled=false.
/// </summary>
Task<string?> TryFetchRawAsync(CancellationToken ct);
}

View File

@@ -7,6 +7,43 @@ namespace PSLauncher.Core.Lan;
/// </summary>
public sealed record PeerSource(Uri ZipUrl, string PeerHost);
/// <summary>
/// Helper interne aux probers (PeerSourceResolver, PeerManifestFetcher) pour
/// fusionner peers actifs (beacons UDP récents), peers connus (cache disque),
/// peers manuels (config). Évite la duplication entre les 2 fetchers.
/// </summary>
internal static class PeerCandidates
{
public static List<string> Build(ILanDiscoveryService discovery, IEnumerable<string> manualUrls)
{
var candidates = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// 1) Peers actifs (UDP beacon < 60s) — les plus fiables
foreach (var p in discovery.ListDiscovered())
{
var url = $"http://{p.Host}:{p.Port}";
if (seen.Add(url)) candidates.Add(url);
}
// 2) Peers connus (cache disque, runs précédents) — essayés rapidement même
// si pas encore reçu de beacon ce run (cold start, < 15s d'uptime)
foreach (var p in discovery.ListKnown())
{
var url = $"http://{p.Host}:{p.Port}";
if (seen.Add(url)) candidates.Add(url);
}
// 3) Peers manuels (config user) — fallback / override explicite
foreach (var url in manualUrls)
{
var trimmed = url.Trim().TrimEnd('/');
if (string.IsNullOrEmpty(trimmed)) continue;
if (seen.Add(trimmed)) candidates.Add(trimmed);
}
return candidates;
}
}
/// <summary>
/// Trouve un peer LAN qui a la version demandée. Combine peers découverts auto
/// (UDP discovery) et peers manuels (config), probe chacun avec un timeout court,

View File

@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Manifests;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
@@ -35,6 +36,7 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IManifestCache _manifestCache;
private readonly ILogger<LanCacheServer> _logger;
private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);
@@ -45,10 +47,12 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
public LanCacheServer(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IManifestCache manifestCache,
ILogger<LanCacheServer> logger)
{
_configProvider = configProvider;
_cache = cache;
_manifestCache = manifestCache;
_logger = logger;
}
@@ -151,6 +155,8 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
const string hasPrefix = "/v1/has/";
const string zipPrefix = "/v1/zip/";
const string manifestPath = "/v1/manifest";
const string infoPath = "/v1/info";
if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
{
var version = request.Path.Substring(hasPrefix.Length);
@@ -161,6 +167,14 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
var version = request.Path.Substring(zipPrefix.Length);
await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
}
else if (request.Path == manifestPath)
{
await HandleManifestAsync(stream, ct).ConfigureAwait(false);
}
else if (request.Path == infoPath)
{
await HandleInfoAsync(stream, ct).ConfigureAwait(false);
}
else
{
await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
@@ -197,6 +211,54 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
}
/// <summary>
/// Endpoint léger d'auto-discovery active. Retourne la même structure que
/// le beacon UDP : <c>{v, host, port, versions}</c>. Sert au cold-start
/// d'un client qui veut probe ses peers connus du cache disque ou manuels
/// SANS attendre les 15 s du prochain beacon UDP.
/// </summary>
private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct)
{
var versions = _cache.ListCached().Select(c => c.Version).ToList();
var json = JsonSerializer.Serialize(new
{
v = 1,
host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}")
.Replace("http://", "").Split(':')[0],
port = _boundPort ?? 0,
versions,
});
var body = Encoding.UTF8.GetBytes(json);
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
}
/// <summary>
/// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas
/// d'accès Internet. Le client validera la signature Ed25519 du manifest
/// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais
/// fetché OVH avec succès).
/// </summary>
private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct)
{
var path = _manifestCache.GetCachedFilePath();
if (!File.Exists(path))
{
await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false);
return;
}
try
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to serve cached manifest");
await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false);
}
}
private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))

View File

@@ -7,6 +7,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
@@ -15,21 +16,35 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
{
private const int BeaconIntervalMs = 15_000;
private const int PeerTtlSeconds = 60;
/// <summary>TTL des peers persistés sur disque entre deux runs du launcher.</summary>
private const int KnownPeerStaleHours = 168; // 1 semaine
private const string KnownPeersFileName = "lan-peers.json";
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IConfigStore _configStore;
private readonly HttpClient _http;
private readonly ILogger<LanDiscoveryService> _logger;
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Peers pré-chargés depuis le disque au démarrage. Pas de TTL court — ils
/// servent de "seed" pour ne pas attendre les 15s du premier beacon UDP.
/// PeerSourceResolver / PeerManifestFetcher les utilisent en plus de ListDiscovered().</summary>
private List<DiscoveredPeer> _knownPeers = new();
private string? _ourLanIp;
private DateTime _lastDiskFlush = DateTime.MinValue;
public LanDiscoveryService(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IConfigStore configStore,
HttpClient http,
ILogger<LanDiscoveryService> logger)
{
_configProvider = configProvider;
_cache = cache;
_configStore = configStore;
_http = http;
_logger = logger;
}
@@ -39,6 +54,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
}
/// <summary>
/// Peers connus de runs précédents (persistés sur disque, max 1 semaine).
/// Sert de seed pour démarrer rapidement avant que le premier beacon arrive.
/// Probés en HTTP par PeerSourceResolver — si le peer est offline on aura juste
/// un timeout puis on essaiera le suivant.
/// </summary>
public IReadOnlyList<DiscoveredPeer> ListKnown() => _knownPeers;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cfg = _configProvider();
@@ -50,6 +73,19 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
_ourLanIp = GetLocalLanIp();
// Charge les peers connus de la session précédente. Les probes pourront
// ainsi essayer ces IPs immédiatement, sans attendre les 15s du premier
// beacon UDP. Si un peer est offline, on aura juste un timeout HTTP rapide.
_knownPeers = LoadKnownPeers();
if (_knownPeers.Count > 0)
_logger.LogInformation("Loaded {Count} known peers from disk cache", _knownPeers.Count);
// BOOTSTRAP ACTIF : kick off un probe HTTP /v1/info en parallèle sur tous les
// peers connus (cache disque) + manuels (config). Découverte instantanée au
// lieu d'attendre les 15 s du prochain beacon UDP. Fire-and-forget : le reste
// du service démarre normalement pendant que le probe tourne en background.
_ = Task.Run(() => BootstrapProbeKnownPeersAsync(cfg, stoppingToken), stoppingToken);
// Listener (toujours actif si Client OU Server activé)
UdpClient? listener = null;
try
@@ -113,6 +149,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
LastSeenUtc: DateTime.UtcNow);
_peers[beacon.Host] = peer;
// Persist to disk au plus toutes les 30s pour ne pas spammer le SSD
// (un beacon arrive toutes les 15s par peer × N peers = beaucoup d'écritures).
if ((DateTime.UtcNow - _lastDiskFlush).TotalSeconds >= 30)
{
_lastDiskFlush = DateTime.UtcNow;
SaveKnownPeers(_peers.Values);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
@@ -182,4 +226,142 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("versions")] public List<string>? Versions { get; set; }
}
private sealed class StoredPeer
{
[JsonPropertyName("host")] public string Host { get; set; } = "";
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("versions")] public List<string> Versions { get; set; } = new();
[JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; }
}
private string GetKnownPeersPath() =>
Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName);
private List<DiscoveredPeer> LoadKnownPeers()
{
var path = GetKnownPeersPath();
if (!File.Exists(path)) return new List<DiscoveredPeer>();
try
{
var raw = File.ReadAllText(path);
var stored = JsonSerializer.Deserialize<List<StoredPeer>>(raw);
if (stored is null) return new List<DiscoveredPeer>();
var staleAfter = DateTime.UtcNow.AddHours(-KnownPeerStaleHours);
return stored
.Where(s => s.LastSeenUtc >= staleAfter
&& !string.IsNullOrEmpty(s.Host)
&& s.Port > 0 && s.Port < 65536)
.Select(s => new DiscoveredPeer(s.Host, s.Port, s.Versions, s.LastSeenUtc))
.ToList();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load known peers cache (non-fatal)");
return new List<DiscoveredPeer>();
}
}
/// <summary>
/// Probe HTTP actif en parallèle de tous les peers connus (cache disque) et
/// manuels (config). Pour chaque peer qui répond à /v1/info, on l'injecte dans
/// _peers avec lastSeen=now → il apparaît immédiatement dans ListDiscovered()
/// et donc dans la sidebar + PeerSourceResolver. Aucune attente d'un beacon UDP.
/// Timeout court (1 s) par peer, parallel pour ne pas additionner.
/// </summary>
private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new List<string>();
foreach (var p in _knownPeers)
{
var u = $"http://{p.Host}:{p.Port}";
if (seen.Add(u)) urls.Add(u);
}
foreach (var raw in cfg.ManualPeerUrls ?? new List<string>())
{
var t = raw.Trim().TrimEnd('/');
if (string.IsNullOrEmpty(t)) continue;
if (seen.Add(t)) urls.Add(t);
}
if (urls.Count == 0)
{
_logger.LogDebug("Bootstrap probe: no known/manual peers to probe");
return;
}
_logger.LogInformation("Bootstrap probe: attempting {Count} known/manual peers in parallel", urls.Count);
var tasks = urls.Select(u => ProbeOnePeerAsync(u, ct)).ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
var alive = _peers.Count;
_logger.LogInformation("Bootstrap probe done: {Alive} peer(s) responding", alive);
if (alive > 0)
{
// Persist immediately — la session précédente avait peut-être des peers stale,
// on remet à jour avec ceux qui répondent vraiment maintenant.
SaveKnownPeers(_peers.Values);
_lastDiskFlush = DateTime.UtcNow;
}
}
private async Task ProbeOnePeerAsync(string baseUrl, CancellationToken ct)
{
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(TimeSpan.FromSeconds(1));
using var resp = await _http.GetAsync($"{baseUrl}/v1/info", probeCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return;
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
if (beacon is null || string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0) return;
// Ignore notre propre IP (au cas où une URL manuelle pointerait sur nous-mêmes)
if (_ourLanIp is not null && string.Equals(beacon.Host, _ourLanIp, StringComparison.OrdinalIgnoreCase))
return;
var peer = new DiscoveredPeer(
Host: beacon.Host,
Port: beacon.Port,
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
LastSeenUtc: DateTime.UtcNow);
_peers[beacon.Host] = peer;
_logger.LogInformation("Bootstrap probe: peer alive at {Url} (versions: {V})", baseUrl,
beacon.Versions is { Count: > 0 } ? string.Join(",", beacon.Versions) : "—");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogDebug("Bootstrap probe of {Url} failed: {Reason}", baseUrl, ex.Message);
}
}
private void SaveKnownPeers(IEnumerable<DiscoveredPeer> peers)
{
var path = GetKnownPeersPath();
try
{
var stored = peers
.Where(p => !string.IsNullOrEmpty(p.Host))
.Select(p => new StoredPeer
{
Host = p.Host,
Port = p.Port,
Versions = p.Versions.ToList(),
LastSeenUtc = p.LastSeenUtc,
})
.ToList();
var json = JsonSerializer.Serialize(stored);
// Atomic write : .tmp puis rename pour ne pas corrompre si crash mi-écriture.
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
File.Move(tmp, path, overwrite: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to save known peers cache (non-fatal)");
}
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class PeerManifestFetcher : IPeerManifestFetcher
{
private const int MaxPeersToProbe = 5;
private readonly Func<LanCacheConfig> _configProvider;
private readonly ILanDiscoveryService _discovery;
private readonly HttpClient _http;
private readonly ILogger<PeerManifestFetcher> _logger;
public PeerManifestFetcher(
Func<LanCacheConfig> configProvider,
ILanDiscoveryService discovery,
HttpClient http,
ILogger<PeerManifestFetcher> logger)
{
_configProvider = configProvider;
_discovery = discovery;
_http = http;
_logger = logger;
}
public async Task<string?> TryFetchRawAsync(CancellationToken ct)
{
var cfg = _configProvider();
if (!cfg.ClientEnabled) return null;
// Liste fusionnée : actifs + connus (cache disque) + manuels.
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
if (candidates.Count == 0)
{
_logger.LogDebug("No peers available for manifest fallback");
return null;
}
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
{
ct.ThrowIfCancellationRequested();
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(probeMs);
var url = $"{baseUrl}/v1/manifest";
using var resp = await _http.GetAsync(url, probeCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.LogDebug("Peer {Url} manifest fetch returned {Status}", baseUrl, (int)resp.StatusCode);
continue;
}
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json)) continue;
_logger.LogInformation("Manifest fetched from peer {Url} ({Bytes} bytes)", baseUrl, json.Length);
return json;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogDebug("Peer {Url} manifest probe timed out", baseUrl);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Peer {Url} manifest probe failed", baseUrl);
}
}
_logger.LogInformation("No peer responded with a manifest");
return null;
}
}

View File

@@ -38,25 +38,9 @@ public sealed class PeerSourceResolver : IPeerSourceResolver
return null;
}
// Construit la liste des base URLs à probe : auto-découverts d'abord
// (qui annoncent déjà avoir la version), puis manuels (qu'on probe à l'aveugle).
var candidates = new List<string>();
foreach (var p in _discovery.ListDiscovered())
{
// Optim : si le peer annonce sa liste de versions et que la nôtre n'y est
// pas, skip. Économise un round-trip HTTP. Mais on probe quand même si
// la liste est vide (compat anciens peers / beacon partiel).
if (p.Versions.Count > 0 && !p.Versions.Contains(version, StringComparer.OrdinalIgnoreCase))
continue;
candidates.Add($"http://{p.Host}:{p.Port}");
}
foreach (var url in cfg.ManualPeerUrls)
{
var trimmed = url.Trim().TrimEnd('/');
if (string.IsNullOrEmpty(trimmed)) continue;
if (!candidates.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
candidates.Add(trimmed);
}
// Construit la liste fusionnée : actifs (beacons récents) + connus (cache
// disque) + manuels. PeerCandidates dédoublonne et préserve l'ordre.
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
if (candidates.Count == 0)
{

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

View File

@@ -653,6 +653,11 @@ public static class Strings
"وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN."
);
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
// ---- Badge source du manifest (origine de la liste de versions affichée) ----
public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH在线", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)");
public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer离线", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");
public static string ManifestSourceDiskCache => T("💾 Cache local (hors ligne)", "💾 Local cache (offline)", "💾 本地缓存(离线)", "💾 แคชในเครื่อง (ออฟไลน์)", "💾 ذاكرة محلية (غير متصل)");
public static string MsgLanCacheRestart => T(
"Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?",
"Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?",

View File

@@ -0,0 +1,26 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>
/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages :
/// 1. Permet au <c>LanCacheServer</c> de servir le manifest aux peers du LAN
/// (route <c>GET /v1/manifest</c>).
/// 2. Permet au launcher de fallback sur ce cache si OVH est injoignable et
/// qu'aucun peer LAN ne répond non plus (mode offline complet).
///
/// Stocke le JSON brut (pas un objet désérialisé) pour préserver la signature
/// Ed25519 byte-for-byte — la canonicalisation côté validation exige les bytes
/// originaux. Path : <c>%LocalAppData%\PSLauncher\manifest-cache.json</c>.
/// </summary>
public interface IManifestCache
{
/// <summary>Persiste le JSON brut sur disque (atomic via .tmp + Move).</summary>
Task SaveAsync(string rawJson, CancellationToken ct);
/// <summary>Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest).</summary>
string GetCachedFilePath();
/// <summary>Lit + désérialise le manifest cached. Null si pas de cache.</summary>
Task<RemoteManifest?> TryLoadAsync(CancellationToken ct);
}

View File

@@ -2,8 +2,26 @@ using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>D'où provient le dernier manifest récupéré avec succès.</summary>
public enum ManifestSource
{
/// <summary>Pas encore récupéré dans cette session.</summary>
None,
/// <summary>Source canonique : serveur OVH via Internet.</summary>
Ovh,
/// <summary>Peer LAN (offline ou OVH down). Manifest peut être plus ancien que celui d'OVH.</summary>
Peer,
/// <summary>Cache disque local (offline + aucun peer atteignable). Manifest figé à la dernière fetch.</summary>
DiskCache,
}
public interface IManifestService
{
Task<RemoteManifest> FetchAsync(CancellationToken ct);
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
/// <summary>Source du dernier <see cref="FetchAsync"/> réussi. Sert au call site
/// pour décider de skip les fetch dépendants d'OVH (release notes, signed URL)
/// quand on sait qu'on est offline.</summary>
ManifestSource LastSource { get; }
}

View File

@@ -0,0 +1,61 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
public sealed class ManifestCache : IManifestCache
{
private const string FileName = "manifest-cache.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly IConfigStore _configStore;
private readonly ILogger<ManifestCache> _logger;
public ManifestCache(IConfigStore configStore, ILogger<ManifestCache> logger)
{
_configStore = configStore;
_logger = logger;
}
public string GetCachedFilePath() => Path.Combine(_configStore.ConfigDirectory, FileName);
public async Task SaveAsync(string rawJson, CancellationToken ct)
{
var path = GetCachedFilePath();
var tmp = path + ".tmp";
try
{
await File.WriteAllTextAsync(tmp, rawJson, Encoding.UTF8, ct).ConfigureAwait(false);
// Atomic swap : si on est interrompu pendant l'écriture, le cache reste valide.
File.Move(tmp, path, overwrite: true);
_logger.LogDebug("Manifest cached to {Path} ({Bytes} bytes)", path, rawJson.Length);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache manifest to disk (non-fatal)");
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
}
}
public async Task<RemoteManifest?> TryLoadAsync(CancellationToken ct)
{
var path = GetCachedFilePath();
if (!File.Exists(path)) return null;
try
{
var raw = await File.ReadAllTextAsync(path, Encoding.UTF8, ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<RemoteManifest>(raw, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read manifest cache from {Path}", path);
return null;
}
}
}

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

View File

@@ -204,8 +204,12 @@ public sealed class LanCacheConfig
/// URLs peers ajoutées manuellement (override / fallback de l'auto-discovery).
/// Format : <c>http://10.0.0.5:47623</c> (sans trailing slash). Probées EN PLUS
/// des peers découverts auto, après ceux-ci.
///
/// Default <c>http://10.0.4.100:47623</c> = IP fixe du PC serveur ASTERION
/// dans toutes les salles VR PROSERVE. Permet la découverte instantanée au
/// tout premier démarrage du launcher (avant que le 1er beacon UDP n'arrive).
/// </summary>
public List<string> ManualPeerUrls { get; set; } = new();
public List<string> ManualPeerUrls { get; set; } = new() { "http://10.0.4.100:47623" };
/// <summary>
/// Nombre de ZIPs à conserver en cache local (les N plus récents par mtime).