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

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

View File

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

View File

@@ -543,12 +543,13 @@ public sealed partial class MainViewModel : ObservableObject
var preserveActive = oldActive is not null var preserveActive = oldActive is not null
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying; && oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
// Scan disque : sur des setups multi-channels (firefighter + full à même // Scan disque. Chaque install a un FolderPath (basename unique par install)
// numéro), il y a plusieurs dossiers d'install au même version → on ne // et éventuellement un EntryId (lu depuis .proserve-meta.json — présent
// peut plus keyer par version. On indexe par basename de folder (= la // pour les installs faits par v1.0.8+, null pour les plus anciens).
// clé RowKey des rows côté UI, aussi ce que le remote résout via var installedList = _registry.Scan();
// GetInstallFolderName()). // Index par folder name pour le matching de fallback (installs anciens
var installedByFolderName = _registry.Scan() // sans EntryId, ou remote entries sans Id).
var installedByFolderName = installedList
.ToDictionary(v => Path.GetFileName(v.FolderPath), StringComparer.OrdinalIgnoreCase); .ToDictionary(v => Path.GetFileName(v.FolderPath), StringComparer.OrdinalIgnoreCase);
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache // 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 var remote = canSeeBetas
? rawRemote ? rawRemote
: rawRemote.Where(v => !v.IsBeta).ToList(); : 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 // Row identity : Id de l'entrée manifest quand dispo (unique par entry,
// entries pointent malencontreusement au même folder (setup mal configuré // survit à un rename de folder), sinon fallback sur le folder name
// côté backoffice), on log un warning et on garde la première — l'install // résolu. Ce système survit au cas où deux entries partagent un
// guard client empêchera de toute façon l'écrasement. // installFolderTemplate identique (bug d'admin) — on garde les deux
var remoteByFolderName = new Dictionary<string, VersionManifest>(StringComparer.OrdinalIgnoreCase); // 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) foreach (var v in remote)
{ {
var folder = v.GetInstallFolderName(); var key = RemoteRowKey(v);
if (!remoteByFolderName.TryAdd(folder, v)) if (!remoteByRowKey.TryAdd(key, v))
{ {
_logger.LogWarning( _logger.LogWarning(
"Manifest folder collision : two entries resolve to « {Folder} » (versions {V1} and {V2}). Configure distinct installFolderTemplate.", "Manifest duplicate row key « {Key} » (versions {V1} and {V2}). Second entry dropped.",
folder, remoteByFolderName[folder].Version, v.Version); key, remoteByRowKey[key].Version, v.Version);
} }
} }
// Tri combiné installé + remote. On utilise VersionOrder (SemVer + isBeta) // Match installed ↔ remote — priorité à l'EntryId (identifie l'entrée
// pour respecter la règle « non-beta > beta au même préfixe 3-digit » : // exacte même si le folder a été renommé côté opérateur), fallback sur
// sans ça, une install de 1.5.4.32 beta resterait "featured" après la // le folder name. Sur les installs anciens sans EntryId dans le meta,
// publication de 1.5.4 non-beta. // seul le folder name est utilisé (comportement legacy).
// var installedByRowKey = new Dictionary<string, InstalledVersion>(StringComparer.OrdinalIgnoreCase);
// IMPORTANT : on lookupe isBeta dans `rawRemote` (non filtré) et PAS dans foreach (var inst in installedList)
// `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)
{ {
VersionRowViewModel row; var folderName = Path.GetFileName(inst.FolderPath);
if (installedByFolderName.TryGetValue(folderName, out var inst)) string rowKey;
if (!string.IsNullOrEmpty(inst.EntryId))
{ {
row = VersionRowViewModel.ForInstalled(inst, remoteByFolderName.GetValueOrDefault(folderName)); // Meta contient l'entryId — clé canonique.
rowKey = inst.EntryId!;
} }
else 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); WireRowHandlers(row);
rows.Add(row); rows.Add(row);

View File

@@ -247,12 +247,18 @@ public sealed partial class VersionRowViewModel : ObservableObject
Remote = remote; Remote = remote;
_state = initialState; _state = initialState;
// RowKey — voir doc de la property. Basename du folder si dispo (côté // RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
// installed ou remote via InstallFolderTemplate), fallback sur version. // à 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) var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath) ? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName(); : 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 // 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, FolderPath: dir,
ExecutablePath: exe, ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc, InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir))); SizeBytes: SafeDirectorySize(dir),
EntryId: TryReadEntryId(dir)));
} }
return results return results

View File

@@ -1,8 +1,14 @@
namespace PSLauncher.Models; 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( public sealed record InstalledVersion(
string Version, string Version,
string FolderPath, string FolderPath,
string ExecutablePath, string ExecutablePath,
DateTime InstalledAt, DateTime InstalledAt,
long SizeBytes); long SizeBytes,
string? EntryId = null);