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:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Build outputs
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
packages/
|
||||
|
||||
# Logs / local state
|
||||
*.log
|
||||
*.tlog
|
||||
|
||||
# Local app data (testing)
|
||||
*.local.json
|
||||
|
||||
# Large game build (sample only — should not be in repo)
|
||||
ASTERION_VR/
|
||||
|
||||
# Server-side secrets — never commit (config.example.php sert de template)
|
||||
server/api/config.php
|
||||
server/builds/*
|
||||
!server/builds/.gitkeep
|
||||
|
||||
# Plans
|
||||
.claude/
|
||||
33
PS_Launcher.sln
Normal file
33
PS_Launcher.sln
Normal file
@@ -0,0 +1,33 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.App", "src\PSLauncher.App\PSLauncher.App.csproj", "{A1111111-1111-1111-1111-111111111111}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Core", "src\PSLauncher.Core\PSLauncher.Core.csproj", "{A2222222-2222-2222-2222-222222222222}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Models", "src\PSLauncher.Models\PSLauncher.Models.csproj", "{A3333333-3333-3333-3333-333333333333}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A2222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A2222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A2222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A2222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# PS_Launcher
|
||||
|
||||
Launcher Windows pour l'application UE5 **PROSERVE** : gère plusieurs versions cohabitantes,
|
||||
téléchargement et installation de nouvelles versions depuis un serveur, validation de license,
|
||||
release notes.
|
||||
|
||||
## Composants
|
||||
|
||||
- **`src/`** — Solution C# / .NET 8 / WPF (Visual Studio 2022 + SDK .NET 8 requis)
|
||||
- `PSLauncher.App` : UI WPF (style Epic Games Launcher)
|
||||
- `PSLauncher.Core` : services (Manifest, Download, Install, Process…)
|
||||
- `PSLauncher.Models` : DTOs partagés
|
||||
- **`server/`** — Code serveur PHP 8 (à déployer sous `www/PS_Launcher/` sur OVH mutualisé)
|
||||
|
||||
## Démarrage rapide (dev)
|
||||
|
||||
```
|
||||
dotnet build PS_Launcher.sln
|
||||
dotnet run --project src/PSLauncher.App
|
||||
```
|
||||
|
||||
Pour produire un binaire autonome distribuable (un seul .exe ~70 Mo) :
|
||||
|
||||
```
|
||||
dotnet publish src/PSLauncher.App -c Release
|
||||
# Sortie : src/PSLauncher.App/bin/Release/net8.0-windows/win-x64/publish/PSLauncher.exe
|
||||
```
|
||||
|
||||
## Configuration locale
|
||||
|
||||
Le launcher écrit sa config dans `%LocalAppData%\PSLauncher\config.json` au premier lancement.
|
||||
Édite `serverBaseUrl` pour pointer vers ton serveur :
|
||||
|
||||
```json
|
||||
{
|
||||
"serverBaseUrl": "https://ton-domaine.com/PS_Launcher/api",
|
||||
"installRoot": "C:\\ASTERION\\GIT\\PS_Launcher\\ASTERION_VR"
|
||||
}
|
||||
```
|
||||
|
||||
## État (roadmap)
|
||||
|
||||
- ✅ **v0.1** — Scan + lancement de versions installées
|
||||
- ✅ **v0.2** — Manifest distant, download ZIP, install cohabitante, release notes Markdown
|
||||
- ⏳ **v0.3** — Reprise (HTTP Range), retry Polly, state.json
|
||||
- ⏳ **v0.4** — License (MySQL + Ed25519 + DPAPI)
|
||||
- ⏳ **v0.5** — Settings, suppression manuelle versions, toasts
|
||||
- ⏳ **v1.0** — Auto-update launcher, MSI installer
|
||||
|
||||
Voir le plan détaillé pour la roadmap complète.
|
||||
|
||||
## Licence / interne
|
||||
|
||||
Repo interne ASTERION. Ne pas distribuer.
|
||||
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";
|
||||
12
src/PSLauncher.App/App.xaml
Normal file
12
src/PSLauncher.App/App.xaml
Normal file
@@ -0,0 +1,12 @@
|
||||
<Application x:Class="PSLauncher.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Theme.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
84
src/PSLauncher.App/App.xaml.cs
Normal file
84
src/PSLauncher.App/App.xaml.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.App.ViewModels;
|
||||
using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
_host = Host.CreateDefaultBuilder()
|
||||
.ConfigureLogging(b => { b.ClearProviders(); b.AddDebug(); })
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<IConfigStore, ConfigStore>();
|
||||
services.AddSingleton<LocalConfig>(sp => sp.GetRequiredService<IConfigStore>().Load());
|
||||
|
||||
// HttpClient partagé (timeout long pour les gros téléchargements)
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var http = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
})
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.2"));
|
||||
return http;
|
||||
});
|
||||
|
||||
services.AddSingleton<IInstallationRegistry>(sp =>
|
||||
new InstallationRegistry(
|
||||
sp.GetRequiredService<ILogger<InstallationRegistry>>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().InstallRoot));
|
||||
|
||||
services.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||
services.AddSingleton<IIntegrityService, IntegrityService>();
|
||||
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
||||
|
||||
services.AddSingleton<IManifestService>(sp =>
|
||||
new ManifestService(
|
||||
sp.GetRequiredService<HttpClient>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||
|
||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||
services.AddSingleton<IUpdateChecker, UpdateChecker>();
|
||||
|
||||
services.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_host?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
37
src/PSLauncher.App/PSLauncher.App.csproj
Normal file
37
src/PSLauncher.App/PSLauncher.App.csproj
Normal file
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon></ApplicationIcon>
|
||||
<AssemblyName>PSLauncher</AssemblyName>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="Markdig.Wpf" Version="0.5.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PSLauncher.Core\PSLauncher.Core.csproj" />
|
||||
<ProjectReference Include="..\PSLauncher.Models\PSLauncher.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
103
src/PSLauncher.App/Resources/Theme.xaml
Normal file
103
src/PSLauncher.App/Resources/Theme.xaml
Normal file
@@ -0,0 +1,103 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
||||
|
||||
<!-- Epic Games inspired dark palette -->
|
||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#1B1B1F" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#202024" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#26262B" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#0F0F12" />
|
||||
<SolidColorBrush x:Key="Brush.Border" Color="#37373D" />
|
||||
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
||||
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
||||
<SolidColorBrush x:Key="Brush.Accent" Color="#0078D4" />
|
||||
<SolidColorBrush x:Key="Brush.AccentHover" Color="#1A8CE0" />
|
||||
<SolidColorBrush x:Key="Brush.Play" Color="#0CA84B" />
|
||||
<SolidColorBrush x:Key="Brush.PlayHover" Color="#10C257" />
|
||||
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Bg.Window}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SidebarItem" TargetType="RadioButton">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="20,10" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Play}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="48,14" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.PlayHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#444" />
|
||||
<Setter Property="Foreground" Value="#888" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
|
||||
<Setter Property="Background" Value="#3A3A40" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="VersionPill" TargetType="Border">
|
||||
<Setter Property="Background" Value="#2C2C32" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
27
src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs
Normal file
27
src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.ViewModels;
|
||||
|
||||
public sealed class InstalledVersionViewModel
|
||||
{
|
||||
public InstalledVersion Model { get; }
|
||||
|
||||
public InstalledVersionViewModel(InstalledVersion model) => Model = model;
|
||||
|
||||
public string Display => $"v{Model.Version}";
|
||||
|
||||
public string Subtitle =>
|
||||
$"{FormatSize(Model.SizeBytes)} • Installée le {Model.InstalledAt.ToLocalTime():dd/MM/yyyy}";
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "—";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
359
src/PSLauncher.App/ViewModels/MainViewModel.cs
Normal file
359
src/PSLauncher.App/ViewModels/MainViewModel.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.ViewModels;
|
||||
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly IInstallationRegistry _registry;
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly LocalConfig _config;
|
||||
private readonly IManifestService _manifestService;
|
||||
private readonly IUpdateChecker _updateChecker;
|
||||
private readonly IDownloadManager _downloadManager;
|
||||
private readonly IZipInstaller _zipInstaller;
|
||||
private readonly ILogger<MainViewModel> _logger;
|
||||
|
||||
private CancellationTokenSource? _downloadCts;
|
||||
|
||||
public ObservableCollection<InstalledVersionViewModel> InstalledVersions { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
[NotifyPropertyChangedFor(nameof(HeroTitle))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
|
||||
private InstalledVersionViewModel? _selectedVersion;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
private VersionManifest? _availableUpdate;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
private string? _statusMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
private double _progressPercent;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
private string? _progressDetail;
|
||||
|
||||
public string StatusBadge
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsBusy) return "● En cours";
|
||||
if (AvailableUpdate is not null) return "🔔 MAJ disponible";
|
||||
if (SelectedVersion is null) return "Aucune version";
|
||||
return "● Installée";
|
||||
}
|
||||
}
|
||||
|
||||
public string PrimaryActionLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsBusy) return "Veuillez patienter…";
|
||||
if (AvailableUpdate is not null && SelectedVersion is null)
|
||||
return $"⬇ INSTALLER v{AvailableUpdate.Version}";
|
||||
if (AvailableUpdate is not null)
|
||||
return $"⬇ TÉLÉCHARGER v{AvailableUpdate.Version}";
|
||||
return SelectedVersion is null ? "Aucune version disponible" : "▶ LANCER";
|
||||
}
|
||||
}
|
||||
|
||||
public bool PrimaryActionEnabled => !IsBusy && (SelectedVersion is not null || AvailableUpdate is not null);
|
||||
|
||||
public string HeroTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (AvailableUpdate is not null) return $"PROSERVE — v{AvailableUpdate.Version}";
|
||||
return SelectedVersion is null ? "PROSERVE" : $"PROSERVE — {SelectedVersion.Display}";
|
||||
}
|
||||
}
|
||||
|
||||
public string LicenseSummary => "License : non configurée (v0.4)";
|
||||
|
||||
public string EmptyHint =>
|
||||
$"Aucune version trouvée dans :\n{_config.InstallRoot}\n\n" +
|
||||
"Configure l'URL serveur dans config.json puis clique « Vérifier les mises à jour », " +
|
||||
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe à cet emplacement.";
|
||||
|
||||
public Visibility EmptyHintVisibility => InstalledVersions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility FooterVisibility => IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public string FooterText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ProgressDetail)) return ProgressDetail;
|
||||
return StatusMessage ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public MainViewModel(
|
||||
IInstallationRegistry registry,
|
||||
IProcessLauncher processLauncher,
|
||||
IConfigStore configStore,
|
||||
LocalConfig config,
|
||||
IManifestService manifestService,
|
||||
IUpdateChecker updateChecker,
|
||||
IDownloadManager downloadManager,
|
||||
IZipInstaller zipInstaller,
|
||||
ILogger<MainViewModel> logger)
|
||||
{
|
||||
_registry = registry;
|
||||
_processLauncher = processLauncher;
|
||||
_configStore = configStore;
|
||||
_config = config;
|
||||
_manifestService = manifestService;
|
||||
_updateChecker = updateChecker;
|
||||
_downloadManager = downloadManager;
|
||||
_zipInstaller = zipInstaller;
|
||||
_logger = logger;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Refresh()
|
||||
{
|
||||
InstalledVersions.Clear();
|
||||
foreach (var v in _registry.Scan())
|
||||
InstalledVersions.Add(new InstalledVersionViewModel(v));
|
||||
|
||||
SelectedVersion = !string.IsNullOrEmpty(_config.LastLaunchedVersion)
|
||||
? InstalledVersions.FirstOrDefault(v => v.Model.Version == _config.LastLaunchedVersion)
|
||||
?? InstalledVersions.FirstOrDefault()
|
||||
: InstalledVersions.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(EmptyHint));
|
||||
OnPropertyChanged(nameof(EmptyHintVisibility));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "Vérification des mises à jour…";
|
||||
try
|
||||
{
|
||||
var result = await _updateChecker.CheckAsync(CancellationToken.None);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
StatusMessage = $"Erreur : {result.Error}";
|
||||
AvailableUpdate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
||||
{
|
||||
AvailableUpdate = result.LatestRemote;
|
||||
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
|
||||
}
|
||||
else
|
||||
{
|
||||
AvailableUpdate = null;
|
||||
StatusMessage = result.LatestRemote is not null
|
||||
? $"À jour (v{result.LatestRemote.Version})"
|
||||
: "Aucune version distante trouvée";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Update check failed");
|
||||
StatusMessage = $"Erreur : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCheckUpdates() => !IsBusy;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(PrimaryActionEnabled))]
|
||||
private async Task PrimaryActionAsync()
|
||||
{
|
||||
if (AvailableUpdate is not null)
|
||||
{
|
||||
await DownloadAndInstallAsync(AvailableUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedVersion is null) return;
|
||||
try
|
||||
{
|
||||
_processLauncher.Launch(SelectedVersion.Model);
|
||||
_config.LastLaunchedVersion = SelectedVersion.Model.Version;
|
||||
_configStore.Save(_config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to launch");
|
||||
MessageBox.Show(
|
||||
$"Impossible de lancer la version :\n\n{ex.Message}",
|
||||
"Erreur de lancement",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAndInstallAsync(VersionManifest version)
|
||||
{
|
||||
// 1) Récupère release notes et propose dialog
|
||||
string notes;
|
||||
try
|
||||
{
|
||||
notes = !string.IsNullOrEmpty(version.ReleaseNotesUrl)
|
||||
? await _manifestService.FetchReleaseNotesAsync(version.ReleaseNotesUrl, CancellationToken.None)
|
||||
: "_Aucune release note fournie._";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch release notes");
|
||||
notes = "_Release notes indisponibles._";
|
||||
}
|
||||
|
||||
var dialog = new UpdateAvailableDialog(version, notes)
|
||||
{
|
||||
Owner = Application.Current.MainWindow
|
||||
};
|
||||
dialog.ShowDialog();
|
||||
if (!dialog.DownloadRequested) return;
|
||||
|
||||
IsBusy = true;
|
||||
_downloadCts = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
var url = new Uri(version.Download.Url);
|
||||
var job = new DownloadJob(version.Version, url, version.Download.SizeBytes, version.Download.Sha256);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(p => UpdateDlProgress(version.Version, p));
|
||||
StatusMessage = $"Téléchargement v{version.Version}…";
|
||||
var zipPath = await _downloadManager.DownloadAsync(job, progress, _downloadCts.Token);
|
||||
|
||||
// 2) Extraction
|
||||
StatusMessage = $"Installation v{version.Version}…";
|
||||
var folderName = version.GetInstallFolderName();
|
||||
var target = Path.Combine(_config.InstallRoot, folderName);
|
||||
var installProgress = new Progress<InstallProgress>(ip =>
|
||||
{
|
||||
if (ip.BytesTotal <= 0) return;
|
||||
ProgressPercent = (double)ip.BytesDone / ip.BytesTotal * 100.0;
|
||||
ProgressDetail = $"Extraction : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
|
||||
});
|
||||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, _downloadCts.Token);
|
||||
|
||||
// 3) Nettoyage du ZIP téléchargé (le user peut le rétablir depuis le serveur s'il en a besoin)
|
||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||
|
||||
StatusMessage = $"v{version.Version} installée avec succès";
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
AvailableUpdate = null;
|
||||
Refresh();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusMessage = "Téléchargement annulé";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Download/install failed");
|
||||
MessageBox.Show(
|
||||
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
|
||||
"Erreur",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
StatusMessage = $"Erreur : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadCts?.Dispose();
|
||||
_downloadCts = null;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDlProgress(string version, DownloadProgress p)
|
||||
{
|
||||
if (p.TotalBytes > 0)
|
||||
ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("⬇ v").Append(version).Append(" : ");
|
||||
sb.Append(FormatSize(p.BytesDownloaded));
|
||||
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
|
||||
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
|
||||
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
|
||||
ProgressDetail = sb.ToString();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenFolder()
|
||||
{
|
||||
var path = SelectedVersion?.Model.FolderPath ?? _config.InstallRoot;
|
||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
UseShellExecute = true,
|
||||
Verb = "open"
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelDownload()
|
||||
{
|
||||
_downloadCts?.Cancel();
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 o";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
|
||||
private static string FormatEta(TimeSpan eta)
|
||||
{
|
||||
if (eta.TotalHours >= 1) return $"{(int)eta.TotalHours}h{eta.Minutes:D2}";
|
||||
if (eta.TotalMinutes >= 1) return $"{(int)eta.TotalMinutes}m{eta.Seconds:D2}";
|
||||
return $"{(int)eta.TotalSeconds}s";
|
||||
}
|
||||
}
|
||||
194
src/PSLauncher.App/Views/MainWindow.xaml
Normal file
194
src/PSLauncher.App/Views/MainWindow.xaml
Normal file
@@ -0,0 +1,194 @@
|
||||
<Window x:Class="PSLauncher.App.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
||||
xmlns:views="clr-namespace:PSLauncher.App.Views"
|
||||
Title="PROSERVE Launcher"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="900" MinHeight="600"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||
<RowDefinition Height="*" /> <!-- Body -->
|
||||
<RowDefinition Height="Auto" /> <!-- Footer (download) -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Top bar -->
|
||||
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
||||
Padding="20,12">
|
||||
<Grid>
|
||||
<TextBlock Text="PROSERVE" FontSize="18" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding LicenseSummary}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Body : sidebar + content -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<Border Grid.Column="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,1,0">
|
||||
<StackPanel>
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="📚 Bibliothèque" IsChecked="True" />
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="📰 Nouveautés" IsEnabled="False" />
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="⚙️ Paramètres" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Library content -->
|
||||
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="40,30">
|
||||
|
||||
<!-- Hero -->
|
||||
<Border Background="#2A2A30" CornerRadius="8" Height="280"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
||||
<Grid>
|
||||
<TextBlock Text="PROSERVE"
|
||||
FontSize="64" FontWeight="Bold"
|
||||
Foreground="#3D3D45"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
<Border VerticalAlignment="Bottom" HorizontalAlignment="Left"
|
||||
Margin="24" Background="#000000B0"
|
||||
CornerRadius="4" Padding="12,6">
|
||||
<TextBlock Text="{Binding HeroTitle}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Action row -->
|
||||
<Grid Margin="0,24,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Version selector -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Version :" Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<ComboBox Width="160"
|
||||
ItemsSource="{Binding InstalledVersions}"
|
||||
DisplayMemberPath="Display"
|
||||
SelectedItem="{Binding SelectedVersion, Mode=TwoWay}" />
|
||||
<Border Style="{StaticResource VersionPill}" Margin="12,0,0,0">
|
||||
<TextBlock Text="{Binding StatusBadge}" FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Primary action -->
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource PrimaryButton}"
|
||||
Content="{Binding PrimaryActionLabel}"
|
||||
Command="{Binding PrimaryActionCommand}"
|
||||
IsEnabled="{Binding PrimaryActionEnabled}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,16,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔄 Vérifier les mises à jour"
|
||||
Command="{Binding CheckForUpdatesCommand}"
|
||||
Margin="0,0,10,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="📁 Ouvrir le dossier"
|
||||
Command="{Binding OpenFolderCommand}"
|
||||
Margin="0,0,10,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔍 Rescanner local"
|
||||
Command="{Binding RefreshCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Installed versions list -->
|
||||
<TextBlock Text="Versions installées" FontSize="16" FontWeight="SemiBold"
|
||||
Margin="0,30,0,12"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
<ItemsControl ItemsSource="{Binding InstalledVersions}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
CornerRadius="6" Margin="0,0,0,8" Padding="16,12"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Display}"
|
||||
FontSize="15" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Subtitle}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Empty state -->
|
||||
<TextBlock Text="{Binding EmptyHint}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
TextWrapping="Wrap" Margin="0,12,0,0"
|
||||
Visibility="{Binding EmptyHintVisibility}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Footer (download/install status) -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||
Padding="20,8"
|
||||
Visibility="{Binding FooterVisibility}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding FooterText}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
<ProgressBar Height="4" Margin="0,4,0,0"
|
||||
Value="{Binding ProgressPercent}"
|
||||
Maximum="100"
|
||||
Foreground="{StaticResource Brush.Accent}"
|
||||
Background="#2A2A30"
|
||||
BorderThickness="0" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="Annuler"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0,0,0"
|
||||
Command="{Binding CancelDownloadCommand}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
11
src/PSLauncher.App/Views/MainWindow.xaml.cs
Normal file
11
src/PSLauncher.App/Views/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
50
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
Normal file
50
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
Normal file
@@ -0,0 +1,50 @@
|
||||
<Window x:Class="PSLauncher.App.Views.UpdateAvailableDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Mise à jour disponible"
|
||||
Width="640" Height="540"
|
||||
MinWidth="480" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding Title}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="{Binding Subtitle}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,4,0,18" />
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="16">
|
||||
<FlowDocumentScrollViewer
|
||||
Document="{Binding ReleaseNotesDocument}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Background="Transparent" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="Plus tard"
|
||||
IsCancel="True"
|
||||
Margin="0,0,12,0"
|
||||
Click="OnLater" />
|
||||
<Button Style="{StaticResource PrimaryButton}"
|
||||
Content="⬇ Télécharger"
|
||||
Padding="32,10"
|
||||
Click="OnDownload" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
61
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml.cs
Normal file
61
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using Markdig;
|
||||
using Markdig.Wpf;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
public partial class UpdateAvailableDialog : Window
|
||||
{
|
||||
public UpdateAvailableDialog(VersionManifest version, string releaseNotesMarkdown)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.UseSupportedExtensions()
|
||||
.Build();
|
||||
FlowDocument doc;
|
||||
try
|
||||
{
|
||||
doc = Markdig.Wpf.Markdown.ToFlowDocument(releaseNotesMarkdown, pipeline);
|
||||
}
|
||||
catch
|
||||
{
|
||||
doc = new FlowDocument(new Paragraph(new Run(releaseNotesMarkdown)));
|
||||
}
|
||||
|
||||
DataContext = new
|
||||
{
|
||||
Title = $"Proserve v{version.Version} disponible",
|
||||
Subtitle = $"Date de release : {version.ReleasedAt.ToLocalTime():dd MMMM yyyy} • {FormatSize(version.Download.SizeBytes)}",
|
||||
ReleaseNotesDocument = doc
|
||||
};
|
||||
}
|
||||
|
||||
public bool DownloadRequested { get; private set; }
|
||||
|
||||
private void OnDownload(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DownloadRequested = true;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnLater(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DownloadRequested = false;
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "taille inconnue";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
}
|
||||
81
src/PSLauncher.Core/Configuration/ConfigStore.cs
Normal file
81
src/PSLauncher.Core/Configuration/ConfigStore.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Configuration;
|
||||
|
||||
public sealed class ConfigStore : IConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<ConfigStore> _logger;
|
||||
private readonly string _configFile;
|
||||
|
||||
public string ConfigDirectory { get; }
|
||||
|
||||
public ConfigStore(ILogger<ConfigStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
ConfigDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher");
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
_configFile = Path.Combine(ConfigDirectory, "config.json");
|
||||
}
|
||||
|
||||
public LocalConfig Load()
|
||||
{
|
||||
if (!File.Exists(_configFile))
|
||||
{
|
||||
var fresh = new LocalConfig
|
||||
{
|
||||
InstallRoot = ResolveDefaultInstallRoot()
|
||||
};
|
||||
Save(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(_configFile);
|
||||
var cfg = JsonSerializer.Deserialize<LocalConfig>(fs, JsonOptions) ?? new LocalConfig();
|
||||
if (string.IsNullOrWhiteSpace(cfg.InstallRoot))
|
||||
cfg.InstallRoot = ResolveDefaultInstallRoot();
|
||||
return cfg;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load config; using defaults");
|
||||
return new LocalConfig { InstallRoot = ResolveDefaultInstallRoot() };
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(LocalConfig config)
|
||||
{
|
||||
var tmp = _configFile + ".tmp";
|
||||
using (var fs = File.Create(tmp))
|
||||
{
|
||||
JsonSerializer.Serialize(fs, config, JsonOptions);
|
||||
}
|
||||
File.Move(tmp, _configFile, overwrite: true);
|
||||
}
|
||||
|
||||
private static string ResolveDefaultInstallRoot()
|
||||
{
|
||||
var exeDir = AppContext.BaseDirectory;
|
||||
var sibling = Path.Combine(Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!, "ASTERION_VR");
|
||||
if (Directory.Exists(sibling)) return sibling;
|
||||
|
||||
var dev = @"C:\ASTERION\GIT\PS_Launcher\ASTERION_VR";
|
||||
if (Directory.Exists(dev)) return dev;
|
||||
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"ASTERION_VR");
|
||||
}
|
||||
}
|
||||
10
src/PSLauncher.Core/Configuration/IConfigStore.cs
Normal file
10
src/PSLauncher.Core/Configuration/IConfigStore.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Configuration;
|
||||
|
||||
public interface IConfigStore
|
||||
{
|
||||
LocalConfig Load();
|
||||
void Save(LocalConfig config);
|
||||
string ConfigDirectory { get; }
|
||||
}
|
||||
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal file
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Downloads;
|
||||
|
||||
public sealed class DownloadManager : IDownloadManager
|
||||
{
|
||||
private const int BufferSize = 1 << 20; // 1 MiB
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly IIntegrityService _integrity;
|
||||
private readonly ILogger<DownloadManager> _logger;
|
||||
|
||||
public DownloadManager(
|
||||
HttpClient http,
|
||||
IConfigStore configStore,
|
||||
IIntegrityService integrity,
|
||||
ILogger<DownloadManager> logger)
|
||||
{
|
||||
_http = http;
|
||||
_configStore = configStore;
|
||||
_integrity = integrity;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge le ZIP et retourne le chemin du fichier final (SHA-256 vérifié).
|
||||
/// v0.2 : pas de reprise — un échec impose de tout recommencer. Resume en v0.3.
|
||||
/// </summary>
|
||||
public async Task<string> DownloadAsync(
|
||||
DownloadJob job,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
|
||||
Directory.CreateDirectory(dir);
|
||||
var partial = Path.Combine(dir, $"proserve-{job.Version}.zip.partial");
|
||||
var final = Path.Combine(dir, $"proserve-{job.Version}.zip");
|
||||
|
||||
// Vérif d'espace disque sur le volume du cache (× 1.05 pour marge)
|
||||
if (job.ExpectedSize > 0)
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(dir)!);
|
||||
var needed = (long)(job.ExpectedSize * 1.05);
|
||||
if (drive.AvailableFreeSpace < needed)
|
||||
throw new IOException(
|
||||
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
||||
}
|
||||
|
||||
if (File.Exists(partial)) File.Delete(partial);
|
||||
|
||||
_logger.LogInformation("Downloading {Url} -> {Path}", job.Url, partial);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var total = resp.Content.Headers.ContentLength ?? job.ExpectedSize;
|
||||
|
||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
|
||||
await using (var dst = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true))
|
||||
{
|
||||
var buffer = new byte[BufferSize];
|
||||
long read = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
long lastReportRead = 0;
|
||||
var lastReport = sw.Elapsed;
|
||||
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||
read += n;
|
||||
|
||||
var elapsed = sw.Elapsed;
|
||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||
{
|
||||
var deltaBytes = read - lastReportRead;
|
||||
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
|
||||
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
|
||||
TimeSpan? eta = null;
|
||||
if (bps > 0 && total > 0) eta = TimeSpan.FromSeconds((total - read) / bps);
|
||||
progress?.Report(new DownloadProgress(read, total, bps, eta));
|
||||
lastReportRead = read;
|
||||
lastReport = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||
progress?.Report(new DownloadProgress(read, total, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
// Vérif taille
|
||||
var actualSize = new FileInfo(partial).Length;
|
||||
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
||||
{
|
||||
File.Delete(partial);
|
||||
throw new InvalidDataException(
|
||||
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
||||
}
|
||||
|
||||
// Vérif SHA-256
|
||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Verifying SHA-256...");
|
||||
var sha = await _integrity.ComputeSha256Async(partial, null, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(partial);
|
||||
throw new InvalidDataException(
|
||||
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée (à corriger côté serveur)");
|
||||
}
|
||||
|
||||
if (File.Exists(final)) File.Delete(final);
|
||||
File.Move(partial, final);
|
||||
return final;
|
||||
}
|
||||
}
|
||||
17
src/PSLauncher.Core/Downloads/IDownloadManager.cs
Normal file
17
src/PSLauncher.Core/Downloads/IDownloadManager.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Downloads;
|
||||
|
||||
public interface IDownloadManager
|
||||
{
|
||||
Task<string> DownloadAsync(
|
||||
DownloadJob job,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record DownloadJob(
|
||||
string Version,
|
||||
Uri Url,
|
||||
long ExpectedSize,
|
||||
string ExpectedSha256);
|
||||
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal file
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public interface IInstallationRegistry
|
||||
{
|
||||
IReadOnlyList<InstalledVersion> Scan();
|
||||
InstalledVersion? Get(string version);
|
||||
bool IsInstalled(string version);
|
||||
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
|
||||
}
|
||||
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal file
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public interface IZipInstaller
|
||||
{
|
||||
Task<string> InstallAsync(
|
||||
string zipPath,
|
||||
string targetFolder,
|
||||
IProgress<InstallProgress>? progress,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record InstallProgress(long EntriesDone, long EntriesTotal, long BytesDone, long BytesTotal, string CurrentEntry);
|
||||
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal file
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||
{
|
||||
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
|
||||
|
||||
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex VersionFolderRegex();
|
||||
|
||||
private readonly ILogger<InstallationRegistry> _logger;
|
||||
private readonly Func<string> _installRootProvider;
|
||||
|
||||
public InstallationRegistry(ILogger<InstallationRegistry> logger, Func<string> installRootProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_installRootProvider = installRootProvider;
|
||||
}
|
||||
|
||||
public IReadOnlyList<InstalledVersion> Scan()
|
||||
{
|
||||
var root = _installRootProvider();
|
||||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||
{
|
||||
_logger.LogWarning("Install root does not exist: {Root}", root);
|
||||
return Array.Empty<InstalledVersion>();
|
||||
}
|
||||
|
||||
var results = new List<InstalledVersion>();
|
||||
foreach (var dir in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
var name = Path.GetFileName(dir);
|
||||
var match = VersionFolderRegex().Match(name);
|
||||
if (!match.Success) continue;
|
||||
|
||||
var exe = Path.Combine(dir, ExecutableName);
|
||||
if (!File.Exists(exe))
|
||||
{
|
||||
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new DirectoryInfo(dir);
|
||||
results.Add(new InstalledVersion(
|
||||
Version: match.Groups["v"].Value,
|
||||
FolderPath: dir,
|
||||
ExecutablePath: exe,
|
||||
InstalledAt: info.CreationTimeUtc,
|
||||
SizeBytes: SafeDirectorySize(dir)));
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(v => SemVer.Parse(v.Version))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public InstalledVersion? Get(string version) =>
|
||||
Scan().FirstOrDefault(v => v.Version == version);
|
||||
|
||||
public bool IsInstalled(string version) => Get(version) is not null;
|
||||
|
||||
public Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct)
|
||||
{
|
||||
var existing = Get(version) ?? throw new InvalidOperationException($"Version {version} not installed");
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var trash = Path.Combine(_installRootProvider(), ".trash",
|
||||
$"{Path.GetFileName(existing.FolderPath)}-{DateTime.UtcNow:yyyyMMddHHmmss}");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(trash)!);
|
||||
Directory.Move(existing.FolderPath, trash);
|
||||
try
|
||||
{
|
||||
Directory.Delete(trash, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete trash {Path}", trash);
|
||||
}
|
||||
progress?.Report(1.0);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private long SafeDirectorySize(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DirectoryInfo(dir)
|
||||
.EnumerateFiles("*", SearchOption.AllDirectories)
|
||||
.Sum(f => f.Length);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal file
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
|
||||
{
|
||||
public static SemVer Parse(string s)
|
||||
{
|
||||
var parts = s.Split('.');
|
||||
if (parts.Length != 3
|
||||
|| !int.TryParse(parts[0], out var maj)
|
||||
|| !int.TryParse(parts[1], out var min)
|
||||
|| !int.TryParse(parts[2], out var pat))
|
||||
{
|
||||
return new SemVer(0, 0, 0);
|
||||
}
|
||||
return new SemVer(maj, min, pat);
|
||||
}
|
||||
|
||||
public int CompareTo(SemVer other)
|
||||
{
|
||||
var c = Major.CompareTo(other.Major);
|
||||
if (c != 0) return c;
|
||||
c = Minor.CompareTo(other.Minor);
|
||||
if (c != 0) return c;
|
||||
return Patch.CompareTo(other.Patch);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Major}.{Minor}.{Patch}";
|
||||
}
|
||||
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal file
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PSLauncher.Core.Installations;
|
||||
|
||||
public sealed class ZipInstaller : IZipInstaller
|
||||
{
|
||||
private readonly ILogger<ZipInstaller> _logger;
|
||||
|
||||
public ZipInstaller(ILogger<ZipInstaller> logger) => _logger = logger;
|
||||
|
||||
public async Task<string> InstallAsync(
|
||||
string zipPath,
|
||||
string targetFolder,
|
||||
IProgress<InstallProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(zipPath))
|
||||
throw new FileNotFoundException("ZIP not found", zipPath);
|
||||
|
||||
var stagingFolder = targetFolder + ".tmp";
|
||||
if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true);
|
||||
Directory.CreateDirectory(stagingFolder);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
||||
|
||||
if (Directory.Exists(targetFolder))
|
||||
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
|
||||
|
||||
Directory.Move(stagingFolder, targetFolder);
|
||||
_logger.LogInformation("Installed to {Target}", targetFolder);
|
||||
return targetFolder;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nettoie le staging si échec
|
||||
try { if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractZip(string zipPath, string stagingFolder, IProgress<InstallProgress>? progress, CancellationToken ct)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
long totalBytes = 0;
|
||||
foreach (var e in archive.Entries) totalBytes += e.Length;
|
||||
long total = archive.Entries.Count;
|
||||
|
||||
long doneEntries = 0;
|
||||
long doneBytes = 0;
|
||||
|
||||
// Détecte si toutes les entrées partagent un préfixe racine commun (ex: "Proserve v1.4.6/")
|
||||
// → on strip ce préfixe pour aplatir l'arbo dans stagingFolder.
|
||||
var rootPrefix = DetectCommonRootPrefix(archive);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var relative = entry.FullName;
|
||||
if (rootPrefix is not null && relative.StartsWith(rootPrefix, StringComparison.Ordinal))
|
||||
relative = relative[rootPrefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(relative)) { doneEntries++; continue; }
|
||||
|
||||
var target = Path.GetFullPath(Path.Combine(stagingFolder, relative));
|
||||
if (!target.StartsWith(Path.GetFullPath(stagingFolder), StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"Entrée ZIP suspecte (zip-slip) : {entry.FullName}");
|
||||
|
||||
if (relative.EndsWith('/') || relative.EndsWith('\\'))
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
entry.ExtractToFile(target, overwrite: true);
|
||||
doneBytes += entry.Length;
|
||||
}
|
||||
doneEntries++;
|
||||
progress?.Report(new InstallProgress(doneEntries, total, doneBytes, totalBytes, relative));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DetectCommonRootPrefix(ZipArchive archive)
|
||||
{
|
||||
string? prefix = null;
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
var slash = e.FullName.IndexOf('/');
|
||||
if (slash <= 0) return null;
|
||||
var first = e.FullName[..(slash + 1)];
|
||||
if (prefix is null) prefix = first;
|
||||
else if (prefix != first) return null;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
6
src/PSLauncher.Core/Integrity/IIntegrityService.cs
Normal file
6
src/PSLauncher.Core/Integrity/IIntegrityService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PSLauncher.Core.Integrity;
|
||||
|
||||
public interface IIntegrityService
|
||||
{
|
||||
Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct);
|
||||
}
|
||||
26
src/PSLauncher.Core/Integrity/IntegrityService.cs
Normal file
26
src/PSLauncher.Core/Integrity/IntegrityService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PSLauncher.Core.Integrity;
|
||||
|
||||
public sealed class IntegrityService : IIntegrityService
|
||||
{
|
||||
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
|
||||
{
|
||||
const int bufferSize = 1 << 20; // 1 MiB
|
||||
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
var buffer = new byte[bufferSize];
|
||||
long total = fs.Length;
|
||||
long read = 0;
|
||||
int n;
|
||||
while ((n = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
sha.TransformBlock(buffer, 0, n, null, 0);
|
||||
read += n;
|
||||
if (total > 0) progress?.Report((double)read / total);
|
||||
}
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
9
src/PSLauncher.Core/Manifests/IManifestService.cs
Normal file
9
src/PSLauncher.Core/Manifests/IManifestService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
|
||||
public interface IManifestService
|
||||
{
|
||||
Task<RemoteManifest> FetchAsync(CancellationToken ct);
|
||||
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
|
||||
}
|
||||
46
src/PSLauncher.Core/Manifests/ManifestService.cs
Normal file
46
src/PSLauncher.Core/Manifests/ManifestService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
|
||||
public sealed class ManifestService : IManifestService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly Func<string> _serverBaseUrlProvider;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
|
||||
public ManifestService(HttpClient http, Func<string> serverBaseUrlProvider, ILogger<ManifestService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RemoteManifest> FetchAsync(CancellationToken ct)
|
||||
{
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
_logger.LogInformation("Fetching manifest: {Url}", url);
|
||||
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var manifest = await resp.Content.ReadFromJsonAsync<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Empty manifest");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public async Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Fetching release notes: {Url}", url);
|
||||
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string TrimSlash(string s) => s.TrimEnd('/');
|
||||
}
|
||||
18
src/PSLauncher.Core/PSLauncher.Core.csproj
Normal file
18
src/PSLauncher.Core/PSLauncher.Core.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PSLauncher.Models\PSLauncher.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
src/PSLauncher.Core/Process/IProcessLauncher.cs
Normal file
10
src/PSLauncher.Core/Process/IProcessLauncher.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using PSLauncher.Models;
|
||||
using SysProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace PSLauncher.Core.Process;
|
||||
|
||||
public interface IProcessLauncher
|
||||
{
|
||||
SysProcess Launch(InstalledVersion version, string[]? args = null);
|
||||
bool IsRunning(InstalledVersion version);
|
||||
}
|
||||
44
src/PSLauncher.Core/Process/ProcessLauncher.cs
Normal file
44
src/PSLauncher.Core/Process/ProcessLauncher.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
using SysProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace PSLauncher.Core.Process;
|
||||
|
||||
public sealed class ProcessLauncher : IProcessLauncher
|
||||
{
|
||||
private readonly ILogger<ProcessLauncher> _logger;
|
||||
|
||||
public ProcessLauncher(ILogger<ProcessLauncher> logger) => _logger = logger;
|
||||
|
||||
public SysProcess Launch(InstalledVersion version, string[]? args = null)
|
||||
{
|
||||
if (!File.Exists(version.ExecutablePath))
|
||||
throw new FileNotFoundException("Executable not found", version.ExecutablePath);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = version.ExecutablePath,
|
||||
WorkingDirectory = version.FolderPath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
if (args is not null)
|
||||
{
|
||||
foreach (var arg in args) psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
|
||||
return SysProcess.Start(psi)
|
||||
?? throw new InvalidOperationException("Process.Start returned null");
|
||||
}
|
||||
|
||||
public bool IsRunning(InstalledVersion version)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(version.ExecutablePath);
|
||||
return SysProcess.GetProcessesByName(name).Any(p =>
|
||||
{
|
||||
try { return string.Equals(p.MainModule?.FileName, version.ExecutablePath, StringComparison.OrdinalIgnoreCase); }
|
||||
catch { return false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
16
src/PSLauncher.Core/Updates/IUpdateChecker.cs
Normal file
16
src/PSLauncher.Core/Updates/IUpdateChecker.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Updates;
|
||||
|
||||
public interface IUpdateChecker
|
||||
{
|
||||
Task<UpdateCheckResult> CheckAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record UpdateCheckResult(
|
||||
RemoteManifest? Manifest,
|
||||
VersionManifest? LatestRemote,
|
||||
InstalledVersion? CurrentInstalled,
|
||||
bool IsLatestInstalled,
|
||||
bool IsLatestNewerThanInstalled,
|
||||
string? Error);
|
||||
53
src/PSLauncher.Core/Updates/UpdateChecker.cs
Normal file
53
src/PSLauncher.Core/Updates/UpdateChecker.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Updates;
|
||||
|
||||
public sealed class UpdateChecker : IUpdateChecker
|
||||
{
|
||||
private readonly IManifestService _manifestService;
|
||||
private readonly IInstallationRegistry _registry;
|
||||
private readonly ILogger<UpdateChecker> _logger;
|
||||
|
||||
public UpdateChecker(
|
||||
IManifestService manifestService,
|
||||
IInstallationRegistry registry,
|
||||
ILogger<UpdateChecker> logger)
|
||||
{
|
||||
_manifestService = manifestService;
|
||||
_registry = registry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
|
||||
var latest = manifest.Versions.FirstOrDefault(v => v.Version == manifest.Latest)
|
||||
?? manifest.Versions
|
||||
.OrderByDescending(v => SemVer.Parse(v.Version))
|
||||
.FirstOrDefault();
|
||||
|
||||
var installed = _registry.Scan();
|
||||
var latestInstalled = installed
|
||||
.OrderByDescending(v => SemVer.Parse(v.Version))
|
||||
.FirstOrDefault();
|
||||
|
||||
var isLatestInstalled = latest is not null && latestInstalled is not null
|
||||
&& latestInstalled.Version == latest.Version;
|
||||
var isNewer = latest is not null
|
||||
&& (latestInstalled is null
|
||||
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0);
|
||||
|
||||
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Update check failed");
|
||||
return new UpdateCheckResult(null, null, null, false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/PSLauncher.Models/DownloadProgress.cs
Normal file
7
src/PSLauncher.Models/DownloadProgress.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace PSLauncher.Models;
|
||||
|
||||
public sealed record DownloadProgress(
|
||||
long BytesDownloaded,
|
||||
long TotalBytes,
|
||||
double BytesPerSecond,
|
||||
TimeSpan? Eta);
|
||||
8
src/PSLauncher.Models/InstalledVersion.cs
Normal file
8
src/PSLauncher.Models/InstalledVersion.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace PSLauncher.Models;
|
||||
|
||||
public sealed record InstalledVersion(
|
||||
string Version,
|
||||
string FolderPath,
|
||||
string ExecutablePath,
|
||||
DateTime InstalledAt,
|
||||
long SizeBytes);
|
||||
18
src/PSLauncher.Models/LocalConfig.cs
Normal file
18
src/PSLauncher.Models/LocalConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace PSLauncher.Models;
|
||||
|
||||
public sealed class LocalConfig
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string ServerBaseUrl { get; set; } = "https://example.com/PS_Launcher/api";
|
||||
public string InstallRoot { get; set; } = string.Empty;
|
||||
public string? LastLaunchedVersion { get; set; }
|
||||
public LicenseConfig License { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LicenseConfig
|
||||
{
|
||||
public string? EncryptedKey { get; set; }
|
||||
public DateTime? LastValidationAt { get; set; }
|
||||
public DateTime? CachedEntitlementUntil { get; set; }
|
||||
public string? CachedOwnerName { get; set; }
|
||||
}
|
||||
10
src/PSLauncher.Models/PSLauncher.Models.csproj
Normal file
10
src/PSLauncher.Models/PSLauncher.Models.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
71
src/PSLauncher.Models/RemoteManifest.cs
Normal file
71
src/PSLauncher.Models/RemoteManifest.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PSLauncher.Models;
|
||||
|
||||
public sealed class RemoteManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("product")]
|
||||
public string Product { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("latest")]
|
||||
public string Latest { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("publishedAt")]
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("minLauncherVersion")]
|
||||
public string? MinLauncherVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public List<VersionManifest> Versions { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VersionManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("releasedAt")]
|
||||
public DateTime ReleasedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("executable")]
|
||||
public string Executable { get; set; } = "PROSERVE_UE_5_5.exe";
|
||||
|
||||
[JsonPropertyName("installFolderTemplate")]
|
||||
public string InstallFolderTemplate { get; set; } = "Proserve v{version}";
|
||||
|
||||
[JsonPropertyName("download")]
|
||||
public DownloadDescriptor Download { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("releaseNotesUrl")]
|
||||
public string? ReleaseNotesUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("minLicenseDate")]
|
||||
public DateTime? MinLicenseDate { get; set; }
|
||||
|
||||
[JsonPropertyName("availableForDownload")]
|
||||
public bool AvailableForDownload { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("heroImageUrl")]
|
||||
public string? HeroImageUrl { get; set; }
|
||||
|
||||
public string GetInstallFolderName() => InstallFolderTemplate.Replace("{version}", Version);
|
||||
}
|
||||
|
||||
public sealed class DownloadDescriptor
|
||||
{
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user