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