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:
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>1.0.7</Version>
|
||||
<AssemblyVersion>1.0.7.0</AssemblyVersion>
|
||||
<FileVersion>1.0.7.0</FileVersion>
|
||||
<Version>1.0.8</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -26,6 +26,38 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
public VersionManifest? Remote { get; }
|
||||
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>
|
||||
/// True si la version est taggée BÊTA dans le manifest (champ
|
||||
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
||||
@@ -197,6 +229,13 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
Installed = installed;
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -96,6 +96,20 @@
|
||||
FontSize="10" FontWeight="Bold"
|
||||
Foreground="#1A0A00" />
|
||||
</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"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
@@ -518,6 +532,19 @@
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="#1A0A00" />
|
||||
</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"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
|
||||
@@ -11,15 +11,26 @@ public interface IInstallationRegistry
|
||||
|
||||
/// <summary>
|
||||
/// É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
|
||||
/// <c>MainViewModel</c> après l'extraction du ZIP, pour que le futur <c>Scan</c>
|
||||
/// trouve le bon exe sans avoir besoin du manifest en mémoire.
|
||||
/// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
|
||||
/// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
|
||||
/// 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>
|
||||
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>
|
||||
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
|
||||
|
||||
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,16 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||
// 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
|
||||
// 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 readonly ILogger<InstallationRegistry> _logger;
|
||||
@@ -123,14 +132,44 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||
/// <summary>
|
||||
/// É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,
|
||||
/// 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>
|
||||
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 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 });
|
||||
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>
|
||||
@@ -174,6 +213,14 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||
{
|
||||
public string Executable { get; set; } = string.Empty;
|
||||
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) =>
|
||||
|
||||
@@ -317,6 +317,16 @@ public static class Strings
|
||||
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
|
||||
|
||||
// ==================== 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(
|
||||
"Une autre opération est déjà en cours.",
|
||||
"Another operation is already in progress.",
|
||||
|
||||
@@ -46,6 +46,16 @@ public sealed class LauncherInfo
|
||||
|
||||
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")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user