(string)($c['name'] ?? ''), $channelRegistry['channels']); /** * Sanitize la liste de channels postée. On garde uniquement ceux qui existent * dans le registre — un attaquant ne peut pas tagger une version sur un channel * non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public). */ function sanitizeChannels(array $posted, array $allowedNames): array { $out = []; foreach ($posted as $c) { $c = (string)$c; if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) { $out[] = $c; } } if (empty($out)) $out = ['default']; return $out; } /** * ID stable interne pour identifier une entrée du manifest. Permet d'avoir * plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police + * v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas * un identifiant unique. Le tag de channels distingue qui voit quoi. * * Format : 'v' + 8 hex chars random. Immutable une fois généré. */ function generate_entry_id(): string { try { return 'v' . bin2hex(random_bytes(4)); } catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); } } function loadManifest(string $path): array { if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []]; $manifest = json_decode(file_get_contents($path), true) ?? []; // Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy // pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change // plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids // jetables et les actions tomberaient à plat au refresh. $dirty = false; foreach ($manifest['versions'] ?? [] as &$v) { if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; } } unset($v); if ($dirty) saveManifest($path, $manifest); return $manifest; } function saveManifest(string $path, array $manifest): void { // Re-tri par SemVer descendant. Pour des entrées de même version, // tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter // sur un ordre déterministe. usort($manifest['versions'], function($a, $b) { $cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0'); return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? ''); }); file_put_contents( $path, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" ); } function find_entry_index_by_id(array $manifest, string $id): ?int { foreach ($manifest['versions'] ?? [] as $i => $v) { if (($v['id'] ?? '') === $id) return $i; } return null; } /** * Construit le corps HTML de l'email de release announce. Message en anglais * (= langue par défaut du parc client international VR PROSERVE), inclut les * release notes brutes en Markdown (sans rendu — les MUA modernes Markdown * basique en clair reste très lisible et évite la dépendance Markdig PHP). * Le owner_name de la license est utilisé pour personnaliser le greeting. * Le logo (optionnel via notifications.logo_url) est inséré dans le * 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). */ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string { $versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8'); $ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', 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). // 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'); // Wrap les release notes en
 pour préserver le Markdown formatting
    // (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.
    $notesBlock = $notesEsc !== ''
        ? "

Release notes

\n" . "
{$notesEsc}
" : '

No release notes for this version.

'; // 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 // en blanc sur le fond bleu et le destinataire voit quand même la marque. $logoBlock = $logoEsc !== '' ? "\"ASTERION" : 'ASTERION VR'; // Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste // CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word, // qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres // règles CSS modernes. Le résultat précédent ressemblait à un email plain // text sans bandeau bleu. Avec ce layout : // • → fond bleu solide visible dans TOUS les MUA // • Styles inline sur chaque
→ pas de strip Outlook // • width="640" attribute (vs CSS max-width) → respecté par Outlook // • Pas de gradient (impossible OOTB en HTML email standard) — solid blue // URL d'install du launcher (= installer Inno Setup self-extracting Windows). // Servi statiquement par Apache depuis server/installer/. Si l'opérateur // change ce path, mettre à jour ici. Pas de paramétrage config volontaire : // le path est canonique côté projet et tous les launchers en partagent un. $installerUrl = rtrim($baseUrl, '/') !== '' ? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe' : 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe'; $installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8'); return <<
{$logoBlock}

PROSERVE v{$versionEsc} is available!

A new version is ready to install via your PROSERVE Launcher.

Hi {$ownerEsc},

We're happy to let you know that PROSERVE version {$versionEsc} has just been published and is now available for download.

If you already have the PROSERVE Launcher installed, simply open it on any of your machines, click « Check for updates », and install the new version with one click.

First install or new workstation?

If this is your first time, or you're setting up a new PC, download the PROSERVE Launcher installer below. You'll need to run it on EACH workstation where PROSERVE will be used.

⬇ Download PROSERVE Launcher (Windows)

Direct link: {$installerUrlEsc}

{$notesBlock}

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.

Happy training!
The ASTERION VR team

This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
HTML; } /** * Normalise un nom de fichier ZIP saisi par l'admin : * - strip tout chemin (path traversal défense, basename) * - whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets, underscores) * - assure le suffixe .zip * - retombe sur la valeur par défaut si l'input est vide ou inexploitable */ function ps_normalize_zip_filename(string $raw, string $version): string { $default = "proserve-{$version}.zip"; $name = trim($raw); if ($name === '') return $default; // Strip tout chemin → garde juste le filename (sécurité) $name = basename($name); // Whitelist : on garde un set de chars filename-safe sur Windows + Linux $name = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name); if ($name === '' || $name === '.zip' || $name === '.') return $default; if (!str_ends_with(strtolower($name), '.zip')) $name .= '.zip'; return $name; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { Auth::checkCsrf(); $action = $_POST['action'] ?? ''; $manifest = loadManifest($manifestPath); try { if ($action === 'add') { $version = trim($_POST['version'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? ''); $releasedAt = trim($_POST['released_at'] ?? ''); $notes = $_POST['notes'] ?? ''; $available = isset($_POST['available']); $isBeta = isset($_POST['is_beta']); $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; // X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne). // Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de // pré-release sans bumper le numéro de release final (ex. 1.5.4.13 // pendant le dev, 1.5.4 au release). if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) { throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B attendu).'); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { throw new Exception('Date min_license_date invalide.'); } // Le numéro de version peut être DUPLIQUÉ entre channels — c'est // exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour // pompier coexistent). On rejette uniquement la collision sur le // nom de ZIP, parce que là on aurait une vraie ambiguïté physique // : deux entries pointant sur le même fichier sur disque. $zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version); foreach ($manifest['versions'] as $v) { $existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: ''); if ($existingZip === $zipFilenameProposed) { throw new Exception( "Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v" . htmlspecialchars($v['version'] ?? '?') . ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)." ); } } $releasedAtIso = $releasedAt !== '' ? (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z') : (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; // Le ZIP a déjà été normalisé pour le check anti-collision plus haut. $zipFilename = $zipFilenameProposed; $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $entryId = generate_entry_id(); $exeName = trim((string)($_POST['executable'] ?? 'PROSERVE_UE_5_7.exe')); // Validation stricte : pas de path traversal possible dans le nom du fichier. if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) { $exeName = 'PROSERVE_UE_5_7.exe'; } $entry = [ 'id' => $entryId, 'version' => $version, 'releasedAt' => $releasedAtIso, 'executable' => $exeName, 'installFolderTemplate' => 'PROSERVE v{version}', 'channels' => $channels, 'download' => [ 'url' => "{$base}/builds/{$zipFilename}", 'sizeBytes' => 0, 'sha256' => 'REPLACE_AFTER_BUILD', ], // Release notes adressables par id stable — supporte plusieurs entries // de même version sans collision (chaque release a son propre .md). 'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}", 'minLicenseDate' => $minLicDate, 'availableForDownload' => $available, ]; if ($isBeta) { $entry['isBeta'] = true; if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes; } $manifest['versions'][] = $entry; saveManifest($manifestPath, $manifest); if ($notes !== '') { if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); file_put_contents("$notesDir/{$entryId}.md", $notes); } $message = "v{$version} ajoutée (id {$entryId}). Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; } elseif ($action === 'edit_notes') { $entryId = (string)($_POST['id'] ?? ''); $notes = $_POST['notes'] ?? ''; $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); file_put_contents("$notesDir/{$entryId}.md", $notes); $message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour."; } elseif ($action === 'edit_meta') { $entryId = (string)($_POST['id'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? ''); $releasedAt = trim($_POST['released_at'] ?? ''); $newZipName = trim((string)($_POST['zip_filename'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { throw new Exception('min_license_date invalide.'); } $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? ''; $manifest['versions'][$idx]['minLicenseDate'] = $minLicDate; if ($releasedAt !== '') { $manifest['versions'][$idx]['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); } if ($newZipName !== '') { $normalized = ps_normalize_zip_filename($newZipName, $version); // Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry foreach ($manifest['versions'] as $other) { if (($other['id'] ?? '') === $entryId) continue; $otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: ''); if ($otherZip === $normalized) { throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom."); } } $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; $manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}"; $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; $manifest['versions'][$idx]['download']['sizeBytes'] = 0; } saveManifest($manifestPath, $manifest); $message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.'); } elseif ($action === 'set_channels') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $manifest['versions'][$idx]['channels'] = $channels; saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest."; } elseif ($action === 'set_beta') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $isBeta = !empty($_POST['is_beta']); $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; if ($isBeta) { $manifest['versions'][$idx]['isBeta'] = true; if ($betaNotes !== null) { $manifest['versions'][$idx]['betaNotes'] = $betaNotes; } else { unset($manifest['versions'][$idx]['betaNotes']); } } else { unset($manifest['versions'][$idx]['isBeta']); unset($manifest['versions'][$idx]['betaNotes']); } saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »)."; } elseif ($action === 'toggle_available') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true); saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Disponibilité de v{$version} mise à jour."; } elseif ($action === 'delete') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? '?'; // Cleanup release notes file (best effort) $notesFile = "$notesDir/{$entryId}.md"; if (is_file($notesFile)) @unlink($notesFile); $manifest['versions'] = array_values(array_filter( $manifest['versions'], fn($v) => ($v['id'] ?? '') !== $entryId )); saveManifest($manifestPath, $manifest); $message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu)."; } elseif ($action === 'sync' || $action === 'sync_versions') { require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $scope = $action === 'sync_versions' ? 'versions' : 'all'; $force = !empty($_POST['force']); $result = $signer->run($scope, $force); $label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet'; $forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]'; $message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } elseif ($action === 'sync_one') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? '?'; require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $force = !empty($_POST['force']); // SignManifest::run filtre par numéro de version ; en cas d'entries // de même version sur des channels différents, le hash sera recalculé // pour toutes celles qui matchent (chacune pointe sur son propre ZIP, // donc le résultat est correct, juste un peu plus de boulot). $result = $signer->run('versions', $force, $version); $forceLabel = $force ? ' [FORCE]' : ''; $message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } elseif ($action === 'set_skip_hash') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $skipHash = !empty($_POST['skip']); $version = $manifest['versions'][$idx]['version'] ?? '?'; if ($skipHash) { $manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none'; $manifest['versions'][$idx]['download']['sha256'] = ''; } else { unset($manifest['versions'][$idx]['download']['hashAlgorithm']); if (empty($manifest['versions'][$idx]['download']['sha256'])) { $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; } } saveManifest($manifestPath, $manifest); // Re-signature auto pour garder le manifest valide après le changement. require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $resign = $signer->run('versions', false); $message = ($skipHash ? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé." : "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.") . "\n" . implode("\n", $resign['log']); if (!$resign['ok']) $messageType = 'error'; } elseif ($action === 'notify_release') { // Envoie une notif email aux contacts des licenses ÉLIGIBLES pour // cette version. Critères d'éligibilité (= license verra la version // dans son manifest signé) : // • Pas révoquée // • download_entitlement_until >= minLicenseDate de la version // • channel de la license ∈ channels de la version (ou « default ») // • can_see_betas = 1 si la version est isBeta=true // Pour chaque license éligible : split contact_emails + send via Mailer. // Compteurs retournés dans le message admin. $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $entry = $manifest['versions'][$idx]; $version = $entry['version'] ?? '?'; $isBeta = !empty($entry['isBeta']); $vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels']) ? $entry['channels'] : ['default']; $minLic = $entry['minLicenseDate'] ?? '1970-01-01'; // Charge release notes (Markdown brut → on l'envoie tel quel, // les MUA modernes l'affichent OK même sans rendu) $notesFile = "$notesDir/{$entryId}.md"; $notesBody = is_file($notesFile) ? file_get_contents($notesFile) : ''; // Récupère les licenses éligibles $db = Db::get($config); $stmt = $db->prepare( 'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until FROM licenses WHERE revoked_at IS NULL AND download_entitlement_until >= ? AND contact_emails IS NOT NULL AND contact_emails != \'\'' ); $stmt->execute([$minLic]); $licenses = $stmt->fetchAll(); $notifConfig = $config['notifications'] ?? []; // Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche // SANS demander de permission "Télécharger les images". Le path // pointe vers admin/assets/asterion-logo.png par défaut, ou un // override via $notifConfig['logo_path']. Si le fichier n'existe // pas, $logoCid reste vide → le template skip le bloc image et // utilise le fallback texte "ASTERION VR" en blanc. // // PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook // bloque tout hotlink par défaut, et quand l'URL retourne 404, // il n'offre même pas le bouton « Télécharger les images » // (il affiche juste un placeholder rouge cassé). Mieux vaut le // fallback texte que ce visuel anti-pro. $logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png'); $logoCid = ''; $logoMissing = false; $inlineImages = []; if (is_file($logoPath) && is_readable($logoPath)) { $logoCid = 'asterion-logo@pslauncher'; $inlineImages[] = [ 'cid' => $logoCid, 'path' => $logoPath, 'type' => 'image/png', ]; } else { $logoMissing = true; } $totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = []; foreach ($licenses as $lic) { $licChannel = $lic['channel'] ?? 'default'; // Match : la license voit la version si son channel est dans // la liste des channels de la version (ou si version='default' // et license sans channel = public) $licChannelEffective = $licChannel ?? 'default'; if (!in_array($licChannelEffective, $vChannels, true)) continue; if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue; $emails = Mailer::parseEmails($lic['contact_emails']); if (empty($emails)) continue; $licsTouched++; $subject = "PROSERVE v{$version} is now available"; // Logo via CID inline si fichier disponible sur disque, sinon // chaîne vide → le template tombera sur le fallback texte // "ASTERION VR" en blanc sur le bandeau dark navy. On NE // tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque // tout hotlink par défaut et affiche un placeholder cassé // sans même offrir le bouton "Télécharger les images" si // l'URL retourne 404. Cf logoMissing détecté plus haut. $logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : ''; $bodyHtml = renderReleaseAnnounceEmail( $version, $lic['owner_name'] ?? '', $notesBody, $config['base_url'] ?? '', $logoSrc ); foreach ($emails as $email) { // Dédup : ne pas envoyer 2× à la même adresse si elle est // contact sur plusieurs licenses du même client. if (isset($emailsSet[$email])) continue; $emailsSet[$email] = true; $ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages); if ($ok) $totalSent++; else $totalFailed++; } } $message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " . "(à {$licsTouched} license(s) éligibles, " . ($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)"); // Si le logo PNG est introuvable côté serveur, le préviens — sinon // l'opérateur découvre le bug seulement quand un destinataire lui // dit que l'email n'a pas de logo. if ($logoMissing) { $message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" . " Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo."; $messageType = 'error'; } if ($totalFailed > 0) $messageType = 'error'; } } catch (Exception $e) { $message = $e->getMessage(); $messageType = 'error'; } } $manifest = loadManifest($manifestPath); // Liste des ZIPs : tout est flat dans builds/, indexé par filename simple. $zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : []; $zipSizes = []; foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z"); // Pour l'édition : pré-charge les release notes existantes (indexées par // id stable maintenant). Fallback sur l'ancien format {version}.md pour les // notes créées avant v0.27.1, qu'on n'a pas encore migrées. $existingNotes = []; foreach ($manifest['versions'] ?? [] as $v) { $id = $v['id'] ?? ''; $byId = $id !== '' ? "$notesDir/{$id}.md" : null; $byVersion = "$notesDir/{$v['version']}.md"; if ($byId !== null && is_file($byId)) { $existingNotes[$id] = file_get_contents($byId); } elseif (is_file($byVersion)) { $existingNotes[$id] = file_get_contents($byVersion); } else { $existingNotes[$id] = ''; } } Layout::header('Versions', 'versions'); ?>

Versions

Workflow d'une nouvelle release

  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).
  2. Upload le ZIP en SFTP dans www/PS_Launcher/builds/ (le nom doit matcher celui que tu as saisi, par défaut proserve-{version}.zip).
  3. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  4. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.

💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels.

Ajouter une version

Manifest actuel

Signature : NON SIGNÉ" : "SIGNÉ Ed25519" ?> • latest : v
// — un dans un est exclu du // layout tree par certains browsers, ce qui empêche showModal() // de fonctionner (le bouton « ✎ Modifier » devient muet). ?> */ ?>

Éditer v BÊTA

. Pourquoi : directement // ou indirectement dans
Version Channels Release min_license ZIP Hash Visible Actions
v BÊTA présent absent
attendu :
SKIP à calculer
// est foster-parented // de manière imprévisible par le parser HTML, ce qui peut casser // showModal() (élément non connecté au layout tree). $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
Aucune version dans le manifest.
d'édition sont émis ici, hors de la parent, // pour que showModal() fonctionne fiablement (le top layer est indépendant // du DOM context mais l'élément doit être dans le layout tree d'abord). ?>

ZIPs présents dans builds/

Aucun ZIP. Upload tes fichiers via SFTP dans www/PS_Launcher/builds/, puis clique « Sync » ci-dessus.

FichierTailleRéférencé ?
oui" : "orphelin" ?>