Files
PS_Launcher/src/PSLauncher.Core/Manifests/ManifestService.cs
j.foucher d845edb90f v1.0.11 — /manifest : opt-in multiChannel pour ne plus dédupliquer par version
Bug rapporté après v1.0.10 : client sur license channel=full avec les deux
entries v1.5.4.32 dans le backoffice (Entry #1 tagged firefighter+full,
Entry #2 tagged full), le launcher n'affichait toujours qu'une seule ligne.

Root cause côté serveur, PAS côté client cette fois. Manifest.php faisait
un « group by version + pick most specific » dans filterVersions() —
comportement historique introduit pour ne pas faire crasher les vieux
clients qui déduisaient par ToDictionary(v.Version). Résultat : Entry #2
était filtrée avant même d'atteindre le launcher.

Ce filtrage était nécessaire à l'époque (vieux clients) mais bloque tous
les fixes multi-channels client (v1.0.8-1.0.10) qui avaient rendu le
client capable d'afficher plusieurs entries au même numéro.

Fix : opt-in via query param sur /manifest?multiChannel=1

Server (Manifest.php) :
  • filterVersions() prend un `bool $multiChannel = false`.
  • Si true → skip group-by, retourne toutes les entries visibles.
  • Si false (défaut, vieux clients) → comportement historique préservé.
  • Query param `multiChannel` lu depuis $_GET, transmis à filterVersions.

Client (ManifestService.FetchFromOvhAsync) :
  • Ajoute `?multiChannel=1` inconditionnellement. Un serveur ancien
    ignore silencieusement le param (pas de header d'échec).
  • Combiné avec &channel=X quand la license a un channel.

Rétro-compat :
  • Vieux client (v1.0.9-) + serveur nouveau : n'envoie pas multiChannel=1,
    serveur dédupe comme avant, launcher ne crash pas.
  • Client nouveau (v1.0.11+) + serveur ancien : le param est ignoré,
    même comportement qu'avant (dédup côté serveur, une seule row visible).
  • Client nouveau + serveur nouveau (config voulue) : les deux entries
    remontent, le row-key-par-Id de v1.0.10 fait le reste.

Bump : 1.0.10 → 1.0.11 (fix ciblé serveur+client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:16:58 +02:00

237 lines
11 KiB
C#

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.Core.Security;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
public sealed class ManifestService : IManifestService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _http;
private readonly Func<string> _serverBaseUrlProvider;
private readonly Func<string?> _channelProvider;
private readonly IManifestCache _manifestCache;
private readonly IPeerManifestFetcher _peerManifestFetcher;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<ManifestService> _logger;
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
public ManifestService(
HttpClient http,
Func<string> serverBaseUrlProvider,
Func<string?> channelProvider,
IManifestCache manifestCache,
IPeerManifestFetcher peerManifestFetcher,
ISettingsLockService settingsLock,
ILogger<ManifestService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_channelProvider = channelProvider;
_manifestCache = manifestCache;
_peerManifestFetcher = peerManifestFetcher;
_settingsLock = settingsLock;
_logger = logger;
}
/// <summary>
/// No-op depuis v0.28 : le hash settings-lock vient maintenant de la
/// <c>LicenseValidationResponse</c> (per-license). Voir
/// <see cref="PSLauncher.Core.Licensing.LicenseService.ValidateAsync"/> et
/// <see cref="PSLauncher.Core.Licensing.LicenseService.GetCached"/> pour la
/// propagation effective vers <see cref="ISettingsLockService"/>.
/// </summary>
private void PropagateSettingsLock(RemoteManifest? manifest)
{
_ = manifest;
_ = _settingsLock;
}
/// <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;
PropagateSettingsLock(manifest);
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;
PropagateSettingsLock(cached);
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;
PropagateSettingsLock(manifest);
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;
PropagateSettingsLock(localCache);
return localCache;
}
throw new InvalidOperationException("Manifest unavailable: offline, no LAN peer responded, no local cache.");
}
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
{
// Channel = manifest filter par-license. NULL ou vide = on appelle
// /manifest tout court (= versions.json default). Sinon /manifest?channel=X
// (le serveur charge versions-{X}.json avec fallback sur default si fichier absent).
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
var channel = _channelProvider();
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
// Query params : ?channel=X (filtrage par channel license) + &multiChannel=1
// (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au
// même numéro de version sans dédup, le client sait afficher chacune sur
// sa row). Toujours envoyé — les serveurs anciens l'ignorent silencieusement.
var qs = new System.Text.StringBuilder("?multiChannel=1");
if (!string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$"))
{
qs.Append("&channel=").Append(Uri.EscapeDataString(channel!));
}
var url = baseUrl + qs.ToString();
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
ovhCts.CancelAfter(OvhFetchTimeoutMs);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
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, ovhCts.Token).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
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);
// 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(rnCts.Token).ConfigureAwait(false);
}
private static string TrimSlash(string s) => s.TrimEnd('/');
}