From dc72e7eee6f1a88f36ef5ebef4dcfd44e4604e6a Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 7 Jul 2026 17:11:13 +0200 Subject: [PATCH] =?UTF-8?q?v1.0.10=20=E2=80=94=20Fix=20v1.0.8/9=20:=20row?= =?UTF-8?q?=20identity=20par=20entry=20Id,=20pas=20par=20folder=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- installer/PSLauncher.iss | 2 +- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 135 +++++++++++------- .../ViewModels/VersionRowViewModel.cs | 12 +- .../Installations/InstallationRegistry.cs | 3 +- src/PSLauncher.Models/InstalledVersion.cs | 8 +- 6 files changed, 106 insertions(+), 60 deletions(-) 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);