Compare commits
2 Commits
01793eb32d
...
500f7d12e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 500f7d12e6 | |||
| 50da755a92 |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.28.12"
|
#define MyAppVersion "0.29.10"
|
||||||
#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"
|
||||||
|
|||||||
BIN
server/admin/assets/asterion-logo.png
Normal file
BIN
server/admin/assets/asterion-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
|
|||||||
|
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Modal (<dialog>) — éditeur d'une entrée du manifest
|
||||||
|
Remplace les <details> inline qui débordaient horizontalement
|
||||||
|
============================================================ */
|
||||||
|
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
|
||||||
|
showModal() automatiquement). Sans le sélecteur [open], les règles
|
||||||
|
`display: flex` etc. écrasaient le `display: none` du user-agent
|
||||||
|
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
|
||||||
|
cachés tant que pas explicitement ouverts. */
|
||||||
|
dialog.edit-modal[open] {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0; margin: auto;
|
||||||
|
width: min(640px, 92vw); max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
dialog.edit-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header {
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header h3 {
|
||||||
|
margin: 0; font-size: 15px; font-weight: 600;
|
||||||
|
flex: 1; color: var(--text);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 20px; line-height: 1; padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x:hover {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Onglets verticaux compacts en tête */
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex; gap: 4px; padding: 8px 12px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-tabs button {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
padding: 8px 14px; border-radius: 4px 4px 0 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.modal-tabs button:hover {
|
||||||
|
color: var(--text); background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
.modal-tabs button.active {
|
||||||
|
color: var(--text); border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal-body > section[hidden] { display: none; }
|
||||||
|
.modal-body > section { display: block; }
|
||||||
|
.modal-body .field:last-child { margin-bottom: 0; }
|
||||||
|
.modal-body .hint {
|
||||||
|
color: var(--text-secondary); font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.modal-body .warn {
|
||||||
|
color: #FBBF24; font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip-style note in the modal sections explaining a section purpose */
|
||||||
|
.section-intro {
|
||||||
|
background: var(--bg-input); border-left: 3px solid var(--accent);
|
||||||
|
padding: 10px 14px; border-radius: 4px;
|
||||||
|
font-size: 12px; color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liste de checkboxes verticale propre (pour Channels) */
|
||||||
|
.checkbox-list {
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.checkbox-list label {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 4px;
|
||||||
|
font-size: 13px; color: var(--text); font-weight: normal;
|
||||||
|
cursor: pointer; margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.checkbox-list label:hover { background: var(--bg-elevated); }
|
||||||
|
.checkbox-list label code { flex: 0 0 auto; }
|
||||||
|
.checkbox-list label .muted { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
|
||||||
|
.row-actions {
|
||||||
|
display: inline-flex; gap: 6px; align-items: center;
|
||||||
|
flex-wrap: nowrap; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.row-actions .btn { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Overlay blocant affiché pendant les opérations longues
|
||||||
|
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
|
||||||
|
Visible tant que le browser n'a pas reçu la réponse POST,
|
||||||
|
disparaît naturellement au reload de page suivant.
|
||||||
|
============================================================ */
|
||||||
|
.busy-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.busy-overlay[hidden] { display: none; }
|
||||||
|
.busy-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 40px 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.busy-content h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.busy-content p {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.busy-spinner {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border: 4px solid var(--bg-elevated);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: busy-spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
@keyframes busy-spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if ($action === 'set_launcher') {
|
if ($action === 'set_launcher') {
|
||||||
$lver = trim($_POST['launcher_version'] ?? '');
|
$lver = trim($_POST['launcher_version'] ?? '');
|
||||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
|
||||||
|
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
|
||||||
}
|
}
|
||||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
|
||||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||||
}
|
}
|
||||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
@@ -145,14 +146,14 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<input type="hidden" name="action" value="set_launcher">
|
<input type="hidden" name="action" value="set_launcher">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version launcher (X.Y.Z)</label>
|
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version minimale requise (X.Y.Z)</label>
|
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-success" type="submit">Définir</button>
|
<button class="btn btn-success" type="submit">Définir</button>
|
||||||
|
|||||||
201
server/admin/lib/Mailer.php
Normal file
201
server/admin/lib/Mailer.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PSLauncher\Admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petit helper d'envoi d'emails de notification (release announce).
|
||||||
|
*
|
||||||
|
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
|
||||||
|
* passe par le relais SMTP local et marche out-of-the-box sans config
|
||||||
|
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
|
||||||
|
* — on a besoin de notifications opportunistes, pas de delivery garantie.
|
||||||
|
*
|
||||||
|
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
|
||||||
|
* même : on remplace juste <see cref="sendInternal"/> par un appel
|
||||||
|
* <code>$mailer->send()</code>.
|
||||||
|
*/
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
|
||||||
|
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
|
||||||
|
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
|
||||||
|
* popup pour un copy/paste avec une virgule en trop).
|
||||||
|
*
|
||||||
|
* @return list<string> emails uniques lowercase
|
||||||
|
*/
|
||||||
|
public static function parseEmails(?string $raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null || trim($raw) === '') return [];
|
||||||
|
// Split sur tout séparateur courant : virgule, point-virgule, espace,
|
||||||
|
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
|
||||||
|
// Excel, ou ligne par ligne — peu importe.
|
||||||
|
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($tokens as $t) {
|
||||||
|
$t = strtolower(trim($t));
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
if (!in_array($t, $out, true)) $out[] = $t;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse : prend un tableau d'emails et le formate pour stockage DB
|
||||||
|
* (séparé par virgules). NULL si la liste est vide pour économiser
|
||||||
|
* une ligne en DB sur les licenses sans contacts.
|
||||||
|
*/
|
||||||
|
public static function joinForStorage(array $emails): ?string
|
||||||
|
{
|
||||||
|
$emails = array_values(array_filter(
|
||||||
|
array_map('trim', $emails),
|
||||||
|
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
|
||||||
|
));
|
||||||
|
return empty($emails) ? null : implode(', ', $emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
|
||||||
|
* à un destinataire. Best-effort : retourne true en cas de succès apparent
|
||||||
|
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
|
||||||
|
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
|
||||||
|
* résultat brut.
|
||||||
|
*
|
||||||
|
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
|
||||||
|
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
|
||||||
|
* Les images sont attachées comme pièces jointes inline, référencées dans
|
||||||
|
* le HTML via <c><img src="cid:..."></c>. Avantage vs hotlink HTTP :
|
||||||
|
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
|
||||||
|
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
|
||||||
|
*
|
||||||
|
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
|
||||||
|
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
|
||||||
|
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
|
||||||
|
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
|
||||||
|
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
|
||||||
|
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
|
||||||
|
*/
|
||||||
|
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
|
||||||
|
{
|
||||||
|
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
|
||||||
|
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
|
||||||
|
$replyTo = $notifConfig['reply_to'] ?? '';
|
||||||
|
|
||||||
|
// Filtre les inline images : ne garde que celles dont le fichier existe
|
||||||
|
// ET est lisible. Si une image manque, on log silencieusement et on
|
||||||
|
// continue avec les autres — l'email part de toute façon.
|
||||||
|
$validImages = [];
|
||||||
|
foreach ($inlineImages as $img) {
|
||||||
|
$p = $img['path'] ?? '';
|
||||||
|
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
|
||||||
|
$validImages[] = $img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
|
||||||
|
// multipart/related contenant multipart/alternative + image(s).
|
||||||
|
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
|
||||||
|
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
|
||||||
|
// pour avoir des "Per-user emails" et faciliter le debugging.
|
||||||
|
$contentType = empty($validImages)
|
||||||
|
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
|
||||||
|
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
|
||||||
|
$headers = [
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
"Content-Type: {$contentType}",
|
||||||
|
'From: ' . self::formatAddress($fromName, $from),
|
||||||
|
'X-Mailer: PSLauncher-Admin',
|
||||||
|
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
|
||||||
|
];
|
||||||
|
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$headers[] = 'Reply-To: ' . $replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Construction du body ===
|
||||||
|
$bodyText = self::htmlToPlainText($bodyHtml);
|
||||||
|
|
||||||
|
// Bloc multipart/alternative (text + html)
|
||||||
|
$altPart = "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyText . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyHtml . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}--\r\n";
|
||||||
|
|
||||||
|
if (empty($validImages)) {
|
||||||
|
// Pas d'images → email simple, juste l'alternative
|
||||||
|
$body = $altPart;
|
||||||
|
} else {
|
||||||
|
// Avec images → multipart/related wrappant l'alt + chaque image
|
||||||
|
$body = "--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
|
||||||
|
$body .= $altPart;
|
||||||
|
foreach ($validImages as $img) {
|
||||||
|
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
|
||||||
|
$type = $img['type'] ?? 'application/octet-stream';
|
||||||
|
$path = $img['path'];
|
||||||
|
$name = basename($path);
|
||||||
|
$data = @file_get_contents($path);
|
||||||
|
if ($data === false) continue;
|
||||||
|
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
|
||||||
|
$body .= "\r\n--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
|
||||||
|
$body .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
|
$body .= "Content-ID: <{$cid}>\r\n";
|
||||||
|
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
|
||||||
|
$body .= $b64;
|
||||||
|
}
|
||||||
|
$body .= "\r\n--{$boundaryRel}--\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un HTML simple en plain text pour la partie text/plain du
|
||||||
|
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
|
||||||
|
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
|
||||||
|
*/
|
||||||
|
private static function htmlToPlainText(string $html): string
|
||||||
|
{
|
||||||
|
// Convert <br> et <p> en sauts de ligne avant strip_tags
|
||||||
|
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
|
||||||
|
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
|
||||||
|
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
|
||||||
|
$s = strip_tags($s);
|
||||||
|
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return trim($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
|
||||||
|
* Échappe les chars spéciaux conformément à RFC 5322.
|
||||||
|
*/
|
||||||
|
private static function formatAddress(string $name, string $email): string
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') return $email;
|
||||||
|
// Encode le name en MIME si pas pur ASCII
|
||||||
|
if (preg_match('/[\x80-\xFF]/', $name)) {
|
||||||
|
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
|
||||||
|
} else {
|
||||||
|
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
|
||||||
|
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
|
||||||
|
$name = '"' . str_replace('"', '\\"', $name) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{$name} <{$email}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
require __DIR__ . '/lib/Channels.php';
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
require __DIR__ . '/lib/Mailer.php';
|
||||||
require __DIR__ . '/../api/lib/Db.php';
|
require __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
use PSLauncher\Admin\Channels;
|
use PSLauncher\Admin\Channels;
|
||||||
|
use PSLauncher\Admin\Mailer;
|
||||||
use PSLauncher\Db;
|
use PSLauncher\Db;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
@@ -117,13 +119,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
||||||
}
|
}
|
||||||
elseif ($action === 'toggle_betas') {
|
elseif ($action === 'toggle_betas') {
|
||||||
|
// Renommé semantically (toggle → set explicit) sans casser le nom
|
||||||
|
// de l'action POST pour préserver les éventuels liens externes
|
||||||
|
// existants. La modal envoie maintenant `enabled=1` si checkbox
|
||||||
|
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
|
$enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||||
$stmt->execute([$id]);
|
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
|
||||||
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
|
$message = $enabled
|
||||||
$row->execute([$id]);
|
|
||||||
$now = (int)$row->fetchColumn();
|
|
||||||
$message = $now
|
|
||||||
? "License #{$id} : accès aux versions BÊTA activé."
|
? "License #{$id} : accès aux versions BÊTA activé."
|
||||||
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
|
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
elseif ($action === 'set_contact_emails') {
|
||||||
|
// Met à jour la liste des emails de contact pour les notifications
|
||||||
|
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
|
||||||
|
// virgules, point-virgules — peu importe), on parse + valide via
|
||||||
|
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
|
||||||
|
// valide → on stocke NULL (= pas de notif possible pour cette
|
||||||
|
// license, juste skip silencieux à l'envoi).
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$raw = (string)($_POST['contact_emails'] ?? '');
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new Exception('License id invalide.');
|
||||||
|
}
|
||||||
|
$parsed = Mailer::parseEmails($raw);
|
||||||
|
$stored = Mailer::joinForStorage($parsed);
|
||||||
|
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
|
||||||
|
->execute([$stored, $id]);
|
||||||
|
$count = count($parsed);
|
||||||
|
$message = $count === 0
|
||||||
|
? "License #{$id} : contacts de notification supprimés."
|
||||||
|
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
|
||||||
|
}
|
||||||
elseif ($action === 'reset_machines') {
|
elseif ($action === 'reset_machines') {
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
||||||
@@ -295,8 +319,21 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<td>#<?= $l['id'] ?></td>
|
<td>#<?= $l['id'] ?></td>
|
||||||
<td>
|
<td>
|
||||||
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
||||||
|
<?php // Clé license affichée en monospace + bouton 1-clic
|
||||||
|
// pour copier dans le presse-papier. Permet à l'admin
|
||||||
|
// de la transmettre rapidement à un client sans passer
|
||||||
|
// par phpMyAdmin. Sensibilité OK : la page admin est
|
||||||
|
// déjà derrière auth + la clé seule ne suffit pas
|
||||||
|
// (validation serveur HMAC + machine_id requis). ?>
|
||||||
|
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
|
||||||
|
<button type="button" class="copy-btn"
|
||||||
|
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
|
||||||
|
title="Copier la clé license"
|
||||||
|
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
|
||||||
|
</div>
|
||||||
<?php if (!empty($l['notes'])): ?>
|
<?php if (!empty($l['notes'])): ?>
|
||||||
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
|
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
||||||
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
|
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
|
||||||
<div style="margin-bottom: 6px;">
|
les éditeurs sont passés dans le modal pour éviter
|
||||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
l'encombrement de la cellule. -->
|
||||||
title="Channel actuellement attribué à cette license">
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
title="Channel actuellement attribué">
|
||||||
</code>
|
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||||
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
</code>
|
||||||
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||||
<?php endif; ?>
|
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
|
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
||||||
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
|
<?php if ($hasLock): ?>
|
||||||
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
|
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
|
||||||
pour trouver l'option. -->
|
<?php endif; ?>
|
||||||
<form method="post" style="display: inline-block; margin-right: 4px;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="toggle_betas">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
|
|
||||||
type="submit" style="font-size: 11px; padding: 4px 10px;"
|
|
||||||
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
|
|
||||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Channel : modifier via un menu compact -->
|
|
||||||
<details style="display: inline-block;">
|
|
||||||
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
|
|
||||||
✎ Channel
|
|
||||||
</summary>
|
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="set_channel">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<select name="channel" style="min-width: 160px;">
|
|
||||||
<?php foreach ($availableChannels as $value => $label):
|
|
||||||
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
|
||||||
?>
|
|
||||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||||
<td style="text-align:right; white-space: nowrap;">
|
<td style="text-align:right; white-space: nowrap;">
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
|
||||||
<summary class="btn btn-secondary">Prolonger</summary>
|
<div class="row-actions">
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
<button type="button" class="btn btn-primary"
|
||||||
|
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||||
|
✎ Modifier
|
||||||
|
</button>
|
||||||
|
<?php if (!$isRevoked): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="revoke">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="unrevoke">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
// Modal d'édition (capturé en buffer, flush après </table>).
|
||||||
|
// Même pattern que versions.php : <dialog> dans <table> est
|
||||||
|
// foster-parented imprévisiblement → on émet hors de la table.
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||||
|
<header>
|
||||||
|
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
|
||||||
|
<button type="button" class="close-x" aria-label="Fermer"
|
||||||
|
onclick="this.closest('dialog').close()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="modal-tabs">
|
||||||
|
<button type="button" data-tab="prolong" class="active">Prolonger</button>
|
||||||
|
<button type="button" data-tab="slots">Slots</button>
|
||||||
|
<button type="button" data-tab="channel">Channel</button>
|
||||||
|
<button type="button" data-tab="beta">BÊTA</button>
|
||||||
|
<button type="button" data-tab="lock">Lock</button>
|
||||||
|
<button type="button" data-tab="contacts">Contacts</button>
|
||||||
|
<button type="button" data-tab="machines">Machines</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- TAB : Prolonger -->
|
||||||
|
<section data-tab="prolong">
|
||||||
|
<p class="section-intro">
|
||||||
|
Repousse la date d'expiration de l'entitlement.
|
||||||
|
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="extend">
|
<input type="hidden" name="action" value="extend">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<input type="date" name="new_until" required style="width: 150px;">
|
<div class="field">
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
<label>Nouvelle date d'expiration</label>
|
||||||
|
<input type="date" name="new_until" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Prolonger</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</section>
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
|
||||||
<summary class="btn btn-secondary">Slots</summary>
|
<!-- TAB : Slots -->
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
<section data-tab="slots" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Nombre maximum de machines pouvant être activées simultanément.
|
||||||
|
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
|
||||||
|
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
Pour réduire en-dessous du nombre actif, libère d'abord les slots
|
||||||
|
via l'onglet « Machines ».
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="set_max_machines">
|
<input type="hidden" name="action" value="set_max_machines">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<input type="number" name="max_machines" min="1" max="100"
|
<div class="field">
|
||||||
value="<?= (int)$l['max_machines'] ?>" required
|
<label>Slots max</label>
|
||||||
style="width: 70px;"
|
<input type="number" name="max_machines" min="1" max="100"
|
||||||
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
|
value="<?= (int)$l['max_machines'] ?>" required>
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</section>
|
||||||
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<!-- TAB : Channel -->
|
||||||
<summary class="btn btn-secondary"
|
<section data-tab="channel" hidden>
|
||||||
title="Mot de passe qui verrouille la section Settings → Avancés du launcher pour cette license. Laisser vide pour retirer le verrouillage.">
|
<p class="section-intro">
|
||||||
<?= $hasLock ? '🔒 Lock' : 'Lock' ?>
|
Quel manifest sert cette license ? « default » = manifest public.
|
||||||
</summary>
|
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
permettent de servir des versions ciblées (police, firefighter, …).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channel">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Channel</label>
|
||||||
|
<select name="channel">
|
||||||
|
<?php foreach ($availableChannels as $value => $label):
|
||||||
|
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : BÊTA -->
|
||||||
|
<section data-tab="beta" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Si activé, le client voit en plus des versions stables toutes les
|
||||||
|
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
|
||||||
|
elles lui sont invisibles (filtrées côté manifest signé).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="toggle_betas">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
|
||||||
|
Voir les versions BÊTA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Lock (settings password) -->
|
||||||
|
<section data-tab="lock" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Mot de passe qui verrouille la section Settings → Avancés du
|
||||||
|
launcher pour cette license. Vide = retirer le verrouillage.
|
||||||
|
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="set_settings_lock_password">
|
<input type="hidden" name="action" value="set_settings_lock_password">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<input type="password" name="settings_lock_password"
|
<div class="field">
|
||||||
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe (≥4)' ?>"
|
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
|
||||||
minlength="0" maxlength="100"
|
<input type="password" name="settings_lock_password"
|
||||||
style="width: 200px;"
|
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
|
||||||
autocomplete="new-password">
|
minlength="0" maxlength="100"
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</section>
|
||||||
<?php if ($l['machines_count'] > 0): ?>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
|
<!-- TAB : Contacts (emails de notification release) -->
|
||||||
<?= Layout::csrfField() ?>
|
<section data-tab="contacts" hidden>
|
||||||
<input type="hidden" name="action" value="reset_machines">
|
<p class="section-intro">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
Liste d'adresses email à notifier quand tu publies une nouvelle
|
||||||
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
|
version de PROSERVE accessible à cette license (matching channel +
|
||||||
</form>
|
entitlement). Format libre : une adresse par ligne, virgules, ou
|
||||||
<?php endif; ?>
|
point-virgules. Adresses invalides ignorées silencieusement.
|
||||||
<?php if (!$isRevoked): ?>
|
</p>
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
<?php
|
||||||
<?= Layout::csrfField() ?>
|
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
|
||||||
<input type="hidden" name="action" value="revoke">
|
// comment c'est stocké en DB (CSV).
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
|
||||||
<button class="btn btn-danger" type="submit">Révoquer</button>
|
$currentDisplay = implode("\n", $currentEmails);
|
||||||
</form>
|
?>
|
||||||
<?php else: ?>
|
<form method="post">
|
||||||
<form method="post" style="display:inline">
|
<?= Layout::csrfField() ?>
|
||||||
<?= Layout::csrfField() ?>
|
<input type="hidden" name="action" value="set_contact_emails">
|
||||||
<input type="hidden" name="action" value="unrevoke">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<div class="field">
|
||||||
<button class="btn btn-secondary" type="submit">Réactiver</button>
|
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
|
||||||
</form>
|
<textarea name="contact_emails" rows="6"
|
||||||
<?php endif; ?>
|
placeholder="ops@asterionvr.com jerome@client.com technicien@partner.fr"
|
||||||
</td>
|
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
|
||||||
</tr>
|
<p class="hint" style="margin: 4px 0 0;">
|
||||||
|
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
|
||||||
|
Vider le champ = retirer toutes les notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Machines -->
|
||||||
|
<section data-tab="machines" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
|
||||||
|
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
La gestion fine machine par machine se fait via le tableau dépliable
|
||||||
|
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
|
||||||
|
</p>
|
||||||
|
<?php if ($l['machines_count'] > 0): ?>
|
||||||
|
<div class="field">
|
||||||
|
<label>Action en bloc</label>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="reset_machines">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// Sous-row dépliable : liste des machines avec remove individuel.
|
// Sous-row dépliable : liste des machines avec remove individuel.
|
||||||
@@ -472,6 +649,14 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
|
<?php // Note : pas de "Déplacer vers une autre license" ici —
|
||||||
|
// ce serait une feature DB-only fragile (le launcher
|
||||||
|
// ré-activerait automatiquement la machine sur sa
|
||||||
|
// license d'origine au prochain validate puisque sa
|
||||||
|
// clé license stockée n'a pas changé). La migration
|
||||||
|
// d'une machine vers une autre license se fait côté
|
||||||
|
// launcher : Settings → License → entrer la nouvelle
|
||||||
|
// clé. ?>
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
|
||||||
|
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||||
|
|
||||||
|
<!-- Handler global pour les onglets des modals (délégation sur document). -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
// 1. Tabs des modals d'édition license
|
||||||
|
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||||
|
if (btn) {
|
||||||
|
var tabName = btn.dataset.tab;
|
||||||
|
var modal = btn.closest('dialog');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
});
|
||||||
|
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||||
|
s.hidden = s.dataset.tab !== tabName;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
|
||||||
|
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
|
||||||
|
var copyBtn = e.target.closest('[data-copy]');
|
||||||
|
if (copyBtn) {
|
||||||
|
var text = copyBtn.dataset.copy || '';
|
||||||
|
var original = copyBtn.textContent;
|
||||||
|
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
|
||||||
|
// Fallback execCommand pour les browsers très anciens.
|
||||||
|
var copyOk = function () {
|
||||||
|
copyBtn.textContent = '✓';
|
||||||
|
copyBtn.style.color = '#16A34A';
|
||||||
|
setTimeout(function () {
|
||||||
|
copyBtn.textContent = original;
|
||||||
|
copyBtn.style.color = '';
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(copyOk, function () {
|
||||||
|
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
|
||||||
|
// browsers sans avoir besoin de la permission Clipboard API.
|
||||||
|
function fallbackCopy(text, onSuccess) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); onSuccess(); }
|
||||||
|
catch (e) { /* silent */ }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php Layout::footer();
|
<?php Layout::footer();
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
require __DIR__ . '/lib/Channels.php';
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
require __DIR__ . '/lib/Mailer.php';
|
||||||
|
require __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
use PSLauncher\Admin\Channels;
|
use PSLauncher\Admin\Channels;
|
||||||
|
use PSLauncher\Admin\Mailer;
|
||||||
|
use PSLauncher\Db;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
$config = require __DIR__ . '/../api/config.php';
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
@@ -97,6 +101,162 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
|
|||||||
return null;
|
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 <c>notifications.logo_url</c>) 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 <pre> 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 !== ''
|
||||||
|
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
|
||||||
|
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
|
||||||
|
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
|
||||||
|
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
|
||||||
|
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
|
||||||
|
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
|
||||||
|
|
||||||
|
// 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 !== ''
|
||||||
|
? "<img src=\"{$logoEsc}\" alt=\"ASTERION VR\" width=\"180\" "
|
||||||
|
. "style=\"display:inline-block; max-height:48px; max-width:240px; height:auto; "
|
||||||
|
. "color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:18px; "
|
||||||
|
. "font-weight:700; text-align:center; line-height:48px;\"/>"
|
||||||
|
: '<span style="color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:22px; font-weight:700;">ASTERION VR</span>';
|
||||||
|
|
||||||
|
// 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 :
|
||||||
|
// • <table bgcolor="#1E40AF"> → fond bleu solide visible dans TOUS les MUA
|
||||||
|
// • Styles inline sur chaque <td> → 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 <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<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;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
|
||||||
|
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#0F172A" align="center"
|
||||||
|
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
|
||||||
|
<div style="margin-bottom:16px;">{$logoBlock}</div>
|
||||||
|
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
|
||||||
|
PROSERVE v{$versionEsc} is available!
|
||||||
|
</h1>
|
||||||
|
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
|
||||||
|
A new version is ready to install via your PROSERVE Launcher.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#FFFFFF"
|
||||||
|
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;">
|
||||||
|
We're happy to let you know that
|
||||||
|
<strong>PROSERVE version {$versionEsc}</strong>
|
||||||
|
has just been published and is now available for download.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 14px;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
|
||||||
|
+ brève instruction. Bordure haut/bas pour le détacher visuellement
|
||||||
|
du paragraphe d'install standard ci-dessus. -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||||
|
style="margin:20px 0; border-top:1px solid #E5E7EB; border-bottom:1px solid #E5E7EB;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 0;">
|
||||||
|
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
|
||||||
|
First install or new workstation?
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 16px; color:#4B5563;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
|
||||||
|
compat Outlook qui ne supporte ni border-radius CSS sur
|
||||||
|
<a> ni padding CSS bien rendu. Centrage via align="center"
|
||||||
|
sur la table wrapper, qui marche dans Outlook ET les MUA
|
||||||
|
modernes (vs `margin:auto` sur table ignoré par Outlook). -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#3B82F6" align="center"
|
||||||
|
style="background-color:#3B82F6; border-radius:6px;">
|
||||||
|
<a href="{$installerUrlEsc}"
|
||||||
|
style="display:inline-block; padding:12px 28px;
|
||||||
|
color:#FFFFFF; text-decoration:none;
|
||||||
|
font-family:'Segoe UI',Arial,sans-serif;
|
||||||
|
font-size:14px; font-weight:600;">
|
||||||
|
⬇ Download PROSERVE Launcher (Windows)
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
|
||||||
|
Direct link:
|
||||||
|
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{$notesBlock}
|
||||||
|
<p style="margin:24px 0 14px;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
Happy training!<br/>
|
||||||
|
<strong>The ASTERION VR team</strong>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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/>
|
||||||
|
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body></html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise un nom de fichier ZIP saisi par l'admin :
|
* Normalise un nom de fichier ZIP saisi par l'admin :
|
||||||
* - strip tout chemin (path traversal défense, basename)
|
* - strip tout chemin (path traversal défense, basename)
|
||||||
@@ -133,8 +293,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$isBeta = isset($_POST['is_beta']);
|
$isBeta = isset($_POST['is_beta']);
|
||||||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||||
|
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
// 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)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||||||
throw new Exception('Date min_license_date invalide.');
|
throw new Exception('Date min_license_date invalide.');
|
||||||
@@ -357,6 +521,127 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
. "\n" . implode("\n", $resign['log']);
|
. "\n" . implode("\n", $resign['log']);
|
||||||
if (!$resign['ok']) $messageType = 'error';
|
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) {
|
} catch (Exception $e) {
|
||||||
$message = $e->getMessage();
|
$message = $e->getMessage();
|
||||||
$messageType = 'error';
|
$messageType = 'error';
|
||||||
@@ -412,16 +697,34 @@ Layout::header('Versions', 'versions');
|
|||||||
<input type="hidden" name="action" value="add">
|
<input type="hidden" name="action" value="add">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version (X.Y.Z)</label>
|
<label>Version (X.Y.Z, ou X.Y.Z.B pour une itération dev)</label>
|
||||||
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
|
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
|
||||||
|
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
|
||||||
</div>
|
</div>
|
||||||
|
<?php
|
||||||
|
// Défauts conventionnels :
|
||||||
|
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
|
||||||
|
// l'entry au moment du release, ça serait absurde d'avoir un
|
||||||
|
// champ vide qu'il faut systématiquement remplir)
|
||||||
|
// • min_license_date = aujourd'hui - 1 an (les licenses sont
|
||||||
|
// valides 1 an, donc une license émise il y a moins d'un an
|
||||||
|
// a accès à cette version par défaut). Modifiable pour les
|
||||||
|
// cas particuliers (release LTS, security patch limité aux
|
||||||
|
// dernières licenses, etc.).
|
||||||
|
$defaultReleasedAt = date('Y-m-d');
|
||||||
|
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
|
||||||
|
?>
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Date de release</label>
|
<label>Date de release</label>
|
||||||
<input type="datetime-local" name="released_at">
|
<input type="date" name="released_at"
|
||||||
|
value="<?= $defaultReleasedAt ?>"
|
||||||
|
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
|
||||||
</div>
|
</div>
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>min_license_date (license requise)</label>
|
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
|
||||||
<input type="date" name="min_license_date" required>
|
<input type="date" name="min_license_date" required
|
||||||
|
value="<?= $defaultMinLicDate ?>"
|
||||||
|
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions');
|
|||||||
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
<form method="post" style="display:inline">
|
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="sync_versions">
|
<input type="hidden" name="action" value="sync_versions">
|
||||||
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
||||||
@@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right; white-space: nowrap;">
|
<td style="text-align: right; white-space: nowrap;">
|
||||||
<?php if ($zipExists && !$hashSkipped): ?>
|
<?php
|
||||||
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
|
// entryId est utilisé partout (boutons row + modal IDs).
|
||||||
<?= Layout::csrfField() ?>
|
$entryId = (string)($v['id'] ?? '');
|
||||||
<input type="hidden" name="action" value="sync_one">
|
$entryIdEsc = htmlspecialchars($entryId);
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
$modalId = 'edit-' . $entryIdEsc;
|
||||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
?>
|
||||||
</form>
|
<div class="row-actions">
|
||||||
<?php endif; ?>
|
<?php if ($zipExists && !$hashSkipped): ?>
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<form method="post" style="display:inline"
|
||||||
<summary class="btn btn-secondary">⚙ Hash</summary>
|
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
|
||||||
<div style="margin-top: 8px; min-width: 280px;">
|
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
|
||||||
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="sync_one">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<input type="hidden" name="force" value="1">
|
||||||
|
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||||
|
✎ Modifier
|
||||||
|
</button>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
|
||||||
|
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="notify_release">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<button class="btn btn-danger" type="submit">✕ Retirer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
|
||||||
|
// — un <dialog> dans un <tr style="display:none"> est exclu du
|
||||||
|
// layout tree par certains browsers, ce qui empêche showModal()
|
||||||
|
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
|
||||||
|
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
|
||||||
|
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||||
|
<header>
|
||||||
|
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
|
||||||
|
<?php if (!empty($v['isBeta'])): ?>
|
||||||
|
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="close-x" aria-label="Fermer"
|
||||||
|
onclick="this.closest('dialog').close()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
|
||||||
|
un seul handler global JS déclaré en bas de page (délégation
|
||||||
|
d'événement sur document → pas besoin de rebind par modal). -->
|
||||||
|
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
|
||||||
|
<button type="button" data-tab="meta" class="active">Méta</button>
|
||||||
|
<button type="button" data-tab="notes">Notes</button>
|
||||||
|
<button type="button" data-tab="beta">BÊTA</button>
|
||||||
|
<button type="button" data-tab="channels">Channels</button>
|
||||||
|
<button type="button" data-tab="advanced">Avancé</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
|
||||||
|
<section data-tab="meta">
|
||||||
|
<p class="section-intro">
|
||||||
|
Date d'expiration mini d'une license pour télécharger cette version,
|
||||||
|
date de release publique, et nom du fichier ZIP côté serveur (utile pour
|
||||||
|
invalider le cache CDN après un re-build).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="edit_meta">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
|
||||||
|
<input type="date" name="min_license_date"
|
||||||
|
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Date de release</label>
|
||||||
|
<?php
|
||||||
|
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
|
||||||
|
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
|
||||||
|
// L'heure n'est pas affichée à l'opérateur — toutes les releases
|
||||||
|
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
|
||||||
|
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
|
||||||
|
?>
|
||||||
|
<input type="date" name="released_at"
|
||||||
|
value="<?= htmlspecialchars($releasedDateOnly) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Renommer le ZIP
|
||||||
|
<span class="muted" style="font-weight: normal;">
|
||||||
|
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||||
|
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Notes -->
|
||||||
|
<section data-tab="notes" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Markdown des release notes de cette version. Affichées dans le dialog
|
||||||
|
d'install côté launcher et accessibles via le menu « Notes de release ».
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="edit_notes">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<div class="field">
|
||||||
|
<textarea name="notes" rows="14"
|
||||||
|
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : BÊTA -->
|
||||||
|
<section data-tab="beta" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Une version BÊTA n'est visible que par les licenses qui ont le flag
|
||||||
|
<code>can_see_betas</code> activé. Les autres clients voient la version
|
||||||
|
comme inexistante.
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_beta">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||||
|
Cette version est une BÊTA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
|
||||||
|
<input type="text" name="beta_notes"
|
||||||
|
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||||
|
placeholder="Tester en priorité…">
|
||||||
|
</div>
|
||||||
|
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Channels -->
|
||||||
|
<section data-tab="channels" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Détermine quelles licenses voient cette version. Coche « default » pour
|
||||||
|
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
|
||||||
|
la page <a href="channels.php">Channels</a>).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channels">
|
||||||
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
|
<div class="field">
|
||||||
|
<div class="checkbox-list">
|
||||||
|
<?php foreach ($channelRegistry['channels'] as $c):
|
||||||
|
$cn = $c['name'] ?? '';
|
||||||
|
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||||||
|
?>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||||||
|
<code><?= htmlspecialchars($cn) ?></code>
|
||||||
|
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="warn">⚠ Re-signe le manifest après modif.</p>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Avancé (skip hash + force re-hash) -->
|
||||||
|
<section data-tab="advanced" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Options de vérification d'intégrité. La sécurité repose sur la signature
|
||||||
|
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
|
||||||
|
utile sur très gros builds pour gagner 30s-3min en fin de DL.
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>Vérification SHA-256 chez les clients</label>
|
||||||
|
<p class="hint" style="margin: 0 0 8px;">
|
||||||
<?php if ($hashSkipped): ?>
|
<?php if ($hashSkipped): ?>
|
||||||
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
|
Actuellement <strong style="color: var(--busy);">désactivée</strong>
|
||||||
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
|
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
|
||||||
uniquement sur la signature Ed25519 du manifest + HTTPS.
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
Désactive la vérif SHA-256 chez les clients pour cette release
|
Actuellement <strong style="color: var(--installed);">activée</strong>
|
||||||
(utile sur très gros builds où on accepte le compromis perf/sécurité).
|
(default sain).
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
<form method="post" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="set_skip_hash">
|
<input type="hidden" name="action" value="set_skip_hash">
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
||||||
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
||||||
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php if ($zipExists): ?>
|
</div>
|
||||||
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
|
<?php if ($zipExists): ?>
|
||||||
|
<div class="field">
|
||||||
|
<label>Forcer un re-hash du ZIP</label>
|
||||||
|
<p class="hint" style="margin: 0 0 8px;">
|
||||||
|
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
|
||||||
|
Utile pour valider une intégrité après transfert SFTP douteux.
|
||||||
|
</p>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="sync_one">
|
<input type="hidden" name="action" value="sync_one">
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||||
<input type="hidden" name="force" value="1">
|
<input type="hidden" name="force" value="1">
|
||||||
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<?php endif; ?>
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
<summary class="btn btn-secondary">Méta</summary>
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
</div>
|
||||||
<?= Layout::csrfField() ?>
|
</section>
|
||||||
<input type="hidden" name="action" value="edit_meta">
|
</div>
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
</dialog>
|
||||||
<div class="field">
|
<?php
|
||||||
<label>min_license_date</label>
|
// Capture le markup du dialog et le pousse dans un buffer global
|
||||||
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
|
||||||
</div>
|
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
|
||||||
<div class="field">
|
// de manière imprévisible par le parser HTML, ce qui peut casser
|
||||||
<label>Date de release (UTC)</label>
|
// showModal() (élément non connecté au layout tree).
|
||||||
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
|
||||||
</div>
|
?>
|
||||||
<div class="field">
|
|
||||||
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
|
|
||||||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
|
||||||
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
|
||||||
<summary class="btn btn-secondary">Notes</summary>
|
|
||||||
<form method="post" style="margin-top: 8px;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="edit_notes">
|
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
||||||
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
|
|
||||||
<br><br>
|
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
|
||||||
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
|
|
||||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="set_beta">
|
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
||||||
<div class="field">
|
|
||||||
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
|
||||||
Cette version est une BÊTA
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Notes pour les testeurs</label>
|
|
||||||
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
|
||||||
placeholder="Tester en priorité…">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
|
||||||
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
|
||||||
<summary class="btn btn-secondary">Channels</summary>
|
|
||||||
<form method="post" style="margin-top: 8px; min-width: 280px;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="set_channels">
|
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
||||||
<div class="field">
|
|
||||||
<?php foreach ($channelRegistry['channels'] as $c):
|
|
||||||
$cn = $c['name'] ?? '';
|
|
||||||
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
|
||||||
?>
|
|
||||||
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;">
|
|
||||||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
|
||||||
<code><?= htmlspecialchars($cn) ?></code>
|
|
||||||
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
|
||||||
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest après modif.</p>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
||||||
<button class="btn btn-danger" type="submit">Retirer</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($manifest['versions'])): ?>
|
<?php if (empty($manifest['versions'])): ?>
|
||||||
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
||||||
@@ -690,6 +1124,11 @@ Layout::header('Versions', 'versions');
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> 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). ?>
|
||||||
|
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>ZIPs présents dans builds/</h2>
|
<h2>ZIPs présents dans builds/</h2>
|
||||||
<?php if (empty($zips)): ?>
|
<?php if (empty($zips)): ?>
|
||||||
@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
|
|||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay busy : visible pendant les opérations longues côté serveur
|
||||||
|
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
|
||||||
|
Affiché en synchrone juste avant le submit du form ; disparaît
|
||||||
|
naturellement au reload de page qui suit la réponse POST. -->
|
||||||
|
<div id="busy-overlay" class="busy-overlay" hidden>
|
||||||
|
<div class="busy-content">
|
||||||
|
<div class="busy-spinner"></div>
|
||||||
|
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
|
||||||
|
<p id="busy-detail">Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.</p>
|
||||||
|
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
|
||||||
|
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handler global pour les onglets des modals d'édition de version.
|
||||||
|
Délégation d'événement sur document → marche pour tous les .modal-tabs,
|
||||||
|
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||||
|
if (!btn) return;
|
||||||
|
var tabName = btn.dataset.tab;
|
||||||
|
var modal = btn.closest('dialog');
|
||||||
|
if (!modal) return;
|
||||||
|
// Active button → toggle .active sur les boutons du même <nav>
|
||||||
|
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
});
|
||||||
|
// Sections → toggle [hidden] selon data-tab
|
||||||
|
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||||
|
s.hidden = s.dataset.tab !== tabName;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
|
||||||
|
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
|
||||||
|
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
|
||||||
|
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
|
||||||
|
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
|
||||||
|
function showBusyOverlay(scope, version) {
|
||||||
|
var overlay = document.getElementById('busy-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
var title = document.getElementById('busy-title');
|
||||||
|
var detail = document.getElementById('busy-detail');
|
||||||
|
if (scope === 'bulk') {
|
||||||
|
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
|
||||||
|
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (3–5 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
|
||||||
|
} else {
|
||||||
|
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
|
||||||
|
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.';
|
||||||
|
}
|
||||||
|
overlay.hidden = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php Layout::footer();
|
<?php Layout::footer();
|
||||||
|
|||||||
@@ -40,4 +40,32 @@ return [
|
|||||||
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
||||||
// puis colle le résultat ci-dessous.
|
// puis colle le résultat ci-dessous.
|
||||||
'admin_password_hash' => '',
|
'admin_password_hash' => '',
|
||||||
|
|
||||||
|
// === NOTIFICATIONS EMAIL (release announce) ===
|
||||||
|
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
|
||||||
|
// contacts d'une license de la sortie d'une nouvelle version.
|
||||||
|
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
|
||||||
|
// OVH mutualisé via le relais SMTP local sans config additionnelle).
|
||||||
|
'notifications' => [
|
||||||
|
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
|
||||||
|
// une adresse hébergée sur ton domaine principal).
|
||||||
|
'from_address' => 'no-reply@asterionvr.com',
|
||||||
|
'from_name' => 'ASTERION VR — PROSERVE Launcher',
|
||||||
|
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
|
||||||
|
// (ex. boîte support partagée). Vide = pas de header Reply-To.
|
||||||
|
'reply_to' => '',
|
||||||
|
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
|
||||||
|
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
|
||||||
|
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
|
||||||
|
// SANS demander la permission « Télécharger les images » au destinataire.
|
||||||
|
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
|
||||||
|
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
|
||||||
|
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
|
||||||
|
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
|
||||||
|
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
|
||||||
|
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
|
||||||
|
// les hotlinks par défaut → le destinataire devra cliquer « afficher
|
||||||
|
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
|
||||||
|
'logo_url' => '',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
|
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
|
||||||
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
|
||||||
|
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/routes/Releasenotes.php';
|
require __DIR__ . '/routes/Releasenotes.php';
|
||||||
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
||||||
return;
|
return;
|
||||||
@@ -64,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/lib/Db.php';
|
require __DIR__ . '/lib/Db.php';
|
||||||
require __DIR__ . '/lib/Crypto.php';
|
require __DIR__ . '/lib/Crypto.php';
|
||||||
require __DIR__ . '/routes/DownloadUrl.php';
|
require __DIR__ . '/routes/DownloadUrl.php';
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ final class DownloadUrl
|
|||||||
{
|
{
|
||||||
public static function handle(array $config, string $version): void
|
public static function handle(array $config, string $version): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
|
||||||
Response::error('invalid_version', 'Version invalide', 400);
|
Response::error('invalid_version', 'Version invalide', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +96,46 @@ final class DownloadUrl
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
|
||||||
|
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
|
||||||
|
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
|
||||||
|
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
|
||||||
|
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
|
||||||
|
// → 404 systématique côté client sans aucune indication serveur.
|
||||||
|
// Maintenant : on lit `download.url` du manifest, on extrait le filename
|
||||||
|
// via parse_url + basename, on vérifie qu'il existe physiquement dans
|
||||||
|
// /builds/, et seulement après on signe.
|
||||||
|
$manifestUrl = $entry['download']['url'] ?? '';
|
||||||
|
if ($manifestUrl === '') {
|
||||||
|
Response::error('manifest_incomplete',
|
||||||
|
"L'entrée manifest pour v{$version} est sans download.url", 500);
|
||||||
|
}
|
||||||
|
$parsedUrl = parse_url($manifestUrl);
|
||||||
|
$urlPath = $parsedUrl['path'] ?? '';
|
||||||
|
$filename = basename($urlPath);
|
||||||
|
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
|
||||||
|
// Évite path traversal et autres injections via un manifest corrompu.
|
||||||
|
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
|
||||||
|
Response::error('manifest_invalid_filename',
|
||||||
|
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
|
||||||
|
}
|
||||||
|
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
|
||||||
|
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
|
||||||
|
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
|
||||||
|
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
|
||||||
|
// serveur claire qui apparaît dans les logs PHP + client.
|
||||||
|
$buildsDir = dirname(__DIR__, 2) . '/builds';
|
||||||
|
$physicalPath = $buildsDir . '/' . $filename;
|
||||||
|
if (!is_file($physicalPath)) {
|
||||||
|
Response::error('file_missing',
|
||||||
|
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
|
||||||
|
500,
|
||||||
|
[
|
||||||
|
'manifestUrl' => $manifestUrl,
|
||||||
|
'expectedPath' => $physicalPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Génère l'URL HMAC-signée
|
// Génère l'URL HMAC-signée
|
||||||
// TTL : 6 h. Compromis entre :
|
// TTL : 6 h. Compromis entre :
|
||||||
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
||||||
@@ -102,7 +143,7 @@ final class DownloadUrl
|
|||||||
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
||||||
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
||||||
$baseUrl = rtrim($config['base_url'], '/');
|
$baseUrl = rtrim($config['base_url'], '/');
|
||||||
$relPath = '/builds/proserve-' . $version . '.zip';
|
$relPath = '/builds/' . $filename;
|
||||||
$exp = time() + 21600; // 6 h
|
$exp = time() + 21600; // 6 h
|
||||||
$secret = $config['hmac_secret'] ?? '';
|
$secret = $config['hmac_secret'] ?? '';
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ final class Releasenotes
|
|||||||
public static function handle(string $key): void
|
public static function handle(string $key): void
|
||||||
{
|
{
|
||||||
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
||||||
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
|
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
|
||||||
|
|
||||||
if (!$isEntryId && !$isVersion) {
|
if (!$isEntryId && !$isVersion) {
|
||||||
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
||||||
|
|||||||
18
server/migrations/004_license_contact_emails.sql
Normal file
18
server/migrations/004_license_contact_emails.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- PS_Launcher schema v4
|
||||||
|
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
|
||||||
|
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
|
||||||
|
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
|
||||||
|
--
|
||||||
|
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
|
||||||
|
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
|
||||||
|
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
|
||||||
|
-- overkill pour une feature de notification opportuniste sans relations.
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
|
||||||
|
|
||||||
|
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
|
||||||
|
-- à notifier → comportement identique à avant. L'admin doit explicitement
|
||||||
|
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».
|
||||||
51
server/releasenotes/1.5.4.md
Normal file
51
server/releasenotes/1.5.4.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# PROSERVE v1.5.4
|
||||||
|
|
||||||
|
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
|
||||||
|
performance improvements across all scenarios.
|
||||||
|
|
||||||
|
This release is packed with new tooling and a major rework of our AI
|
||||||
|
systems. Here's what's new:
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
|
||||||
|
building and tweaking scenarios directly inside PROSERVE.
|
||||||
|
*Still alpha — feedback welcome.*
|
||||||
|
|
||||||
|
- **AI Autonomous Agent — full rework** — smarter decision-making,
|
||||||
|
more believable behaviors, smoother pathfinding and reactions.
|
||||||
|
|
||||||
|
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
|
||||||
|
language. First iteration, expect rapid improvements over the
|
||||||
|
upcoming releases.
|
||||||
|
|
||||||
|
- **PS_Launcher** — new companion tool that simplifies version
|
||||||
|
updates and the launch of PROSERVE on each workstation. Auto-detects
|
||||||
|
new releases, handles install + redists, and surfaces stats and
|
||||||
|
documentation in one place.
|
||||||
|
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
|
||||||
|
|
||||||
|
- **PS_Report** — view, export, and print your mission results and
|
||||||
|
trainee statistics directly from the launcher.
|
||||||
|
|
||||||
|
## 🚀 Gameplay improvements
|
||||||
|
|
||||||
|
- **Aiming** — reduced reaction time and improved shooting precision
|
||||||
|
across all firearms.
|
||||||
|
|
||||||
|
- **Character gaze** — characters now realistically track the
|
||||||
|
trainee and points of interest, adding presence and immersion to
|
||||||
|
every interaction.
|
||||||
|
|
||||||
|
- **Hose behavior (Firefighter edition)** — reworked physics and
|
||||||
|
grip feel for the firehose; more predictable, less jittery, more
|
||||||
|
satisfying to handle.
|
||||||
|
|
||||||
|
## 🌍 Localization
|
||||||
|
|
||||||
|
- **Thai language** added — full UI translation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*As always, your feedback is invaluable. If you spot any issue or
|
||||||
|
have suggestions, reach out to your ASTERION VR account manager.*
|
||||||
@@ -56,16 +56,26 @@ final class SignManifest
|
|||||||
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||||||
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||||||
*
|
*
|
||||||
|
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
|
||||||
|
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
|
||||||
|
* supplantait silencieusement le `--force` du caller — bug subtil parce
|
||||||
|
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
|
||||||
|
* ou force=true), mais ce helper retombait sur sa propre cache lookup
|
||||||
|
* (clé = realpath) → retournait instantanément l'ancien hash sans
|
||||||
|
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
|
||||||
|
* SFTP avec mtime préservé.
|
||||||
|
*
|
||||||
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||||||
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||||||
*/
|
*/
|
||||||
private function getOrComputeSha256(string $path, array &$cache): array
|
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
|
||||||
{
|
{
|
||||||
$size = filesize($path) ?: 0;
|
$size = filesize($path) ?: 0;
|
||||||
$mtime = filemtime($path) ?: 0;
|
$mtime = filemtime($path) ?: 0;
|
||||||
$key = realpath($path) ?: $path;
|
$key = realpath($path) ?: $path;
|
||||||
|
|
||||||
if (isset($cache[$key])
|
if (!$force
|
||||||
|
&& isset($cache[$key])
|
||||||
&& ($cache[$key]['size'] ?? -1) === $size
|
&& ($cache[$key]['size'] ?? -1) === $size
|
||||||
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||||||
&& !empty($cache[$key]['sha256'])) {
|
&& !empty($cache[$key]['sha256'])) {
|
||||||
@@ -192,7 +202,7 @@ final class SignManifest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$r = $this->getOrComputeSha256($zip, $cache);
|
$r = $this->getOrComputeSha256($zip, $cache, $force);
|
||||||
$cacheChanged = true;
|
$cacheChanged = true;
|
||||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||||||
@@ -231,7 +241,7 @@ final class SignManifest
|
|||||||
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||||||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||||||
} else {
|
} else {
|
||||||
$r = $this->getOrComputeSha256($lpath, $cache);
|
$r = $this->getOrComputeSha256($lpath, $cache, $force);
|
||||||
$cacheChanged = true;
|
$cacheChanged = true;
|
||||||
$launcher['download']['sha256'] = $r['sha256'];
|
$launcher['download']['sha256'] = $r['sha256'];
|
||||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
|
|||||||
@@ -70,7 +70,23 @@ foreach ($files as $f) {
|
|||||||
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
||||||
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
||||||
foreach ($statements as $stmt) {
|
foreach ($statements as $stmt) {
|
||||||
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
|
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
|
||||||
|
// de checker si le statement est vide. Sinon : un fichier migration qui
|
||||||
|
// commence par un header de commentaires se retrouve dans le PREMIER
|
||||||
|
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
|
||||||
|
// commence donc par `--`, le check str_starts_with le déclare vide,
|
||||||
|
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
|
||||||
|
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
|
||||||
|
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
|
||||||
|
$lines = preg_split('/\r?\n/', $stmt) ?: [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$t = ltrim($line);
|
||||||
|
if ($t === '' || str_starts_with($t, '--')) continue;
|
||||||
|
$kept[] = $line;
|
||||||
|
}
|
||||||
|
$stmt = trim(implode("\n", $kept));
|
||||||
|
if ($stmt === '') continue;
|
||||||
try {
|
try {
|
||||||
$pdo->exec($stmt);
|
$pdo->exec($stmt);
|
||||||
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop;
|
|||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.ReportTool;
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Core.SteamVr;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -245,6 +246,15 @@ public partial class App : Application
|
|||||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||||
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
||||||
|
|
||||||
|
// Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est
|
||||||
|
// présent dans le ZIP, tue SteamVR + Vive Business Streaming et
|
||||||
|
// fusionne les blocs racine dans le fichier de l'utilisateur
|
||||||
|
// (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer.
|
||||||
|
services.AddSingleton<ISteamVrSettingsDeployer>(sp =>
|
||||||
|
new SteamVrSettingsDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
|
||||||
|
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
|
||||||
|
|
||||||
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
||||||
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
||||||
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
||||||
|
|||||||
@@ -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.28.12</Version>
|
<Version>0.29.10</Version>
|
||||||
<AssemblyVersion>0.28.12.0</AssemblyVersion>
|
<AssemblyVersion>0.29.10.0</AssemblyVersion>
|
||||||
<FileVersion>0.28.12.0</FileVersion>
|
<FileVersion>0.29.10.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>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ using PSLauncher.Core.Health;
|
|||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.ReportTool;
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Core.SteamVr;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IReportToolDeployer _reportDeployer;
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
private readonly IDocToolDeployer _docDeployer;
|
private readonly IDocToolDeployer _docDeployer;
|
||||||
private readonly IApiToolDeployer _apiDeployer;
|
private readonly IApiToolDeployer _apiDeployer;
|
||||||
|
private readonly ISteamVrSettingsDeployer _steamVrDeployer;
|
||||||
private readonly IPeerSourceResolver _peerResolver;
|
private readonly IPeerSourceResolver _peerResolver;
|
||||||
private readonly IZipCacheStore _zipCacheStore;
|
private readonly IZipCacheStore _zipCacheStore;
|
||||||
private readonly ILanCacheServer _lanCacheServer;
|
private readonly ILanCacheServer _lanCacheServer;
|
||||||
@@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private System.Diagnostics.Process? _runningProserve;
|
private System.Diagnostics.Process? _runningProserve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True quand un install vient d'auto-retry suite à un 404 + manifest refresh.
|
||||||
|
/// Évite la boucle infinie : si l'install retry échoue ENCORE avec 404,
|
||||||
|
/// on bascule sur le message "manifest serveur aussi obsolète" au lieu de
|
||||||
|
/// retry indéfiniment. Reseté à false en début d'install et après le retry.
|
||||||
|
/// </summary>
|
||||||
|
private bool _install404RetryConsumed;
|
||||||
|
|
||||||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IReportToolDeployer reportDeployer,
|
IReportToolDeployer reportDeployer,
|
||||||
IDocToolDeployer docDeployer,
|
IDocToolDeployer docDeployer,
|
||||||
IApiToolDeployer apiDeployer,
|
IApiToolDeployer apiDeployer,
|
||||||
|
ISteamVrSettingsDeployer steamVrDeployer,
|
||||||
IPeerSourceResolver peerResolver,
|
IPeerSourceResolver peerResolver,
|
||||||
IZipCacheStore zipCacheStore,
|
IZipCacheStore zipCacheStore,
|
||||||
ILanCacheServer lanCacheServer,
|
ILanCacheServer lanCacheServer,
|
||||||
@@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_reportDeployer = reportDeployer;
|
_reportDeployer = reportDeployer;
|
||||||
_docDeployer = docDeployer;
|
_docDeployer = docDeployer;
|
||||||
_apiDeployer = apiDeployer;
|
_apiDeployer = apiDeployer;
|
||||||
|
_steamVrDeployer = steamVrDeployer;
|
||||||
_peerResolver = peerResolver;
|
_peerResolver = peerResolver;
|
||||||
_zipCacheStore = zipCacheStore;
|
_zipCacheStore = zipCacheStore;
|
||||||
_lanCacheServer = lanCacheServer;
|
_lanCacheServer = lanCacheServer;
|
||||||
@@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||||
row.OpenFolderHandler = OpenVersionFolder;
|
row.OpenFolderHandler = OpenVersionFolder;
|
||||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||||
|
row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
|
||||||
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
||||||
|
|
||||||
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
||||||
@@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purge TOUT pour cette version (state.json + .partial + .zip cache + sidecar
|
||||||
|
/// SHA), afin de forcer un re-DL complet au prochain Install. Utile quand le
|
||||||
|
/// SHA-256 a changé côté serveur et que la source précédente (OVH CDN, peer
|
||||||
|
/// LAN) était obsolète — le cache local du launcher pourrait perpétuer le
|
||||||
|
/// problème en servant aux peers du LAN un ZIP avec sidecar incohérent.
|
||||||
|
///
|
||||||
|
/// Cas d'usage typique : opérateur backoffice qui a rebuild un release (ex.
|
||||||
|
/// v1.5.4) avec nouveau contenu + nouveau SHA, et un PC client qui n'arrive
|
||||||
|
/// pas à re-DL parce que sa source est en cache stale.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ForceFreshDownloadAsync(VersionRowViewModel row)
|
||||||
|
{
|
||||||
|
var confirm = ThemedMessageBox.Show(
|
||||||
|
Strings.MsgForceFreshConfirm(row.Version),
|
||||||
|
Strings.MsgBoxForceFresh,
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
// Si un DL est actif sur cette même row, on le cancel proprement avant
|
||||||
|
// de purger (même pattern que RestartFromZeroAsync).
|
||||||
|
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
|
||||||
|
{
|
||||||
|
_activeDownloadCts.Cancel();
|
||||||
|
if (_activeInstallTask is { } running && !running.IsCompleted)
|
||||||
|
{
|
||||||
|
StatusMessage = "Annulation en cours…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||||
|
if (!ReferenceEquals(awaitTask, running))
|
||||||
|
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding with purge anyway");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during force-fresh"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Purge state.json + .partial (ces deux sont DownloadStateStore-only)
|
||||||
|
_downloadManager.DiscardResumableState(row.Version);
|
||||||
|
|
||||||
|
// 2. Purge le cache LAN (.zip + .sha256 sidecar). Best-effort : si un
|
||||||
|
// autre process garde un handle (rare : LanCacheServer en cours de
|
||||||
|
// serve), on log warning et on continue.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not fully invalidate cache for v{Version} during force-fresh", row.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reset UI state pour que la row reprenne "Installer" propre.
|
||||||
|
row.ResumableBytes = 0;
|
||||||
|
row.State = VersionRowState.AvailableIdle;
|
||||||
|
row.ProgressDetail = null;
|
||||||
|
row.ProgressPercent = 0;
|
||||||
|
StatusMessage = null;
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
_logger.LogInformation("Force-fresh download purge completed for v{Version}", row.Version);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------ Per-row actions ------------
|
// ------------ Per-row actions ------------
|
||||||
|
|
||||||
private void LaunchVersion(VersionRowViewModel row)
|
private void LaunchVersion(VersionRowViewModel row)
|
||||||
@@ -1170,6 +1246,60 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
|
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
|
||||||
/// Les clés vides sont skippées silencieusement.
|
/// Les clés vides sont skippées silencieusement.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Construit la liste finale des processus à terminer avant le merge SteamVR.
|
||||||
|
/// Source principale : les health checks de type "Process" (= les programmes
|
||||||
|
/// que l'opérateur surveille sur le bandeau de santé = ceux qu'il sait
|
||||||
|
/// présents sur sa machine VR). On y ajoute systématiquement les processus
|
||||||
|
/// "guardian" (VIVE Business Streaming, RR*, etc.) qui relancent SteamVR
|
||||||
|
/// même si l'opérateur ne les a pas dans ses health checks, puis on ordonne
|
||||||
|
/// pour que les guardians soient tués EN PREMIER.
|
||||||
|
/// </summary>
|
||||||
|
private List<string> BuildSteamVrProcessKillList()
|
||||||
|
{
|
||||||
|
// 1) Processus configurés dans les health checks (case-insensitive).
|
||||||
|
var fromHealth = _config.HealthChecks.Checks
|
||||||
|
.Where(c => string.Equals(c.Kind, "Process", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(c => (c.Target ?? string.Empty).Trim())
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
|
.Select(StripExeSuffix);
|
||||||
|
|
||||||
|
// 2) Fallback / défense : la liste config (qui contient VBS + SteamVR
|
||||||
|
// canoniques par défaut). Si l'opérateur n'a rien configuré en health
|
||||||
|
// checks, on aura quand même de quoi tuer ce qui faut.
|
||||||
|
var fromConfig = _config.SteamVr.ProcessesToKill
|
||||||
|
.Select(t => (t ?? string.Empty).Trim())
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
|
.Select(StripExeSuffix);
|
||||||
|
|
||||||
|
// 3) Dédup case-insensitive en préservant l'ORDRE D'INSERTION : on
|
||||||
|
// insère d'abord les "guardians" connus (VBS, RR*, Htc*), puis les
|
||||||
|
// health checks, puis le reste de la config. Ainsi VBS est garanti
|
||||||
|
// tué en premier (sinon il relance SteamVR avant qu'on touche au fichier).
|
||||||
|
var guardians = new[]
|
||||||
|
{
|
||||||
|
"VIVE Business Streaming",
|
||||||
|
"RRConsole",
|
||||||
|
"RRServer",
|
||||||
|
"HtcConnectionUtility",
|
||||||
|
};
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var name in guardians.Concat(fromHealth).Concat(fromConfig))
|
||||||
|
{
|
||||||
|
if (seen.Add(name)) result.Add(name);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripExeSuffix(string name)
|
||||||
|
{
|
||||||
|
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return name[..^4].Trim();
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
|
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
|
||||||
{
|
{
|
||||||
var list = new List<string>();
|
var list = new List<string>();
|
||||||
@@ -1200,6 +1330,26 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
return list.ToArray();
|
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>
|
/// <summary>
|
||||||
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
|
/// 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
|
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
|
||||||
@@ -1314,6 +1464,13 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_activeDownloadCts = new CancellationTokenSource();
|
_activeDownloadCts = new CancellationTokenSource();
|
||||||
var ct = _activeDownloadCts.Token;
|
var ct = _activeDownloadCts.Token;
|
||||||
|
|
||||||
|
// Note : on NE reset PAS _install404RetryConsumed ici. Le retry après
|
||||||
|
// 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette
|
||||||
|
// méthode — si on resetait, on aurait potentiellement une boucle
|
||||||
|
// infinie 404→retry→404→retry. Le flag est seulement remis à false
|
||||||
|
// dans le `finally` du HAUT (= install qui n'est PAS un retry) ou
|
||||||
|
// après un install réussi.
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Si reprise d'un DL interrompu, on saute la popup de release notes :
|
// Si reprise d'un DL interrompu, on saute la popup de release notes :
|
||||||
@@ -1370,7 +1527,37 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||||
var urlString = signed ?? row.Remote.Download.Url;
|
var urlString = signed ?? row.Remote.Download.Url;
|
||||||
url = new Uri(urlString);
|
url = new Uri(urlString);
|
||||||
|
|
||||||
|
// ★ Détection serveur-side bug : si l'URL signée pointe vers un
|
||||||
|
// nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint
|
||||||
|
// /download-url cassé (construit l'URL depuis un template hardcodé
|
||||||
|
// au lieu de lire le manifest). Continuer le DL est garanti de
|
||||||
|
// donner 404 → on remonte une erreur claire AVANT de DL 14 Go.
|
||||||
|
// Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait
|
||||||
|
// `proserve-{version}.zip` au lieu de basename(manifest.download.url).
|
||||||
|
if (signed is not null)
|
||||||
|
{
|
||||||
|
var signedFilename = Path.GetFileName(url.AbsolutePath);
|
||||||
|
var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
|
||||||
|
if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.",
|
||||||
|
signedFilename, manifestFilename);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " +
|
||||||
|
$"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " +
|
||||||
|
$"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer
|
||||||
|
// avec l'URL après un éventuel refresh manifest pour détecter le
|
||||||
|
// cas "le manifest serveur est aussi obsolète" (les deux URLs sont
|
||||||
|
// identiques alors qu'on attendait un changement).
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Install v{Version}: download URL resolved to {Url} (source: {Source})",
|
||||||
|
row.Version, url, _currentPeerHost ?? "OVH");
|
||||||
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
||||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
||||||
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
||||||
@@ -1506,6 +1693,127 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled
|
||||||
|
// dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance
|
||||||
|
// sinon) et fusionne les blocs racine dans le fichier de l'utilisateur.
|
||||||
|
// Best-effort : si SteamVR n'est pas installé ou si le merge échoue,
|
||||||
|
// on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner
|
||||||
|
// sans, juste sans le mapping tracker custom.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai
|
||||||
|
// merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce
|
||||||
|
// stade, aucun fichier touché. Bénéfice : sur un PC où la conf
|
||||||
|
// SteamVR est déjà conforme à la release (cas typique d'une
|
||||||
|
// ré-install ou d'un PC mis-à-jour récemment), on saute TOUT
|
||||||
|
// le processus de fermeture sans déranger l'opérateur.
|
||||||
|
var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct);
|
||||||
|
switch (check.Outcome)
|
||||||
|
{
|
||||||
|
case SteamVrMergeCheckOutcome.NoSourceFile:
|
||||||
|
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
case SteamVrMergeCheckOutcome.DisabledByConfig:
|
||||||
|
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
case SteamVrMergeCheckOutcome.SteamNotFound:
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||||||
|
row.Version);
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
case SteamVrMergeCheckOutcome.UpToDate:
|
||||||
|
// ★ Cas optimal : la conf est déjà alignée → on ne ferme
|
||||||
|
// PAS SteamVR/VBS, on ne montre PAS de popup, on log juste.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge",
|
||||||
|
row.Version, check.TargetPath);
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
case SteamVrMergeCheckOutcome.InvalidJson:
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"),
|
||||||
|
Strings.MsgBoxSteamVrMergeFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
case SteamVrMergeCheckOutcome.Needed:
|
||||||
|
// On continue vers le pop-up + kill + merge.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ÉTAPE 2 — Construit la liste des processus à tuer. Source :
|
||||||
|
// les health checks de type "Process" configurés par l'opérateur.
|
||||||
|
// C'est lui qui sait quelle VR runtime tourne sur la machine ;
|
||||||
|
// on lui fait confiance plutôt que de hardcoder une liste obsolète.
|
||||||
|
//
|
||||||
|
// Sécurité : on s'assure que VIVE Business Streaming et SteamVR
|
||||||
|
// sont DANS la liste (sinon le merge échouera car SteamVR garde
|
||||||
|
// son fichier ouvert). On dédup case-insensitive et on ordonne
|
||||||
|
// VBS en tête (autres "guardians" qui relancent SteamVR aussi).
|
||||||
|
var killList = BuildSteamVrProcessKillList();
|
||||||
|
|
||||||
|
// ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des
|
||||||
|
// programmes à fermer + nombre de blocs qui changeront.
|
||||||
|
if (killList.Count > 0)
|
||||||
|
{
|
||||||
|
var listPreview = string.Join("\n • ", killList.Take(10));
|
||||||
|
if (killList.Count > 10) listPreview += $"\n • … (+{killList.Count - 10})";
|
||||||
|
var ok = ThemedMessageBox.Show(
|
||||||
|
Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange),
|
||||||
|
Strings.MsgSteamVrPreFlightTitle,
|
||||||
|
MessageBoxButton.OKCancel, MessageBoxImage.Information,
|
||||||
|
MessageBoxResult.OK);
|
||||||
|
if (ok != MessageBoxResult.OK)
|
||||||
|
{
|
||||||
|
// Annulé par l'opérateur : on skip le merge mais on
|
||||||
|
// continue l'install (les blocs SteamVR ne seront pas
|
||||||
|
// mis à jour ; l'opérateur le sait).
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User cancelled SteamVR merge phase, skipping merge but continuing install");
|
||||||
|
goto AfterSteamVrMerge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = Strings.StatusDeployingSteamVr(row.Version);
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct);
|
||||||
|
switch (svResult.Status)
|
||||||
|
{
|
||||||
|
case SteamVrMergeStatus.Merged:
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})",
|
||||||
|
row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath);
|
||||||
|
break;
|
||||||
|
case SteamVrMergeStatus.SkippedNoSourceFile:
|
||||||
|
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||||||
|
break;
|
||||||
|
case SteamVrMergeStatus.SkippedDisabledByConfig:
|
||||||
|
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||||||
|
break;
|
||||||
|
case SteamVrMergeStatus.SteamNotFound:
|
||||||
|
// Pas de popup — beaucoup de machines de dev n'ont pas Steam,
|
||||||
|
// pas la peine de spammer l'opérateur.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||||||
|
row.Version);
|
||||||
|
break;
|
||||||
|
case SteamVrMergeStatus.InvalidJson:
|
||||||
|
case SteamVrMergeStatus.Failed:
|
||||||
|
// Popup quand-même : c'est probablement un bug à investiguer
|
||||||
|
// (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.).
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"),
|
||||||
|
Strings.MsgBoxSteamVrMergeFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)");
|
||||||
|
}
|
||||||
|
AfterSteamVrMerge:
|
||||||
|
|
||||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||||
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||||
if (_config.Database.AutoApplyMigrations)
|
if (_config.Database.AutoApplyMigrations)
|
||||||
@@ -1697,6 +2005,9 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
|
// Reset le flag retry — un futur install (même version ou autre)
|
||||||
|
// a droit à sa propre tentative de retry sur 404.
|
||||||
|
_install404RetryConsumed = false;
|
||||||
RebuildList();
|
RebuildList();
|
||||||
_toastService.ShowSuccess(
|
_toastService.ShowSuccess(
|
||||||
Strings.ToastInstallSuccessTitle,
|
Strings.ToastInstallSuccessTitle,
|
||||||
@@ -1713,16 +2024,195 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
// valeur d'avant le DL (souvent 0% ou l'ancien %).
|
// valeur d'avant le DL (souvent 0% ou l'ancien %).
|
||||||
RefreshResumableBytes(row);
|
RefreshResumableBytes(row);
|
||||||
}
|
}
|
||||||
|
catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Cas spécifique : SHA-256 mismatch après DL. Causes typiques :
|
||||||
|
// • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas
|
||||||
|
// encore expiré (TTL 24h-72h) suite à un re-build serveur
|
||||||
|
// • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur
|
||||||
|
// • Le manifest a été modifié manuellement sans re-build du ZIP
|
||||||
|
//
|
||||||
|
// Actions automatiques :
|
||||||
|
// 1. Purge le cache local pour cette version (le ZIP que NOUS
|
||||||
|
// avions cached avant cette tentative pourrait être obsolète
|
||||||
|
// lui aussi ; mieux vaut tout vider pour ne pas servir du
|
||||||
|
// contenu pourri aux autres peers du LAN)
|
||||||
|
// 2. Affiche un message d'erreur SPÉCIFIQUE avec la source du
|
||||||
|
// DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer
|
||||||
|
// 3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer
|
||||||
|
// après que le cache CDN OVH ait expiré
|
||||||
|
_logger.LogError(shaEx,
|
||||||
|
"SHA-256 verify failed for v{Version} (source: {Source})",
|
||||||
|
row.Version, _currentPeerHost ?? "OVH");
|
||||||
|
row.State = VersionRowState.AvailableIdle;
|
||||||
|
row.ProgressDetail = null;
|
||||||
|
row.ProgressPercent = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception purgeEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version);
|
||||||
|
}
|
||||||
|
var sourceLabel = _currentPeerHost is null
|
||||||
|
? Strings.SourceOvh
|
||||||
|
: Strings.SourcePeer(_currentPeerHost);
|
||||||
|
// 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);
|
||||||
|
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch);
|
||||||
|
RefreshResumableBytes(row);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Install failed for {Version}", row.Version);
|
_logger.LogError(ex, "Install failed for {Version}", row.Version);
|
||||||
row.State = VersionRowState.AvailableIdle;
|
row.State = VersionRowState.AvailableIdle;
|
||||||
row.ProgressDetail = null;
|
row.ProgressDetail = null;
|
||||||
row.ProgressPercent = 0;
|
row.ProgressPercent = 0;
|
||||||
ThemedMessageBox.Show(
|
|
||||||
Strings.MsgInstallFailed(ex.Message),
|
// Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
// sur le serveur (typiquement après un re-upload pour buster le cache
|
||||||
StatusMessage = Strings.StatusError(ex.Message);
|
// CDN OVH). Le manifest local référence l'ancienne URL → la DL fail
|
||||||
|
// en 404.
|
||||||
|
//
|
||||||
|
// Stratégie en 2 temps :
|
||||||
|
// 1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install
|
||||||
|
// transparent (l'opérateur ne voit rien d'autre qu'un message
|
||||||
|
// "rafraîchissement…" puis la nouvelle DL qui démarre).
|
||||||
|
// 2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME
|
||||||
|
// est obsolète (le rename a été fait au filesystem sans MAJ
|
||||||
|
// du manifest serveur). Message clair pour que l'opérateur
|
||||||
|
// sache que c'est un problème côté serveur, pas client.
|
||||||
|
bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (is404 && !_install404RetryConsumed)
|
||||||
|
{
|
||||||
|
// PREMIER 404 : on tente le refresh + retry une fois.
|
||||||
|
_install404RetryConsumed = true;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install",
|
||||||
|
row.Version);
|
||||||
|
|
||||||
|
// Capture l'URL actuelle pour diagnostic (avant refresh).
|
||||||
|
var urlBefore = row.Remote?.Download.Url;
|
||||||
|
|
||||||
|
bool refreshOk = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
|
refreshOk = true;
|
||||||
|
}
|
||||||
|
catch (Exception refreshEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupère la row fraîche depuis le rebuild (l'instance peut
|
||||||
|
// avoir changé) pour relire la nouvelle URL du manifest.
|
||||||
|
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
|
||||||
|
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
|
||||||
|
var urlAfter = freshRow?.Remote?.Download.Url;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
|
||||||
|
row.Version, urlBefore, urlAfter);
|
||||||
|
|
||||||
|
if (refreshOk && freshRow is not null && freshRow.Remote is not null
|
||||||
|
&& !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// L'URL a changé → le manifest s'est bien rafraîchi avec une
|
||||||
|
// nouvelle URL pointant vers le fichier renommé. Auto-retry
|
||||||
|
// l'install. On déclenche via la même mécanique que le clic
|
||||||
|
// Install (SafeInstallAsync) pour bénéficier du try/catch
|
||||||
|
// global et de la mécanique standard.
|
||||||
|
StatusMessage = Strings.StatusAutoRetryAfter404(row.Version);
|
||||||
|
// ⚠ on libère les flags du finally en avance pour que le
|
||||||
|
// retry puisse re-prendre _activeRow / IsBusy. Le finally
|
||||||
|
// s'exécutera quand même mais idempotent (déjà null).
|
||||||
|
_activeDownloadCts?.Dispose();
|
||||||
|
_activeDownloadCts = null;
|
||||||
|
_activeRow = null;
|
||||||
|
_activeInstallTask = null;
|
||||||
|
_currentPeerHost = null;
|
||||||
|
IsBusy = false;
|
||||||
|
IsDownloadActive = false;
|
||||||
|
// Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions.
|
||||||
|
_activeInstallTask = SafeInstallAsync(freshRow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL identique après refresh OU refresh échoué → tomber dans
|
||||||
|
// le message "manifest serveur stale" (cas 2).
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale",
|
||||||
|
row.Version);
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgServerSideStaleBody(row.Version),
|
||||||
|
Strings.MsgBoxStaleManifest,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
StatusMessage = Strings.StatusManifestRefreshed;
|
||||||
|
}
|
||||||
|
else if (is404)
|
||||||
|
{
|
||||||
|
// DEUXIÈME 404 dans la même session install (déjà retried).
|
||||||
|
// On reset le flag pour que la prochaine action user manuelle
|
||||||
|
// (autre clic Install) reparte avec une tentative de retry fraîche.
|
||||||
|
_install404RetryConsumed = false;
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgServerSideStaleBody(row.Version),
|
||||||
|
Strings.MsgBoxStaleManifest,
|
||||||
|
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(
|
||||||
|
Strings.MsgInstallFailed(ex.Message),
|
||||||
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
|
}
|
||||||
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
|
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
|
||||||
// Idem : si une erreur réseau interrompt le DL en cours de route, le
|
// Idem : si une erreur réseau interrompt le DL en cours de route, le
|
||||||
// partial est plus gros qu'avant — il faut refléter le nouveau %.
|
// partial est plus gros qu'avant — il faut refléter le nouveau %.
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
= new();
|
= new();
|
||||||
public bool HasApiBackups => ApiBackups.Count > 0;
|
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||||
|
|
||||||
|
// ----- SteamVR settings merge -----
|
||||||
|
[ObservableProperty] private bool _steamVrAutoMerge;
|
||||||
|
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
|
||||||
|
/// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
|
||||||
|
[ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Path canonique attendu si Steam est installé à l'emplacement par défaut.
|
||||||
|
/// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble
|
||||||
|
/// la résolution standard et n'ait pas à le deviner / chercher dans la doc.
|
||||||
|
/// </summary>
|
||||||
|
public string SteamVrCanonicalPath =>
|
||||||
|
PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;
|
||||||
|
|
||||||
// ----- Cache LAN P2P -----
|
// ----- Cache LAN P2P -----
|
||||||
[ObservableProperty] private bool _lanServerEnabled;
|
[ObservableProperty] private bool _lanServerEnabled;
|
||||||
[ObservableProperty] private bool _lanClientEnabled;
|
[ObservableProperty] private bool _lanClientEnabled;
|
||||||
@@ -257,6 +270,12 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
_apiMaxBackups = config.ApiTool.MaxBackups;
|
||||||
|
|
||||||
|
_steamVrAutoMerge = config.SteamVr.AutoMerge;
|
||||||
|
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
|
||||||
|
_steamVrProcessesToKill = string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
config.SteamVr.ProcessesToKill ?? new List<string>());
|
||||||
|
|
||||||
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
|
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
|
||||||
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
|
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
|
||||||
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
|
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
|
||||||
@@ -429,6 +448,23 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
||||||
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
||||||
|
|
||||||
|
_config.SteamVr.AutoMerge = SteamVrAutoMerge;
|
||||||
|
_config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
|
||||||
|
? null : SteamVrSettingsPathOverride.Trim();
|
||||||
|
_config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
|
||||||
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(l =>
|
||||||
|
{
|
||||||
|
var name = l.Trim();
|
||||||
|
// Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
|
||||||
|
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
name = name[..^4].Trim();
|
||||||
|
return name;
|
||||||
|
})
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
|
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
|
||||||
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
|
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
|
||||||
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
|
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
||||||
|
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
||||||
private void Launch() => LaunchHandler?.Invoke(this);
|
private void Launch() => LaunchHandler?.Invoke(this);
|
||||||
@@ -231,6 +232,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
||||||
private bool CanRestartFromZero() => HasResumableDownload;
|
private bool CanRestartFromZero() => HasResumableDownload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
|
||||||
|
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
|
||||||
|
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
|
||||||
|
/// sidecar SHA), même quand il n'y a pas de partial en cours.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes)
|
||||||
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,8 @@
|
|||||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
@@ -631,6 +633,8 @@
|
|||||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
|||||||
@@ -650,6 +650,52 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- SteamVR : merge _steamvr/steamvr.vrsettings → fichier user à l'install -->
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVr}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding SteamVrAutoMerge}"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsSteamVrAutoMerge}" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrSettingsPath}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding SteamVrSettingsPathOverride, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
<!-- Affiche le path canonique attendu si Steam est à l'emplacement
|
||||||
|
par défaut. Évite à l'opérateur d'avoir à chercher dans la doc. -->
|
||||||
|
<TextBlock FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap">
|
||||||
|
<Run Text="Auto-détection standard : " />
|
||||||
|
<Run Text="{Binding SteamVrCanonicalPath, Mode=OneWay}"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrProcesses}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding SteamVrProcessesToKill, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
MinHeight="120" MaxHeight="200"
|
||||||
|
VerticalScrollBarVisibility="Auto" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
|
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
|||||||
@@ -45,9 +45,20 @@ public sealed class ApiToolDeployer : IApiToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
try
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
try
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
@@ -312,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
|
// 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.
|
// 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.
|
// 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);
|
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
var reporterTask = Task.Run(async () =>
|
var reporterTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var lastReport = TimeSpan.Zero;
|
|
||||||
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
|
|
||||||
var lastFlush = TimeSpan.Zero;
|
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
|
try
|
||||||
{
|
{
|
||||||
while (!reporterCts.IsCancellationRequested)
|
while (!reporterCts.IsCancellationRequested)
|
||||||
@@ -327,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
||||||
var snapshot = Interlocked.Read(ref aggregateBytes);
|
var snapshot = Interlocked.Read(ref aggregateBytes);
|
||||||
var elapsed = sw.Elapsed;
|
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;
|
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));
|
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
|
||||||
lastReport = elapsed;
|
|
||||||
lastReportBytes = snapshot;
|
|
||||||
|
|
||||||
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
||||||
@@ -455,16 +478,84 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
|
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
|
||||||
// On force un refresh d'URL via le callback du job, puis on jette une
|
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
|
||||||
// exception transitoire pour que Polly retry avec la nouvelle URL.
|
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
|
||||||
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
|
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
|
||||||
|
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
|
||||||
|
// vers le fichier actuellement présent sur le serveur. Si refresh donne
|
||||||
|
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
|
||||||
|
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
|
||||||
|
//
|
||||||
|
// Note importante : on compare à `url` (la valeur utilisée dans la
|
||||||
|
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
|
||||||
|
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
|
||||||
|
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
|
||||||
|
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
|
||||||
|
// (= URL réellement utilisée par CETTE requête, OLD) détecte
|
||||||
|
// correctement que _currentUrl est maintenant NEW → retry. Si on
|
||||||
|
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
|
||||||
|
// 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.RequestedRangeNotSatisfiable;
|
||||||
|
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
|
||||||
{
|
{
|
||||||
if (job.RefreshUrlAsync is not null)
|
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||||
|
bool urlChanged = !Uri.Equals(url, _currentUrl);
|
||||||
|
if (urlChanged)
|
||||||
{
|
{
|
||||||
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
_logger.LogInformation(
|
||||||
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
|
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
|
||||||
|
(int)resp.StatusCode, seg.Index, url, _currentUrl);
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
|
||||||
|
isTransient: true);
|
||||||
}
|
}
|
||||||
|
// URL unchanged → soit le serveur insiste sur la même URL morte,
|
||||||
|
// soit le refresh a fallback sur le manifest cached (donc stale).
|
||||||
|
// 404 non-transient pour arrêter Polly et remonter une erreur claire
|
||||||
|
// qui guide l'opérateur vers « Vérifier les MAJ ».
|
||||||
|
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"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);
|
||||||
}
|
}
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
|
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
|
||||||
|
|
||||||
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
|
// 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).
|
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
|
||||||
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
// 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 static partial Regex VersionFolderRegex();
|
||||||
|
|
||||||
private readonly ILogger<InstallationRegistry> _logger;
|
private readonly ILogger<InstallationRegistry> _logger;
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
namespace PSLauncher.Core.Installations;
|
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)
|
public static SemVer Parse(string s)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
|
||||||
|
|
||||||
var parts = s.Split('.');
|
var parts = s.Split('.');
|
||||||
if (parts.Length != 3
|
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|
||||||
|| !int.TryParse(parts[0], out var maj)
|
// 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[1], out var min)
|
||||||
|| !int.TryParse(parts[2], out var pat))
|
|| !int.TryParse(parts[2], out var pat))
|
||||||
{
|
{
|
||||||
return new SemVer(0, 0, 0);
|
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)
|
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;
|
if (c != 0) return c;
|
||||||
c = Minor.CompareTo(other.Minor);
|
c = Minor.CompareTo(other.Minor);
|
||||||
if (c != 0) return c;
|
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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
|
|||||||
{
|
{
|
||||||
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Si la version est DÉJÀ installée (cas typique : ré-installation
|
||||||
|
// d'un build sans bumper le numéro de version pendant des tests),
|
||||||
|
// on renomme l'existant en backup horodaté pour ne pas bloquer
|
||||||
|
// l'install. Le backup est supprimé en arrière-plan après que le
|
||||||
|
// swap soit confirmé (sinon on doublerait la conso disque le temps
|
||||||
|
// de la copie). Si le swap échoue, on restaure le backup.
|
||||||
|
string? renamedBackup = null;
|
||||||
if (Directory.Exists(targetFolder))
|
if (Directory.Exists(targetFolder))
|
||||||
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
|
{
|
||||||
|
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
||||||
|
Directory.Move(targetFolder, renamedBackup);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Existing install at {Target} renamed to {Backup} before re-install",
|
||||||
|
targetFolder, renamedBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(stagingFolder, targetFolder);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback : restaure le backup si on a échoué après le rename
|
||||||
|
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(renamedBackup, targetFolder);
|
||||||
|
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
|
||||||
|
}
|
||||||
|
catch (Exception restoreEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(restoreEx,
|
||||||
|
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
Directory.Move(stagingFolder, targetFolder);
|
|
||||||
_logger.LogInformation("Installed to {Target}", targetFolder);
|
_logger.LogInformation("Installed to {Target}", targetFolder);
|
||||||
|
|
||||||
|
// Cleanup background du backup (14 Go d'install × N réinstallations
|
||||||
|
// remplirait le disque sinon). Best-effort : si on ne peut pas
|
||||||
|
// delete (handle Windows tenace, AV qui scanne), on log et on
|
||||||
|
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
|
||||||
|
if (renamedBackup is not null)
|
||||||
|
{
|
||||||
|
var bakToDelete = renamedBackup;
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(bakToDelete, recursive: true);
|
||||||
|
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Could not delete old install backup {Backup} — operator can remove manually",
|
||||||
|
bakToDelete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return targetFolder;
|
return targetFolder;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -45,4 +45,20 @@ public interface IZipCacheStore
|
|||||||
|
|
||||||
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
||||||
IReadOnlyList<CachedZip> ListCached();
|
IReadOnlyList<CachedZip> ListCached();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache
|
||||||
|
/// local, peu importe son âge ou si la version est encore installée. Utilisé
|
||||||
|
/// dans deux cas :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de
|
||||||
|
/// la version concernée pour ne pas servir du contenu obsolète aux peers
|
||||||
|
/// LAN (et pour forcer un re-DL complet au prochain Install).</item>
|
||||||
|
/// <item>Action manuelle "Forcer re-téléchargement" depuis le menu UI :
|
||||||
|
/// l'opérateur veut purger explicitement (typiquement quand la source
|
||||||
|
/// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Best-effort : log les erreurs I/O mais ne throw pas.
|
||||||
|
/// </summary>
|
||||||
|
Task InvalidateAsync(string version, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore
|
|||||||
}, ct).ConfigureAwait(false);
|
}, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task InvalidateAsync(string version, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var zipPath = GetCachedZipPath(version);
|
||||||
|
var shaPath = GetShaPath(version);
|
||||||
|
bool zipDeleted = false, shaDeleted = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
|
||||||
|
version, zipPath);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
|
||||||
|
version, shaPath);
|
||||||
|
}
|
||||||
|
if (zipDeleted || shaDeleted)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
|
||||||
|
version, zipDeleted, shaDeleted);
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<CachedZip> ListCached()
|
public IReadOnlyList<CachedZip> ListCached()
|
||||||
{
|
{
|
||||||
var dir = _downloadStore.GetDownloadsDirectory();
|
var dir = _downloadStore.GetDownloadsDirectory();
|
||||||
|
|||||||
@@ -311,6 +311,107 @@ public static class Strings
|
|||||||
$"فشل التنزيل أو التثبيت:\n\n{detail}"
|
$"فشل التنزيل أو التثبيت:\n\n{detail}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- SHA-256 mismatch après DL : message ciblé avec source + action ----
|
||||||
|
public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH(公共 CDN)", "OVH (CDN สาธารณะ)", "OVH (CDN عام)");
|
||||||
|
public static string SourcePeer(string host) => T(
|
||||||
|
$"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »",
|
||||||
|
$"LAN peer « {host} »", $"عقدة LAN « {host} »");
|
||||||
|
public static string MsgBoxShaMismatch => T(
|
||||||
|
"Fichier téléchargé corrompu ou obsolète",
|
||||||
|
"Downloaded file corrupted or outdated",
|
||||||
|
"下载的文件已损坏或过时",
|
||||||
|
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
|
||||||
|
"الملف المنزّل تالف أو قديم"
|
||||||
|
);
|
||||||
|
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",
|
||||||
|
$"⚠ v{version}: SHA-256 invalid — cache purged, retry",
|
||||||
|
$"⚠ v{version}:SHA-256 无效 — 缓存已清除,请重试",
|
||||||
|
$"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่",
|
||||||
|
$"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة"
|
||||||
|
);
|
||||||
|
// ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ----
|
||||||
|
public static string MsgBoxStaleManifest => T(
|
||||||
|
"Manifest local obsolète",
|
||||||
|
"Local manifest outdated",
|
||||||
|
"本地清单已过时",
|
||||||
|
"Manifest ในเครื่องล้าสมัย",
|
||||||
|
"البيان المحلي قديم"
|
||||||
|
);
|
||||||
|
public static string MsgStaleManifestBody(string version) => T(
|
||||||
|
$"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.",
|
||||||
|
$"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.",
|
||||||
|
$"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"安装\" 以使用新 URL 重试。",
|
||||||
|
$"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"ติดตั้ง\" อีกครั้งเพื่อลองด้วย URL ใหม่",
|
||||||
|
$"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد."
|
||||||
|
);
|
||||||
|
public static string StatusManifestRefreshed => T(
|
||||||
|
"Manifest rafraîchi — re-clique Installer",
|
||||||
|
"Manifest refreshed — click Install again",
|
||||||
|
"清单已刷新 — 再次点击安装",
|
||||||
|
"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…",
|
||||||
|
$"v{version}:清单已刷新,自动重试下载…",
|
||||||
|
$"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…",
|
||||||
|
$"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…"
|
||||||
|
);
|
||||||
|
public static string MsgServerSideStaleBody(string version) => T(
|
||||||
|
$"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».",
|
||||||
|
$"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».",
|
||||||
|
$"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断:\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后,点击 \"检查更新\" 然后 \"安装\"。",
|
||||||
|
$"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"ตรวจสอบการอัปเดต\" จากนั้น \"ติดตั้ง\"",
|
||||||
|
$"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string MenuForceFreshDownload => T(
|
||||||
|
"↻ Forcer re-téléchargement (vider cache local)",
|
||||||
|
"↻ Force re-download (purge local cache)",
|
||||||
|
"↻ 强制重新下载(清除本地缓存)",
|
||||||
|
"↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)",
|
||||||
|
"↻ فرض إعادة التنزيل (مسح الذاكرة)"
|
||||||
|
);
|
||||||
|
public static string MsgBoxForceFresh => T(
|
||||||
|
"Forcer le re-téléchargement",
|
||||||
|
"Force re-download",
|
||||||
|
"强制重新下载",
|
||||||
|
"บังคับดาวน์โหลดใหม่",
|
||||||
|
"فرض إعادة التنزيل"
|
||||||
|
);
|
||||||
|
public static string MsgForceFreshConfirm(string version) => T(
|
||||||
|
$"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.",
|
||||||
|
$"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.",
|
||||||
|
$"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。",
|
||||||
|
$"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย",
|
||||||
|
$"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً."
|
||||||
|
);
|
||||||
|
|
||||||
public static string MsgUninstallFailed(string detail) => T(
|
public static string MsgUninstallFailed(string detail) => T(
|
||||||
$"Suppression échouée : {detail}",
|
$"Suppression échouée : {detail}",
|
||||||
$"Uninstall failed: {detail}",
|
$"Uninstall failed: {detail}",
|
||||||
@@ -625,6 +726,72 @@ public static class Strings
|
|||||||
public static string SettingsApiBackups => SettingsReportBackups;
|
public static string SettingsApiBackups => SettingsReportBackups;
|
||||||
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
||||||
|
|
||||||
|
// ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ----
|
||||||
|
public static string StatusDeployingSteamVr(string version) => T(
|
||||||
|
$"Configuration SteamVR pour v{version}…",
|
||||||
|
$"Configuring SteamVR for v{version}…",
|
||||||
|
$"正在为 v{version} 配置 SteamVR…",
|
||||||
|
$"กำลังกำหนดค่า SteamVR สำหรับ v{version}…",
|
||||||
|
$"جارٍ تهيئة SteamVR لـ v{version}…"
|
||||||
|
);
|
||||||
|
public static string MsgBoxSteamVrMergeFailed => T(
|
||||||
|
"Configuration SteamVR échouée",
|
||||||
|
"SteamVR configuration failed",
|
||||||
|
"SteamVR 配置失败",
|
||||||
|
"การกำหนดค่า SteamVR ล้มเหลว",
|
||||||
|
"فشل تهيئة SteamVR"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrPreFlightTitle => T(
|
||||||
|
"Fermeture de SteamVR & Vive Business Streaming",
|
||||||
|
"Closing SteamVR & Vive Business Streaming",
|
||||||
|
"正在关闭 SteamVR 和 Vive Business Streaming",
|
||||||
|
"ปิด SteamVR และ Vive Business Streaming",
|
||||||
|
"إغلاق SteamVR و Vive Business Streaming"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T(
|
||||||
|
$"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?",
|
||||||
|
$"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?",
|
||||||
|
$"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改(追踪器映射、绑定),启动器必须关闭这些程序:\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗?",
|
||||||
|
$"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?",
|
||||||
|
$"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrMergeFailed(string detail) => T(
|
||||||
|
$"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.",
|
||||||
|
$"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.",
|
||||||
|
$"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。",
|
||||||
|
$"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก",
|
||||||
|
$"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string SettingsSteamVr => T(
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrAutoMerge => T(
|
||||||
|
"Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)",
|
||||||
|
"Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)",
|
||||||
|
"安装时合并 steamvr.vrsettings(终止 SteamVR + Vive Business Streaming,编辑 JSON,下次启动 PROSERVE 时让 SteamVR 重新启动)",
|
||||||
|
"รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)",
|
||||||
|
"دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrSettingsPath => T(
|
||||||
|
"Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)",
|
||||||
|
"Path to steamvr.vrsettings (empty = auto-detect via Steam registry)",
|
||||||
|
"steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)",
|
||||||
|
"พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)",
|
||||||
|
"مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrProcesses => T(
|
||||||
|
"Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.",
|
||||||
|
"Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.",
|
||||||
|
"合并前要终止的进程(每行一个名称,无 .exe)。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。",
|
||||||
|
"โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ",
|
||||||
|
"العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً."
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Cache LAN P2P ----
|
// ---- Cache LAN P2P ----
|
||||||
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
||||||
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
|||||||
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Première barrière : vérifier que la base configurée existe, sinon la
|
||||||
|
// créer. Sans ça la toute première install d'un PC neuf (base pas
|
||||||
|
// encore présente côté MySQL) plantait dès `OpenAsync` avec
|
||||||
|
// "Unknown database 'proserveapi'" et l'install s'arrêtait là.
|
||||||
|
await EnsureDatabaseExistsAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
|||||||
return conn;
|
return conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre une connexion SANS spécifier <c>Database</c> dans la connection
|
||||||
|
/// string, puis exécute <c>CREATE DATABASE IF NOT EXISTS</c>. Évite l'erreur
|
||||||
|
/// "Unknown database" à la toute première install d'un PC neuf où l'opérateur
|
||||||
|
/// n'a pas créé la base à la main.
|
||||||
|
///
|
||||||
|
/// L'identifier est quoté avec des backticks pour gérer les noms exotiques
|
||||||
|
/// (espaces, caractères spéciaux) ; on filtre quand même les backticks
|
||||||
|
/// pour éviter une injection si la config était trafiquée à la main.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureDatabaseExistsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Database))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Database config has no Database name — cannot ensure-exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pwd = _passwordProvider() ?? string.Empty;
|
||||||
|
var csb = new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = cfg.Host,
|
||||||
|
Port = cfg.Port,
|
||||||
|
UserID = cfg.User,
|
||||||
|
Password = pwd,
|
||||||
|
// PAS de Database = on se connecte au serveur sans base sélectionnée.
|
||||||
|
// Le user mysql doit avoir CREATE DATABASE sinon ça échouera ici —
|
||||||
|
// dans ce cas l'opérateur sait que la base doit être créée à la main.
|
||||||
|
Pooling = false,
|
||||||
|
ConnectionTimeout = 10,
|
||||||
|
DefaultCommandTimeout = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize l'identifier : MySQL identifiers peuvent contenir n'importe
|
||||||
|
// quoi entre backticks, mais on rejette les backticks internes par
|
||||||
|
// sécurité (pas d'injection même si la config est falsifiée).
|
||||||
|
var safeDbName = cfg.Database.Replace("`", "");
|
||||||
|
var ddl = $"CREATE DATABASE IF NOT EXISTS `{safeDbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
|
||||||
|
|
||||||
|
await using var conn = new MySqlConnection(csb.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var cmd = new MySqlCommand(ddl, conn);
|
||||||
|
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
// affected == 1 → base créée. affected == 0 → existait déjà.
|
||||||
|
if (affected > 0)
|
||||||
|
_logger.LogInformation("Created database `{Database}` (did not exist yet)", safeDbName);
|
||||||
|
else
|
||||||
|
_logger.LogDebug("Database `{Database}` already exists, no-op", safeDbName);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string ddl = $"""
|
const string ddl = $"""
|
||||||
|
|||||||
@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
// Cas typique : PC fraichement déployé où XAMPP sera installé après.
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
// L'install PROSERVE peut tout de même s'exécuter — le déploiement
|
||||||
|
// produira un dossier prêt à servir dès qu'Apache sera up.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
114
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal file
114
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
namespace PSLauncher.Core.SteamVr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fusionne récursivement le <c>steamvr.vrsettings</c> bundled dans le ZIP
|
||||||
|
/// PROSERVE (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
|
||||||
|
///
|
||||||
|
/// Sémantique de merge (deep, récursive) :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Clé absente côté cible → ajoutée (clone profond du source)</item>
|
||||||
|
/// <item>Clé présente des deux côtés, valeurs JSON objet → <b>recursivement fusionnées</b>
|
||||||
|
/// (les sous-clés cible non touchées par le source sont préservées —
|
||||||
|
/// critique pour des blocs comme <c>"trackers"</c> qui contiennent
|
||||||
|
/// potentiellement déjà d'autres devices)</item>
|
||||||
|
/// <item>Clé présente, valeurs primitives ou de types différents (string,
|
||||||
|
/// bool, number, array) → la valeur source écrase celle de cible</item>
|
||||||
|
/// <item>Clés racine côté cible qui ne sont PAS dans le source → 100 %
|
||||||
|
/// préservées (on ne touche QUE ce que le source apporte)</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne
|
||||||
|
/// rétablissent leurs valeurs en mémoire au prochain flush vers disque
|
||||||
|
/// (Vive Business Streaming en premier car il relance SteamVR sinon).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISteamVrSettingsDeployer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run : détermine si un merge réel modifierait la cible. Permet à
|
||||||
|
/// MainViewModel de skipper la phase entière (popup + kill SteamVR/VBS +
|
||||||
|
/// merge) quand la cible est déjà à jour. <b>Aucun processus tué, aucun
|
||||||
|
/// fichier touché.</b> Bénéfice opérationnel : sur un PC où la conf SteamVR
|
||||||
|
/// est déjà conforme à la release courante, la ré-install ne dérange pas
|
||||||
|
/// l'opérateur ni les sessions VR éventuellement en cours.
|
||||||
|
/// </summary>
|
||||||
|
Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
|
||||||
|
string installFolder,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <param name="processesToKill">
|
||||||
|
/// Liste explicite des noms de processus à terminer avant le merge.
|
||||||
|
/// MainViewModel construit cette liste depuis les health checks de type
|
||||||
|
/// "Process" (= les processus que l'opérateur surveille déjà = ceux qui
|
||||||
|
/// risquent de garder un handle sur le fichier SteamVR ou de relancer
|
||||||
|
/// SteamVR). VBS doit y être en tête. Si null ou vide, on retombe sur
|
||||||
|
/// la liste par défaut de <see cref="SteamVrConfig.ProcessesToKill"/>.
|
||||||
|
/// </param>
|
||||||
|
Task<SteamVrMergeResult> MergeAsync(
|
||||||
|
string installFolder,
|
||||||
|
IReadOnlyList<string>? processesToKill,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résultat du dry-run <see cref="ISteamVrSettingsDeployer.CheckMergeNeededAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum SteamVrMergeCheckOutcome
|
||||||
|
{
|
||||||
|
/// <summary>La cible contient déjà toutes les clés du source avec les
|
||||||
|
/// mêmes valeurs (deep equality) — aucun changement ne serait apporté.
|
||||||
|
/// MainViewModel doit skipper la phase sans déranger l'opérateur.</summary>
|
||||||
|
UpToDate,
|
||||||
|
/// <summary>Le merge apporterait au moins un changement (clé ajoutée ou
|
||||||
|
/// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder.</summary>
|
||||||
|
Needed,
|
||||||
|
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP — rien à faire.</summary>
|
||||||
|
NoSourceFile,
|
||||||
|
/// <summary>Option <c>AutoMerge=false</c> dans la config — l'opérateur l'a désactivé.</summary>
|
||||||
|
DisabledByConfig,
|
||||||
|
/// <summary>Steam pas détecté sur la machine.</summary>
|
||||||
|
SteamNotFound,
|
||||||
|
/// <summary>JSON source ou cible invalide. MainViewModel peut log warn.</summary>
|
||||||
|
InvalidJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="Outcome">État global du check.</param>
|
||||||
|
/// <param name="TargetPath">Chemin résolu vers <c>steamvr.vrsettings</c>. Non-null
|
||||||
|
/// si Steam a été localisé, même si le fichier n'existe pas encore.</param>
|
||||||
|
/// <param name="RootKeysWouldChange">Nombre de clés racine qui seraient ajoutées
|
||||||
|
/// ou modifiées. <c>0</c> ssi <see cref="Outcome"/> est <see cref="SteamVrMergeCheckOutcome.UpToDate"/>.
|
||||||
|
/// Affiché dans le popup pour donner du contexte à l'opérateur.</param>
|
||||||
|
/// <param name="ErrorMessage">Détail technique en cas d'<see cref="SteamVrMergeCheckOutcome.InvalidJson"/>.</param>
|
||||||
|
public sealed record SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome Outcome,
|
||||||
|
string? TargetPath,
|
||||||
|
int RootKeysWouldChange,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public enum SteamVrMergeStatus
|
||||||
|
{
|
||||||
|
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP, no-op silencieux.</summary>
|
||||||
|
SkippedNoSourceFile,
|
||||||
|
/// <summary>Option <c>SteamVrConfig.AutoMerge=false</c>, l'opérateur l'a explicitement désactivé.</summary>
|
||||||
|
SkippedDisabledByConfig,
|
||||||
|
/// <summary>Steam introuvable (ni via registre ni au path par défaut).</summary>
|
||||||
|
SteamNotFound,
|
||||||
|
/// <summary>Le fichier source ou cible n'est pas un JSON valide.</summary>
|
||||||
|
InvalidJson,
|
||||||
|
/// <summary>Tout s'est bien passé.</summary>
|
||||||
|
Merged,
|
||||||
|
/// <summary>Erreur I/O ou autre exception non récupérable.</summary>
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="KeysReplaced">Nombre de clés racine qui existaient déjà côté cible (overwritées).</param>
|
||||||
|
/// <param name="KeysAdded">Nombre de clés racine ajoutées (n'existaient pas côté cible).</param>
|
||||||
|
/// <param name="TargetPath">Chemin du fichier modifié. Null si le merge a été skippé.</param>
|
||||||
|
/// <param name="ProcessesKilled">Nombre total de processus tués avant le merge.</param>
|
||||||
|
/// <param name="ErrorMessage">Détail technique en cas de Failed / InvalidJson.</param>
|
||||||
|
public sealed record SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus Status,
|
||||||
|
int KeysReplaced,
|
||||||
|
int KeysAdded,
|
||||||
|
string? TargetPath,
|
||||||
|
int ProcessesKilled,
|
||||||
|
string? ErrorMessage = null);
|
||||||
589
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal file
589
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
using SysProcess = System.Diagnostics.Process;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.SteamVr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
|
||||||
|
{
|
||||||
|
/// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
|
||||||
|
private const string SourceFolderName = "_steamvr";
|
||||||
|
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
|
||||||
|
private const string SettingsFileName = "steamvr.vrsettings";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Délai d'attente entre le kill des processus et l'ouverture du fichier.
|
||||||
|
/// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
|
||||||
|
/// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher
|
||||||
|
/// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly Func<SteamVrConfig> _configProvider;
|
||||||
|
private readonly ILogger<SteamVrSettingsDeployer> _logger;
|
||||||
|
|
||||||
|
public SteamVrSettingsDeployer(
|
||||||
|
Func<SteamVrConfig> configProvider,
|
||||||
|
ILogger<SteamVrSettingsDeployer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
|
||||||
|
string installFolder,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = _configProvider();
|
||||||
|
|
||||||
|
// 1) Fichier source présent ?
|
||||||
|
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Option activée ?
|
||||||
|
if (!config.AutoMerge)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Cible localisée ?
|
||||||
|
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
|
||||||
|
? config.SettingsFilePathOverride!
|
||||||
|
: FindSteamVrSettingsPath();
|
||||||
|
if (targetPath is null)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Parse source. Si invalide, le vrai merge échouera de toute façon.
|
||||||
|
JsonObject sourceObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
|
||||||
|
sourceObj = JsonNode.Parse(sourceText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("source root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
|
||||||
|
$"Source JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire.
|
||||||
|
if (!File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)",
|
||||||
|
targetPath);
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Parse cible. Si invalide, on signale mais le vrai merge serait
|
||||||
|
// contraint d'écrire (= overwrite) une cible illisible.
|
||||||
|
JsonObject targetObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetText = await File.ReadAllTextAsync(targetPath, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetText))
|
||||||
|
{
|
||||||
|
// Fichier vide ⇒ merge ajouterait tout le source.
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
|
||||||
|
}
|
||||||
|
targetObj = JsonNode.Parse(targetText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("target root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
|
||||||
|
$"Cible JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou
|
||||||
|
// modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés
|
||||||
|
// d'un bloc qui change ne sont pas comptées individuellement.
|
||||||
|
int rootKeysChanging = 0;
|
||||||
|
foreach (var (key, srcValue) in sourceObj)
|
||||||
|
{
|
||||||
|
if (!targetObj.ContainsKey(key))
|
||||||
|
{
|
||||||
|
rootKeysChanging++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var tgtValue = targetObj[key];
|
||||||
|
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
if (WouldDeepMergeChange(tgtChild, srcChild))
|
||||||
|
rootKeysChanging++;
|
||||||
|
}
|
||||||
|
else if (!JsonNodesEqual(tgtValue, srcValue))
|
||||||
|
{
|
||||||
|
rootKeysChanging++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = rootKeysChanging > 0
|
||||||
|
? SteamVrMergeCheckOutcome.Needed
|
||||||
|
: SteamVrMergeCheckOutcome.UpToDate;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}",
|
||||||
|
outcome, rootKeysChanging, targetPath);
|
||||||
|
return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SteamVrMergeResult> MergeAsync(
|
||||||
|
string installFolder,
|
||||||
|
IReadOnlyList<string>? processesToKill,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = _configProvider();
|
||||||
|
|
||||||
|
// 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒
|
||||||
|
// pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent
|
||||||
|
// pas forcément un _steamvr/).
|
||||||
|
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge",
|
||||||
|
SettingsFileName, installFolder);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Opt-out par config (peut être désactivé sur des machines de dev).
|
||||||
|
if (!config.AutoMerge)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Résout le path du fichier cible (override config OU registre OU fallback).
|
||||||
|
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
|
||||||
|
? config.SettingsFilePathOverride!
|
||||||
|
: FindSteamVrSettingsPath();
|
||||||
|
if (targetPath is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Steam install not found via registry + fallback, cannot merge SteamVR settings");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0,
|
||||||
|
"Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il
|
||||||
|
// relance auto SteamVR sinon. On parse + write avec un délai de
|
||||||
|
// règlement APRES, parce que les processus flushent leurs settings
|
||||||
|
// au shutdown.
|
||||||
|
//
|
||||||
|
// Le caller (MainViewModel) construit la liste depuis les health
|
||||||
|
// checks de type Process (= les programmes que l'opérateur surveille
|
||||||
|
// déjà). Si rien n'est passé, on retombe sur la liste par défaut
|
||||||
|
// de la config — utile pour les setups sans health checks configurés.
|
||||||
|
var killList = (processesToKill is not null && processesToKill.Count > 0)
|
||||||
|
? processesToKill
|
||||||
|
: config.ProcessesToKill;
|
||||||
|
var killed = await KillProcessesAsync(killList, ct);
|
||||||
|
if (killed > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release",
|
||||||
|
killed, (int)PostKillSettleDelay.TotalMilliseconds);
|
||||||
|
await Task.Delay(PostKillSettleDelay, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Parse le JSON source. Si le fichier est invalide, on log et on
|
||||||
|
// abandonne — pas la peine de risquer un overwrite avec une cible
|
||||||
|
// potentiellement valide.
|
||||||
|
JsonObject sourceObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
|
||||||
|
sourceObj = JsonNode.Parse(sourceText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("source root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
|
||||||
|
$"Source JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Parse le JSON cible (si absent ou vide → on part d'un objet
|
||||||
|
// vide, on créera le fichier from scratch). Si le fichier existe
|
||||||
|
// mais est invalide, on abandonne (mieux vaut un fichier intact
|
||||||
|
// que de l'écraser).
|
||||||
|
JsonObject targetObj;
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetText = await File.ReadAllTextAsync(targetPath, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetText))
|
||||||
|
{
|
||||||
|
targetObj = new JsonObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetObj = JsonNode.Parse(targetText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("target root is not a JSON object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
|
||||||
|
$"Cible JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Target SteamVR settings file does not exist yet, creating: {Path}",
|
||||||
|
targetPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
targetObj = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Deep merge récursif :
|
||||||
|
// • Clé absente côté cible → ajoute (copie complète du source)
|
||||||
|
// • Clé présente des DEUX côtés ET les deux valeurs sont des objets
|
||||||
|
// JSON → recurse (préserve les sous-clés cible non touchées par le source)
|
||||||
|
// • Sinon (types différents, ou tableau, ou primitive) → le source gagne
|
||||||
|
// (full replace de la valeur)
|
||||||
|
//
|
||||||
|
// Cas concret du bloc "trackers" : si le fichier user contient déjà
|
||||||
|
// "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...",
|
||||||
|
// "/devices/htc/TKR_LEFT" : "ancienne valeur" }
|
||||||
|
// et que le source apporte
|
||||||
|
// "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...",
|
||||||
|
// "/devices/htc_business_streaming/TKR_RIGHT" : "..." }
|
||||||
|
// → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming
|
||||||
|
// (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé
|
||||||
|
// différente, htc_business_streaming/* ajoutées).
|
||||||
|
//
|
||||||
|
// Les compteurs top-level reflètent uniquement les clés RACINE
|
||||||
|
// (remplacées ou ajoutées) parce que c'est le niveau visible dans
|
||||||
|
// les logs et le seul niveau que l'opérateur a besoin de monitorer.
|
||||||
|
int replaced = 0, added = 0;
|
||||||
|
foreach (var (key, srcValue) in sourceObj.ToList())
|
||||||
|
{
|
||||||
|
if (!targetObj.ContainsKey(key))
|
||||||
|
{
|
||||||
|
targetObj[key] = srcValue?.DeepClone();
|
||||||
|
added++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clé présente des deux côtés. Si les deux valeurs sont des
|
||||||
|
// objets JSON, on deep-merge ; sinon le source remplace.
|
||||||
|
if (srcValue is JsonObject srcChild
|
||||||
|
&& targetObj[key] is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
DeepMergeInto(tgtChild, srcChild);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetObj[key] = srcValue?.DeepClone();
|
||||||
|
}
|
||||||
|
replaced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
|
||||||
|
// indenté à 3 espaces dans ses propres écritures — on respecte le
|
||||||
|
// style pour minimiser le diff visuel pour un opérateur qui ouvre
|
||||||
|
// le fichier en éditeur.
|
||||||
|
var json = targetObj.ToJsonString(new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
});
|
||||||
|
var tmpPath = targetPath + ".pslauncher-tmp";
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, ct);
|
||||||
|
// File.Move(overwrite=true) est atomique sur NTFS (.NET 5+)
|
||||||
|
File.Move(tmpPath, targetPath, overwrite: true);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
|
||||||
|
replaced, added, targetPath);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SteamVR merge failed unexpectedly");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.Failed, 0, 0, targetPath, 0,
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path canonique de Steam x86 quand il est installé à l'emplacement
|
||||||
|
/// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher
|
||||||
|
/// la valeur attendue en placeholder du TextBox d'override.
|
||||||
|
/// </summary>
|
||||||
|
public static string CanonicalSettingsPath { get; } = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
"Steam", "config", "steamvr.vrsettings");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résout le path vers <c>steamvr.vrsettings</c>. Stratégie : on essaie les
|
||||||
|
/// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un
|
||||||
|
/// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut
|
||||||
|
/// ne pas exister encore — on le créera au write.
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
|
||||||
|
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
|
||||||
|
/// <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
|
||||||
|
/// existant — Steam n'est probablement pas installé sur cette machine.
|
||||||
|
/// </summary>
|
||||||
|
private string? FindSteamVrSettingsPath()
|
||||||
|
{
|
||||||
|
// Énumère les candidats dans l'ordre de priorité. Pour chacun on
|
||||||
|
// vérifie que la racine Steam (= grand-parent du fichier final) existe
|
||||||
|
// physiquement avant d'accepter — sinon un opérateur qui a désinstallé
|
||||||
|
// Steam puis ré-installé ailleurs avait des fantômes du registre qui
|
||||||
|
// nous renvoyaient vers un dossier inexistant.
|
||||||
|
var candidates = new (string Path, string Source)[]
|
||||||
|
{
|
||||||
|
(TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"),
|
||||||
|
"HKCU\\Software\\Valve\\Steam"),
|
||||||
|
(TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"),
|
||||||
|
"HKLM\\Wow6432Node\\Valve\\Steam"),
|
||||||
|
(CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (candidate, source) in candidates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate)) continue;
|
||||||
|
// grand-parent = .../Steam (parent = .../Steam/config)
|
||||||
|
var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate));
|
||||||
|
if (steamRoot is null || !Directory.Exists(steamRoot))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)",
|
||||||
|
source, steamRoot);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Resolved SteamVR settings target via {Source}: {Path} (file {Exists})",
|
||||||
|
source, candidate, File.Exists(candidate) ? "exists" : "will be created");
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit une string sous une clé registre + sous-clé nommée, normalise les
|
||||||
|
/// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
|
||||||
|
/// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide.
|
||||||
|
/// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas
|
||||||
|
/// fatal — on tombera sur le candidat suivant.
|
||||||
|
/// </summary>
|
||||||
|
private string TryRegistryPath(RegistryKey root, string subKey, string valueName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = root.OpenSubKey(subKey);
|
||||||
|
if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
var normalized = raw.Replace('/', '\\').TrimEnd('\\');
|
||||||
|
return Path.Combine(normalized, "config", SettingsFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}",
|
||||||
|
root.Name, subKey, valueName);
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tue tous les processus dont le nom (sans .exe) correspond à un des
|
||||||
|
/// patterns de <paramref name="processNames"/> (case-insensitive). Best-effort :
|
||||||
|
/// si on ne peut pas killer un process (AccessDenied, déjà parti), on log
|
||||||
|
/// et on continue. Retourne le nombre total de process effectivement tués.
|
||||||
|
///
|
||||||
|
/// L'ordre des entrées dans la liste compte : Vive Business Streaming
|
||||||
|
/// doit être en TÊTE car il relance SteamVR sinon.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Fusionne récursivement <paramref name="source"/> dans <paramref name="target"/>.
|
||||||
|
/// Règles par clé :
|
||||||
|
/// • absente côté cible → ajout (clone profond de la valeur source)
|
||||||
|
/// • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse
|
||||||
|
/// • sinon → la valeur source écrase celle de cible
|
||||||
|
///
|
||||||
|
/// Les arrays JSON sont traités comme des "primitives" (= replace complet,
|
||||||
|
/// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans
|
||||||
|
/// ses settings de toute façon, c'est juste pour avoir un comportement
|
||||||
|
/// prévisible si jamais.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run récursif : applique mentalement le même algo que
|
||||||
|
/// <see cref="DeepMergeInto"/> et retourne true dès qu'au moins UNE clé du
|
||||||
|
/// source serait ajoutée ou modifiée côté target. Short-circuit pour ne
|
||||||
|
/// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff.
|
||||||
|
/// </summary>
|
||||||
|
private static bool WouldDeepMergeChange(JsonObject target, JsonObject source)
|
||||||
|
{
|
||||||
|
foreach (var (key, srcValue) in source)
|
||||||
|
{
|
||||||
|
if (!target.ContainsKey(key)) return true;
|
||||||
|
var tgtValue = target[key];
|
||||||
|
|
||||||
|
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
if (WouldDeepMergeChange(tgtChild, srcChild)) return true;
|
||||||
|
}
|
||||||
|
else if (!JsonNodesEqual(tgtValue, srcValue))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
|
||||||
|
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
|
||||||
|
/// on l'implémente à la main. Pour les primitives, on compare via la
|
||||||
|
/// serialization JSON par défaut — ToJsonString produit un format canonique
|
||||||
|
/// pour chaque type (number, string, bool, null), donc deux valeurs
|
||||||
|
/// logiquement égales produiront la même chaîne.
|
||||||
|
/// </summary>
|
||||||
|
private static bool JsonNodesEqual(JsonNode? a, JsonNode? b)
|
||||||
|
{
|
||||||
|
if (a is null && b is null) return true;
|
||||||
|
if (a is null || b is null) return false;
|
||||||
|
|
||||||
|
if (a is JsonObject aObj && b is JsonObject bObj)
|
||||||
|
{
|
||||||
|
if (aObj.Count != bObj.Count) return false;
|
||||||
|
foreach (var (key, aVal) in aObj)
|
||||||
|
{
|
||||||
|
if (!bObj.TryGetPropertyValue(key, out var bVal)) return false;
|
||||||
|
if (!JsonNodesEqual(aVal, bVal)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is JsonArray aArr && b is JsonArray bArr)
|
||||||
|
{
|
||||||
|
if (aArr.Count != bArr.Count) return false;
|
||||||
|
for (int i = 0; i < aArr.Count; i++)
|
||||||
|
{
|
||||||
|
if (!JsonNodesEqual(aArr[i], bArr[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is JsonValue && b is JsonValue)
|
||||||
|
{
|
||||||
|
// Primitives : la sérialisation canonique gère uniformément les
|
||||||
|
// numbers (int/long/double), strings, bools, null.
|
||||||
|
return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types incompatibles (ex: object vs primitive)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeepMergeInto(JsonObject target, JsonObject source)
|
||||||
|
{
|
||||||
|
foreach (var (key, srcValue) in source.ToList())
|
||||||
|
{
|
||||||
|
if (!target.ContainsKey(key))
|
||||||
|
{
|
||||||
|
target[key] = srcValue?.DeepClone();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srcValue is JsonObject srcChild
|
||||||
|
&& target[key] is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
DeepMergeInto(tgtChild, srcChild);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
target[key] = srcValue?.DeepClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> KillProcessesAsync(
|
||||||
|
IReadOnlyList<string> processNames,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
foreach (var name in processNames)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var trimmed = name?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
// GetProcessesByName attend le nom SANS le .exe ; un opérateur peut
|
||||||
|
// l'avoir saisi avec → strip défensif.
|
||||||
|
if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
trimmed = trimmed[..^4];
|
||||||
|
|
||||||
|
SysProcess[] procs;
|
||||||
|
try { procs = SysProcess.GetProcessesByName(trimmed); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in procs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (p.HasExited) { p.Dispose(); continue; }
|
||||||
|
_logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id);
|
||||||
|
p.Kill(entireProcessTree: true);
|
||||||
|
// Wait un peu pour que le process release ses handles
|
||||||
|
try { await p.WaitForExitAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* race avec Kill possible, ok */ }
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Could not kill process {Name} (PID {Pid}) — continuing",
|
||||||
|
p.ProcessName, p.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { p.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,15 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ApiToolConfig ApiTool { get; set; } = new();
|
public ApiToolConfig ApiTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier
|
||||||
|
/// <c>_steamvr/</c> avec <c>steamvr.vrsettings</c>, le launcher arrête SteamVR
|
||||||
|
/// et Vive Business Streaming, fusionne les blocs racine du JSON source dans le
|
||||||
|
/// fichier SteamVR de l'utilisateur (replace si la clé existe, ajoute sinon),
|
||||||
|
/// puis laisse SteamVR redémarrer naturellement quand l'utilisateur lance PROSERVE.
|
||||||
|
/// </summary>
|
||||||
|
public SteamVrConfig SteamVr { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
|
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
|
||||||
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
|
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
|
||||||
@@ -315,6 +324,64 @@ public sealed class HealthCheckEntry
|
|||||||
public int? PingTimeoutMs { get; set; } = 1000;
|
public int? PingTimeoutMs { get; set; } = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version
|
||||||
|
/// PROSERVE. Le ZIP peut contenir un dossier <c>_steamvr/steamvr.vrsettings</c>
|
||||||
|
/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers,
|
||||||
|
/// etc.). Si <see cref="AutoMerge"/> est true, le launcher :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>tue SteamVR + Vive Business Streaming (qui le relance auto)</item>
|
||||||
|
/// <item>localise <c>steamvr.vrsettings</c> via le registre Steam (ou <see cref="SettingsFilePathOverride"/>)</item>
|
||||||
|
/// <item>parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente)</item>
|
||||||
|
/// <item>écrit le résultat atomiquement (rename .tmp → final)</item>
|
||||||
|
/// </list>
|
||||||
|
/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après
|
||||||
|
/// l'install.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SteamVrConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Si true, le launcher applique le merge automatiquement à chaque install
|
||||||
|
/// quand <c>_steamvr/steamvr.vrsettings</c> est présent dans le ZIP. Default
|
||||||
|
/// true (transparent pour l'opérateur), mais peut être désactivé pour les
|
||||||
|
/// machines de dev où le settings doit rester intact.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoMerge { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path explicite vers le <c>steamvr.vrsettings</c> à modifier. Null →
|
||||||
|
/// auto-détection via :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings</item>
|
||||||
|
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem</item>
|
||||||
|
/// <item>fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public string? SettingsFilePathOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processus à tuer avant le merge (sans le suffixe .exe). Default = la
|
||||||
|
/// liste typique SteamVR + Vive Business Streaming. Vive Business Streaming
|
||||||
|
/// relance auto SteamVR ⇒ il faut le tuer EN PREMIER. Editable depuis
|
||||||
|
/// Settings pour les setups exotiques (autres runtimes XR).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ProcessesToKill { get; set; } = new()
|
||||||
|
{
|
||||||
|
// Vive Business Streaming (à tuer en premier — relance SteamVR sinon)
|
||||||
|
"VIVE Business Streaming",
|
||||||
|
"RRConsole",
|
||||||
|
"RRServer",
|
||||||
|
"HtcConnectionUtility",
|
||||||
|
// SteamVR
|
||||||
|
"vrserver",
|
||||||
|
"vrmonitor",
|
||||||
|
"vrcompositor",
|
||||||
|
"vrdashboard",
|
||||||
|
"vrwebhelper",
|
||||||
|
"vrstartup",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
|
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
|
||||||
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et
|
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et
|
||||||
|
|||||||
Reference in New Issue
Block a user