Compare commits
10 Commits
55ce5318ae
...
e7d4b7a04c
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d4b7a04c | |||
| 9cb0502338 | |||
| 466755ddc2 | |||
| 6da628d687 | |||
| 03483d02e1 | |||
| c24e67f560 | |||
| 5411f607b6 | |||
| fed5be5916 | |||
| f2a1de9aac | |||
| 56069f606d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -39,3 +39,8 @@ server/builds/*
|
||||
# Binaires copiés par le post-publish à la racine du repo
|
||||
/PSLauncher.exe
|
||||
/PSLauncher.Updater.exe
|
||||
/PSLauncher-*.exe
|
||||
/PSLauncher*.bak
|
||||
|
||||
# Sortie de l'installeur Inno Setup
|
||||
/installer/output/
|
||||
|
||||
32
build-all.bat
Normal file
32
build-all.bat
Normal file
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
REM Build complet : launcher + updater (sans creer l'installeur).
|
||||
REM Pour l'installeur Inno Setup, utiliser build-installer.bat.
|
||||
|
||||
setlocal
|
||||
set "REPO=%~dp0"
|
||||
echo.
|
||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||
echo.
|
||||
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
||||
if %ERRORLEVEL% NEQ 0 goto :fail
|
||||
|
||||
echo.
|
||||
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||
echo.
|
||||
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
||||
if %ERRORLEVEL% NEQ 0 goto :fail
|
||||
|
||||
echo.
|
||||
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
||||
echo %REPO%PSLauncher.exe
|
||||
echo %REPO%PSLauncher.Updater.exe
|
||||
echo.
|
||||
pause
|
||||
endlocal & exit /b 0
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo [ECHEC] Build echoue avec le code %ERRORLEVEL%.
|
||||
echo.
|
||||
pause
|
||||
endlocal & exit /b %ERRORLEVEL%
|
||||
23
build-launcher.bat
Normal file
23
build-launcher.bat
Normal file
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
REM Build du launcher principal (PSLauncher.exe).
|
||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self-contained)
|
||||
|
||||
setlocal
|
||||
set "REPO=%~dp0"
|
||||
echo.
|
||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||
echo.
|
||||
|
||||
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
||||
set "EXITCODE=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
if %EXITCODE% NEQ 0 (
|
||||
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
||||
) else (
|
||||
echo [OK] PSLauncher.exe disponible a la racine du repo :
|
||||
echo %REPO%PSLauncher.exe
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
endlocal & exit /b %EXITCODE%
|
||||
24
build-updater.bat
Normal file
24
build-updater.bat
Normal file
@@ -0,0 +1,24 @@
|
||||
@echo off
|
||||
REM Build de l'updater (PSLauncher.Updater.exe).
|
||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.Updater.exe (~34 Mo single-file)
|
||||
REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier.
|
||||
|
||||
setlocal
|
||||
set "REPO=%~dp0"
|
||||
echo.
|
||||
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||
echo.
|
||||
|
||||
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
||||
set "EXITCODE=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
if %EXITCODE% NEQ 0 (
|
||||
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
||||
) else (
|
||||
echo [OK] PSLauncher.Updater.exe disponible a la racine du repo :
|
||||
echo %REPO%PSLauncher.Updater.exe
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
endlocal & exit /b %EXITCODE%
|
||||
@@ -28,7 +28,9 @@ AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
|
||||
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
|
||||
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC.
|
||||
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=output
|
||||
@@ -37,8 +39,9 @@ SetupIconFile=
|
||||
Compression=lzma2/ultra
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
UsedUserAreasWarning=no
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
UninstallDisplayName={#MyAppName}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
RewriteEngine On
|
||||
RewriteBase /PS_Launcher/
|
||||
|
||||
# Forward du header Authorization vers PHP — Apache FastCGI le strippe par défaut
|
||||
# sur OVH mutualisé. Sans ça, $_SERVER['HTTP_AUTHORIZATION'] est vide quand le
|
||||
# client envoie "Authorization: Bearer ...".
|
||||
RewriteCond %{HTTP:Authorization} ^(.+)$
|
||||
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1,L=0]
|
||||
|
||||
# HTTPS forcé (si le mutualisé OVH ne le force pas déjà)
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
192
server/admin/launcher.php
Normal file
192
server/admin/launcher.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/Layout.php';
|
||||
|
||||
use PSLauncher\Admin\Auth;
|
||||
use PSLauncher\Admin\Layout;
|
||||
|
||||
Auth::requireLogin();
|
||||
$config = require __DIR__ . '/../api/config.php';
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$manifestPath = "$root/manifest/versions.json";
|
||||
$buildsDir = "$root/builds";
|
||||
|
||||
$message = null; $messageType = 'success';
|
||||
|
||||
function loadManifest(string $path): array
|
||||
{
|
||||
if (!is_file($path)) return ['schemaVersion' => 1, 'versions' => []];
|
||||
return json_decode(file_get_contents($path), true) ?? [];
|
||||
}
|
||||
|
||||
function saveManifest(string $path, array $manifest): void
|
||||
{
|
||||
// usort attend une référence — on ne peut pas lui passer `?? []` en PHP 8.
|
||||
if (isset($manifest['versions']) && is_array($manifest['versions'])) {
|
||||
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
|
||||
}
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
||||
);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
Auth::checkCsrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
$manifest = loadManifest($manifestPath);
|
||||
|
||||
try {
|
||||
if ($action === 'set_launcher') {
|
||||
$lver = trim($_POST['launcher_version'] ?? '');
|
||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
||||
}
|
||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||
}
|
||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||
$manifest['launcher'] = [
|
||||
'version' => $lver,
|
||||
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
||||
'download' => [
|
||||
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
|
||||
'sizeBytes' => 0,
|
||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||
],
|
||||
'releaseNotesUrl' => null,
|
||||
];
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
|
||||
}
|
||||
elseif ($action === 'remove_launcher') {
|
||||
unset($manifest['launcher']);
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Section launcher retirée du manifest (auto-update désactivé).";
|
||||
}
|
||||
elseif ($action === 'sync_launcher') {
|
||||
require_once "$root/tools/SignManifest.php";
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||
$result = $signer->run('launcher');
|
||||
$message = "Sortie du script (scope: launcher) :\n" . implode("\n", $result['log']);
|
||||
if (!$result['ok']) $messageType = 'error';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$message = $e->getMessage();
|
||||
$messageType = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = loadManifest($manifestPath);
|
||||
$launcher = $manifest['launcher'] ?? null;
|
||||
$launcherFiles = is_dir("$buildsDir/launcher")
|
||||
? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: [])
|
||||
: [];
|
||||
|
||||
$launcherZipPresent = false;
|
||||
$launcherHashed = false;
|
||||
if ($launcher) {
|
||||
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
|
||||
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
|
||||
$launcherHashed = !empty($launcher['download']['sha256'])
|
||||
&& !str_starts_with($launcher['download']['sha256'], 'REPLACE');
|
||||
}
|
||||
|
||||
Layout::header('Launcher', 'launcher');
|
||||
?>
|
||||
<h1>Launcher — auto-update</h1>
|
||||
|
||||
<?php Layout::flash($message, $messageType); ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>État actuel</h2>
|
||||
<?php if ($launcher): ?>
|
||||
<p>
|
||||
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
|
||||
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
|
||||
• Exe : <?= $launcherZipPresent
|
||||
? "<span class='badge badge-success'>présent</span>"
|
||||
: "<span class='badge badge-warning'>absent</span>" ?>
|
||||
• Hash : <?= $launcherHashed
|
||||
? "<span class='badge badge-success'>OK</span>"
|
||||
: "<span class='badge badge-warning'>à calculer</span>" ?>
|
||||
• Signature manifest : <?= empty($manifest['signature'])
|
||||
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
|
||||
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
|
||||
</p>
|
||||
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Aucune version du launcher déclarée — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workflow d'une nouvelle version du launcher</h2>
|
||||
<ol class="muted">
|
||||
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
|
||||
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
|
||||
<li>Renomme la copie : <code>PSLauncher.exe → PSLauncher-X.Y.Z.exe</code>.</li>
|
||||
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
||||
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
|
||||
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Définir / mettre à jour</h2>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_launcher">
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Version launcher (X.Y.Z)</label>
|
||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label>Version minimale requise (X.Y.Z)</label>
|
||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">Définir</button>
|
||||
</form>
|
||||
|
||||
<form method="post" style="margin-top: 16px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_launcher">
|
||||
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
|
||||
</form>
|
||||
|
||||
<?php if ($launcher): ?>
|
||||
<form method="post" style="margin-top: 16px;"
|
||||
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="remove_launcher">
|
||||
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Exes présents dans builds/launcher/</h2>
|
||||
<?php if (empty($launcherFiles)): ?>
|
||||
<p class="muted">Aucun exe pour l'instant. Upload les binaires via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Fichier</th><th>Taille</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($launcherFiles as $f): ?>
|
||||
<tr>
|
||||
<td><code><?= htmlspecialchars($f) ?></code></td>
|
||||
<td class="muted"><?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php Layout::footer();
|
||||
@@ -38,6 +38,7 @@ HTML;
|
||||
'dashboard' => ['index.php', 'Dashboard'],
|
||||
'licenses' => ['licenses.php', 'Licenses'],
|
||||
'versions' => ['versions.php', 'Versions'],
|
||||
'launcher' => ['launcher.php', 'Launcher'],
|
||||
'audit' => ['audit.php', 'Audit'],
|
||||
];
|
||||
$links = '';
|
||||
|
||||
@@ -132,41 +132,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
|
||||
}
|
||||
elseif ($action === 'set_launcher') {
|
||||
$lver = trim($_POST['launcher_version'] ?? '');
|
||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
||||
}
|
||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||
}
|
||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||
$manifest['launcher'] = [
|
||||
'version' => $lver,
|
||||
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
||||
'download' => [
|
||||
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
|
||||
'sizeBytes' => 0,
|
||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||
],
|
||||
'releaseNotesUrl' => null,
|
||||
];
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Sync ».";
|
||||
}
|
||||
elseif ($action === 'remove_launcher') {
|
||||
unset($manifest['launcher']);
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Section launcher retirée du manifest (auto-update désactivé).";
|
||||
}
|
||||
elseif ($action === 'sync') {
|
||||
// Appel direct à la classe — pas d'exec(), fonctionne même quand la fonction
|
||||
// est désactivée par le mutualisé OVH.
|
||||
elseif ($action === 'sync' || $action === 'sync_versions') {
|
||||
require_once "$root/tools/SignManifest.php";
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||
$result = $signer->run();
|
||||
$message = "Sortie du script :\n" . implode("\n", $result['log']);
|
||||
$scope = $action === 'sync_versions' ? 'versions' : 'all';
|
||||
$result = $signer->run($scope);
|
||||
$label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet';
|
||||
$message = "Sortie du script (scope: {$label}) :\n" . implode("\n", $result['log']);
|
||||
if (!$result['ok']) $messageType = 'error';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
@@ -233,81 +205,6 @@ Layout::header('Versions', 'versions');
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card LAUNCHER (auto-update du PSLauncher.exe lui-même) -->
|
||||
<div class="card">
|
||||
<h2>Auto-update du launcher</h2>
|
||||
<?php
|
||||
$launcher = $manifest['launcher'] ?? null;
|
||||
$launcherFiles = is_dir("$buildsDir/launcher") ? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: []) : [];
|
||||
$launcherZipPresent = false;
|
||||
$launcherHashed = false;
|
||||
if ($launcher) {
|
||||
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
|
||||
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
|
||||
$launcherHashed = !empty($launcher['download']['sha256']) && !str_starts_with($launcher['download']['sha256'], 'REPLACE');
|
||||
}
|
||||
?>
|
||||
<?php if ($launcher): ?>
|
||||
<p>
|
||||
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
|
||||
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
|
||||
• Exe : <?= $launcherZipPresent
|
||||
? "<span class='badge badge-success'>présent</span>"
|
||||
: "<span class='badge badge-warning'>absent</span>" ?>
|
||||
• Hash : <?= $launcherHashed
|
||||
? "<span class='badge badge-success'>OK</span>"
|
||||
: "<span class='badge badge-warning'>à calculer</span>" ?>
|
||||
</p>
|
||||
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Section launcher absente du manifest — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Définir / mettre à jour</h3>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_launcher">
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Version launcher (X.Y.Z)</label>
|
||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label>Version minimale requise (X.Y.Z)</label>
|
||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">Définir</button>
|
||||
</form>
|
||||
|
||||
<?php if ($launcher): ?>
|
||||
<form method="post" style="margin-top: 12px;"
|
||||
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="remove_launcher">
|
||||
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Workflow</h3>
|
||||
<ol class="muted">
|
||||
<li>Définis la version ci-dessus (ex : <code>0.6.0</code>).</li>
|
||||
<li>Upload <code>PSLauncher-X.Y.Z.exe</code> via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
||||
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> dans le tableau du manifest ci-dessous — il hashera la section launcher en plus des versions Proserve.</li>
|
||||
</ol>
|
||||
|
||||
<?php if (!empty($launcherFiles)): ?>
|
||||
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Exes présents dans builds/launcher/</h3>
|
||||
<ul class="muted">
|
||||
<?php foreach ($launcherFiles as $f): ?>
|
||||
<li><code><?= htmlspecialchars($f) ?></code> — <?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<h2 style="margin: 0; flex: 1;">Manifest actuel</h2>
|
||||
@@ -322,8 +219,8 @@ Layout::header('Versions', 'versions');
|
||||
</span>
|
||||
<form method="post" style="display:inline">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync">
|
||||
<button class="btn btn-primary" type="submit">🔁 Sync (sign-manifest)</button>
|
||||
<input type="hidden" name="action" value="sync_versions">
|
||||
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,15 +24,34 @@ final class DownloadUrl
|
||||
}
|
||||
|
||||
// Auth via Authorization: Bearer <licenseKey>
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
|
||||
// Fallback : certains hébergements masquent Authorization. On accepte aussi ?key=
|
||||
$licenseKey = trim((string)($_GET['key'] ?? ''));
|
||||
if ($licenseKey === '') {
|
||||
Response::error('unauthorized', 'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
|
||||
}
|
||||
} else {
|
||||
// Apache OVH (et autres hébergements FastCGI) strippe parfois le header. On
|
||||
// tente plusieurs sources :
|
||||
// - $_SERVER['HTTP_AUTHORIZATION'] (cas standard)
|
||||
// - $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (mod_rewrite passthrough)
|
||||
// - apache_request_headers() (présent quand mod_php)
|
||||
// - getallheaders() (idem)
|
||||
// - ?key=... (fallback explicite)
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if ($authHeader === '' && function_exists('apache_request_headers')) {
|
||||
$h = apache_request_headers();
|
||||
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
|
||||
}
|
||||
if ($authHeader === '' && function_exists('getallheaders')) {
|
||||
$h = getallheaders();
|
||||
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
|
||||
}
|
||||
|
||||
$licenseKey = '';
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
|
||||
$licenseKey = trim($m[1]);
|
||||
} else {
|
||||
$licenseKey = trim((string)($_GET['key'] ?? ''));
|
||||
}
|
||||
if ($licenseKey === '') {
|
||||
Response::error('unauthorized',
|
||||
'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
|
||||
}
|
||||
|
||||
$db = Db::get($config);
|
||||
|
||||
@@ -26,13 +26,12 @@ final class SignManifest
|
||||
private function out(string $line): void { $this->log[] = $line; }
|
||||
|
||||
/**
|
||||
* Recompute sizes / sha256 for every version that has its ZIP uploaded,
|
||||
* bump 'latest' to the highest signed version, then sign the manifest
|
||||
* with the Ed25519 private key from config.php (if available).
|
||||
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
|
||||
*
|
||||
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
|
||||
* @return array{ok:bool, log:string[]}
|
||||
*/
|
||||
public function run(): array
|
||||
public function run(string $scope = 'all'): array
|
||||
{
|
||||
if (!is_file($this->manifestPath)) {
|
||||
$this->out("Manifest introuvable : {$this->manifestPath}");
|
||||
@@ -45,8 +44,11 @@ final class SignManifest
|
||||
return ['ok' => false, 'log' => $this->log];
|
||||
}
|
||||
|
||||
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||||
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||||
|
||||
$hashedVersions = [];
|
||||
foreach ($manifest['versions'] as &$v) {
|
||||
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
||||
$version = $v['version'] ?? '?';
|
||||
$url = $v['download']['url'] ?? '';
|
||||
if ($url === '') {
|
||||
@@ -84,7 +86,7 @@ final class SignManifest
|
||||
|
||||
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
|
||||
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
|
||||
if (isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
||||
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
||||
$launcher = &$manifest['launcher'];
|
||||
$lver = $launcher['version'] ?? '';
|
||||
$lurl = $launcher['download']['url'] ?? '';
|
||||
@@ -110,7 +112,8 @@ final class SignManifest
|
||||
}
|
||||
|
||||
// Bump auto de `latest` sur la plus haute version effectivement uploadée
|
||||
if (!empty($hashedVersions)) {
|
||||
// (uniquement si on a touché aux versions)
|
||||
if ($doVersions && !empty($hashedVersions)) {
|
||||
usort($hashedVersions, 'version_compare');
|
||||
$newLatest = end($hashedVersions);
|
||||
if (($manifest['latest'] ?? null) !== $newLatest) {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<ApplicationIcon></ApplicationIcon>
|
||||
<AssemblyName>PSLauncher</AssemblyName>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.5.0</Version>
|
||||
<AssemblyVersion>0.5.0.0</AssemblyVersion>
|
||||
<FileVersion>0.5.0.0</FileVersion>
|
||||
<Version>0.7.0</Version>
|
||||
<AssemblyVersion>0.7.0.0</AssemblyVersion>
|
||||
<FileVersion>0.7.0.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -158,11 +158,14 @@ public sealed class LicenseService : ILicenseService
|
||||
var key = GetDecryptedKey();
|
||||
if (string.IsNullOrEmpty(key)) return null;
|
||||
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version;
|
||||
// On utilise ?key=… plutôt que le header Authorization parce que Apache OVH
|
||||
// (et beaucoup de mutualisés FastCGI) strippe ce header avant qu'il atteigne
|
||||
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
||||
var encodedKey = Uri.EscapeDataString(key);
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
|
||||
@@ -84,23 +84,45 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
|
||||
File.Move(downloadedPath, newPath);
|
||||
}
|
||||
|
||||
// 5. Lance l'updater détaché
|
||||
// 5. Lance l'updater détaché. Si le target est dans un dossier protégé
|
||||
// (Program Files, etc.), on ne pourra pas le remplacer sans admin :
|
||||
// on demande l'élévation via ShellExecute + Verb=runas (UAC popup).
|
||||
var pid = Environment.ProcessId;
|
||||
var needsElevation = !CanWriteTo(targetExe);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = updaterPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = needsElevation, // requis pour Verb=runas
|
||||
CreateNoWindow = !needsElevation,
|
||||
Verb = needsElevation ? "runas" : "",
|
||||
Arguments = string.Join(' ', new[]
|
||||
{
|
||||
$"--target=\"{targetExe}\"",
|
||||
$"--source=\"{newPath}\"",
|
||||
$"--pid={pid}",
|
||||
"--launch",
|
||||
}),
|
||||
};
|
||||
psi.ArgumentList.Add($"--target={targetExe}");
|
||||
psi.ArgumentList.Add($"--source={newPath}");
|
||||
psi.ArgumentList.Add($"--pid={pid}");
|
||||
psi.ArgumentList.Add("--launch");
|
||||
|
||||
_logger.LogInformation("Spawning updater {Updater} (target={Target}, source={Source}, pid={Pid})",
|
||||
updaterPath, targetExe, newPath, pid);
|
||||
_logger.LogInformation("Spawning updater {Updater} (target={Target}, source={Source}, pid={Pid}, elevate={Elevate})",
|
||||
updaterPath, targetExe, newPath, pid, needsElevation);
|
||||
SysProcess.Start(psi);
|
||||
|
||||
return true; // l'appelant doit Application.Current.Shutdown() après
|
||||
}
|
||||
|
||||
/// <summary>Test rapide : peut-on écrire à côté du fichier cible (= dans son dossier) ?</summary>
|
||||
private static bool CanWriteTo(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(filePath)!;
|
||||
var probe = Path.Combine(dir, ".pslauncher-write-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
|
||||
File.WriteAllText(probe, "");
|
||||
File.Delete(probe);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,46 +5,52 @@ namespace PSLauncher.Updater;
|
||||
/// <summary>
|
||||
/// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne).
|
||||
///
|
||||
/// Args attendus :
|
||||
/// Args :
|
||||
/// --target=<chemin_complet_de_PSLauncher.exe>
|
||||
/// --source=<chemin_du_nouveau_PSLauncher.exe>
|
||||
/// [--launch] : relance PSLauncher.exe une fois la copie faite
|
||||
/// [--pid=<PID>] : attend que ce process se termine avant de copier
|
||||
///
|
||||
/// Workflow :
|
||||
/// 1. Si --pid fourni, attend la sortie du process.
|
||||
/// 2. Sinon, attend que le fichier target soit déverrouillé (poll 250 ms).
|
||||
/// 3. Backup target → target.bak (au cas où).
|
||||
/// 4. Copie source → target.
|
||||
/// 5. Optionnellement relance target.
|
||||
/// 6. Supprime source (le .new) puis target.bak après 30 s.
|
||||
/// Logs : %LocalAppData%/PSLauncher/logs/updater.log (rotated, last 5 runs).
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static string _logPath = "";
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
InitLog();
|
||||
try
|
||||
{
|
||||
Log("==== PSLauncher.Updater started ====");
|
||||
Log("args: " + string.Join(' ', args));
|
||||
|
||||
var opts = ParseArgs(args);
|
||||
if (string.IsNullOrEmpty(opts.Target) || string.IsNullOrEmpty(opts.Source))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: PSLauncher.Updater --target=<exe> --source=<new.exe> [--launch] [--pid=N]");
|
||||
Log("ERROR missing --target/--source");
|
||||
return 2;
|
||||
}
|
||||
Log($"target = {opts.Target}");
|
||||
Log($"source = {opts.Source}");
|
||||
Log($"pid = {opts.Pid}");
|
||||
Log($"launch = {opts.Launch}");
|
||||
|
||||
// 1. Attendre la sortie du launcher principal
|
||||
// 1. Wait for the main launcher to exit
|
||||
if (opts.Pid is int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var proc = Process.GetProcessById(pid);
|
||||
Log($"Waiting for PID {pid} to exit...");
|
||||
proc.WaitForExit(60_000);
|
||||
Log("PID exited.");
|
||||
}
|
||||
catch { /* déjà mort, OK */ }
|
||||
catch (Exception ex) { Log($"PID lookup: {ex.Message} (already dead, continuing)"); }
|
||||
}
|
||||
|
||||
// 2. Attendre que le target soit déverrouillé
|
||||
// 2. Wait for target file to be unlocked
|
||||
var deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
@@ -54,65 +60,144 @@ internal static class Program
|
||||
using var fs = File.Open(opts.Target, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
catch (IOException) { Thread.Sleep(250); }
|
||||
}
|
||||
Log("target unlocked.");
|
||||
|
||||
// 3. Backup
|
||||
var backup = opts.Target + ".bak";
|
||||
try { if (File.Exists(backup)) File.Delete(backup); } catch { }
|
||||
try { if (File.Exists(opts.Target)) File.Move(opts.Target, backup); } catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Backup failed: {ex.Message}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// 4. Copy new file in place
|
||||
try { if (File.Exists(backup)) File.Delete(backup); } catch (Exception ex) { Log($"backup pre-clean: {ex.Message}"); }
|
||||
try
|
||||
{
|
||||
File.Copy(opts.Source, opts.Target, overwrite: true);
|
||||
if (File.Exists(opts.Target))
|
||||
{
|
||||
File.Move(opts.Target, backup);
|
||||
Log($"backup OK -> {backup}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Copy failed: {ex.Message}");
|
||||
// Tente la restauration
|
||||
Log($"BACKUP FAILED: {ex.Message}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// 4. Copy new file
|
||||
try
|
||||
{
|
||||
File.Copy(opts.Source, opts.Target, overwrite: true);
|
||||
Log("copy OK.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"COPY FAILED: {ex.Message}");
|
||||
try { if (File.Exists(backup)) File.Move(backup, opts.Target, overwrite: true); } catch { }
|
||||
return 4;
|
||||
}
|
||||
|
||||
// 5. Relance
|
||||
// 4b. Strip Mark-of-the-Web (zone identifier) qui peut faire silencieusement bloquer
|
||||
// un exe téléchargé d'internet quand on essaie de le lancer via explorer.exe.
|
||||
try
|
||||
{
|
||||
File.Delete(opts.Target + ":Zone.Identifier");
|
||||
Log("zone identifier stripped (or absent).");
|
||||
}
|
||||
catch (Exception ex) { Log($"zone strip: {ex.Message}"); }
|
||||
|
||||
// 5. Relance — on essaie d'abord explorer.exe pour dé-élever, fallback sur
|
||||
// Process.Start direct (qui peut hériter de l'admin si on était en runas).
|
||||
if (opts.Launch)
|
||||
{
|
||||
try
|
||||
if (!TryRelaunchViaExplorer(opts.Target))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
Log("explorer trick failed — fallback to direct Process.Start");
|
||||
try
|
||||
{
|
||||
FileName = opts.Target,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = opts.Target,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
|
||||
});
|
||||
Log("direct relaunch OK.");
|
||||
}
|
||||
catch (Exception ex) { Log($"DIRECT RELAUNCH FAILED: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Cleanup différé (le .new + .bak après 30s, en background détaché)
|
||||
try { File.Delete(opts.Source); } catch { }
|
||||
// .bak laissé sur place : si le nouveau exe plante au lancement, l'utilisateur
|
||||
// peut renommer .bak → .exe pour récupérer l'ancien.
|
||||
// 6. Cleanup
|
||||
try { File.Delete(opts.Source); Log("cleaned up source .new"); } catch { }
|
||||
Log("==== Updater done ====");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Updater fatal: {ex}");
|
||||
Log("FATAL: " + ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lance l'exe via explorer.exe pour dé-élever : explorer tourne dans la session
|
||||
/// utilisateur normale, donc le child est non-admin. Retourne false si on n'a pas
|
||||
/// pu spawn explorer.exe du tout.
|
||||
/// </summary>
|
||||
private static bool TryRelaunchViaExplorer(string targetExe)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = "\"" + targetExe + "\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
var p = Process.Start(psi);
|
||||
Log($"explorer.exe spawned (PID {p?.Id})");
|
||||
return p is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"explorer.exe spawn failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitLog()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
Directory.CreateDirectory(dir);
|
||||
_logPath = Path.Combine(dir, "updater.log");
|
||||
// simple rotation : si > 256 KiB on archive
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(_logPath);
|
||||
if (fi.Exists && fi.Length > 256 * 1024)
|
||||
{
|
||||
var archive = _logPath + "." + DateTime.UtcNow.ToString("yyyyMMddHHmmss") + ".log";
|
||||
File.Move(_logPath, archive);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch { _logPath = ""; }
|
||||
}
|
||||
|
||||
private static void Log(string line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_logPath)) return;
|
||||
try
|
||||
{
|
||||
File.AppendAllText(_logPath,
|
||||
DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class Options
|
||||
{
|
||||
public string? Target { get; set; }
|
||||
@@ -126,11 +211,24 @@ internal static class Program
|
||||
var o = new Options();
|
||||
foreach (var a in args)
|
||||
{
|
||||
if (a.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)) o.Target = a["--target=".Length..];
|
||||
else if (a.StartsWith("--source=", StringComparison.OrdinalIgnoreCase)) o.Source = a["--source=".Length..];
|
||||
else if (a.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase)) o.Pid = int.TryParse(a["--pid=".Length..], out var p) ? p : null;
|
||||
else if (a.Equals("--launch", StringComparison.OrdinalIgnoreCase)) o.Launch = true;
|
||||
// Strip surrounding quotes that come from the launcher's quoted arguments
|
||||
var arg = a;
|
||||
if (arg.StartsWith("--target=", StringComparison.OrdinalIgnoreCase))
|
||||
o.Target = StripQuotes(arg["--target=".Length..]);
|
||||
else if (arg.StartsWith("--source=", StringComparison.OrdinalIgnoreCase))
|
||||
o.Source = StripQuotes(arg["--source=".Length..]);
|
||||
else if (arg.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase))
|
||||
o.Pid = int.TryParse(arg["--pid=".Length..], out var p) ? p : null;
|
||||
else if (arg.Equals("--launch", StringComparison.OrdinalIgnoreCase))
|
||||
o.Launch = true;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s)
|
||||
{
|
||||
if (s.Length >= 2 && s[0] == '"' && s[^1] == '"')
|
||||
return s.Substring(1, s.Length - 2);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user