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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user