Initial scaffolding: PS_Launcher v0.2

Client (C# / .NET 8 / WPF, MVVM via CommunityToolkit.Mvvm):
- PSLauncher.App: WPF UI dark theme (Epic-style sidebar + hero + big play button)
  with UpdateAvailableDialog rendering Markdown release notes via Markdig.Wpf.
- PSLauncher.Core: services for installation registry (scans Proserve v{X.Y.Z}/),
  process launcher, manifest fetch, SHA-256 integrity, HTTP download with
  progress, ZIP install via .tmp + atomic rename, update orchestrator.
- PSLauncher.Models: RemoteManifest, InstalledVersion, LocalConfig DTOs.

Server (PHP 8 for OVH mutualisé, deployed under www/PS_Launcher/):
- Front controller + routes /manifest and /releasenotes/{version}.
- Static signed-manifest workflow with tools/sign-manifest.php CLI to
  recompute SHA-256 and sizeBytes after each ZIP upload.
- .htaccess: HTTPS redirect, rewrite, security headers.
- config.example.php template; real config.php is gitignored.

Cohabiting versions: each release lives in its own Proserve v{version}/ folder
under installRoot. Old versions are never deleted automatically.

Roadmap: v0.3 = HTTP Range resume + Polly retry + state.json,
v0.4 = MySQL license + Ed25519 signatures + DPAPI, v0.5 = settings/UX polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 08:54:45 +02:00
commit 1c8c6803e8
48 changed files with 2269 additions and 0 deletions

28
server/.htaccess Normal file
View File

@@ -0,0 +1,28 @@
RewriteEngine On
RewriteBase /PS_Launcher/
# HTTPS forcé
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Pas de listing de répertoires
Options -Indexes
# /PS_Launcher/api/* → api/index.php (le PATH_INFO est exposé via QUERY_STRING)
RewriteRule ^api/?$ api/index.php [QSA,L]
RewriteRule ^api/(.*)$ api/index.php/$1 [QSA,L]
# Bloquer l'accès direct aux fichiers sensibles
<FilesMatch "\.(sql|md\.bak|log|env)$">
Require all denied
</FilesMatch>
# Headers de sécurité
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set Referrer-Policy "no-referrer"
</IfModule>
# Type MIME pour les ZIPs (assure la pris en compte de Range:)
AddType application/zip .zip

66
server/README.md Normal file
View File

@@ -0,0 +1,66 @@
# PS_Launcher — Côté serveur
Contenu de ce dossier à uploader **sous `www/PS_Launcher/`** sur le mutualisé OVH.
## Arborescence finale après upload
```
www/
└── PS_Launcher/
├── .htaccess
├── api/
│ ├── config.php ← À ÉDITER avec tes vraies valeurs (DB, secrets)
│ ├── index.php
│ ├── lib/Response.php
│ └── routes/
│ ├── Manifest.php
│ └── Releasenotes.php
├── manifest/
│ └── versions.json ← Régénéré par tools/sign-manifest.php
├── releasenotes/
│ ├── 1.4.5.md
│ └── 1.4.6.md
├── builds/ ← Tu uploades ici les ZIPs en SFTP
│ └── proserve-1.4.6.zip
└── tools/
└── sign-manifest.php ← Lance après chaque upload de ZIP
```
## Workflow de release
1. Packager Unreal → produit le dossier `Proserve v1.4.6/`.
2. Le zipper localement : le contenu du ZIP doit reproduire ce qui est attendu côté client (le client extrait dans `Proserve v1.4.6/`, donc le ZIP peut soit contenir un dossier racine `Proserve v1.4.6/`, soit son contenu directement — voir ci-dessous).
3. Uploader le ZIP via SFTP dans `www/PS_Launcher/builds/proserve-1.4.6.zip`.
4. Éditer `manifest/versions.json` pour ajouter (ou modifier) l'entrée `1.4.6` et le champ `latest`.
5. Ajouter `releasenotes/1.4.6.md`.
6. Se connecter en SSH OVH et lancer :
```
cd www/PS_Launcher
php tools/sign-manifest.php
```
Cela calcule automatiquement `sizeBytes` et `sha256` à partir du ZIP.
## Convention du contenu du ZIP
Le client extrait le ZIP dans `installRoot/Proserve v{version}/`. Donc le ZIP doit contenir **directement** les fichiers `PROSERVE_UE_5_5.exe`, `Engine/`, `PROSERVE_UE_5_5/`, etc. à sa racine (pas de dossier englobant).
Test rapide en ligne de commande :
```
unzip -l proserve-1.4.6.zip | head -10
```
Doit lister `PROSERVE_UE_5_5.exe` à la racine, pas `Proserve v1.4.6/PROSERVE_UE_5_5.exe`.
## Test de l'API depuis ton poste
Une fois uploadé, vérifier :
```
curl https://www.tondomaine.com/PS_Launcher/api/health
curl https://www.tondomaine.com/PS_Launcher/api/manifest
curl https://www.tondomaine.com/PS_Launcher/api/releasenotes/1.4.6
```
## À mettre à jour avant prod
- `api/config.php` : tous les `replace_me` / `replace_with_*`. Ce fichier ne doit JAMAIS être commit dans un repo public.
- Dans `manifest/versions.json`, remplace `www.exemple-asterion.com` par ton vrai domaine.

View File

@@ -0,0 +1,30 @@
<?php
// === À RENSEIGNER LORS DU DÉPLOIEMENT ===
// Ce fichier ne doit JAMAIS contenir de creds en clair commitées sur git public.
// Place ce fichier sur le serveur uniquement. Pour git, prévois un config.example.php.
return [
// Base path public sous lequel le launcher est servi (utilisé pour générer les URLs absolues)
'base_url' => 'https://www.exemple-asterion.com/PS_Launcher',
// MySQL (utilisé à partir de v0.4, license)
'db' => [
'dsn' => 'mysql:host=localhost;dbname=pslauncher;charset=utf8mb4',
'username' => 'replace_me',
'password' => 'replace_me',
],
// HMAC secret pour les URLs présignées de /builds/ (v0.6)
'hmac_secret' => 'replace_with_random_64_bytes_hex',
// Clés Ed25519 pour signer manifest et réponses license (v0.4)
// Génère un keypair via tools/generate-keypair.php
'ed25519' => [
'private_key_hex' => '', // 64 bytes hex
'public_key_hex' => '', // 32 bytes hex (à embarquer aussi dans le launcher)
],
// JWT (v0.4)
'jwt_secret' => 'replace_with_random_secret',
'jwt_ttl_seconds' => 900, // 15 min
];

52
server/api/index.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Response.php';
use PSLauncher\Response;
// Charge la config (hors git ; copier config.example.php → config.php au déploiement)
$configPath = __DIR__ . '/config.php';
if (!is_file($configPath)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'config_missing', 'message' => 'config.php absent — copier config.example.php']);
exit;
}
$config = require $configPath;
// Le path est passé via REQUEST_URI parce que le RewriteRule utilise PATH_INFO
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH) ?? '/';
// Strip prefix /PS_Launcher/api
if (preg_match('#^.*?/PS_Launcher/api(.*)$#', $path, $m)) {
$route = trim($m[1], '/');
} else {
$route = trim($path, '/');
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
try {
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
require __DIR__ . '/routes/Manifest.php';
\PSLauncher\Routes\Manifest::handle($config);
return;
}
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]);
return;
}
if ($method === 'GET' && $route === 'health') {
Response::json(['status' => 'ok', 'serverTime' => gmdate('c')]);
}
Response::error('not_found', "Route not found: {$route}", 404);
} catch (\Throwable $e) {
error_log('[PSLauncher] ' . $e->getMessage() . "\n" . $e->getTraceAsString());
Response::error('server_error', 'Internal server error', 500);
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace PSLauncher;
final class Response
{
public static function json($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
public static function error(string $code, string $message, int $status = 400, array $extra = []): void
{
self::json(array_merge(['error' => $code, 'message' => $message], $extra), $status);
}
public static function file(string $path, string $contentType, int $maxAgeSeconds = 0): void
{
if (!is_file($path)) {
self::error('not_found', 'File not found', 404);
}
header('Content-Type: ' . $contentType);
header('Content-Length: ' . filesize($path));
if ($maxAgeSeconds > 0) {
header(sprintf('Cache-Control: public, max-age=%d, must-revalidate', $maxAgeSeconds));
header('ETag: "' . md5_file($path) . '"');
} else {
header('Cache-Control: no-store');
}
readfile($path);
exit;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Response;
final class Manifest
{
public static function handle(array $config): void
{
$file = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($file)) {
Response::error('manifest_missing', 'versions.json not found on server', 404);
}
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
header('Cache-Control: public, max-age=300, must-revalidate');
header('ETag: "' . md5_file($file) . '"');
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . filesize($file));
readfile($file);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Response;
final class Releasenotes
{
public static function handle(string $version): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
Response::error('invalid_version', 'Bad version format', 400);
}
$file = dirname(__DIR__, 2) . "/releasenotes/{$version}.md";
if (!is_file($file)) {
Response::error('not_found', "Release notes for {$version} not found", 404);
}
header('Cache-Control: public, max-age=3600, must-revalidate');
header('Content-Type: text/markdown; charset=utf-8');
header('Content-Length: ' . filesize($file));
readfile($file);
}
}

1
server/builds/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.

View File

@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"product": "PROSERVE_UE",
"latest": "1.4.6",
"publishedAt": "2026-04-29T10:15:00Z",
"minLauncherVersion": "1.0.0",
"versions": [
{
"version": "1.4.6",
"releasedAt": "2026-04-29T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"download": {
"url": "https://www.exemple-asterion.com/PS_Launcher/builds/proserve-1.4.6.zip",
"sizeBytes": 0,
"sha256": "REPLACE_AFTER_BUILD"
},
"releaseNotesUrl": "https://www.exemple-asterion.com/PS_Launcher/api/releasenotes/1.4.6",
"minLicenseDate": "2025-01-01",
"availableForDownload": true
},
{
"version": "1.4.5",
"releasedAt": "2026-04-15T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"download": {
"url": "https://www.exemple-asterion.com/PS_Launcher/builds/proserve-1.4.5.zip",
"sizeBytes": 0,
"sha256": "REPLACE_AFTER_BUILD"
},
"releaseNotesUrl": "https://www.exemple-asterion.com/PS_Launcher/api/releasenotes/1.4.5",
"minLicenseDate": "2024-06-01",
"availableForDownload": true
}
],
"signature": null
}

View File

@@ -0,0 +1,12 @@
# Proserve v1.4.5
*Date de release : 15 avril 2026*
## Nouveautés
- Compatibilité initiale Unreal Engine 5.5
- Refactoring du système de skybox
## Corrections
- Diverses corrections mineures de stabilité

View File

@@ -0,0 +1,18 @@
# Proserve v1.4.6
*Date de release : 29 avril 2026*
## Nouveautés
- **Nouvelle scène d'accueil** avec lighting refait
- **Performance** : amélioration des temps de chargement (-30%) sur les machines équipées d'un SSD NVMe
- Ajout du support manettes Quest 3
## Corrections
- Correction du crash au démarrage en présence de plus de 2 GPU
- Corrigé le bug d'audio désynchronisé dans la scène Démo
## Notes
- Si vous mettez à jour depuis 1.4.4 ou plus ancien, pensez à supprimer votre cache de shaders.

View File

@@ -0,0 +1,52 @@
<?php
/**
* Met à jour les champs `download.sizeBytes` et `download.sha256` de versions.json
* en lisant les ZIPs présents dans /builds/, puis (v0.4) signera le manifest avec Ed25519.
*
* Usage (à exécuter via SSH OVH dans www/PS_Launcher/) :
* php tools/sign-manifest.php
*
* Convention attendue : pour chaque entrée du manifest, le ZIP est dans
* builds/proserve-{version}.zip
*/
declare(strict_types=1);
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
if (!is_file($manifestPath)) {
fwrite(STDERR, "Manifest not found: $manifestPath\n");
exit(1);
}
$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
foreach ($manifest['versions'] as &$v) {
$version = $v['version'];
$zip = "$buildsDir/proserve-{$version}.zip";
if (!is_file($zip)) {
echo " [skip] $version : ZIP absent ($zip)\n";
continue;
}
$size = filesize($zip);
echo " [hash] $version : $size octets...";
$sha = hash_file('sha256', $zip);
echo " sha256={$sha}\n";
$v['download']['sizeBytes'] = $size;
$v['download']['sha256'] = $sha;
}
unset($v);
// (v0.4) Signature Ed25519 — pour l'instant on laisse 'signature' = null.
// $config = require __DIR__ . '/../api/config.php';
// $sk = sodium_hex2bin($config['ed25519']['private_key_hex']);
// $payload = json_encode($manifest, JSON_UNESCAPED_SLASHES);
// $manifest['signature'] = base64_encode(sodium_crypto_sign_detached($payload, $sk));
file_put_contents(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
echo "Manifest mis à jour : $manifestPath\n";