v1.0.6 — Version finale non-beta > dernière beta au même préfixe 3-digit

Bug rapporté : après avoir itéré des builds beta 1.5.4.30 / .31 / .32
(isBeta=true, 4-digit), publier 1.5.4 (isBeta=false, 3-digit) comme
release finale ne rendait PAS 1.5.4 la version "courante" côté launcher.
Deux cas d'échec :

  1. Client canSeeBetas=true (opérateur/testeur) : les deux visibles,
     1.5.4.32 restait en tête du tri (SemVer strict : 1.5.4 == 1.5.4.0
     < 1.5.4.32).
  2. Client canSeeBetas=false avec 1.5.4.32 déjà installé : l'install
     locale n'est jamais filtrée par isBeta, donc restait en tête aussi.

Root cause : SemVer.CompareTo() traite le 4ᵉ digit comme un patch
post-release (documenté ainsi dans SemVer.cs pour supporter les itérations
de test 1.5.4.13 alignées sur leur release stable 1.5.4). Cette sémantique
casse quand le 4-digit est en fait un "pré-release" beta destiné à être
supplanté par la 3-digit finale.

Fix : nouveau helper VersionOrder.Compare(versionA, isBetaA, versionB,
isBetaB) qui ajoute une règle par-dessus SemVer :

  Au MÊME préfixe 3-digit (X.Y.Z égaux) ET statut beta différent, la
  version isBeta=false l'emporte, indépendamment du 4ᵉ digit.

Hors ce cas exact (préfixes 3-digit différents, ou même statut beta des
deux côtés) : SemVer strict, aucune régression sur les scénarios existants
(1.5.4.30 beta < 1.5.4.32 beta reste vrai, 1.5.4 < 1.5.5 reste vrai, etc.).

Application aux deux hotspots :

  • UpdateChecker.CheckAsync — tri par VersionOrder au lieu de SemVer,
    ET pour la comparaison isNewer, retrouve l'isBeta d'origine de l'install
    locale via lookup dans manifest.Versions (si l'entrée existe encore).
  • MainViewModel.RebuildList — tri combiné installé/remote via VersionOrder.
    L'isBeta est lookupé dans le RAW remote (avant le filtre canSeeBetas),
    sinon un client sans droits beta ayant installé une beta perdrait
    l'info et retomberait sur SemVer strict.

Migration : aucune côté data. Les versions publiées comme beta restent
identifiées par leur flag isBeta ; le launcher les considère automatiquement
comme pré-release dès qu'une non-beta au même préfixe 3-digit apparaît
dans le manifest. Publish 1.5.4 (isBeta=false) → devient la version featured
même sur les postes ayant 1.5.4.32 installé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:38:20 +02:00
parent d52e29151e
commit c20d4603a2
5 changed files with 98 additions and 8 deletions

View File

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

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>1.0.5</Version>
<AssemblyVersion>1.0.5.0</AssemblyVersion>
<FileVersion>1.0.5.0</FileVersion>
<Version>1.0.6</Version>
<AssemblyVersion>1.0.6.0</AssemblyVersion>
<FileVersion>1.0.6.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -557,8 +557,24 @@ public sealed partial class MainViewModel : ObservableObject
: rawRemote.Where(v => !v.IsBeta).ToList();
var remoteByVer = remote.ToDictionary(v => v.Version);
// Tri combiné installé + remote. On utilise VersionOrder (SemVer + isBeta)
// pour respecter la règle « non-beta > beta au même préfixe 3-digit » :
// sans ça, une install de 1.5.4.32 beta resterait "featured" après la
// publication de 1.5.4 non-beta.
//
// IMPORTANT : on lookupe isBeta dans `rawRemote` (non filtré) et PAS dans
// `remoteByVer` (filtré par canSeeBetas). Sinon un client sans droits beta
// qui aurait installé 1.5.4.32 beta ne verrait plus l'info isBeta pour
// cette entrée (elle a été retirée par le filtre) → VersionOrder retomberait
// sur SemVer strict → 1.5.4.32 > 1.5.4 → featured resterait sur la beta
// installée. En regardant `rawRemote` on garde l'info d'origine.
var rawBetaByVer = rawRemote
.GroupBy(v => v.Version)
.ToDictionary(g => g.Key, g => g.Any(v => v.IsBeta));
bool IsBetaOf(string ver) => rawBetaByVer.TryGetValue(ver, out var isBeta) && isBeta;
var allVersions = installed.Keys.Union(remoteByVer.Keys)
.OrderByDescending(v => SemVer.Parse(v))
.OrderByDescending(v => v, Comparer<string>.Create((a, b) =>
VersionOrder.Compare(a, IsBetaOf(a), b, IsBetaOf(b))))
.ToList();
var rows = new List<VersionRowViewModel>();

View File

@@ -0,0 +1,55 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
/// <summary>
/// Comparaison de versions PROSERVE qui prend en compte le flag <c>isBeta</c>
/// du manifest en plus du SemVer numérique.
///
/// Règle : au même préfixe 3-digit <c>X.Y.Z</c>, une version <c>isBeta=false</c>
/// est considérée SUPÉRIEURE à une version <c>isBeta=true</c>, quel que soit le
/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta <c>1.5.4.30
/// .31 .32</c>, l'opérateur publie <c>1.5.4</c> non-beta comme release finale.
/// Sous SemVer pur, <c>1.5.4 == 1.5.4.0 &lt; 1.5.4.32</c> donc le launcher
/// resterait pointé sur la beta ; ce helper inverse la comparaison pour ce
/// cas précis afin que la release finale soit considérée comme LA version
/// courante.
///
/// Hors ce cas (préfixes 3-digit différents, ou même statut beta des deux
/// côtés), on retombe sur <see cref="SemVer.CompareTo"/> — donc :
/// <list type="bullet">
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4 gagne (règle spéciale)</item>
/// <item><c>1.5.4.30 (beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4.32 gagne (SemVer)</item>
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.5.10 (beta)</c> → 1.5.5.10 gagne (SemVer, préfixes différents)</item>
/// <item><c>1.5.4</c> vs <c>1.5.5</c> → 1.5.5 gagne (SemVer)</item>
/// </list>
/// </summary>
public static class VersionOrder
{
/// <summary>
/// Retourne &lt;0 si A &lt; B, 0 si égales, &gt;0 si A &gt; B. Voir la doc de
/// <see cref="VersionOrder"/> pour la règle isBeta.
/// </summary>
public static int Compare(string versionA, bool isBetaA, string versionB, bool isBetaB)
{
var svA = SemVer.Parse(versionA);
var svB = SemVer.Parse(versionB);
// Cas spécial : même préfixe 3-digit ET statut beta différent.
// La version non-beta l'emporte, indépendamment du 4ᵉ digit (traité
// comme un suffixe pré-release dans ce contexte particulier).
if (svA.Major == svB.Major
&& svA.Minor == svB.Minor
&& svA.Patch == svB.Patch
&& isBetaA != isBetaB)
{
return isBetaA ? -1 : 1;
}
return svA.CompareTo(svB);
}
/// <summary>Overload pratique pour une <see cref="VersionManifest"/>.</summary>
public static int Compare(VersionManifest a, VersionManifest b)
=> Compare(a.Version, a.IsBeta, b.Version, b.IsBeta);
}

View File

@@ -28,22 +28,41 @@ public sealed class UpdateChecker : IUpdateChecker
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
// On prend toujours la plus haute version SemVer disponible et téléchargeable.
// On prend toujours la plus haute version disponible et téléchargeable, en
// ordonnant via VersionOrder — ça respecte la règle « non-beta > beta au
// même préfixe 3-digit » (ex : 1.5.4 non-beta > 1.5.4.32 beta), sinon
// c'est du SemVer strict.
var latest = manifest.Versions
.Where(v => v.AvailableForDownload)
.OrderByDescending(v => SemVer.Parse(v.Version))
.OrderByDescending(v => v, Comparer<VersionManifest>.Create(VersionOrder.Compare))
.FirstOrDefault();
var installed = _registry.Scan();
// Pour les installs locales on ne connaît pas leur statut beta d'origine
// (l'entrée manifest a pu disparaître entre-temps). On les compare en
// SemVer pur — c'est acceptable parce que le vrai check « faut-il updater »
// ci-dessous confronte l'install LA PLUS HAUTE au meilleur remote, qui
// lui embarque son isBeta.
var latestInstalled = installed
.OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault();
var isLatestInstalled = latest is not null && latestInstalled is not null
&& latestInstalled.Version == latest.Version;
// Pour « isNewer », on doit décider si `latest` (remote) supersede
// `latestInstalled` (local). On cherche l'entrée manifest correspondante
// à l'install locale pour retrouver son isBeta d'origine. Si absente
// (typiquement : install locale d'une ancienne beta que l'opérateur a
// supprimée du manifest), on la traite comme non-beta pour ne pas
// fausser la comparaison en défaveur de l'install locale.
var installedRemote = latestInstalled is null
? null
: manifest.Versions.FirstOrDefault(v => v.Version == latestInstalled.Version);
var installedIsBeta = installedRemote?.IsBeta ?? false;
var isNewer = latest is not null
&& (latestInstalled is null
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0);
|| VersionOrder.Compare(latest.Version, latest.IsBeta,
latestInstalled.Version, installedIsBeta) > 0);
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
}