Compare commits

...

10 Commits

Author SHA1 Message Date
e7d4b7a04c Add per-module build scripts at repo root
build-launcher.bat: publishes PSLauncher.App only (Release single-file).
build-updater.bat:  publishes PSLauncher.Updater only.
build-all.bat:      both, sequentially, fast-fail on the first error.

All three pause at the end so a double-click from Explorer shows the
result before the window closes. The existing build-installer.bat
(launcher + updater + Inno Setup .exe) stays as the full release
pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:34:23 +02:00
9cb0502338 LicenseService: signed URL via ?key= instead of Authorization header
Apache FastCGI on OVH mutualisé strips Authorization headers before
PHP sees them. The mod_rewrite [E=HTTP_AUTHORIZATION:%1] workaround
in .htaccess does not survive on this hosting profile (probably
because rewrite runs after the FastCGI handler accepts the request).
The endpoint already accepts ?key= as a fallback path, so use that
from the client. HTTPS still protects the key on the wire and the
launcher never logs URLs containing the key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:29:08 +02:00
466755ddc2 DownloadUrl: tolerate Apache FastCGI stripping the Authorization header
OVH mutualisé serves PHP via FastCGI which strips the Authorization
header by default for security. The user's curl test of GET
/api/download-url/{ver} returned 401 even with -H "Authorization:
Bearer <key>" because $_SERVER['HTTP_AUTHORIZATION'] was empty server-side.

Fix on two layers:

1. .htaccess at root re-injects the header into the Apache env so PHP
   can read it via the standard $_SERVER variable. The mod_rewrite
   one-liner `RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1]` is the de-
   facto FastCGI workaround.

2. DownloadUrl.php now reads from any of: $_SERVER['HTTP_AUTHORIZATION'],
   $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (passthrough variant),
   apache_request_headers(), getallheaders(). Belt and braces — works
   regardless of host config. Falls back to ?key= as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:26:38 +02:00
6da628d687 gitignore: also exclude .bak backup files at repo root 2026-05-02 11:21:48 +02:00
03483d02e1 launcher.php: fix PHP 8 fatal in saveManifest
`usort($manifest['versions'] ?? [], ...)` worked in PHP 7 but is a
fatal error in PHP 8 — usort requires its first arg by reference and
the null-coalesce operator produces an rvalue, not a variable. The
existing versions.php didn't trigger it because it sorts
$manifest['versions'] directly. Fixed launcher.php to guard with an
isset/is_array check and only sort when there's actually a versions
array.

Triggered by clicking "Définir" in the new Launcher page → 500 page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:21:29 +02:00
c24e67f560 Bump app version 0.5.0 -> 0.6.0 for the self-update test loop
The auto-update test was looping: the manifest declared launcher
v0.6.0 but the binary uploaded as PSLauncher-0.6.0.exe was still
internally v0.5.0 (same file, just renamed). After swap, the new
launcher's AssemblyVersion still reported 0.5.0, the manifest still
advertised 0.6.0, and the prompt fired on every restart.

Bump <Version> / <AssemblyVersion> / <FileVersion> to 0.6.0 so the
freshly published PSLauncher.exe genuinely identifies as 0.6.0. After
the swap, IsNewerThanCurrent will return false and the prompt will
stop reappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:06:51 +02:00
5411f607b6 Updater: file logging + relaunch fallback to direct Process.Start
The updater was a black box on failure (no console window, errors went
to a discarded stderr). When the user reported "launcher closes but
doesn't relaunch" there was nothing to inspect.

Add a log sink at %LocalAppData%/PSLauncher/logs/updater.log with
simple 256 KiB rotation. Every step (args parsed, PID wait, target
unlock, backup, copy, zone-strip, relaunch) writes a timestamped
line, plus the FATAL stack trace on uncaught exceptions.

Also strip the file's :Zone.Identifier ADS after copy. Internet-
downloaded exes carry this Mark-of-the-Web stream and Windows can
silently refuse to launch them via explorer.exe with no visible
error.

Relaunch becomes two-tier: try `explorer.exe "<target>"` for
de-elevation; if that fails or nothing spawns, fall back to direct
Process.Start with UseShellExecute=true. Better to inherit admin
than not relaunch at all.

ParseArgs strips surrounding quotes from --target/--source values
since the launcher now wraps paths in quotes (necessary for
UseShellExecute=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:04:42 +02:00
fed5be5916 gitignore: stop tracking built artifacts (PSLauncher-X.Y.Z.exe, installer output)
Last commit accidentally captured two large binaries that landed in the
working tree during the self-update test prep:
- PSLauncher-0.6.0.exe at repo root
- installer/output/PSLauncher-Setup-0.5.0.exe

Neither belongs in version control. Add /PSLauncher-*.exe and
/installer/output/ to .gitignore and `git rm --cached` the existing
entries so the next push doesn't reupload them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:57:56 +02:00
f2a1de9aac Self-update: UAC-aware, de-elevated relaunch, user-mode installer
Three coordinated changes so the auto-update flow works on every
existing install location.

LauncherSelfUpdater.cs
----------------------
- Probe the target directory with a write/delete of a temp file. If
  it fails (e.g. install in Program Files without admin), set
  UseShellExecute=true + Verb=runas on the Updater process so UAC
  prompts the user once for elevation. If the directory is writable
  (user-mode install or portable layout), skip elevation entirely.
- Switched from ProcessStartInfo.ArgumentList to a quoted Arguments
  string because Verb=runas requires UseShellExecute=true, which
  ignores ArgumentList. Paths get explicit quotes to survive spaces.

Updater Program.cs
------------------
After the file swap, the relaunch was a direct Process.Start(target).
With UAC elevation that propagates admin to the new launcher, then to
its child PROSERVE_UE_5_5.exe — undesirable. Replace with
`explorer.exe "<target>"`: explorer is always running in the user's
normal token, opens the exe via shell, the new process inherits the
non-elevated session. Standard de-elevation trick.

installer/PSLauncher.iss
------------------------
Switch from system-wide install (Program Files, requires admin) to
per-user (DefaultDirName={localappdata}\Programs\..., PrivilegesRequired=
lowest). Auto-update then never needs UAC at all on fresh installs.
Existing installs in Program Files keep working thanks to the runas
fallback above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:57:32 +02:00
56069f606d backoffice: dedicated Launcher page, scope-aware Sync
The launcher and the game versions are different release cadences
managed by the same operator. Mixing them on one page muddied the
workflow. Split them into two top-level nav entries.

SignManifest::run(string $scope)
--------------------------------
- 'all' (default, used by CLI tools/sign-manifest.php) — unchanged.
- 'versions' — only re-hashes the Proserve ZIPs and bumps `latest`.
- 'launcher' — only re-hashes the launcher EXE.
The Ed25519 sign step always runs at the end so the manifest stays
verifiable. Selectively hashing avoids unrelated noise (e.g. mass-hash
14 GB ZIPs when all you wanted was to update the launcher exe).

admin/launcher.php (new page)
-----------------------------
Self-contained page with the launcher state, Set/Remove forms, the
blue "🔁 Hasher le launcher + signer" button, and a list of the .exe
files present in builds/launcher/. Workflow doc inline.

admin/versions.php
------------------
Cleaned up: launcher card and its set_launcher / remove_launcher /
sync_launcher actions removed. The remaining global Sync button is
relabeled and now triggers scope='versions' (only Proserve ZIPs).

Layout::navHtml gains a "Launcher" item between Versions and Audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:53:14 +02:00
15 changed files with 516 additions and 188 deletions

5
.gitignore vendored
View File

@@ -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
View 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
View 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
View 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%

View File

@@ -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}

View File

@@ -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
View 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();

View File

@@ -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 = '';

View File

@@ -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>

View File

@@ -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);
// 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'] ?? '';
}
} else {
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);

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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; }
}
}

View File

@@ -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=&lt;chemin_complet_de_PSLauncher.exe&gt;
/// --source=&lt;chemin_du_nouveau_PSLauncher.exe&gt;
/// [--launch] : relance PSLauncher.exe une fois la copie faite
/// [--pid=&lt;PID&gt;] : 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,37 +60,56 @@ 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)
{
if (!TryRelaunchViaExplorer(opts.Target))
{
Log("explorer trick failed — fallback to direct Process.Start");
try
{
Process.Start(new ProcessStartInfo
@@ -93,26 +118,86 @@ internal static class Program
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
});
Log("direct relaunch OK.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
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;
}
}