v1.0.10 — Fix v1.0.8/9 : row identity par entry Id, pas par folder name

Bug rapporté : après update client vers 1.0.9, un opérateur ne voyait qu'UNE
ligne alors que le manifest en contenait deux au même numéro. Root cause :
mon refactor v1.0.8 keyait les rows par folder name résolu via
GetInstallFolderName(). Sur les manifests existants (où l'opérateur n'avait
pas encore migré vers un installFolderTemplate distinct par channel), les
deux entries résolvent au même dossier « PROSERVE v1.5.4.32 » — le dico
TryAdd droppait la seconde silencieusement (juste un log warn).

Fix : row identity passe sur l'Id de l'entrée manifest (unique par entry,
auto-généré server-side depuis longtemps via generate_entry_id()). Le folder
name reste utilisé pour le matching installed ↔ remote quand l'entryId
manque (installs d'avant v1.0.8 qui n'ont pas encore été re-installés).

Bénéfices :
  • Les deux entries s'affichent MÊME si elles partagent un
    installFolderTemplate. L'install guard côté client bloquera l'écrasement
    au moment de l'install avec le message clair habituel.
  • Migration transparente : les installs existants continuent d'être matchés
    par folder name tant qu'ils n'ont pas d'EntryId dans leur meta. Au
    ré-install, le meta reçoit son EntryId et le matching devient canonique.

Détails :

── Model ─────────────────────────────────────────────────────────────
• InstalledVersion : nouveau champ optionnel EntryId (default null pour la
  rétro-compat des call sites existants).

── Registry ──────────────────────────────────────────────────────────
• Scan() populate EntryId via TryReadEntryId(dir). null si le fichier meta
  n'existe pas ou si la clé est absente (install antérieur à v1.0.8).

── MainViewModel.RebuildList ─────────────────────────────────────────
• remoteByRowKey : keyé par Id de l'entry (fallback folder name si le
  manifest est très vieux et n'a pas d'id).
• installedByRowKey : keyé par EntryId lu du meta ; fallback = folder name
  ; fallback ultime = héritage de l'Id d'un remote match si un install
  legacy pointe vers un remote qui, lui, a un Id.
• Tri VersionOrder : lookup version + isBeta par rowKey via deux dicos
  rawByRowKey/installedByRowKey (au lieu de folder name).

── VersionRowViewModel ───────────────────────────────────────────────
• RowKey : priorité (Remote.Id → Installed.EntryId → folder name → Version).
  Aligné avec la logique RebuildList pour que les lookups par RowKey
  trouvent la row correcte.

Bump : 1.0.9 → 1.0.10 (fix critique du refactor v1.0.8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 17:11:13 +02:00
parent 53c9e0519c
commit dc72e7eee6
6 changed files with 106 additions and 60 deletions

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.9</Version>
<AssemblyVersion>1.0.9.0</AssemblyVersion>
<FileVersion>1.0.9.0</FileVersion>
<Version>1.0.10</Version>
<AssemblyVersion>1.0.10.0</AssemblyVersion>
<FileVersion>1.0.10.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -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<string, VersionManifest>(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<string, VersionManifest>(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<string>.Create((a, b) =>
VersionOrder.Compare(VersionOf(a), IsBetaOf(a), VersionOf(b), IsBetaOf(b))))
.ToList();
var rows = new List<VersionRowViewModel>();
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<string, InstalledVersion>(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<string>.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<VersionRowViewModel>();
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);

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +1,14 @@
namespace PSLauncher.Models;
/// <param name="EntryId">
/// Id de l'entrée manifest source, lu depuis <c>.proserve-meta.json</c>. 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.
/// </param>
public sealed record InstalledVersion(
string Version,
string FolderPath,
string ExecutablePath,
DateTime InstalledAt,
long SizeBytes);
long SizeBytes,
string? EntryId = null);