v1.0.0 🎉 — Production release. i18n Espagnol + Allemand (launcher + emails).

Première release MAJEURE après ~30 itérations 0.x. Le système est complet
et stable pour la production : distribution PROSERVE multi-channels, install
launcher cross-fleet, monitoring santé VR, sécurité (signatures Ed25519,
HMAC URLs présignées, settings lock per-license, RFC1918 filter cache LAN).

i18n nouvelles langues (Espagnol + Allemand) :
 - Strings.cs : signature T() étendue avec 2 params optionnels (es?/de?)
   en plus des 5 obligatoires. Fallback automatique sur l'anglais quand
   non traduit → infrastructure prête immédiatement, traduction progressive
   sur les ~317 strings du launcher selon les besoins terrain.
 - ~30 strings critiques traduites maintenant (top bar, body, status badges,
   actions principales Launch/Install/Cancel/Yes/No, MessageBox titles).
 - Available[] inclut es/Español + de/Deutsch ; auto-détection Windows
   pour es-* / de-* funcionne au premier launch.
 - Init() whitelist mise à jour.

Emails release announce localisés par license :
 - Migration 005 : ajout `language VARCHAR(8) NULL` sur licenses (whitelist
   fr/en/es/de/zh/th/ar, NULL = fallback English).
 - getEmailStrings() PHP étendu : dictionnaire 7 langues × 14 strings.
   Traduction complète es + de (gérable : 28 nouvelles strings, vs 634
   pour traduire le launcher en intégralité).
 - notify_release boucle par license : récupère language + locale fallback
   'en' + render avec la bonne locale → chaque destinataire reçoit son
   email dans la langue de SA license, indépendamment des autres clients.
 - Subject localisé aussi (« PROSERVE v1.5.4 ya está disponible » /
   « PROSERVE v1.5.4 ist jetzt verfügbar »).
 - Release notes body reste TOUJOURS en anglais (évite d'avoir à maintenir
   N traductions du Markdown des notes).
 - Direction RTL préservée pour l'arabe (dir="rtl" sur <html> + <table>).

Admin UI : dropdown langue dans la modal Contacts d'une license + whitelist
backend (set_contact_emails accepte fr/en/es/de/zh/th/ar).

Bumps : 0.29.10 → 1.0.0 — milestone V1, premier release majeur en prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:01:50 +02:00
parent 500f7d12e6
commit 73d084a703
6 changed files with 353 additions and 89 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 "0.29.10" #define MyAppVersion "1.0.0"
#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

@@ -169,25 +169,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
elseif ($action === 'set_contact_emails') { elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact pour les notifications // Met à jour la liste des emails de contact + la langue préférée
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne, // pour les notifications de nouvelles versions. L'admin saisit en
// virgules, point-virgules — peu importe), on parse + valide via // texte libre (1 par ligne, virgules, point-virgules — peu importe),
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email // on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
// valide → on stocke NULL (= pas de notif possible pour cette // Vide / aucun email valide → on stocke NULL (= pas de notif possible
// license, juste skip silencieux à l'envoi). // pour cette license, juste skip silencieux à l'envoi).
//
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? ''); $raw = (string)($_POST['contact_emails'] ?? '');
$lang = trim((string)($_POST['language'] ?? ''));
if ($id <= 0) { if ($id <= 0) {
throw new Exception('License id invalide.'); throw new Exception('License id invalide.');
} }
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
}
$parsed = Mailer::parseEmails($raw); $parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed); $stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?') $db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
->execute([$stored, $id]); ->execute([$stored, $lang !== '' ? $lang : null, $id]);
$count = count($parsed); $count = count($parsed);
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
$message = $count === 0 $message = $count === 0
? "License #{$id} : contacts de notification supprimés." ? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release."; : "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
} }
elseif ($action === 'reset_machines') { elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
@@ -546,17 +555,45 @@ Layout::header('Licenses', 'licenses');
version de PROSERVE accessible à cette license (matching channel + version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement. point-virgules. Adresses invalides ignorées silencieusement.
La langue choisie ci-dessous est utilisée pour le contenu de l'email
(greeting + instructions) — les release notes elles-mêmes restent
toujours en anglais.
</p> </p>
<?php <?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe // Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV). // comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? ''); $currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails); $currentDisplay = implode("\n", $currentEmails);
$currentLang = trim((string)($l['language'] ?? ''));
$langOptions = [
'' => 'English (defaut)',
'fr' => 'Français',
'en' => 'English',
'es' => 'Español',
'de' => 'Deutsch',
'zh' => '中文 (Chinese)',
'th' => 'ภาษาไทย (Thai)',
'ar' => 'العربية (Arabic)',
];
?> ?>
<form method="post"> <form method="post">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails"> <input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Langue de l'email</label>
<select name="language">
<?php foreach ($langOptions as $code => $label):
$sel = ($currentLang === $code) ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<p class="hint" style="margin: 4px 0 0;">
Langue dans laquelle l'email de notification sera rédigé.
Le contenu des release notes reste toujours en anglais.
</p>
</div>
<div class="field"> <div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label> <label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6" <textarea name="contact_emails" rows="6"

View File

@@ -102,33 +102,212 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
} }
/** /**
* Construit le corps HTML de l'email de release announce. Message en anglais * Strings localisées pour l'email de release announce. Couvre les 5 langues
* (= langue par défaut du parc client international VR PROSERVE), inclut les * supportées par le launcher (Strings.cs côté .NET) : fr, en, zh, th, ar.
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown * Le release notes BODY lui-même reste TOUJOURS en anglais — seul le texte
* basique en clair reste très lisible et évite la dépendance Markdig PHP). * d'accompagnement (greeting, instructions, CTAs) est localisé. Permet de
* Le owner_name de la license est utilisé pour personnaliser le greeting. * notifier des clients internationaux dans leur langue sans avoir à
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le * maintenir N traductions du Markdown des notes.
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID *
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs). * Placeholders dans les chaînes :
* {version} → numéro de version (ex. "1.5.4.16")
* {owner} → nom du client (ex. "Asterion VR")
*
* Fallback : si la locale demandée n'est pas en table → 'en' (= comportement
* conservateur, le marché VR international a globalement l'anglais comme
* lingua franca tech).
*
* @return array<string, string> dictionnaire de strings prêtes à interpoler
*/ */
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string function getEmailStrings(string $locale): array
{ {
static $cache = null;
if ($cache !== null) {
return $cache[$locale] ?? $cache['en'];
}
$cache = [
'en' => [
'subject' => 'PROSERVE v{version} is now available',
'title' => 'PROSERVE v{version} is available!',
'subtitle' => 'A new version is ready to install via your PROSERVE Launcher.',
'greeting' => 'Hi {owner},',
'intro' => 'We\'re happy to let you know that <strong>PROSERVE version {version}</strong> has just been published and is now available for download.',
'instructions' => 'If you already have the <strong>PROSERVE Launcher</strong> installed, simply open it on any of your machines, click <em>« Check for updates »</em>, and install the new version with one click.',
'firstInstallTitle' => 'First install or new workstation?',
'firstInstallBody' => 'If this is your first time, or you\'re setting up a new PC, download the PROSERVE Launcher installer below. <strong>You\'ll need to run it on EACH workstation</strong> where PROSERVE will be used.',
'downloadButton' => '⬇ Download PROSERVE Launcher (Windows)',
'directLink' => 'Direct link:',
'releaseNotesTitle' => 'Release notes',
'releaseNotesNone' => 'No release notes for this version.',
'support' => 'As always, if you run into any issue (download failure, install error, anything weird in-game), don\'t hesitate to reach out — we\'re here to help.',
'closing' => 'Happy training!',
'signature' => 'The ASTERION VR team',
'footer' => 'This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.',
],
'fr' => [
'subject' => 'PROSERVE v{version} est disponible',
'title' => 'PROSERVE v{version} est disponible !',
'subtitle' => 'Une nouvelle version est prête à installer via votre PROSERVE Launcher.',
'greeting' => 'Bonjour {owner},',
'intro' => 'Nous avons le plaisir de vous annoncer que <strong>la version {version} de PROSERVE</strong> vient d\'être publiée et est désormais disponible au téléchargement.',
'instructions' => 'Si vous avez déjà le <strong>PROSERVE Launcher</strong> installé, ouvrez-le simplement sur n\'importe lequel de vos postes, cliquez sur <em>« Vérifier les mises à jour »</em> et installez la nouvelle version en un clic.',
'firstInstallTitle' => 'Première installation ou nouveau poste ?',
'firstInstallBody' => 'S\'il s\'agit de votre première installation, ou si vous configurez un nouveau PC, téléchargez le PROSERVE Launcher ci-dessous. <strong>Il doit être lancé sur CHAQUE poste</strong> où PROSERVE sera utilisé.',
'downloadButton' => '⬇ Télécharger PROSERVE Launcher (Windows)',
'directLink' => 'Lien direct :',
'releaseNotesTitle' => 'Notes de version',
'releaseNotesNone' => 'Pas de notes de version pour cette release.',
'support' => 'Comme toujours, en cas de problème (échec de téléchargement, erreur d\'installation, comportement inattendu en VR), n\'hésitez pas à nous contacter — nous sommes là pour vous aider.',
'closing' => 'Bon entraînement !',
'signature' => 'L\'équipe ASTERION VR',
'footer' => 'Cette notification automatique a été envoyée parce que votre email est enregistré comme contact sur l\'une de vos licenses PROSERVE.<br/>Pour ne plus recevoir ces emails, demandez à votre gestionnaire de compte ASTERION VR de retirer votre adresse des contacts license.',
],
'zh' => [
'subject' => 'PROSERVE v{version} 现已发布',
'title' => 'PROSERVE v{version} 已发布!',
'subtitle' => '新版本已准备好通过您的 PROSERVE Launcher 安装。',
'greeting' => '你好 {owner}',
'intro' => '我们很高兴地通知您,<strong>PROSERVE 版本 {version}</strong> 刚刚发布,现已可供下载。',
'instructions' => '如果您已经安装了 <strong>PROSERVE Launcher</strong>,只需在任何一台机器上打开它,单击<em>「检查更新」</em>,然后单击「安装」即可完成新版本的安装。',
'firstInstallTitle' => '首次安装或新工作站?',
'firstInstallBody' => '如果这是您的第一次使用,或者您正在配置一台新 PC请在下方下载 PROSERVE Launcher 安装程序。<strong>您需要在使用 PROSERVE 的每台工作站上运行它</strong>。',
'downloadButton' => '⬇ 下载 PROSERVE Launcher (Windows)',
'directLink' => '直接链接:',
'releaseNotesTitle' => '版本说明',
'releaseNotesNone' => '此版本没有版本说明。',
'support' => '一如既往,如果您遇到任何问题(下载失败、安装错误、游戏中出现异常情况),请随时联系我们 — 我们随时为您提供帮助。',
'closing' => '训练愉快!',
'signature' => 'ASTERION VR 团队',
'footer' => '此自动通知已发送,因为您的联系电子邮件已注册到您的某个 PROSERVE 许可证中。<br/>要停止接收这些通知,请要求您的 ASTERION VR 客户经理从许可证联系人中删除您的地址。',
],
'th' => [
'subject' => 'PROSERVE v{version} พร้อมใช้งานแล้ว',
'title' => 'PROSERVE v{version} พร้อมใช้งาน!',
'subtitle' => 'เวอร์ชันใหม่พร้อมติดตั้งผ่าน PROSERVE Launcher ของคุณ',
'greeting' => 'สวัสดี {owner},',
'intro' => 'เรายินดีที่จะแจ้งให้คุณทราบว่า <strong>PROSERVE เวอร์ชัน {version}</strong> เพิ่งได้รับการเผยแพร่และพร้อมให้ดาวน์โหลดแล้ว',
'instructions' => 'หากคุณติดตั้ง <strong>PROSERVE Launcher</strong> ไว้แล้ว เพียงเปิดบนเครื่องใดก็ได้ของคุณ คลิก <em>« ตรวจสอบการอัปเดต »</em> แล้วติดตั้งเวอร์ชันใหม่ด้วยคลิกเดียว',
'firstInstallTitle' => 'ติดตั้งครั้งแรก หรือเวิร์กสเตชันใหม่?',
'firstInstallBody' => 'หากเป็นครั้งแรกของคุณ หรือคุณกำลังตั้งค่า PC ใหม่ ดาวน์โหลด PROSERVE Launcher ด้านล่าง <strong>คุณต้องเรียกใช้บนเวิร์กสเตชันทุกเครื่อง</strong>ที่จะใช้ PROSERVE',
'downloadButton' => '⬇ ดาวน์โหลด PROSERVE Launcher (Windows)',
'directLink' => 'ลิงก์โดยตรง:',
'releaseNotesTitle' => 'บันทึกการเผยแพร่',
'releaseNotesNone' => 'ไม่มีบันทึกการเผยแพร่สำหรับเวอร์ชันนี้',
'support' => 'เช่นเคย หากคุณพบปัญหาใดๆ (ดาวน์โหลดล้มเหลว ติดตั้งผิดพลาด อะไรแปลกๆ ในเกม) อย่าลังเลที่จะติดต่อ — เราพร้อมช่วยเหลือ',
'closing' => 'ฝึกซ้อมให้สนุก!',
'signature' => 'ทีม ASTERION VR',
'footer' => 'การแจ้งเตือนอัตโนมัตินี้ถูกส่งเนื่องจากอีเมลติดต่อของคุณถูกลงทะเบียนกับหนึ่งในใบอนุญาต PROSERVE ของคุณ<br/>เพื่อหยุดรับการแจ้งเตือนเหล่านี้ ขอให้ผู้จัดการบัญชี ASTERION VR ของคุณลบที่อยู่ของคุณออกจากผู้ติดต่อใบอนุญาต',
],
'ar' => [
'subject' => 'PROSERVE v{version} متوفر الآن',
'title' => 'PROSERVE v{version} متوفر!',
'subtitle' => 'إصدار جديد جاهز للتثبيت عبر PROSERVE Launcher الخاص بك.',
'greeting' => 'مرحباً {owner}،',
'intro' => 'يسعدنا أن نعلمك بأن <strong>الإصدار {version} من PROSERVE</strong> قد تم نشره للتو وأصبح متاحاً للتنزيل.',
'instructions' => 'إذا كان لديك <strong>PROSERVE Launcher</strong> مثبتاً بالفعل، فما عليك سوى فتحه على أي من أجهزتك، والنقر فوق <em>«التحقق من التحديثات»</em>، وتثبيت الإصدار الجديد بنقرة واحدة.',
'firstInstallTitle' => 'تثبيت لأول مرة أو محطة عمل جديدة؟',
'firstInstallBody' => 'إذا كانت هذه المرة الأولى لك، أو إذا كنت تعد جهاز كمبيوتر جديداً، فقم بتنزيل PROSERVE Launcher أدناه. <strong>ستحتاج إلى تشغيله على كل محطة عمل</strong> سيتم استخدام PROSERVE فيها.',
'downloadButton' => '⬇ تنزيل PROSERVE Launcher (Windows)',
'directLink' => 'رابط مباشر:',
'releaseNotesTitle' => 'ملاحظات الإصدار',
'releaseNotesNone' => 'لا توجد ملاحظات إصدار لهذه النسخة.',
'support' => 'كما هو الحال دائماً، إذا واجهت أي مشكلة (فشل التنزيل، خطأ في التثبيت، أي شيء غريب في اللعبة)، فلا تتردد في التواصل معنا — نحن هنا للمساعدة.',
'closing' => 'تدريب سعيد!',
'signature' => 'فريق ASTERION VR',
'footer' => 'تم إرسال هذا الإشعار التلقائي لأن بريدك الإلكتروني للاتصال مسجل في أحد تراخيص PROSERVE الخاصة بك.<br/>للتوقف عن تلقي هذه الإشعارات، اطلب من مدير حسابك في ASTERION VR إزالة عنوانك من جهات اتصال الترخيص.',
],
'es' => [
'subject' => 'PROSERVE v{version} ya está disponible',
'title' => '¡PROSERVE v{version} ya está disponible!',
'subtitle' => 'Una nueva versión está lista para instalar a través de tu PROSERVE Launcher.',
'greeting' => 'Hola {owner},',
'intro' => 'Nos complace informarte que <strong>la versión {version} de PROSERVE</strong> acaba de publicarse y ya está disponible para descargar.',
'instructions' => 'Si ya tienes el <strong>PROSERVE Launcher</strong> instalado, simplemente ábrelo en cualquiera de tus máquinas, haz clic en <em>«Buscar actualizaciones»</em> e instala la nueva versión con un solo clic.',
'firstInstallTitle' => '¿Primera instalación o nueva estación de trabajo?',
'firstInstallBody' => 'Si es la primera vez, o si estás configurando un PC nuevo, descarga el instalador de PROSERVE Launcher abajo. <strong>Deberás ejecutarlo en CADA estación de trabajo</strong> donde se utilice PROSERVE.',
'downloadButton' => '⬇ Descargar PROSERVE Launcher (Windows)',
'directLink' => 'Enlace directo:',
'releaseNotesTitle' => 'Notas de versión',
'releaseNotesNone' => 'No hay notas de versión para esta release.',
'support' => 'Como siempre, si encuentras algún problema (fallo de descarga, error de instalación, comportamiento inesperado en el juego), no dudes en contactarnos — estamos aquí para ayudar.',
'closing' => '¡Buen entrenamiento!',
'signature' => 'El equipo ASTERION VR',
'footer' => 'Esta notificación automática se envió porque tu dirección de correo está registrada como contacto en una de tus licencias PROSERVE.<br/>Para dejar de recibir estos correos, pide a tu gestor de cuenta ASTERION VR que elimine tu dirección de los contactos de la licencia.',
],
'de' => [
'subject' => 'PROSERVE v{version} ist jetzt verfügbar',
'title' => 'PROSERVE v{version} ist verfügbar!',
'subtitle' => 'Eine neue Version ist bereit zur Installation über deinen PROSERVE Launcher.',
'greeting' => 'Hallo {owner},',
'intro' => 'Wir freuen uns, dir mitzuteilen, dass <strong>die Version {version} von PROSERVE</strong> soeben veröffentlicht wurde und jetzt zum Download bereitsteht.',
'instructions' => 'Wenn du den <strong>PROSERVE Launcher</strong> bereits installiert hast, öffne ihn einfach auf einer beliebigen deiner Maschinen, klicke auf <em>«Updates suchen»</em> und installiere die neue Version mit einem Klick.',
'firstInstallTitle' => 'Erstinstallation oder neue Arbeitsstation?',
'firstInstallBody' => 'Wenn dies das erste Mal ist oder du einen neuen PC einrichtest, lade den PROSERVE Launcher-Installer unten herunter. <strong>Du musst ihn auf JEDER Arbeitsstation ausführen</strong>, auf der PROSERVE verwendet wird.',
'downloadButton' => '⬇ PROSERVE Launcher herunterladen (Windows)',
'directLink' => 'Direkter Link:',
'releaseNotesTitle' => 'Versionshinweise',
'releaseNotesNone' => 'Keine Versionshinweise für diese Version.',
'support' => 'Wie immer, wenn du auf Probleme stößt (Download-Fehler, Installationsfehler, irgendetwas Seltsames im Spiel), zögere nicht, uns zu kontaktieren — wir sind hier, um zu helfen.',
'closing' => 'Viel Spaß beim Training!',
'signature' => 'Das ASTERION VR Team',
'footer' => 'Diese automatische Benachrichtigung wurde gesendet, weil deine Kontakt-E-Mail bei einer deiner PROSERVE-Lizenzen registriert ist.<br/>Um diese E-Mails nicht mehr zu erhalten, bitte deinen ASTERION VR Account Manager, deine Adresse aus den Lizenz-Kontakten zu entfernen.',
],
];
return $cache[$locale] ?? $cache['en'];
}
/**
* Construit le corps HTML de l'email de release announce, dans la langue
* <c>$locale</c> demandée (fallback 'en'). Le contenu de la release note
* elle-même (Markdown) reste en anglais — seul le texte d'accompagnement
* (greeting, instructions, CTAs) est localisé via <see cref="getEmailStrings"/>.
* Le owner_name personnalise le greeting. Le logo (optionnel via CID embed
* <c>notifications.logo_path</c>) est inséré dans le bandeau d'en-tête navy.
*
* Pour l'arabe : on ajoute <c>dir="rtl"</c> sur le wrapper table → Outlook,
* Gmail et Apple Mail rendent correctement le texte droit→gauche, et les
* sous-éléments inline (HTML tags, code, URLs) restent affichés LTR comme
* attendu (mixed bidi handling natif).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = '', string $locale = 'en'): string
{
// Récupère le dictionnaire de strings localisées + interpolation simple
// des placeholders {version} et {owner}. On échappe APRÈS interpolation
// pour ne pas double-encoder les < > de balises HTML déjà dans les
// strings (cf. <strong> dans les templates de getEmailStrings).
$s = getEmailStrings($locale);
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8'); $versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8'); $ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8'); $notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed). // $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=. // Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8'); $logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
// Direction RTL pour l'arabe. Les autres langues sont LTR par défaut.
// Note : on POSITIONNE rtl sur la table racine — les sub-blocks restent
// align="center" / align="left" selon le contexte (les tags HTML inline
// gardent leur direction naturelle via bidi-isolate du browser).
$dirAttr = ($locale === 'ar') ? ' dir="rtl"' : '';
// Helper d'interpolation : remplace {version} et {owner} dans une string
// localisée. NOTE : on ne réencode PAS — les strings dans getEmailStrings
// contiennent volontairement des <strong>, <em>, etc. à rendre comme HTML.
$tr = function (string $key) use ($s, $versionEsc, $ownerEsc): string {
return strtr($s[$key] ?? '', ['{version}' => $versionEsc, '{owner}' => $ownerEsc]);
};
// Wrap les release notes en <pre> pour préserver le Markdown formatting // Wrap les release notes en <pre> pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que // (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut. // ses notes sont en Markdown et l'opérateur peut lire le format brut.
// IMPORTANT : le titre "Release notes" est localisé MAIS le contenu reste
// toujours en anglais (= ce que l'admin a saisi dans la modal Notes).
$notesBlock = $notesEsc !== '' $notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n" ? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">" . htmlspecialchars($s['releaseNotesTitle'], ENT_QUOTES, 'UTF-8') . "</h3>\n"
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; " . "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; " . "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; " . "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
. "direction: ltr; text-align: left; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>" . "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>'; : '<p style="color: #6B7280; font-style: italic;">' . htmlspecialchars($s['releaseNotesNone'], ENT_QUOTES, 'UTF-8') . '</p>';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque // Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche // les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
@@ -158,38 +337,50 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe'; : 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8'); $installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
// Toutes les strings utilisateur sont passées par $tr() qui interpole
// {version}/{owner} dans le template localisé pour la $locale demandée.
$titleStr = $tr('title');
$subtitleStr = $tr('subtitle');
$greetingStr = $tr('greeting');
$introStr = $tr('intro');
$instructionsStr = $tr('instructions');
$fiTitleStr = $tr('firstInstallTitle');
$fiBodyStr = $tr('firstInstallBody');
$downloadBtnStr = $tr('downloadButton');
$directLinkStr = $tr('directLink');
$supportStr = $tr('support');
$closingStr = $tr('closing');
$signatureStr = $tr('signature');
$footerStr = $tr('footer');
return <<<HTML return <<<HTML
<!DOCTYPE html> <!DOCTYPE html>
<html> <html{$dirAttr}>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;"> <body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640" <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
style="max-width:640px; margin:0 auto; border-collapse:collapse;"> style="max-width:640px; margin:0 auto; border-collapse:collapse;"{$dirAttr}>
<tr> <tr>
<td bgcolor="#0F172A" align="center" <td bgcolor="#0F172A" align="center"
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;"> style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
<div style="margin-bottom:16px;">{$logoBlock}</div> <div style="margin-bottom:16px;">{$logoBlock}</div>
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;"> <h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
PROSERVE v{$versionEsc} is available! {$titleStr}
</h1> </h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;"> <p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
A new version is ready to install via your PROSERVE Launcher. {$subtitleStr}
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<td bgcolor="#FFFFFF" <td bgcolor="#FFFFFF"
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;"> style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p> <p style="margin:0 0 14px;">{$greetingStr}</p>
<p style="margin:0 0 14px;"> <p style="margin:0 0 14px;">
We're happy to let you know that {$introStr}
<strong>PROSERVE version {$versionEsc}</strong>
has just been published and is now available for download.
</p> </p>
<p style="margin:0 0 14px;"> <p style="margin:0 0 14px;">
If you already have the <strong>PROSERVE Launcher</strong> installed, {$instructionsStr}
simply open it on any of your machines, click
<em>« Check for updates »</em>, and install the new version with one click.
</p> </p>
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement <!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
@@ -200,13 +391,10 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
<tr> <tr>
<td style="padding:18px 0;"> <td style="padding:18px 0;">
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;"> <p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
First install or new workstation? {$fiTitleStr}
</p> </p>
<p style="margin:0 0 16px; color:#4B5563;"> <p style="margin:0 0 16px; color:#4B5563;">
If this is your first time, or you're setting up a new PC, {$fiBodyStr}
download the PROSERVE Launcher installer below.
<strong>You'll need to run it on EACH workstation</strong>
where PROSERVE will be used.
</p> </p>
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour <!-- Bouton stylé via table + bgcolor (vs CSS button) pour
compat Outlook qui ne supporte ni border-radius CSS sur compat Outlook qui ne supporte ni border-radius CSS sur
@@ -222,13 +410,13 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
color:#FFFFFF; text-decoration:none; color:#FFFFFF; text-decoration:none;
font-family:'Segoe UI',Arial,sans-serif; font-family:'Segoe UI',Arial,sans-serif;
font-size:14px; font-weight:600;"> font-size:14px; font-weight:600;">
⬇ Download PROSERVE Launcher (Windows) {$downloadBtnStr}
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;"> <p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
Direct link: {$directLinkStr}
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a> <a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
</p> </p>
</td> </td>
@@ -237,19 +425,17 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
{$notesBlock} {$notesBlock}
<p style="margin:24px 0 14px;"> <p style="margin:24px 0 14px;">
As always, if you run into any issue (download failure, install error, {$supportStr}
anything weird in-game), don't hesitate to reach out — we're here to help.
</p> </p>
<p style="margin:0;"> <p style="margin:0;">
Happy training!<br/> {$closingStr}<br/>
<strong>The ASTERION VR team</strong> <strong>{$signatureStr}</strong>
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;"> <td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/> {$footerStr}
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
</td> </td>
</tr> </tr>
</table> </table>
@@ -547,10 +733,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$notesFile = "$notesDir/{$entryId}.md"; $notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : ''; $notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles // Récupère les licenses éligibles. On inclut maintenant `language`
// pour pouvoir localiser l'email — fallback 'en' si NULL ou inconnu.
$db = Db::get($config); $db = Db::get($config);
$stmt = $db->prepare( $stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until 'SELECT id, owner_name, channel, can_see_betas, contact_emails, language, download_entitlement_until
FROM licenses FROM licenses
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
AND download_entitlement_until >= ? AND download_entitlement_until >= ?
@@ -603,7 +790,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($emails)) continue; if (empty($emails)) continue;
$licsTouched++; $licsTouched++;
$subject = "PROSERVE v{$version} is now available"; // Locale par license : whitelist contre les langues supportées,
// fallback 'en' pour les licenses sans préférence définie.
$locale = trim((string)($lic['language'] ?? ''));
if (!in_array($locale, ['fr', 'en', 'zh', 'th', 'ar'], true)) {
$locale = 'en';
}
// Sujet localisé via le même dictionnaire que le corps.
$subjectTemplate = getEmailStrings($locale)['subject'] ?? 'PROSERVE v{version} is now available';
$subject = str_replace('{version}', $version, $subjectTemplate);
// Logo via CID inline si fichier disponible sur disque, sinon // Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte // chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE // "ASTERION VR" en blanc sur le bandeau dark navy. On NE
@@ -617,7 +812,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$lic['owner_name'] ?? '', $lic['owner_name'] ?? '',
$notesBody, $notesBody,
$config['base_url'] ?? '', $config['base_url'] ?? '',
$logoSrc $logoSrc,
$locale
); );
foreach ($emails as $email) { foreach ($emails as $email) {

View File

@@ -0,0 +1,11 @@
-- PS_Launcher schema v5
-- Ajoute language sur la table licenses : code de langue 2-letter (fr/en/zh/th/ar)
-- utilisé pour localiser les emails de notification de release. NULL = English
-- (defaut conservateur pour les licenses existantes qui n'ont pas été éditées).
-- Le contenu de la release note elle-même reste TOUJOURS en anglais — seul le
-- texte d'accompagnement (greeting, instructions, CTAs) est localisé.
--
-- À jouer après 004_license_contact_emails.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN language VARCHAR(8) NULL AFTER contact_emails;

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>0.29.10</Version> <Version>1.0.0</Version>
<AssemblyVersion>0.29.10.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>0.29.10.0</FileVersion> <FileVersion>1.0.0.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

@@ -8,7 +8,11 @@ namespace PSLauncher.Core.Localization;
/// session. Tout changement de langue nécessite un redémarrage du launcher /// session. Tout changement de langue nécessite un redémarrage du launcher
/// (les bindings XAML x:Static ne refresh pas dynamiquement). /// (les bindings XAML x:Static ne refresh pas dynamiquement).
/// ///
/// Langues supportées : fr (par défaut), en, zh (Simplified), th, ar. /// Langues supportées :
/// • fr (par défaut), en, zh (Simplified), th, ar : traduction complète historique
/// • es, de : ajoutées en v0.29.11, traduction progressive — les strings non encore
/// traduites tombent automatiquement sur l'anglais via T() (cf. fallback dans
/// <see cref="T(string,string,string,string,string,string?,string?)"/>).
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window. /// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
/// </summary> /// </summary>
public static class Strings public static class Strings
@@ -27,6 +31,8 @@ public static class Strings
("auto", "Automatique (langue du système)"), ("auto", "Automatique (langue du système)"),
("fr", "Français"), ("fr", "Français"),
("en", "English"), ("en", "English"),
("es", "Español"),
("de", "Deutsch"),
("zh", "中文"), ("zh", "中文"),
("th", "ไทย"), ("th", "ไทย"),
("ar", "العربية"), ("ar", "العربية"),
@@ -39,7 +45,7 @@ public static class Strings
/// </summary> /// </summary>
public static void Init(string? configValue) public static void Init(string? configValue)
{ {
var supported = new[] { "fr", "en", "zh", "th", "ar" }; var supported = new[] { "fr", "en", "es", "de", "zh", "th", "ar" };
var v = configValue?.Trim().ToLowerInvariant() ?? "auto"; var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0) if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
@@ -62,9 +68,21 @@ public static class Strings
catch { /* culture inconnue, on garde le défaut */ } catch { /* culture inconnue, on garde le défaut */ }
} }
private static string T(string fr, string en, string zh, string th, string ar) => _lang switch /// <summary>
/// Helper de localisation. Les 5 premiers paramètres (fr/en/zh/th/ar) sont
/// requis = chaque string DOIT exister dans ces langues (traduction historique
/// complète). Les 2 derniers (es, de) sont OPTIONNELS — si non fournis, on
/// retombe sur l'anglais. Stratégie progressive : on traduit en es/de
/// uniquement les strings les plus visibles (top bar, actions principales,
/// états licenses, errors common) ; le reste reste en anglais pour les
/// utilisateurs es/de tant qu'on n'a pas traduit. Mieux qu'un crash ou
/// une chaîne vide, et l'anglais est globalement lisible par ces locuteurs.
/// </summary>
private static string T(string fr, string en, string zh, string th, string ar, string? es = null, string? de = null) => _lang switch
{ {
"en" => en, "en" => en,
"es" => es ?? en, // fallback English si pas encore traduit
"de" => de ?? en, // idem
"zh" => zh, "zh" => zh,
"th" => th, "th" => th,
"ar" => ar, "ar" => ar,
@@ -72,34 +90,36 @@ public static class Strings
}; };
// ==================== TOP BAR ==================== // ==================== TOP BAR ====================
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات"); public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات", "🔄 Buscar actualizaciones", "🔄 Updates suchen");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات"); public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات", "⚙ Ajustes", "⚙ Einstellungen");
// ==================== BODY ==================== // ==================== BODY ====================
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ"); public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ", "Versiones", "Versionen");
public static string BodyVersionsHint => T( public static string BodyVersionsHint => T(
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.", "Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
"Launch, install or remove a version. Installed and available versions can coexist.", "Launch, install or remove a version. Installed and available versions can coexist.",
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。", "启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้", "เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش." "تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش.",
"Inicia, instala o elimina una versión. Las versiones instaladas y las disponibles en el servidor pueden coexistir.",
"Starte, installiere oder entferne eine Version. Installierte und verfügbare Versionen können koexistieren."
); );
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية"); public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية", "VERSIÓN ACTUAL", "AKTUELLE VERSION");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى"); public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى", "OTRAS VERSIONES", "ANDERE VERSIONEN");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في "); public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ", "Publicado el ", "Veröffentlicht am ");
// ==================== STATUS BADGES ==================== // ==================== STATUS BADGES ====================
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة"); public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة", "● Instalada", "● Installiert");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة"); public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة", "○ Disponible", "○ Verfügbar");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…"); public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…", "⬇ Descargando…", "⬇ Wird heruntergeladen…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…"); public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…", "📦 Instalando…", "📦 Wird installiert…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…"); public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…", "🗑 Eliminando…", "🗑 Wird entfernt…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…"); public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…", "🔍 Verificando…", "🔍 Wird überprüft…");
// ==================== BETA BADGE ==================== // ==================== BETA BADGE ====================
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary> /// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي"); public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي", "BETA", "BETA");
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary> /// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
public static string BetaBadgeTooltipDefault => T( public static string BetaBadgeTooltipDefault => T(
@@ -120,17 +140,17 @@ public static class Strings
); );
// ==================== ACTIONS ==================== // ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل"); public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ Iniciar", "▶ Starten");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل"); public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ INICIAR", "▶ STARTEN");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ Instalar", "⬇ Installieren");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ INSTALAR", "⬇ INSTALLIEREN");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء"); public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء", "Cancelar", "Abbrechen");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق"); public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق", "OK", "OK");
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم"); public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم", "Sí", "Ja");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا"); public static string ActionNo => T("Non", "No", "否", "ไม่", "لا", "No", "Nein");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً"); public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً", "Más tarde", "Später");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ"); public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ", "Guardar", "Speichern");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق"); public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق", "Cerrar", "Schließen");
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license. // Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger. // Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license. // Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
@@ -270,13 +290,13 @@ public static class Strings
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن"); public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
// ==================== MESSAGEBOX TITLES ==================== // ==================== MESSAGEBOX TITLES ====================
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ"); public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ", "Error", "Fehler");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل"); public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل", "Error al iniciar", "Startfehler");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد"); public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد", "Confirmar", "Bestätigen");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة"); public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة", "Información", "Information");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار"); public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار", "Por favor espera", "Bitte warten");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة"); public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة", "Cambio de idioma", "Sprachänderung");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار"); public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ==================== // ==================== MESSAGEBOX MESSAGES ====================
public static string MsgBusy => T( public static string MsgBusy => T(
@@ -860,7 +880,7 @@ public static class Strings
); );
// ---- Mode auto (auto-launch + auto-relaunch après exit) ---- // ---- Mode auto (auto-launch + auto-relaunch après exit) ----
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي"); public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي", "AUTO", "AUTO");
public static string AutoModeTooltipActive => T( public static string AutoModeTooltipActive => T(
"Cette version est désignée pour le lancement automatique. Clique pour désactiver.", "Cette version est désignée pour le lancement automatique. Clique pour désactiver.",
"This version is set for auto-launch. Click to disable.", "This version is set for auto-launch. Click to disable.",