From c20d4603a2c3772d9d2fde2201c8d8052765e1ca Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 7 Jul 2026 15:38:20 +0200 Subject: [PATCH] =?UTF-8?q?v1.0.6=20=E2=80=94=20Version=20finale=20non-bet?= =?UTF-8?q?a=20>=20derni=C3=A8re=20beta=20au=20m=C3=AAme=20pr=C3=A9fixe=20?= =?UTF-8?q?3-digit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- installer/PSLauncher.iss | 2 +- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 18 +++++- .../Installations/VersionOrder.cs | 55 +++++++++++++++++++ src/PSLauncher.Core/Updates/UpdateChecker.cs | 25 ++++++++- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 src/PSLauncher.Core/Installations/VersionOrder.cs diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 4954334..a003d66 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -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" diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 748edba..20e5ecb 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 1.0.5 - 1.0.5.0 - 1.0.5.0 + 1.0.6 + 1.0.6.0 + 1.0.6.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 135a612..6644782 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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.Create((a, b) => + VersionOrder.Compare(a, IsBetaOf(a), b, IsBetaOf(b)))) .ToList(); var rows = new List(); diff --git a/src/PSLauncher.Core/Installations/VersionOrder.cs b/src/PSLauncher.Core/Installations/VersionOrder.cs new file mode 100644 index 0000000..08a4d4a --- /dev/null +++ b/src/PSLauncher.Core/Installations/VersionOrder.cs @@ -0,0 +1,55 @@ +using PSLauncher.Models; + +namespace PSLauncher.Core.Installations; + +/// +/// Comparaison de versions PROSERVE qui prend en compte le flag isBeta +/// du manifest en plus du SemVer numérique. +/// +/// Règle : au même préfixe 3-digit X.Y.Z, une version isBeta=false +/// est considérée SUPÉRIEURE à une version isBeta=true, quel que soit le +/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta 1.5.4.30 +/// .31 .32, l'opérateur publie 1.5.4 non-beta comme release finale. +/// Sous SemVer pur, 1.5.4 == 1.5.4.0 < 1.5.4.32 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 — donc : +/// +/// 1.5.4 (non-beta) vs 1.5.4.32 (beta) → 1.5.4 gagne (règle spéciale) +/// 1.5.4.30 (beta) vs 1.5.4.32 (beta) → 1.5.4.32 gagne (SemVer) +/// 1.5.4 (non-beta) vs 1.5.5.10 (beta) → 1.5.5.10 gagne (SemVer, préfixes différents) +/// 1.5.4 vs 1.5.5 → 1.5.5 gagne (SemVer) +/// +/// +public static class VersionOrder +{ + /// + /// Retourne <0 si A < B, 0 si égales, >0 si A > B. Voir la doc de + /// pour la règle isBeta. + /// + 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); + } + + /// Overload pratique pour une . + public static int Compare(VersionManifest a, VersionManifest b) + => Compare(a.Version, a.IsBeta, b.Version, b.IsBeta); +} diff --git a/src/PSLauncher.Core/Updates/UpdateChecker.cs b/src/PSLauncher.Core/Updates/UpdateChecker.cs index 4591cc2..87cfb52 100644 --- a/src/PSLauncher.Core/Updates/UpdateChecker.cs +++ b/src/PSLauncher.Core/Updates/UpdateChecker.cs @@ -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.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); }