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:
28
server/.htaccess
Normal file
28
server/.htaccess
Normal 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
66
server/README.md
Normal 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.
|
||||
30
server/api/config.example.php
Normal file
30
server/api/config.example.php
Normal 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
52
server/api/index.php
Normal 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);
|
||||
}
|
||||
38
server/api/lib/Response.php
Normal file
38
server/api/lib/Response.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
server/api/routes/Manifest.php
Normal file
23
server/api/routes/Manifest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
server/api/routes/Releasenotes.php
Normal file
24
server/api/routes/Releasenotes.php
Normal 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
1
server/builds/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.
|
||||
38
server/manifest/versions.json
Normal file
38
server/manifest/versions.json
Normal 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
|
||||
}
|
||||
12
server/releasenotes/1.4.5.md
Normal file
12
server/releasenotes/1.4.5.md
Normal 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é
|
||||
18
server/releasenotes/1.4.6.md
Normal file
18
server/releasenotes/1.4.6.md
Normal 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.
|
||||
52
server/tools/sign-manifest.php
Normal file
52
server/tools/sign-manifest.php
Normal 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";
|
||||
Reference in New Issue
Block a user