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:
2026-07-07 16:54:44 +02:00
parent f9820620dd
commit 28c5ca5877
10 changed files with 340 additions and 35 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.7" #define MyAppVersion "1.0.8"
#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

@@ -450,6 +450,26 @@ HTML;
* - assure le suffixe .zip * - assure le suffixe .zip
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable * - retombe sur la valeur par défaut si l'input est vide ou inexploitable
*/ */
/**
* Valide un installFolderTemplate côté admin. Contraintes :
* - doit contenir {version} (sinon deux entries au même template résolvent
* au même dossier une fois substitué, cassant l'anti-collision).
* - charset filename-safe : [A-Za-z0-9_.-] + espaces. Pas de séparateur de
* chemin (/, \) ni de caractères spéciaux qui casseraient un basename
* côté client (Path.GetFileName).
* - non vide, longueur raisonnable (backoffice UI n'accepte pas les URLs).
*/
function ps_is_valid_install_folder_template(string $tpl): bool
{
$tpl = trim($tpl);
if ($tpl === '' || strlen($tpl) > 120) return false;
if (!str_contains($tpl, '{version}')) return false;
// On check le "reste" du template (partie non-{version}) — le placeholder
// {version} lui-même contient `{` et `}` qui ne sont pas dans notre whitelist.
$stripped = str_replace('{version}', '', $tpl);
return (bool)preg_match('/^[A-Za-z0-9 _.\-]*$/', $stripped);
}
function ps_normalize_zip_filename(string $raw, string $version): string function ps_normalize_zip_filename(string $raw, string $version): string
{ {
$default = "proserve-{$version}.zip"; $default = "proserve-{$version}.zip";
@@ -521,12 +541,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) { if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
$exeName = 'PROSERVE_UE_5_7.exe'; $exeName = 'PROSERVE_UE_5_7.exe';
} }
// installFolderTemplate — nom du sous-dossier créé côté client par le
// launcher. Doit contenir {version} et être unique par (version, channel)
// pour éviter l'écrasement silencieux à l'install quand deux entries
// partagent un numéro. Default intelligent : si un channel non-default
// est sélectionné, on préfixe avec "PROSERVE-<channel>". Sinon on garde
// le legacy "PROSERVE v{version}".
$installFolderTemplateInput = trim((string)($_POST['install_folder_template'] ?? ''));
if ($installFolderTemplateInput === '') {
$nonDefaultChannels = array_values(array_filter($channels, fn($c) => $c !== 'default'));
$installFolderTemplateInput = count($nonDefaultChannels) === 1
? "PROSERVE-{$nonDefaultChannels[0]} v{version}"
: "PROSERVE v{version}";
}
if (!ps_is_valid_install_folder_template($installFolderTemplateInput)) {
throw new Exception(
"InstallFolderTemplate invalide « {$installFolderTemplateInput} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
// Validation anti-collision cross-entries : résous le template avec la
// version courante, refuse si une autre entrée résoud au même dossier.
$installFolderResolved = str_replace('{version}', $version, $installFolderTemplateInput);
foreach ($manifest['versions'] as $vExisting) {
$existingTpl = (string)($vExisting['installFolderTemplate'] ?? 'PROSERVE v{version}');
$existingResolved = str_replace('{version}', $vExisting['version'] ?? '', $existingTpl);
if ($existingResolved === $installFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($vExisting['version'] ?? '?')
. " utilise déjà le dossier « {$installFolderResolved} ». Choisis un installFolderTemplate distinct (ex : « PROSERVE-{$version}-<channel> v{version} » ou en dur « PROSERVE-<channel> v{version} »)."
);
}
}
$entry = [ $entry = [
'id' => $entryId, 'id' => $entryId,
'version' => $version, 'version' => $version,
'releasedAt' => $releasedAtIso, 'releasedAt' => $releasedAtIso,
'executable' => $exeName, 'executable' => $exeName,
'installFolderTemplate' => 'PROSERVE v{version}', 'installFolderTemplate' => $installFolderTemplateInput,
'channels' => $channels, 'channels' => $channels,
'download' => [ 'download' => [
'url' => "{$base}/builds/{$zipFilename}", 'url' => "{$base}/builds/{$zipFilename}",
@@ -594,6 +645,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0; $manifest['versions'][$idx]['download']['sizeBytes'] = 0;
} }
// installFolderTemplate — édition optionnelle. Laisser vide = ne pas
// changer. Valide + anti-collision cross-entries obligatoire.
$newFolderTpl = trim((string)($_POST['install_folder_template'] ?? ''));
if ($newFolderTpl !== '') {
if (!ps_is_valid_install_folder_template($newFolderTpl)) {
throw new Exception(
"InstallFolderTemplate invalide « {$newFolderTpl} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
$newFolderResolved = str_replace('{version}', $version, $newFolderTpl);
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherTpl = (string)($other['installFolderTemplate'] ?? 'PROSERVE v{version}');
$otherResolved = str_replace('{version}', $other['version'] ?? '', $otherTpl);
if ($otherResolved === $newFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($other['version'] ?? '?')
. " utilise déjà le dossier « {$newFolderResolved} ». Choisis un installFolderTemplate distinct."
);
}
}
$manifest['versions'][$idx]['installFolderTemplate'] = $newFolderTpl;
}
saveManifest($manifestPath, $manifest); saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.'); $message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
} }
@@ -931,6 +1005,11 @@ Layout::header('Versions', 'versions');
<label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label> <label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120"> <input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120">
</div> </div>
<div class="field">
<label>Dossier d'install côté client <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, defaut <code>PROSERVE v{version}</code> ; DOIT contenir <code>{version}</code>. Utilise un nom distinct par channel si tu as plusieurs entrées au même numéro de version — ex. <code>PROSERVE-firefighter v{version}</code>)</span></label>
<input type="text" name="install_folder_template" placeholder="PROSERVE v{version}" maxlength="120" pattern="[A-Za-z0-9 _.\-\{\}]+"
title="Nom du sous-dossier créé par le launcher dans le InstallRoot. DOIT contenir {version}. Sur un channel non-default, préfixe avec le nom du channel pour éviter d'écraser une install d'un autre channel au même numéro de version.">
</div>
<div class="field"> <div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label> <label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;"> <div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
@@ -1161,6 +1240,15 @@ Layout::header('Versions', 'versions');
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer"> <input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p> <p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div> </div>
<div class="field">
<label>Dossier d'install côté client
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars($v['installFolderTemplate'] ?? 'PROSERVE v{version}') ?></code>)
</span>
</label>
<input type="text" name="install_folder_template" placeholder="laisse vide pour ne rien changer" maxlength="120">
<p class="muted" style="font-size: 11px;">DOIT contenir <code>{version}</code>. Change-le si deux entries au même numéro se retrouvent à cibler le même dossier (ex : <code>PROSERVE-firefighter v{version}</code>).</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;"> <div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button> <button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button> <button type="submit" class="btn btn-primary">Enregistrer Méta</button>

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.7</Version> <Version>1.0.8</Version>
<AssemblyVersion>1.0.7.0</AssemblyVersion> <AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.7.0</FileVersion> <FileVersion>1.0.8.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,7 +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;
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 // 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à // les versions taggées isBeta=true. Les installations locales déjà
@@ -555,7 +561,22 @@ 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();
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) // Tri combiné installé + remote. On utilise VersionOrder (SemVer + isBeta)
// pour respecter la règle « non-beta > beta au même préfixe 3-digit » : // 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. // publication de 1.5.4 non-beta.
// //
// IMPORTANT : on lookupe isBeta dans `rawRemote` (non filtré) et PAS dans // IMPORTANT : on lookupe isBeta dans `rawRemote` (non filtré) et PAS dans
// `remoteByVer` (filtré par canSeeBetas). Sinon un client sans droits beta // `remoteByFolderName` (filtré par canSeeBetas). Sinon un client sans droits
// qui aurait installé 1.5.4.32 beta ne verrait plus l'info isBeta pour // 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 // 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 // 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. // installée. En regardant `rawRemote` on garde l'info d'origine.
var rawBetaByVer = rawRemote var rawBetaByFolderName = rawRemote
.GroupBy(v => v.Version) .GroupBy(v => v.GetInstallFolderName(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Any(v => v.IsBeta)); .ToDictionary(g => g.Key, g => g.Any(v => v.IsBeta), StringComparer.OrdinalIgnoreCase);
bool IsBetaOf(string ver) => rawBetaByVer.TryGetValue(ver, out var isBeta) && isBeta; bool IsBetaOf(string folderName)
var allVersions = installed.Keys.Union(remoteByVer.Keys) => rawBetaByFolderName.TryGetValue(folderName, out var isBeta) && isBeta;
.OrderByDescending(v => v, Comparer<string>.Create((a, b) => // Extrait un numéro de version depuis un folder name (via VersionManifest.
VersionOrder.Compare(a, IsBetaOf(a), b, IsBetaOf(b)))) // 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(); .ToList();
var rows = new List<VersionRowViewModel>(); var rows = new List<VersionRowViewModel>();
foreach (var ver in allVersions) foreach (var folderName in allFolderNames)
{ {
VersionRowViewModel row; 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 else
{ {
row = VersionRowViewModel.ForRemote(remoteByVer[ver]); row = VersionRowViewModel.ForRemote(remoteByFolderName[folderName]);
} }
WireRowHandlers(row); WireRowHandlers(row);
rows.Add(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. // bon objet et que la barre de progress de la row avance live.
if (preserveActive && oldActive is not null) 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) if (matching is not null)
{ {
matching.State = oldActive.State; matching.State = oldActive.State;
@@ -1463,8 +1495,14 @@ public sealed partial class MainViewModel : ObservableObject
var target = _config.AutoMode.SelectedVersion; var target = _config.AutoMode.SelectedVersion;
if (string.IsNullOrEmpty(target)) return; if (string.IsNullOrEmpty(target)) return;
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null) // AutoMode config stocke le numéro de version (pas le RowKey). Sur un
?? OtherVersions.FirstOrDefault(r => r.Version == target); // 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) if (row is null || !row.IsInstalled)
{ {
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target); _logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
@@ -1495,6 +1533,39 @@ public sealed partial class MainViewModel : ObservableObject
try 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 : // 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. // l'utilisateur a déjà confirmé sa décision la première fois.
var isResume = row.HasResumableDownload; var isResume = row.HasResumableDownload;
@@ -1694,7 +1765,7 @@ public sealed partial class MainViewModel : ObservableObject
try try
{ {
if (!string.IsNullOrWhiteSpace(row.Remote.Executable)) 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) 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 // Récupère la row fraîche depuis le rebuild (l'instance peut
// avoir changé) pour relire la nouvelle URL du manifest. // avoir changé) pour relire la nouvelle URL du manifest.
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null) // Match par RowKey (folder name) plutôt que Version pour supporter
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version); // 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; var urlAfter = freshRow?.Remote?.Download.Url;
_logger.LogInformation( _logger.LogInformation(
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}", "Manifest URL for v{Version} — before: {Before}, after refresh: {After}",

View File

@@ -26,6 +26,38 @@ public sealed partial class VersionRowViewModel : ObservableObject
public VersionManifest? Remote { get; } public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null; public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
/// <summary>
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
/// pour distinguer deux entrées manifest partageant le même numéro sur des
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
/// 1.5.4.32). Priorité :
/// <list type="number">
/// <item>Basename du dossier d'install (côté installed, ou côté remote
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
/// deux entries au même version DOIVENT avoir des dossiers distincts
/// (validé backoffice + install guard client).</item>
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
/// identique à avant).</item>
/// </list>
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
/// ambigu sur les setups multi-channels.
/// </summary>
public string RowKey { get; }
/// <summary>
/// Channel principal à afficher comme badge sur la row (null / vide → pas
/// de badge). Sélection : le premier channel non-« default » de
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
/// remote sans Channels ou uniquement « default », c'est null.
/// </summary>
public string? ChannelBadge => Remote?.Channels?
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
public bool HasChannelBadge => !string.IsNullOrEmpty(ChannelBadge);
/// <summary> /// <summary>
/// True si la version est taggée BÊTA dans le manifest (champ /// True si la version est taggée BÊTA dans le manifest (champ
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge /// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
@@ -197,6 +229,13 @@ public sealed partial class VersionRowViewModel : ObservableObject
Installed = installed; Installed = installed;
Remote = remote; Remote = remote;
_state = initialState; _state = initialState;
// RowKey — voir doc de la property. Basename du folder si dispo (côté
// installed ou remote via InstallFolderTemplate), fallback sur version.
var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName();
RowKey = !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

@@ -96,6 +96,20 @@
FontSize="10" FontWeight="Bold" FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" /> Foreground="#1A0A00" />
</Border> </Border>
<!-- Badge CHANNEL : bleu, à côté des autres badges. Affiché
quand la row est sur un channel spécifique (non-default) —
sert à distinguer deux entrées manifest partageant un
numéro de version sur des channels différents (ex :
firefighter vs full à v1.5.4.32). -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#3B82F6"
ToolTip="{Binding ChannelBadge}"
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding ChannelBadge}"
FontSize="10" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0" <Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>
@@ -518,6 +532,19 @@
FontSize="11" FontWeight="Bold" FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" /> Foreground="#1A0A00" />
</Border> </Border>
<!-- Badge CHANNEL : pill bleue. Visible sur les entrées
manifest taggées avec un channel non-default (firefighter,
police, etc.) pour distinguer visuellement plusieurs
builds au même numéro de version. -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#3B82F6"
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
FontSize="11" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0" <Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>

View File

@@ -11,15 +11,26 @@ public interface IInstallationRegistry
/// <summary> /// <summary>
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec /// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
/// le nom du .exe à lancer (récupéré du manifest serveur). À appeler par /// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
/// <c>MainViewModel</c> après l'extraction du ZIP, pour que le futur <c>Scan</c> /// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
/// trouve le bon exe sans avoir besoin du manifest en mémoire. /// collisions inter-channels. À appeler par <c>MainViewModel</c> après
/// l'extraction du ZIP, pour que le futur <c>Scan</c> trouve le bon exe et
/// pour que l'install guard puisse comparer l'entryId d'un dossier existant
/// avant écrasement.
/// </summary> /// </summary>
Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct); Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct);
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary> /// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct); Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary> /// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
bool IsRedistInstalled(string installDir); bool IsRedistInstalled(string installDir);
/// <summary>
/// Lit l'<c>entryId</c> stocké dans <c>.proserve-meta.json</c> du dossier
/// d'install. Retourne null si le fichier n'existe pas, si la clé n'a
/// jamais été écrite (installs antérieurs à ce feature), ou en cas
/// d'erreur de parse. Utilisé pour l'install guard anti-collision.
/// </summary>
string? TryReadEntryId(string installDir);
} }

View File

@@ -26,7 +26,16 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit // nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de // optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts. // cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)] //
// Le groupe optionnel `(-<channel>)?` permet de discriminer plusieurs installs
// au même numéro de version mais sur des channels différents :
// PROSERVE v1.5.4.32
// PROSERVE-firefighter v1.5.4.32
// PROSERVE-full v1.5.4.32
// Le channel n'est PAS capturé ici — c'est juste un suffixe autorisé dans le
// nom de dossier. L'association exacte channel ↔ dossier passe par le lookup
// <see cref="VersionManifest.GetInstallFolderName"/> dans MainViewModel.
[GeneratedRegex(@"^PROSERVE(-[a-z0-9_-]+)? v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex(); private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger; private readonly ILogger<InstallationRegistry> _logger;
@@ -123,14 +132,44 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
/// <summary> /// <summary>
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install /// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller, /// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>). /// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>) et
/// l'<paramref name="entryId"/> de l'entrée manifest source. L'entryId sert au
/// pre-install guard : un futur install qui vise le même dossier avec un id
/// différent (= une autre entrée manifest sur un channel différent partageant
/// le numéro de version) sera refusé pour éviter l'écrasement silencieux.
/// </summary> /// </summary>
public Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct) public async Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct)
{ {
var path = Path.Combine(installDir, MetadataFileName); var path = Path.Combine(installDir, MetadataFileName);
var meta = new InstallMetadata { Executable = executableName }; // Préserve un éventuel RedistInstalledAt existant (relance install sans
// ré-installer les redists → on ne veut pas perdre le timestamp).
InstallMetadata? existing = null;
if (File.Exists(path))
{
try { existing = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
catch { existing = null; }
}
var meta = new InstallMetadata
{
Executable = executableName,
EntryId = entryId,
RedistInstalledAt = existing?.RedistInstalledAt,
};
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
return File.WriteAllTextAsync(path, json, ct); await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public string? TryReadEntryId(string installDir)
{
var path = Path.Combine(installDir, MetadataFileName);
if (!File.Exists(path)) return null;
try
{
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
return string.IsNullOrWhiteSpace(meta?.EntryId) ? null : meta.EntryId;
}
catch { return null; }
} }
/// <summary> /// <summary>
@@ -174,6 +213,14 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
{ {
public string Executable { get; set; } = string.Empty; public string Executable { get; set; } = string.Empty;
public DateTime? RedistInstalledAt { get; set; } public DateTime? RedistInstalledAt { get; set; }
/// <summary>
/// Id de l'entrée manifest source (<see cref="VersionManifest.Id"/>). Null
/// pour les installs antérieurs à cette feature (rétro-compat). Utilisé
/// par l'install guard : refuse un install si un autre entryId est déjà
/// présent dans le dossier cible.
/// </summary>
public string? EntryId { get; set; }
} }
public InstalledVersion? Get(string version) => public InstalledVersion? Get(string version) =>

View File

@@ -317,6 +317,16 @@ public static class Strings
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise"); public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ==================== // ==================== MESSAGEBOX MESSAGES ====================
public static string MsgInstallCollision(string version, string folderName) => T(
$"Impossible d'installer v{version} : le dossier « {folderName} » est déjà occupé par une autre édition (channel différent) de cette version.\n\nPour éviter d'écraser silencieusement l'install existante, configure un « installFolderTemplate » distinct côté backoffice pour cette entrée (ex : « PROSERVE-firefighter v{{version}} »), puis relance l'install.",
$"Cannot install v{version}: the folder \"{folderName}\" is already used by another edition (different channel) of this version.\n\nTo avoid silently overwriting the existing install, configure a distinct \"installFolderTemplate\" for this entry in the backoffice (e.g. \"PROSERVE-firefighter v{{version}}\"), then retry the install.",
$"无法安装 v{version}:文件夹 \"{folderName}\" 已被此版本的另一个版本(不同频道)占用。\n\n为避免静默覆盖现有安装请在后台为该条目配置不同的 \"installFolderTemplate\"(例如 \"PROSERVE-firefighter v{{version}}\"),然后重试安装。",
$"ไม่สามารถติดตั้ง v{version}: โฟลเดอร์ \"{folderName}\" ถูกใช้แล้วโดยเวอร์ชันอื่น (channel ต่างกัน) ของเวอร์ชันนี้\n\nเพื่อหลีกเลี่ยงการเขียนทับการติดตั้งที่มีอยู่โดยไม่แจ้ง โปรดกำหนดค่า \"installFolderTemplate\" ที่แตกต่างกันสำหรับรายการนี้ในหลังบ้าน (เช่น \"PROSERVE-firefighter v{{version}}\") จากนั้นลองติดตั้งใหม่",
$"لا يمكن تثبيت v{version}: المجلد \"{folderName}\" مستخدم بالفعل بواسطة إصدار آخر (قناة مختلفة) من هذه النسخة.\n\nلتجنب الكتابة فوق التثبيت الموجود دون تنبيه، قم بإعداد \"installFolderTemplate\" مميز لهذا الإدخال في لوحة الإدارة (مثال \"PROSERVE-firefighter v{{version}}\")، ثم أعد المحاولة.",
$"No se puede instalar v{version}: la carpeta «{folderName}» ya está ocupada por otra edición (canal distinto) de esta versión.\n\nPara evitar sobrescribir la instalación existente sin aviso, configura un «installFolderTemplate» distinto para esta entrada en el backoffice (por ejemplo, «PROSERVE-firefighter v{{version}}»), y luego reintenta la instalación.",
$"v{version} kann nicht installiert werden: Der Ordner „{folderName}\" wird bereits von einer anderen Ausgabe (anderer Channel) dieser Version verwendet.\n\nUm ein stilles Überschreiben der bestehenden Installation zu vermeiden, konfigurieren Sie im Backoffice ein anderes installFolderTemplate\" für diesen Eintrag (z. B. „PROSERVE-firefighter v{{version}}\") und wiederholen Sie die Installation."
);
public static string MsgBusy => T( public static string MsgBusy => T(
"Une autre opération est déjà en cours.", "Une autre opération est déjà en cours.",
"Another operation is already in progress.", "Another operation is already in progress.",

View File

@@ -46,6 +46,16 @@ public sealed class LauncherInfo
public sealed class VersionManifest public sealed class VersionManifest
{ {
/// <summary>
/// Identifiant unique de l'entrée manifest, généré par le backoffice
/// (<c>generate_entry_id()</c>). Optionnel côté client : les vieux
/// manifests sans <c>id</c> continuent de fonctionner (rétro-compat).
/// Utilisé principalement pour le row-key côté UI et pour l'install guard
/// anti-collision (deux entries partageant version + folder → refuse).
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("version")] [JsonPropertyName("version")]
public string Version { get; set; } = string.Empty; public string Version { get; set; } = string.Empty;