From 1c8c6803e80ffe7b3748b1133d486387a61d9f0d Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 1 May 2026 08:54:45 +0200 Subject: [PATCH] Initial scaffolding: PS_Launcher v0.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 37 ++ PS_Launcher.sln | 33 ++ README.md | 54 +++ server/.htaccess | 28 ++ server/README.md | 66 ++++ server/api/config.example.php | 30 ++ server/api/index.php | 52 +++ server/api/lib/Response.php | 38 ++ server/api/routes/Manifest.php | 23 ++ server/api/routes/Releasenotes.php | 24 ++ server/builds/.gitkeep | 1 + server/manifest/versions.json | 38 ++ server/releasenotes/1.4.5.md | 12 + server/releasenotes/1.4.6.md | 18 + server/tools/sign-manifest.php | 52 +++ src/PSLauncher.App/App.xaml | 12 + src/PSLauncher.App/App.xaml.cs | 84 ++++ src/PSLauncher.App/PSLauncher.App.csproj | 37 ++ src/PSLauncher.App/Resources/Theme.xaml | 103 +++++ .../ViewModels/InstalledVersionViewModel.cs | 27 ++ .../ViewModels/MainViewModel.cs | 359 ++++++++++++++++++ src/PSLauncher.App/Views/MainWindow.xaml | 194 ++++++++++ src/PSLauncher.App/Views/MainWindow.xaml.cs | 11 + .../Views/UpdateAvailableDialog.xaml | 50 +++ .../Views/UpdateAvailableDialog.xaml.cs | 61 +++ .../Configuration/ConfigStore.cs | 81 ++++ .../Configuration/IConfigStore.cs | 10 + .../Downloads/DownloadManager.cs | 126 ++++++ .../Downloads/IDownloadManager.cs | 17 + .../Installations/IInstallationRegistry.cs | 11 + .../Installations/IZipInstaller.cs | 12 + .../Installations/InstallationRegistry.cs | 99 +++++ src/PSLauncher.Core/Installations/SemVer.cs | 28 ++ .../Installations/ZipInstaller.cs | 99 +++++ .../Integrity/IIntegrityService.cs | 6 + .../Integrity/IntegrityService.cs | 26 ++ .../Manifests/IManifestService.cs | 9 + .../Manifests/ManifestService.cs | 46 +++ src/PSLauncher.Core/PSLauncher.Core.csproj | 18 + .../Process/IProcessLauncher.cs | 10 + .../Process/ProcessLauncher.cs | 44 +++ src/PSLauncher.Core/Updates/IUpdateChecker.cs | 16 + src/PSLauncher.Core/Updates/UpdateChecker.cs | 53 +++ src/PSLauncher.Models/DownloadProgress.cs | 7 + src/PSLauncher.Models/InstalledVersion.cs | 8 + src/PSLauncher.Models/LocalConfig.cs | 18 + .../PSLauncher.Models.csproj | 10 + src/PSLauncher.Models/RemoteManifest.cs | 71 ++++ 48 files changed, 2269 insertions(+) create mode 100644 .gitignore create mode 100644 PS_Launcher.sln create mode 100644 README.md create mode 100644 server/.htaccess create mode 100644 server/README.md create mode 100644 server/api/config.example.php create mode 100644 server/api/index.php create mode 100644 server/api/lib/Response.php create mode 100644 server/api/routes/Manifest.php create mode 100644 server/api/routes/Releasenotes.php create mode 100644 server/builds/.gitkeep create mode 100644 server/manifest/versions.json create mode 100644 server/releasenotes/1.4.5.md create mode 100644 server/releasenotes/1.4.6.md create mode 100644 server/tools/sign-manifest.php create mode 100644 src/PSLauncher.App/App.xaml create mode 100644 src/PSLauncher.App/App.xaml.cs create mode 100644 src/PSLauncher.App/PSLauncher.App.csproj create mode 100644 src/PSLauncher.App/Resources/Theme.xaml create mode 100644 src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs create mode 100644 src/PSLauncher.App/ViewModels/MainViewModel.cs create mode 100644 src/PSLauncher.App/Views/MainWindow.xaml create mode 100644 src/PSLauncher.App/Views/MainWindow.xaml.cs create mode 100644 src/PSLauncher.App/Views/UpdateAvailableDialog.xaml create mode 100644 src/PSLauncher.App/Views/UpdateAvailableDialog.xaml.cs create mode 100644 src/PSLauncher.Core/Configuration/ConfigStore.cs create mode 100644 src/PSLauncher.Core/Configuration/IConfigStore.cs create mode 100644 src/PSLauncher.Core/Downloads/DownloadManager.cs create mode 100644 src/PSLauncher.Core/Downloads/IDownloadManager.cs create mode 100644 src/PSLauncher.Core/Installations/IInstallationRegistry.cs create mode 100644 src/PSLauncher.Core/Installations/IZipInstaller.cs create mode 100644 src/PSLauncher.Core/Installations/InstallationRegistry.cs create mode 100644 src/PSLauncher.Core/Installations/SemVer.cs create mode 100644 src/PSLauncher.Core/Installations/ZipInstaller.cs create mode 100644 src/PSLauncher.Core/Integrity/IIntegrityService.cs create mode 100644 src/PSLauncher.Core/Integrity/IntegrityService.cs create mode 100644 src/PSLauncher.Core/Manifests/IManifestService.cs create mode 100644 src/PSLauncher.Core/Manifests/ManifestService.cs create mode 100644 src/PSLauncher.Core/PSLauncher.Core.csproj create mode 100644 src/PSLauncher.Core/Process/IProcessLauncher.cs create mode 100644 src/PSLauncher.Core/Process/ProcessLauncher.cs create mode 100644 src/PSLauncher.Core/Updates/IUpdateChecker.cs create mode 100644 src/PSLauncher.Core/Updates/UpdateChecker.cs create mode 100644 src/PSLauncher.Models/DownloadProgress.cs create mode 100644 src/PSLauncher.Models/InstalledVersion.cs create mode 100644 src/PSLauncher.Models/LocalConfig.cs create mode 100644 src/PSLauncher.Models/PSLauncher.Models.csproj create mode 100644 src/PSLauncher.Models/RemoteManifest.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3179e82 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/PS_Launcher.sln b/PS_Launcher.sln new file mode 100644 index 0000000..83b5f74 --- /dev/null +++ b/PS_Launcher.sln @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..be53ed9 --- /dev/null +++ b/README.md @@ -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. diff --git a/server/.htaccess b/server/.htaccess new file mode 100644 index 0000000..9a66488 --- /dev/null +++ b/server/.htaccess @@ -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 + + Require all denied + + +# Headers de sécurité + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "DENY" + Header set Referrer-Policy "no-referrer" + + +# Type MIME pour les ZIPs (assure la pris en compte de Range:) +AddType application/zip .zip diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d9271c1 --- /dev/null +++ b/server/README.md @@ -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. diff --git a/server/api/config.example.php b/server/api/config.example.php new file mode 100644 index 0000000..34c30f1 --- /dev/null +++ b/server/api/config.example.php @@ -0,0 +1,30 @@ + '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 +]; diff --git a/server/api/index.php b/server/api/index.php new file mode 100644 index 0000000..16b40ec --- /dev/null +++ b/server/api/index.php @@ -0,0 +1,52 @@ + '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); +} diff --git a/server/api/lib/Response.php b/server/api/lib/Response.php new file mode 100644 index 0000000..37f6e2c --- /dev/null +++ b/server/api/lib/Response.php @@ -0,0 +1,38 @@ + $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; + } +} diff --git a/server/api/routes/Manifest.php b/server/api/routes/Manifest.php new file mode 100644 index 0000000..ef1dd86 --- /dev/null +++ b/server/api/routes/Manifest.php @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs new file mode 100644 index 0000000..9dff42c --- /dev/null +++ b/src/PSLauncher.App/App.xaml.cs @@ -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(); + services.AddSingleton(sp => sp.GetRequiredService().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(sp => + new InstallationRegistry( + sp.GetRequiredService>(), + () => sp.GetRequiredService().InstallRoot)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => + new ManifestService( + sp.GetRequiredService(), + () => sp.GetRequiredService().ServerBaseUrl, + sp.GetRequiredService>())); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + + var window = _host.Services.GetRequiredService(); + window.DataContext = _host.Services.GetRequiredService(); + MainWindow = window; + window.Show(); + } + + protected override void OnExit(ExitEventArgs e) + { + _host?.Dispose(); + base.OnExit(e); + } +} diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj new file mode 100644 index 0000000..340e77b --- /dev/null +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -0,0 +1,37 @@ + + + + WinExe + net8.0-windows + true + enable + latest + enable + + PSLauncher + PSLauncher.App + + + true + true + win-x64 + true + true + + + + + + + + + + + + + + + + + + diff --git a/src/PSLauncher.App/Resources/Theme.xaml b/src/PSLauncher.App/Resources/Theme.xaml new file mode 100644 index 0000000..9a05fbd --- /dev/null +++ b/src/PSLauncher.App/Resources/Theme.xaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs b/src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs new file mode 100644 index 0000000..77b2d9d --- /dev/null +++ b/src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs @@ -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; +} diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..b0cb16a --- /dev/null +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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 _logger; + + private CancellationTokenSource? _downloadCts; + + public ObservableCollection 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 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(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(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"; + } +} diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml new file mode 100644 index 0000000..ee50c42 --- /dev/null +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +