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