diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index 3a12834..6f9e4b4 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.9"
+#define MyAppVersion "1.0.10"
#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 1b1604a..b6f553e 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.9
- 1.0.9.0
- 1.0.9.0
+ 1.0.10
+ 1.0.10.0
+ 1.0.10.0
true
diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs
index 30874bb..21e7b50 100644
--- a/src/PSLauncher.App/ViewModels/MainViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs
@@ -543,12 +543,13 @@ public sealed partial class MainViewModel : ObservableObject
var preserveActive = oldActive is not null
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
- // Scan disque : sur des setups multi-channels (firefighter + full à même
- // numéro), il y a plusieurs dossiers d'install au même version → on ne
- // peut plus keyer par version. On indexe par basename de folder (= la
- // clé RowKey des rows côté UI, aussi ce que le remote résout via
- // GetInstallFolderName()).
- var installedByFolderName = _registry.Scan()
+ // Scan disque. Chaque install a un FolderPath (basename unique par install)
+ // et éventuellement un EntryId (lu depuis .proserve-meta.json — présent
+ // pour les installs faits par v1.0.8+, null pour les plus anciens).
+ var installedList = _registry.Scan();
+ // Index par folder name pour le matching de fallback (installs anciens
+ // sans EntryId, ou remote entries sans Id).
+ var installedByFolderName = installedList
.ToDictionary(v => Path.GetFileName(v.FolderPath), StringComparer.OrdinalIgnoreCase);
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
@@ -561,63 +562,95 @@ public sealed partial class MainViewModel : ObservableObject
var remote = canSeeBetas
? rawRemote
: rawRemote.Where(v => !v.IsBeta).ToList();
- // Index remote par folder name résolu (clé stable, unique par entry).
- // Sur du multi-channel valide, chaque entry a son propre folder. Si deux
- // entries pointent malencontreusement au même folder (setup mal configuré
- // côté backoffice), on log un warning et on garde la première — l'install
- // guard client empêchera de toute façon l'écrasement.
- var remoteByFolderName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Row identity : Id de l'entrée manifest quand dispo (unique par entry,
+ // survit à un rename de folder), sinon fallback sur le folder name
+ // résolu. Ce système survit au cas où deux entries partagent un
+ // installFolderTemplate identique (bug d'admin) — on garde les deux
+ // rows dans l'UI, l'install guard client bloquera l'écrasement au
+ // moment de l'install.
+ static string RemoteRowKey(VersionManifest v)
+ => !string.IsNullOrEmpty(v.Id) ? v.Id! : v.GetInstallFolderName();
+
+ var remoteByRowKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var v in remote)
{
- var folder = v.GetInstallFolderName();
- if (!remoteByFolderName.TryAdd(folder, v))
+ var key = RemoteRowKey(v);
+ if (!remoteByRowKey.TryAdd(key, v))
{
_logger.LogWarning(
- "Manifest folder collision : two entries resolve to « {Folder} » (versions {V1} and {V2}). Configure distinct installFolderTemplate.",
- folder, remoteByFolderName[folder].Version, v.Version);
+ "Manifest duplicate row key « {Key} » (versions {V1} and {V2}). Second entry dropped.",
+ key, remoteByRowKey[key].Version, 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
- // `remoteByFolderName` (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 rawBetaByFolderName = rawRemote
- .GroupBy(v => v.GetInstallFolderName(), StringComparer.OrdinalIgnoreCase)
- .ToDictionary(g => g.Key, g => g.Any(v => v.IsBeta), StringComparer.OrdinalIgnoreCase);
- bool IsBetaOf(string folderName)
- => rawBetaByFolderName.TryGetValue(folderName, out var isBeta) && isBeta;
- // Extrait un numéro de version depuis un folder name (via VersionManifest.
- // GetInstallFolderName() ou InstalledVersion) pour alimenter VersionOrder.
- string VersionOf(string folderName)
- => remoteByFolderName.TryGetValue(folderName, out var r) ? r.Version
- : installedByFolderName.TryGetValue(folderName, out var i) ? i.Version
- : folderName;
-
- var allFolderNames = installedByFolderName.Keys
- .Union(remoteByFolderName.Keys, StringComparer.OrdinalIgnoreCase)
- .OrderByDescending(f => f, Comparer.Create((a, b) =>
- VersionOrder.Compare(VersionOf(a), IsBetaOf(a), VersionOf(b), IsBetaOf(b))))
- .ToList();
-
- var rows = new List();
- foreach (var folderName in allFolderNames)
+ // Match installed ↔ remote — priorité à l'EntryId (identifie l'entrée
+ // exacte même si le folder a été renommé côté opérateur), fallback sur
+ // le folder name. Sur les installs anciens sans EntryId dans le meta,
+ // seul le folder name est utilisé (comportement legacy).
+ var installedByRowKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var inst in installedList)
{
- VersionRowViewModel row;
- if (installedByFolderName.TryGetValue(folderName, out var inst))
+ var folderName = Path.GetFileName(inst.FolderPath);
+ string rowKey;
+ if (!string.IsNullOrEmpty(inst.EntryId))
{
- row = VersionRowViewModel.ForInstalled(inst, remoteByFolderName.GetValueOrDefault(folderName));
+ // Meta contient l'entryId — clé canonique.
+ rowKey = inst.EntryId!;
}
else
{
- row = VersionRowViewModel.ForRemote(remoteByFolderName[folderName]);
+ // Legacy install : essaie de retrouver l'entrée remote par folder
+ // name (unique dans le manifest sur setup sain). Si match, on
+ // hérite de son Id comme rowKey. Sinon, c'est un install orphelin
+ // → clé = folder name.
+ var remoteMatch = remote.FirstOrDefault(r =>
+ string.Equals(r.GetInstallFolderName(), folderName, StringComparison.OrdinalIgnoreCase));
+ rowKey = remoteMatch is not null && !string.IsNullOrEmpty(remoteMatch.Id)
+ ? remoteMatch.Id!
+ : folderName;
+ }
+ installedByRowKey.TryAdd(rowKey, inst);
+ }
+
+ // Tri combiné — on ordonne par VersionOrder (SemVer + isBeta), en
+ // lookupant version + isBeta depuis les deux dicos par rowKey.
+ // isBeta est lu depuis `rawRemote` (non filtré par canSeeBetas) pour
+ // que la comparaison reste correcte même si une beta a été retirée
+ // de la vue courante.
+ var rawByRowKey = rawRemote
+ .GroupBy(RemoteRowKey, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
+ (string Version, bool IsBeta) VersionAndBeta(string rowKey)
+ {
+ if (rawByRowKey.TryGetValue(rowKey, out var r)) return (r.Version, r.IsBeta);
+ if (installedByRowKey.TryGetValue(rowKey, out var i)) return (i.Version, false);
+ return (rowKey, false);
+ }
+
+ var allRowKeys = installedByRowKey.Keys
+ .Union(remoteByRowKey.Keys, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(k => k, Comparer.Create((a, b) =>
+ {
+ var (va, ba) = VersionAndBeta(a);
+ var (vb, bb) = VersionAndBeta(b);
+ return VersionOrder.Compare(va, ba, vb, bb);
+ }))
+ .ToList();
+
+ var rows = new List();
+ foreach (var rowKey in allRowKeys)
+ {
+ VersionRowViewModel row;
+ var hasInst = installedByRowKey.TryGetValue(rowKey, out var inst);
+ var hasRem = remoteByRowKey.TryGetValue(rowKey, out var rem);
+ if (hasInst)
+ {
+ row = VersionRowViewModel.ForInstalled(inst!, rem);
+ }
+ else
+ {
+ row = VersionRowViewModel.ForRemote(rem!);
}
WireRowHandlers(row);
rows.Add(row);
diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
index 1418ae9..0eda622 100644
--- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
@@ -247,12 +247,18 @@ public sealed partial class VersionRowViewModel : ObservableObject
Remote = remote;
_state = initialState;
- // RowKey — voir doc de la property. Basename du folder si dispo (côté
- // installed ou remote via InstallFolderTemplate), fallback sur version.
+ // RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
+ // à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
+ // l'install, (3) folder name basename, (4) version en dernier recours.
+ // Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
+ // par RowKey trouvent la row correcte.
var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName();
- RowKey = !string.IsNullOrWhiteSpace(folderName) ? folderName! : version;
+ RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
+ : !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
+ : !string.IsNullOrWhiteSpace(folderName) ? folderName!
+ : version;
}
// Les commandes sont câblées par le MainViewModel après instanciation
diff --git a/src/PSLauncher.Core/Installations/InstallationRegistry.cs b/src/PSLauncher.Core/Installations/InstallationRegistry.cs
index df858c1..a9d5f43 100644
--- a/src/PSLauncher.Core/Installations/InstallationRegistry.cs
+++ b/src/PSLauncher.Core/Installations/InstallationRegistry.cs
@@ -76,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
FolderPath: dir,
ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc,
- SizeBytes: SafeDirectorySize(dir)));
+ SizeBytes: SafeDirectorySize(dir),
+ EntryId: TryReadEntryId(dir)));
}
return results
diff --git a/src/PSLauncher.Models/InstalledVersion.cs b/src/PSLauncher.Models/InstalledVersion.cs
index c675022..68341b6 100644
--- a/src/PSLauncher.Models/InstalledVersion.cs
+++ b/src/PSLauncher.Models/InstalledVersion.cs
@@ -1,8 +1,14 @@
namespace PSLauncher.Models;
+///
+/// Id de l'entrée manifest source, lu depuis .proserve-meta.json. Null
+/// pour les installs antérieurs à v1.0.8 (rétro-compat). Sert au matching
+/// installed ↔ remote quand deux entries partagent un folder name résolu.
+///
public sealed record InstalledVersion(
string Version,
string FolderPath,
string ExecutablePath,
DateTime InstalledAt,
- long SizeBytes);
+ long SizeBytes,
+ string? EntryId = null);