v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign : - Pages versions.php + licenses.php : remplace les <details> inline qui débordaient horizontalement par UN bouton « ✎ Modifier » par row qui ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes, BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel, BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets. - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la clé aux clients. - Overlay « hashing en cours » plein écran sur tous les boutons de hash (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé par scope (bulk vs single). - Date de release passée de datetime-local à date (l'heure n'a pas de sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an pour min_license_date (= license standard couvre les releases sur 1 an). Emails de notification release : - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV) - Onglet « Contacts » sur la modal licenses pour saisir les emails par license (parsing tolérant : CSV, ligne par ligne, point-virgule) - Bouton « ✉ Notifier » par version : POST notify_release filtre les licenses éligibles (channel match + min_license_date + can_see_betas pour BÊTA) et envoie un email HTML à chaque contact (dédup global) - Template email table-based + bgcolor (compat Outlook/Word engine), navy foncé #0F172A, logo Asterion en CID embed (= affichage direct sans demande de permission Outlook), bouton download installer centré (align="center" + margin auto), release notes en <pre> - Mailer.php helper : parse emails, multipart/related avec attachments inline, fallback execCommand pour clipboard 4-digit version support (X.Y.Z.B) : - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent = 0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve le format d'origine au round-trip. - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php, Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit - Use case : dev/test iterations cohabitant avec leur release stable Bugs fixes : - migrate.php : strip ligne par ligne les commentaires SQL avant le check is-empty. Sans ça, le PREMIER chunk d'un fichier migration (= header + premier ALTER) commençait par `--` et était silencieusement skip → ALTER jamais appliqué. Affectait migrations 003, 004. - SignManifest::getOrComputeSha256 ignorait son cache interne quand force demandé par le caller, retournant l'ancien hash en 0 ms même après re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force. Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique. - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh triggers, avec HEAD probe pour comparer taille serveur vs manifest → message d'erreur explicite si ZIP tronqué. Bps display lissé sur une fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un segment finit / Polly retry. SHA mismatch popup enrichi avec les deux SHAs (attendu vs calculé) extraits via regex de l'exception. - DownloadUrl.php : signature de l'URL utilisait un template hardcodé /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit maintenant download.url du manifest et signe le filename réel. Strings i18n (5 langues) : - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch, stale manifest, manifest refreshed auto-retry, force fresh menu Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes). 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>0.29.7</Version>
|
||||
<AssemblyVersion>0.29.7.0</AssemblyVersion>
|
||||
<FileVersion>0.29.7.0</FileVersion>
|
||||
<Version>0.29.10</Version>
|
||||
<AssemblyVersion>0.29.10.0</AssemblyVersion>
|
||||
<FileVersion>0.29.10.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -1330,6 +1330,26 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extrait les deux hashes SHA-256 du message de l'<see cref="InvalidDataException"/>
|
||||
/// jetée par <c>VerifyAndFinalizeAsync</c>. Format source :
|
||||
/// <c>"Checksum SHA-256 invalide : attendu {expected}, calculé {computed}"</c>
|
||||
/// Si parsing échoue (changement de format, log custom, etc.), retourne
|
||||
/// "?" pour les deux — le reste du message reste informatif.
|
||||
/// Méthode statique sans dépendance externe (regex pure), facile à tester.
|
||||
/// </summary>
|
||||
private static (string Expected, string Computed) ParseShaMismatchValues(string exceptionMessage)
|
||||
{
|
||||
// 64 chars hex = SHA-256 hex. Non-greedy, case-insensitive — couvre les
|
||||
// cas où le format de message évolue légèrement (ponctuation, langue).
|
||||
var m = System.Text.RegularExpressions.Regex.Match(
|
||||
exceptionMessage,
|
||||
@"attendu\s+([0-9a-fA-F]{64}).*calcul[eé]\s+([0-9a-fA-F]{64})",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (m.Success) return (m.Groups[1].Value, m.Groups[2].Value);
|
||||
return ("?", "?");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
|
||||
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
|
||||
@@ -2038,7 +2058,14 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
var sourceLabel = _currentPeerHost is null
|
||||
? Strings.SourceOvh
|
||||
: Strings.SourcePeer(_currentPeerHost);
|
||||
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel);
|
||||
// Extrait les deux SHAs depuis le message de l'exception (format :
|
||||
// "Checksum SHA-256 invalide : attendu X, calculé Y") — permet à
|
||||
// l'opérateur de comparer ce qui est attendu (manifest signé) vs ce
|
||||
// qui a été effectivement téléchargé (= ce que la source a servi).
|
||||
// Si parse échoue, on tombe en fallback avec "?" — le message a
|
||||
// toujours du sens grâce au texte explicatif.
|
||||
var (expectedSha, computedSha) = ParseShaMismatchValues(shaEx.Message);
|
||||
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel, expectedSha, computedSha);
|
||||
ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusShaMismatch(row.Version);
|
||||
@@ -2145,6 +2172,40 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusManifestRefreshed;
|
||||
}
|
||||
// Cas spécifique HTTP 416 : range demandé hors du fichier. Le ZIP
|
||||
// sur le serveur est plus PETIT que ce que le manifest annonce
|
||||
// (sizeBytes). Causes typiques :
|
||||
// • Upload SFTP tronqué (coupé en cours) → le ZIP fait p.ex. 7 Go
|
||||
// au lieu des 13 Go attendus, le manifest a la vieille taille
|
||||
// • Le manifest n'a pas été re-synchronisé après un rebuild
|
||||
// (nouvelle taille pas re-hashée → sizeBytes obsolète)
|
||||
//
|
||||
// On AUTO-PURGE le cache local (state.json + partial + LAN cache
|
||||
// .zip + sidecar) pour éviter qu'un partial à mi-route reste en
|
||||
// ligne et garbage les futurs retries. L'opérateur reçoit un message
|
||||
// d'erreur explicite avec les tailles, et l'action concrète à faire
|
||||
// côté serveur (re-upload + re-sync manifest).
|
||||
else if (ex.Message.Contains("HTTP 416", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.Message.Contains("416 on segment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"416 detected during install of v{Version} — file size mismatch server vs manifest",
|
||||
row.Version);
|
||||
try
|
||||
{
|
||||
_downloadManager.DiscardResumableState(row.Version);
|
||||
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||||
}
|
||||
catch (Exception purgeEx)
|
||||
{
|
||||
_logger.LogWarning(purgeEx, "Could not purge after 416 for v{Version}", row.Version);
|
||||
}
|
||||
ThemedMessageBox.Show(
|
||||
ex.Message,
|
||||
Strings.MsgBoxSizeMismatch,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusSizeMismatch(row.Version);
|
||||
}
|
||||
else
|
||||
{
|
||||
ThemedMessageBox.Show(
|
||||
|
||||
@@ -312,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
|
||||
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
|
||||
// Tous les 5 s ou 50 Mo, persist le state.json.
|
||||
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
|
||||
//
|
||||
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
|
||||
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
|
||||
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
|
||||
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
|
||||
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
|
||||
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
|
||||
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
|
||||
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
|
||||
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
|
||||
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
|
||||
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
|
||||
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var reporterTask = Task.Run(async () =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var lastReport = TimeSpan.Zero;
|
||||
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
|
||||
var lastFlush = TimeSpan.Zero;
|
||||
long lastFlushBytes = lastReportBytes;
|
||||
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
|
||||
// Fenêtre glissante (time, bytes) pour lissage du débit.
|
||||
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
|
||||
try
|
||||
{
|
||||
while (!reporterCts.IsCancellationRequested)
|
||||
@@ -327,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
|
||||
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
||||
var snapshot = Interlocked.Read(ref aggregateBytes);
|
||||
var elapsed = sw.Elapsed;
|
||||
var deltaSec = (elapsed - lastReport).TotalSeconds;
|
||||
var bps = deltaSec > 0 ? (snapshot - lastReportBytes) / deltaSec : 0;
|
||||
|
||||
// Push le nouveau sample, évince le plus ancien si on dépasse.
|
||||
bpsWindow.Enqueue((elapsed, snapshot));
|
||||
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
|
||||
|
||||
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
|
||||
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
|
||||
double bps = 0;
|
||||
if (bpsWindow.Count >= 2)
|
||||
{
|
||||
var oldest = bpsWindow.Peek();
|
||||
var span = (elapsed - oldest.At).TotalSeconds;
|
||||
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
|
||||
}
|
||||
TimeSpan? eta = null;
|
||||
if (bps > 0 && total > snapshot) eta = TimeSpan.FromSeconds((total - snapshot) / bps);
|
||||
if (bps > 0 && total > snapshot)
|
||||
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
|
||||
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
|
||||
lastReport = elapsed;
|
||||
lastReportBytes = snapshot;
|
||||
|
||||
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
||||
@@ -475,7 +498,8 @@ public sealed class DownloadManager : IDownloadManager
|
||||
// un faux 404 fatal sur tous les segments sauf le premier.
|
||||
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|
||||
|| resp.StatusCode == HttpStatusCode.Gone
|
||||
|| resp.StatusCode == HttpStatusCode.NotFound;
|
||||
|| resp.StatusCode == HttpStatusCode.NotFound
|
||||
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
|
||||
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
|
||||
{
|
||||
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||
@@ -499,6 +523,37 @@ public sealed class DownloadManager : IDownloadManager
|
||||
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
|
||||
isTransient: false);
|
||||
}
|
||||
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
|
||||
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
|
||||
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
|
||||
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
|
||||
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
|
||||
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
|
||||
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
|
||||
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||
{
|
||||
long? actualSize = null;
|
||||
try
|
||||
{
|
||||
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
|
||||
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
if (headResp.IsSuccessStatusCode)
|
||||
actualSize = headResp.Content.Headers.ContentLength;
|
||||
}
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
|
||||
|
||||
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
|
||||
var expectedStr = $"{state.TotalBytes:N0}";
|
||||
_logger.LogError(
|
||||
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
|
||||
seg.Index, actualStr, expectedStr);
|
||||
throw new HttpResumableException(
|
||||
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
|
||||
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
|
||||
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
|
||||
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
|
||||
isTransient: false);
|
||||
}
|
||||
throw new HttpResumableException(
|
||||
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
|
||||
|
||||
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
|
||||
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque).
|
||||
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
||||
// 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)]
|
||||
private static partial Regex VersionFolderRegex();
|
||||
|
||||
private readonly ILogger<InstallationRegistry> _logger;
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
|
||||
/// <summary>
|
||||
/// Version produit PROSERVE. Formats acceptés :
|
||||
/// <list type="bullet">
|
||||
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
|
||||
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
|
||||
/// </list>
|
||||
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
|
||||
/// <c>1.5.4 == 1.5.4.0 < 1.5.4.1 < 1.5.4.13 < 1.5.5</c>.
|
||||
/// L'absence de 4ᵉ digit est équivalente à <c>.0</c> pour la comparaison,
|
||||
/// mais conservée distincte dans <see cref="ToString"/> (= <c>1.5.4</c>,
|
||||
/// PAS <c>1.5.4.0</c>) pour ne pas re-écrire les versions historiques
|
||||
/// du manifest qui n'avaient que 3 digits.
|
||||
/// </summary>
|
||||
public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable<SemVer>
|
||||
{
|
||||
/// <summary>
|
||||
/// True si la version originale avait un 4ᵉ digit explicite (même <c>.0</c>).
|
||||
/// Sert uniquement à <see cref="ToString"/> pour préserver le format d'origine
|
||||
/// lors d'un round-trip Parse → ToString.
|
||||
/// </summary>
|
||||
public bool HasExplicitBuild { get; init; }
|
||||
|
||||
public static SemVer Parse(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
|
||||
|
||||
var parts = s.Split('.');
|
||||
if (parts.Length != 3
|
||||
|| !int.TryParse(parts[0], out var maj)
|
||||
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|
||||
// Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
|
||||
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
|
||||
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
|
||||
if (!int.TryParse(parts[0], out var maj)
|
||||
|| !int.TryParse(parts[1], out var min)
|
||||
|| !int.TryParse(parts[2], out var pat))
|
||||
{
|
||||
return new SemVer(0, 0, 0);
|
||||
}
|
||||
return new SemVer(maj, min, pat);
|
||||
if (parts.Length == 3)
|
||||
{
|
||||
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
|
||||
}
|
||||
// 4 digits — itération dev
|
||||
if (!int.TryParse(parts[3], out var build))
|
||||
{
|
||||
return new SemVer(0, 0, 0);
|
||||
}
|
||||
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
|
||||
}
|
||||
|
||||
public int CompareTo(SemVer other)
|
||||
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
|
||||
if (c != 0) return c;
|
||||
c = Minor.CompareTo(other.Minor);
|
||||
if (c != 0) return c;
|
||||
return Patch.CompareTo(other.Patch);
|
||||
c = Patch.CompareTo(other.Patch);
|
||||
if (c != 0) return c;
|
||||
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
|
||||
return Build.CompareTo(other.Build);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Major}.{Minor}.{Patch}";
|
||||
public override string ToString() => HasExplicitBuild || Build > 0
|
||||
? $"{Major}.{Minor}.{Patch}.{Build}"
|
||||
: $"{Major}.{Minor}.{Patch}";
|
||||
}
|
||||
|
||||
@@ -323,12 +323,12 @@ public static class Strings
|
||||
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
|
||||
"الملف المنزّل تالف أو قديم"
|
||||
);
|
||||
public static string MsgShaMismatchBody(string version, string sourceLabel) => T(
|
||||
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h)\n • Un peer LAN sert un ZIP avec un sidecar SHA corrompu\n • Le SHA dans le manifest a été modifié sans re-build du ZIP\n\nLe cache local pour cette version a été automatiquement purgé. Réessaye l'installation dans quelques minutes (le temps que la source se rafraîchisse) ou utilise « ↻ Forcer re-téléchargement » dans le menu \"…\" de la version.",
|
||||
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h)\n • A LAN peer is serving a ZIP with a corrupt SHA sidecar\n • The manifest SHA was changed without rebuilding the ZIP\n\nThe local cache for this version was automatically purged. Retry the install in a few minutes (let the source refresh) or use « ↻ Force re-download » in the version's \"…\" menu.",
|
||||
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 局域网节点提供带有损坏的 SHA sidecar 的 ZIP\n • 清单 SHA 已更改但未重建 ZIP\n\n此版本的本地缓存已自动清除。请在几分钟后重试安装(让源刷新)或使用版本的 \"…\" 菜单中的 \"↻ 强制重新下载\"。",
|
||||
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุหลังการ rebuild เซิร์ฟเวอร์ (TTL 24-72 ชม.)\n • LAN peer ให้บริการ ZIP ที่มี SHA sidecar เสียหาย\n • SHA ใน manifest ถูกแก้ไขโดยไม่ rebuild ZIP\n\nแคชในเครื่องสำหรับเวอร์ชันนี้ถูกล้างอัตโนมัติ ลองติดตั้งอีกครั้งในไม่กี่นาที (ให้ source รีเฟรช) หรือใช้ \"↻ บังคับดาวน์โหลดใหม่\" ในเมนู \"…\" ของเวอร์ชัน",
|
||||
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 الخاص به لا يطابق القيمة المتوقعة من البيان.\n\nالأسباب المحتملة:\n • لم تنته صلاحية ذاكرة التخزين المؤقت لـ OVH CDN بعد إعادة بناء الخادم (TTL 24-72 ساعة)\n • عقدة LAN تقدم ZIP بـ SHA sidecar تالف\n • تم تعديل SHA في البيان دون إعادة بناء ZIP\n\nتم مسح ذاكرة التخزين المؤقت المحلية لهذا الإصدار تلقائياً. أعد المحاولة بعد دقائق (دع المصدر ينعش) أو استخدم \"↻ فرض إعادة التنزيل\" في قائمة \"…\" للإصدار."
|
||||
public static string MsgShaMismatchBody(string version, string sourceLabel, string expectedSha, string computedSha) => T(
|
||||
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nAttendu (manifest) : {expectedSha}\nCalculé (téléchargé) : {computedSha}\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h) → l'URL est correcte mais le contenu servi est l'ancien\n • L'admin a re-uploadé le ZIP mais n'a pas cliqué « 🔁 Hasher les versions + signer » après → le manifest référence l'ancien SHA\n • SFTP a préservé le mtime du fichier → le cache .hashcache.json du serveur considère le ZIP inchangé et skip le re-hash\n\nLe cache local pour cette version a été automatiquement purgé. Actions à essayer dans l'ordre :\n 1. Côté admin serveur : « 🔁 Hasher les versions + signer » (avec « Force re-hash » si nécessaire)\n 2. Côté launcher : « ↻ Forcer re-téléchargement » dans le menu \"…\" puis Installer\n 3. Si toujours échec : compare le SHA attendu ci-dessus avec le sha256 calculé manuellement sur ton ZIP serveur (sha256sum / certutil)",
|
||||
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nExpected (manifest) : {expectedSha}\nComputed (downloaded): {computedSha}\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h) → URL is correct but stale content is served\n • Admin re-uploaded the ZIP but didn't click « 🔁 Hash + sign » after → manifest references old SHA\n • SFTP preserved the file mtime → server-side .hashcache.json considers the ZIP unchanged and skips re-hashing\n\nLocal cache for this version was automatically purged. Actions to try in order:\n 1. Server-side admin: « 🔁 Hash + sign » (with « Force re-hash » if needed)\n 2. Launcher: « ↻ Force re-download » in the \"…\" menu then Install\n 3. Still failing: compare the expected SHA above with the sha256 manually computed on your server ZIP (sha256sum / certutil)",
|
||||
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n预期(清单):{expectedSha}\n计算(下载):{computedSha}\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 管理员重新上传 ZIP 后未点击「🔁 哈希 + 签名」→ 清单引用旧 SHA\n • SFTP 保留了文件 mtime → 服务器端缓存认为 ZIP 未变更并跳过重新哈希\n\n此版本的本地缓存已自动清除。",
|
||||
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nคาดหวัง (manifest) : {expectedSha}\nคำนวณ (ดาวน์โหลด) : {computedSha}\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุ\n • Admin อัปโหลด ZIP ใหม่แต่ไม่กด « 🔁 Hash + sign »\n • SFTP รักษา mtime ไฟล์\n\nแคชในเครื่องถูกล้างอัตโนมัติแล้ว",
|
||||
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 لا يطابق البيان.\n\nمتوقع (البيان) : {expectedSha}\nمحسوب (المنزل) : {computedSha}\n\nالأسباب المحتملة:\n • لم تنته ذاكرة CDN OVH بعد\n • أعاد المسؤول رفع ZIP دون النقر « 🔁 Hash + sign »\n • حافظ SFTP على mtime الملف\n\nتم مسح ذاكرة التخزين المؤقت المحلية تلقائياً."
|
||||
);
|
||||
public static string StatusShaMismatch(string version) => T(
|
||||
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
|
||||
@@ -359,6 +359,22 @@ public static class Strings
|
||||
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง",
|
||||
"تم تحديث البيان — انقر فوق تثبيت مرة أخرى"
|
||||
);
|
||||
// ---- HTTP 416 : range demandé hors du fichier (file plus petit que sizeBytes) ----
|
||||
public static string MsgBoxSizeMismatch => T(
|
||||
"Taille de fichier incohérente sur le serveur",
|
||||
"Server file size mismatch",
|
||||
"服务器文件大小不一致",
|
||||
"ขนาดไฟล์เซิร์ฟเวอร์ไม่ตรงกัน",
|
||||
"حجم الملف على الخادم غير متطابق"
|
||||
);
|
||||
public static string StatusSizeMismatch(string version) => T(
|
||||
$"⚠ v{version} : taille du ZIP serveur ≠ manifest — re-upload nécessaire",
|
||||
$"⚠ v{version}: server ZIP size ≠ manifest — re-upload needed",
|
||||
$"⚠ v{version}:服务器 ZIP 大小 ≠ 清单 — 需要重新上传",
|
||||
$"⚠ v{version}: ขนาด ZIP เซิร์ฟเวอร์ ≠ manifest — ต้องอัปโหลดใหม่",
|
||||
$"⚠ v{version}: حجم ZIP الخادم ≠ البيان — مطلوب إعادة الرفع"
|
||||
);
|
||||
|
||||
public static string StatusAutoRetryAfter404(string version) => T(
|
||||
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
|
||||
$"v{version}: manifest refreshed, auto-retrying download…",
|
||||
|
||||
Reference in New Issue
Block a user