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:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user