v1.0.8 — Multi-channels : afficher plusieurs entries au même numéro + install guard anti-collision
Contexte : après le fix v1.0.5 (/download-url disambigué par filename), un
opérateur peut avoir un manifest avec deux entries partageant un numéro de
version sur des channels différents (ex : proserve-firefighter-1.5.4.32 vs
proserve-full-1.5.4.32). Deux problèmes restants :
1. Le client n'affichait qu'UNE row : `remote.ToDictionary(v.Version)` dans
RebuildList crashait sur duplicate key.
2. À l'install, les deux entries résolvaient au même dossier via le default
`installFolderTemplate = "PROSERVE v{version}"` → l'install le plus récent
écrasait silencieusement le précédent (ZipInstaller rename en .bak-{ts}
puis delete en background).
Solution end-to-end :
── Client ─────────────────────────────────────────────────────────────
• RebuildList refactor : index par folder name (résolu via GetInstallFolder-
Name()) au lieu de par version. Deux entries au même numéro deviennent
visibles dès qu'elles ont des templates distincts. Warning log si deux
entries résolvent au même folder.
• VersionRowViewModel : nouveau RowKey (basename du folder ou fallback
Version), ChannelBadge (premier channel non-default). Sites de lookup
(DL-in-flight preservation, 404 retry) migrés sur RowKey.
• MainWindow.xaml : badge bleu channel affiché à côté du badge BÊTA, dans
la row compact ET dans FeaturedVersion.
• Install guard : refuse une install si le dossier cible contient déjà un
.proserve-meta.json avec un entryId différent. Le meta stocke maintenant
l'entryId à chaque WriteInstallMetadataAsync. Message clair localisé
(FR/EN/CN/TH/AR/ES/DE) qui pointe l'opérateur vers le backoffice.
• VersionManifest client model : nouveau champ optionnel `Id` (mappé sur
le champ serveur existant), utilisé pour identifier l'entrée source.
• Registry regex broadened : accepte `PROSERVE(-<channel>)? v...` en plus
du `PROSERVE v...` legacy. Les folders custom par channel sont scannés.
── Serveur admin (versions.php) ──────────────────────────────────────
• Nouveau champ éditable `install_folder_template` dans le formulaire
d'ajout ET dans edit_meta. Validation regex (contient {version}, charset
whitelisted).
• Default intelligent à la création : si un seul channel non-default est
coché, pré-remplit avec "PROSERVE-<channel> v{version}". Sinon garde
"PROSERVE v{version}" (legacy).
• Validation croisée : refuse la save si deux entries résolvent au même
dossier, avec un message clair qui suggère un template alternatif.
── Rétro-compat ──────────────────────────────────────────────────────
• Vieux installs (sans entryId dans meta) : install guard fail-open, se
laisse écraser à la ré-install et retrofit l'entryId.
• Vieux manifests (sans `id` sur les entries) : `Id` est null côté client,
l'install guard reste passif, comportement identique à v1.0.7.
• Vieux serveurs (sans `install_folder_template` éditable) : le manifest
reste avec le default généré par generate_entry_id, aucune breaking
change. Le badge channel s'affiche quand même si `channels` est renseigné.
• Setups mono-channel (99 % des cas) : aucun changement visible, sort et
matching identiques.
Bump : 1.0.7 → 1.0.8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -543,7 +543,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
var preserveActive = oldActive is not null
|
||||
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
|
||||
|
||||
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
||||
// 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()
|
||||
.ToDictionary(v => Path.GetFileName(v.FolderPath), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
|
||||
// les versions taggées isBeta=true. Les installations locales déjà
|
||||
@@ -555,7 +561,22 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
var remote = canSeeBetas
|
||||
? rawRemote
|
||||
: rawRemote.Where(v => !v.IsBeta).ToList();
|
||||
var remoteByVer = remote.ToDictionary(v => v.Version);
|
||||
// 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);
|
||||
foreach (var v in remote)
|
||||
{
|
||||
var folder = v.GetInstallFolderName();
|
||||
if (!remoteByFolderName.TryAdd(folder, v))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Manifest folder collision : two entries resolve to « {Folder} » (versions {V1} and {V2}). Configure distinct installFolderTemplate.",
|
||||
folder, remoteByFolderName[folder].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 » :
|
||||
@@ -563,31 +584,40 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// publication de 1.5.4 non-beta.
|
||||
//
|
||||
// IMPORTANT : on lookupe isBeta dans `rawRemote` (non filtré) et PAS dans
|
||||
// `remoteByVer` (filtré par canSeeBetas). Sinon un client sans droits beta
|
||||
// qui aurait installé 1.5.4.32 beta ne verrait plus l'info isBeta pour
|
||||
// `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 rawBetaByVer = rawRemote
|
||||
.GroupBy(v => v.Version)
|
||||
.ToDictionary(g => g.Key, g => g.Any(v => v.IsBeta));
|
||||
bool IsBetaOf(string ver) => rawBetaByVer.TryGetValue(ver, out var isBeta) && isBeta;
|
||||
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
||||
.OrderByDescending(v => v, Comparer<string>.Create((a, b) =>
|
||||
VersionOrder.Compare(a, IsBetaOf(a), b, IsBetaOf(b))))
|
||||
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 ver in allVersions)
|
||||
foreach (var folderName in allFolderNames)
|
||||
{
|
||||
VersionRowViewModel row;
|
||||
if (installed.TryGetValue(ver, out var inst))
|
||||
if (installedByFolderName.TryGetValue(folderName, out var inst))
|
||||
{
|
||||
row = VersionRowViewModel.ForInstalled(inst, remoteByVer.GetValueOrDefault(ver));
|
||||
row = VersionRowViewModel.ForInstalled(inst, remoteByFolderName.GetValueOrDefault(folderName));
|
||||
}
|
||||
else
|
||||
{
|
||||
row = VersionRowViewModel.ForRemote(remoteByVer[ver]);
|
||||
row = VersionRowViewModel.ForRemote(remoteByFolderName[folderName]);
|
||||
}
|
||||
WireRowHandlers(row);
|
||||
rows.Add(row);
|
||||
@@ -614,7 +644,9 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// bon objet et que la barre de progress de la row avance live.
|
||||
if (preserveActive && oldActive is not null)
|
||||
{
|
||||
var matching = rows.FirstOrDefault(r => r.Version == oldActive.Version);
|
||||
// Match par RowKey (folder name) plutôt que Version : sur du multi-
|
||||
// channel deux rows peuvent partager le numéro de version.
|
||||
var matching = rows.FirstOrDefault(r => r.RowKey == oldActive.RowKey);
|
||||
if (matching is not null)
|
||||
{
|
||||
matching.State = oldActive.State;
|
||||
@@ -1463,8 +1495,14 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
var target = _config.AutoMode.SelectedVersion;
|
||||
if (string.IsNullOrEmpty(target)) return;
|
||||
|
||||
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == target);
|
||||
// AutoMode config stocke le numéro de version (pas le RowKey). Sur un
|
||||
// setup correctement configuré (multi-channel avec installFolderTemplate
|
||||
// distincts par channel), au plus UNE row installée peut partager ce
|
||||
// numéro à la fois — l'install guard client (v1.0.8+) empêche l'écrasement.
|
||||
// Donc la comparaison par Version est safe. Si l'invariant est violé
|
||||
// (setup manuel bidouillé), le premier match par ordre de tri gagne.
|
||||
var row = (FeaturedVersion?.Version == target && FeaturedVersion.IsInstalled ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == target && r.IsInstalled);
|
||||
if (row is null || !row.IsInstalled)
|
||||
{
|
||||
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
|
||||
@@ -1495,6 +1533,39 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Install guard anti-collision multi-channels : refuser AVANT de DL
|
||||
// 14 Go si le dossier cible contient déjà un install d'une AUTRE
|
||||
// entrée manifest (ex : firefighter et full à même version qui
|
||||
// pointent tous deux sur "PROSERVE v1.5.4.32"). Sans ça, le ZIP
|
||||
// installer renomme l'existant en .bak-{ts} puis le supprime en
|
||||
// arrière-plan quelques secondes plus tard — le premier install
|
||||
// disparaît silencieusement.
|
||||
//
|
||||
// Décision de collision : dossier cible existe ET son
|
||||
// .proserve-meta.json a un entryId ET cet entryId ≠ celui qu'on
|
||||
// s'apprête à installer. Si l'un des deux entryId est absent
|
||||
// (install antérieur à cette feature, ou manifest sans id), on
|
||||
// ne peut pas prouver la collision → on laisse passer (fail-open,
|
||||
// le ré-install écrase mais l'opérateur peut retrofit ensuite).
|
||||
// ------------------------------------------------------------------
|
||||
var targetForGuard = Path.Combine(_config.InstallRoot, row.Remote.GetInstallFolderName());
|
||||
if (Directory.Exists(targetForGuard) && !string.IsNullOrEmpty(row.Remote.Id))
|
||||
{
|
||||
var existingEntryId = _registry.TryReadEntryId(targetForGuard);
|
||||
if (existingEntryId is not null && existingEntryId != row.Remote.Id)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Install guard : collision on {Target} — existing entryId={Existing}, new entryId={New} (v{Version})",
|
||||
targetForGuard, existingEntryId, row.Remote.Id, row.Version);
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgInstallCollision(row.Version, row.Remote.GetInstallFolderName()),
|
||||
Strings.MsgBoxError,
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Si reprise d'un DL interrompu, on saute la popup de release notes :
|
||||
// l'utilisateur a déjà confirmé sa décision la première fois.
|
||||
var isResume = row.HasResumableDownload;
|
||||
@@ -1694,7 +1765,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.Remote.Executable))
|
||||
await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, ct);
|
||||
await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, row.Remote.Id, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2183,8 +2254,10 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
// Récupère la row fraîche depuis le rebuild (l'instance peut
|
||||
// avoir changé) pour relire la nouvelle URL du manifest.
|
||||
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
|
||||
// Match par RowKey (folder name) plutôt que Version pour supporter
|
||||
// les setups multi-channels où deux entries partagent un numéro.
|
||||
var freshRow = (FeaturedVersion?.RowKey == row.RowKey ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.RowKey == row.RowKey);
|
||||
var urlAfter = freshRow?.Remote?.Download.Url;
|
||||
_logger.LogInformation(
|
||||
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
|
||||
|
||||
Reference in New Issue
Block a user