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>
237 lines
11 KiB
C#
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 <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('/');
|
|
}
|