Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee | ||
|
|
8fe67053e1 | ||
|
|
927df6c2b2 | ||
|
|
197f5186d0 | ||
|
|
98cf17c294 | ||
|
|
4da86bcc6a | ||
|
|
52761f53b6 | ||
|
|
cf75a22f19 | ||
|
|
009c84de9f | ||
|
|
9f9b9b9d4d | ||
|
|
12ab8377c9 | ||
|
|
5aa22df409 | ||
|
|
3df009912b | ||
|
|
89570947aa | ||
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db | ||
|
|
51eecbb57a | ||
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 | ||
|
|
97df0ba721 |
No files matched your search
@@ -68,3 +68,4 @@
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
website/deploy/*.conf text eol=lf
|
||||
@@ -3,9 +3,9 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Company>Xbhost</Company>
|
||||
<Authors>Xbhost</Authors>
|
||||
<Copyright>© Xbhost</Copyright>
|
||||
<Company>LanisysGames</Company>
|
||||
<Authors>LanisysGames</Authors>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
tools\ui-snapshot.bat = tools\ui-snapshot.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
tools\publish-site.bat = tools\publish-site.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -70,6 +71,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
|
||||
website\deploy\installer-site.sh = website\deploy\installer-site.sh
|
||||
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
|
||||
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
|
||||
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -182,6 +196,14 @@ Global
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -201,6 +223,8 @@ Global
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,7 +32,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| Projet | Rôle |
|
||||
|---|---|
|
||||
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -52,7 +53,8 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
|
||||
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
|
||||
│ FeedbackWindow (bouton « Aide »)
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
@@ -366,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
|
||||
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
|
||||
| Icône | Remplacez `Assets/launcher.ico` |
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
@@ -428,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
|
||||
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
|
||||
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
|
||||
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
|
||||
simplement qu'une nouvelle version existe.
|
||||
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
|
||||
|
||||
### Sécurité
|
||||
|
||||
@@ -440,6 +442,7 @@ simplement qu'une nouvelle version existe.
|
||||
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
|
||||
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
|
||||
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
|
||||
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
|
||||
|
||||
### Déroulement
|
||||
|
||||
@@ -462,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
|
||||
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
|
||||
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
|
||||
|
||||
### Version prioritaire (correctif critique)
|
||||
|
||||
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
|
||||
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
|
||||
|
||||
| Point | Comportement |
|
||||
|---|---|
|
||||
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
|
||||
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
|
||||
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
|
||||
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
|
||||
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
|
||||
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
|
||||
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
|
||||
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
|
||||
|
||||
### Mise en place (une seule fois)
|
||||
|
||||
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
|
||||
@@ -489,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
|
||||
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
|
||||
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
|
||||
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
|
||||
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
|
||||
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
|
||||
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
|
||||
|
||||
Vérification à tout moment :
|
||||
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
|
||||
@@ -512,7 +534,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
|
||||
|
||||
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
|
||||
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
|
||||
@@ -520,6 +542,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
@@ -534,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
|
||||
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
|
||||
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
|
||||
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
|
||||
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
|
||||
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
|
||||
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
|
||||
@@ -627,7 +662,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
@@ -672,3 +709,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
|
||||
## 9. Site vitrine
|
||||
|
||||
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
|
||||
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
|
||||
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
|
||||
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
|
||||
|
||||
| Page | Contenu |
|
||||
|---|---|
|
||||
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
|
||||
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
|
||||
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
|
||||
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
|
||||
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
|
||||
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
|
||||
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
|
||||
|
||||
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
|
||||
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
|
||||
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
|
||||
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
|
||||
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
|
||||
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
|
||||
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
|
||||
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
|
||||
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
|
||||
pas affichées (`Site:Launcher:HiddenSections`).
|
||||
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
|
||||
toujours encodés (jamais interprétés comme du HTML).
|
||||
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
|
||||
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
|
||||
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
|
||||
aucune donnée écrite sur le disque.
|
||||
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
|
||||
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
|
||||
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
|
||||
liens `javascript:`/`data:` retirés, images du site uniquement.
|
||||
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
|
||||
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
|
||||
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
|
||||
@@ -68,7 +68,7 @@ Conséquences :
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
@@ -324,6 +324,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
|
||||
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
|
||||
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
|
||||
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
|
||||
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
|
||||
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
|
||||
|
||||
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
|
||||
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
|
||||
@@ -825,7 +827,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
|
||||
|
||||
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
|
||||
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
|
||||
@@ -866,6 +868,78 @@ autorisation « formulaires »), signature par la clé de ce PC, requête signé
|
||||
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
|
||||
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
|
||||
|
||||
### 11 septies. Mode maintenance du launcher (7f ✅)
|
||||
|
||||
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
|
||||
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
|
||||
des jeux** et le **menu d'administration** ; puis la terminer.
|
||||
|
||||
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
|
||||
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
|
||||
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
|
||||
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
|
||||
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
|
||||
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
|
||||
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
|
||||
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
|
||||
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
|
||||
révision, état, message, début.
|
||||
|
||||
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
|
||||
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
|
||||
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
|
||||
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
|
||||
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
|
||||
non antérieure à la dernière acceptée, révision non inférieure).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
|
||||
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
|
||||
|
||||
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
|
||||
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
|
||||
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
|
||||
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
|
||||
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
|
||||
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
|
||||
sans cache.
|
||||
|
||||
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
|
||||
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
|
||||
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
|
||||
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
|
||||
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
|
||||
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
|
||||
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
|
||||
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
|
||||
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
|
||||
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
|
||||
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
|
||||
vérifié, un 404 termine la maintenance.
|
||||
|
||||
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
|
||||
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
|
||||
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
|
||||
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
|
||||
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
|
||||
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
|
||||
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
|
||||
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
|
||||
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
|
||||
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
|
||||
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
|
||||
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
|
||||
eux-mêmes les connexions si nécessaire.
|
||||
|
||||
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
|
||||
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
|
||||
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
|
||||
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
|
||||
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
|
||||
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
|
||||
catégorie « Mode maintenance ».
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
@@ -894,6 +968,13 @@ côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish`
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
|
||||
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
|
||||
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
|
||||
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
|
||||
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
|
||||
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
@@ -937,8 +1018,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
|
||||
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
|
||||
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
|
||||
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
|
||||
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
|
||||
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
@@ -969,6 +1054,8 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
|
||||
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
|
||||
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
|
||||
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
|
||||
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
|
||||
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 395 KiB |
@@ -32,7 +32,8 @@ public static class ApiModules
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule()
|
||||
new Modules.Feedback.FeedbackModule(),
|
||||
new Modules.Maintenance.MaintenanceModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,15 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
|
||||
public string FeedbackFormsPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
|
||||
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
|
||||
/// </summary>
|
||||
public string? MaintenancePublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
|
||||
public string MaintenancePublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
|
||||
public string? FilesRootFullPath { get; private set; }
|
||||
|
||||
@@ -403,11 +412,24 @@ public sealed class LauncherApiOptions
|
||||
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
|
||||
: null;
|
||||
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
|
||||
|
||||
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
|
||||
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
|
||||
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
|
||||
: null;
|
||||
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
|
||||
if (MaintenancePublicFileFullPath is { } maintenance &&
|
||||
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
|
||||
public const string MaintenanceFileName = "maintenance.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -197,7 +197,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
/// <summary>
|
||||
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
|
||||
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
@@ -222,8 +222,17 @@ public sealed class FeedbackModule : IApiModule
|
||||
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
|
||||
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
|
||||
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
|
||||
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
|
||||
var changes = FeedbackFormsAudit.Changes(previous, published);
|
||||
foreach (var (action, name, details) in changes
|
||||
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
|
||||
.Reverse())
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"« {name} »", details);
|
||||
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
|
||||
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
|
||||
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
|
||||
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
|
||||
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
|
||||
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
|
||||
/// maintenance (c'est elle qui permet de la terminer).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModule : IApiModule
|
||||
{
|
||||
public string Name => "Maintenance";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
|
||||
{
|
||||
var current = store.Current;
|
||||
return new MaintenanceState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = store.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
|
||||
var now = time.GetUtcNow();
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
|
||||
MaintenanceRules.Digest(document), actor, now);
|
||||
|
||||
MaintenanceDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(document, request.BaseRevision, now);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
|
||||
"(server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var action = published.Enabled switch
|
||||
{
|
||||
true when !previous.Enabled => "maintenance.enable",
|
||||
false when previous.Enabled => "maintenance.disable",
|
||||
_ => "maintenance.update"
|
||||
};
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
|
||||
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -13,7 +15,8 @@ namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
|
||||
/// et du mode maintenance ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
@@ -33,8 +36,8 @@ public sealed class SigningModule : IApiModule
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
|
||||
ILogger<SigningModule> logger)
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
|
||||
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
@@ -78,6 +81,55 @@ public sealed class SigningModule : IApiModule
|
||||
};
|
||||
}
|
||||
|
||||
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
FeedbackFormsSignatureState? feedbackForms = null;
|
||||
if (forms.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = forms.Current;
|
||||
feedbackForms = current.Revision == 0
|
||||
? new FeedbackFormsSignatureState()
|
||||
: new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Digest = FeedbackFormRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature des formulaires des demandes");
|
||||
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
MaintenanceSignatureState? maintenanceState = null;
|
||||
if (maintenance.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = maintenance.Current;
|
||||
maintenanceState = current.Revision == 0
|
||||
? new MaintenanceSignatureState()
|
||||
: new MaintenanceSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Enabled = current.Enabled,
|
||||
Digest = MaintenanceRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du mode maintenance");
|
||||
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
@@ -97,7 +149,9 @@ public sealed class SigningModule : IApiModule
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
Catalog = catalog,
|
||||
FeedbackForms = feedbackForms,
|
||||
Maintenance = maintenanceState
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
|
||||
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
|
||||
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
|
||||
/// <param name="Details">Détail de chaque changement, en clair.</param>
|
||||
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
|
||||
|
||||
/// <summary>
|
||||
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
|
||||
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
|
||||
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
|
||||
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
|
||||
/// numérotées (1/2, 2/2…) plutôt que tronqué.
|
||||
/// </summary>
|
||||
public static class FeedbackFormsAudit
|
||||
{
|
||||
public const string Published = "feedback.forms";
|
||||
public const string Created = "feedback.form_create";
|
||||
public const string Updated = "feedback.form_update";
|
||||
public const string Deleted = "feedback.form_delete";
|
||||
|
||||
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
|
||||
internal const int MaxDetailsLength = 480;
|
||||
|
||||
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
|
||||
private const int MaxShownValueLength = 80;
|
||||
|
||||
private const string Separator = " ; ";
|
||||
|
||||
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
|
||||
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<FeedbackFormChange>();
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
|
||||
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
|
||||
}
|
||||
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
|
||||
changes.Add(new FeedbackFormChange(Deleted, form.Label,
|
||||
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
|
||||
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
|
||||
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
|
||||
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
|
||||
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
|
||||
}
|
||||
|
||||
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
|
||||
{
|
||||
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
|
||||
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
|
||||
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
|
||||
{
|
||||
// Place réservée au numéro « (12/12) ».
|
||||
var room = Math.Max(20, maxLength - 8);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var detail in details.SelectMany(d => Split(d, room)))
|
||||
{
|
||||
if (current.Length == 0) current = detail;
|
||||
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = detail;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Split(string text, int room)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i += room)
|
||||
yield return text.Substring(i, Math.Min(room, text.Length - i));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande
|
||||
|
||||
private static List<string> CreatedDetails(FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
|
||||
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
|
||||
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
|
||||
details.Add($"{form.Fields.Count} champ(s)");
|
||||
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
|
||||
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
|
||||
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
|
||||
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
|
||||
if (old.TechByDefault != form.TechByDefault)
|
||||
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
|
||||
if (old.CustomIcon != form.CustomIcon)
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
|
||||
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
|
||||
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
|
||||
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
|
||||
details.Add($"champ supprimé : « {field.Label} »");
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
if (old.Field(field.Id) is not { } before)
|
||||
{
|
||||
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
|
||||
continue;
|
||||
}
|
||||
var parts = FieldDetails(before, field);
|
||||
if (parts.Count == 0) continue;
|
||||
var prefix = $"champ « {field.Label} » modifié : ";
|
||||
var combined = prefix + string.Join(", ", parts);
|
||||
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
|
||||
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
|
||||
else details.AddRange(parts.Select(p => prefix + p));
|
||||
}
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
details.Add("ordre des champs modifié");
|
||||
return details;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Champs
|
||||
|
||||
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
|
||||
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
|
||||
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
|
||||
if ((old.Hint ?? "") != (field.Hint ?? ""))
|
||||
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
|
||||
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
|
||||
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
|
||||
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
|
||||
if (old.ShowInList != field.ShowInList)
|
||||
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
|
||||
parts.AddRange(OptionDetails(old.Choices, field.Choices));
|
||||
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
|
||||
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
|
||||
{
|
||||
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
|
||||
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
|
||||
foreach (var option in now)
|
||||
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
|
||||
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
|
||||
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
|
||||
yield return "ordre des choix modifié";
|
||||
}
|
||||
|
||||
private static string FieldSummary(FeedbackField field)
|
||||
{
|
||||
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
|
||||
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
|
||||
if (field.ShowInList) parts.Add("en aperçu dans la liste");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string KindText(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "zone de texte",
|
||||
FeedbackFieldKind.Text => "texte court",
|
||||
FeedbackFieldKind.Choice => "liste de choix",
|
||||
FeedbackFieldKind.Checkboxes => "cases à cocher",
|
||||
FeedbackFieldKind.Rating => "note en étoiles",
|
||||
FeedbackFieldKind.YesNo => "case oui/non",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Texte
|
||||
|
||||
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
|
||||
private static string ValueChange(string name, bool feminine, string? before, string? after)
|
||||
{
|
||||
var e = feminine ? "e" : "";
|
||||
before = OneLine(before);
|
||||
after = OneLine(after);
|
||||
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
|
||||
if (after.Length == 0) return $"{name} retiré{e}";
|
||||
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
|
||||
? $"{name} : {Quote(before)} → {Quote(after)}"
|
||||
: $"{name} modifié{e}";
|
||||
}
|
||||
|
||||
private static string OneLine(string? value) =>
|
||||
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private static string Quote(string? value) => $"« {OneLine(value)} »";
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
|
||||
/// retirés, modifiés ; captures d'écran, icône).
|
||||
/// </summary>
|
||||
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
|
||||
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) continue;
|
||||
var parts = new List<string>();
|
||||
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
|
||||
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
|
||||
old.TechByDefault != form.TechByDefault)
|
||||
parts.Add("présentation modifiée");
|
||||
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
|
||||
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var addedFields = newIds.Count(id => !oldIds.Contains(id));
|
||||
var removedFields = oldIds.Count(id => !newIds.Contains(id));
|
||||
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
|
||||
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
|
||||
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
|
||||
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
|
||||
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
parts.Add("ordre modifié");
|
||||
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
|
||||
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
|
||||
}
|
||||
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
|
||||
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
|
||||
/// l'adresse), écrite atomiquement, lisible par tous ;
|
||||
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
|
||||
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
|
||||
/// Rien de publié : maintenance inactive (révision 0).
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceStore
|
||||
{
|
||||
public const string FileName = "maintenance.json";
|
||||
private const string HistoryFolder = "maintenance-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _lock = new();
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
public MaintenanceStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
|
||||
|
||||
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
PublicUrl = publicUrl;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
public string? PublicUrl { get; }
|
||||
|
||||
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
|
||||
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
|
||||
public MaintenanceDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
private MaintenanceDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
|
||||
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
|
||||
/// change pas (modification du message seulement).
|
||||
/// </summary>
|
||||
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
|
||||
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors, now))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? MaintenanceRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
|
||||
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = MaintenanceRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(published.Revision, json);
|
||||
_current = published;
|
||||
return (published, previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
|
||||
.Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,8 +286,11 @@ Limites :
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
|
||||
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute) ;
|
||||
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
|
||||
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
@@ -454,6 +457,50 @@ joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formu
|
||||
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
|
||||
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
|
||||
|
||||
## 3 decies. Mode maintenance du launcher (PC et VPS)
|
||||
|
||||
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
|
||||
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
|
||||
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
|
||||
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
|
||||
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
|
||||
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
|
||||
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
|
||||
(`catalog.conf`, étape 3 bis).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
|
||||
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
|
||||
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
|
||||
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
|
||||
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
|
||||
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
|
||||
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
|
||||
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
|
||||
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
|
||||
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
|
||||
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
|
||||
|
||||
Comportement des launchers :
|
||||
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
|
||||
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
|
||||
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
|
||||
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
|
||||
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
|
||||
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
|
||||
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
|
||||
du jeu doivent refuser les connexions, c'est à eux de le faire.
|
||||
|
||||
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
|
||||
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
|
||||
|
||||
## 4. Créer ta clé d'administrateur (PC)
|
||||
|
||||
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
|
||||
@@ -584,11 +631,14 @@ recréer les administrateurs.
|
||||
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
|
||||
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
|
||||
├─ feedback-forms-history/ 20 dernières révisions publiées
|
||||
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
|
||||
├─ maintenance-history/ 20 dernières révisions publiées
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
├─ keyring.json trousseau publié (écrit par le service, 644)
|
||||
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
|
||||
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
|
||||
# étapes 3 bis, 3 quinquies et 3 nonies :
|
||||
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
|
||||
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
@@ -36,3 +36,12 @@ location = /catalog/feedback-forms.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
|
||||
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
|
||||
location = /catalog/maintenance.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
@@ -315,6 +316,37 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
|
||||
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
|
||||
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
|
||||
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
|
||||
/// </summary>
|
||||
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
|
||||
/// <param name="since">
|
||||
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
|
||||
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
|
||||
/// </param>
|
||||
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = baseRevision + 1,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
|
||||
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
|
||||
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
|
||||
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
|
||||
|
||||
@@ -147,6 +147,10 @@ public static class ApiRoutes
|
||||
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
|
||||
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance du launcher
|
||||
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
|
||||
public const string Maintenance = "v1/maintenance";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
|
||||
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
|
||||
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
|
||||
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
|
||||
/// désinstallation des jeux et le menu d'administration.
|
||||
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
|
||||
/// </summary>
|
||||
public sealed record MaintenanceDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-maintenance";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Vrai : maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
|
||||
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
|
||||
public sealed record MaintenanceState
|
||||
{
|
||||
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
|
||||
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
|
||||
public sealed record MaintenancePublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
|
||||
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
|
||||
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
|
||||
/// </summary>
|
||||
public static class MaintenanceRules
|
||||
{
|
||||
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
|
||||
public const int MaxBytes = 8 * 1024;
|
||||
|
||||
public const int MaxMessageLength = 300;
|
||||
public const int MaxMessageLines = 6;
|
||||
|
||||
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
|
||||
public const string Title = "Maintenance en cours";
|
||||
|
||||
public const string Subtitle = "Merci de revenir plus tard.";
|
||||
|
||||
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
|
||||
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
|
||||
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
|
||||
|
||||
/// <summary>Lit maintenance.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
|
||||
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
|
||||
/// signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(MaintenanceDocument document)
|
||||
{
|
||||
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
|
||||
string.IsNullOrEmpty(document.Message) ? null : document.Message,
|
||||
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
|
||||
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
|
||||
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
|
||||
/// </summary>
|
||||
public static string? CleanMessage(string? value, out string? problem)
|
||||
{
|
||||
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
|
||||
if (problem is not null) return null;
|
||||
if (text is null) return null;
|
||||
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
|
||||
{
|
||||
problem = $"{MaxMessageLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
if (FeedbackLinks.Contains(text))
|
||||
{
|
||||
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
|
||||
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Document de maintenance absent.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
|
||||
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
|
||||
|
||||
if (document.Message is { } message)
|
||||
{
|
||||
var clean = CleanMessage(message, out var problem);
|
||||
if (problem is not null) errors.Add($"Message : {problem}");
|
||||
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
|
||||
}
|
||||
|
||||
if (document.Enabled)
|
||||
{
|
||||
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
|
||||
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
|
||||
errors.Add("Début de la maintenance : à la seconde près.");
|
||||
else if (now is { } reference && since > reference + MaxSinceSkew)
|
||||
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
|
||||
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
|
||||
public static string Describe(MaintenanceDocument document) =>
|
||||
document.Enabled
|
||||
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
|
||||
: "désactivée";
|
||||
|
||||
private static string OneLine(string text)
|
||||
{
|
||||
var line = text.Replace('\n', ' ');
|
||||
return line.Length <= 120 ? line : line[..119] + "…";
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,13 @@ public static class AdminPermissions
|
||||
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
|
||||
public const string FeedbackForms = "feedback.forms";
|
||||
|
||||
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
|
||||
public const string MaintenanceManage = "maintenance.manage";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
|
||||
MaintenanceManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
@@ -57,6 +61,7 @@ public static class AdminPermissions
|
||||
FeedbackRead => "Demandes des joueurs (lecture)",
|
||||
FeedbackManage => "Demandes des joueurs (traitement)",
|
||||
FeedbackForms => "Formulaires des demandes",
|
||||
MaintenanceManage => "Mode maintenance",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -76,6 +81,9 @@ public static class AdminPermissions
|
||||
"(à donner avec la lecture).",
|
||||
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
|
||||
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
|
||||
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
|
||||
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
|
||||
"l'autorisation « mode maintenance » du trousseau.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public static class AdminRoles
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
|
||||
AdminPermissions.MaintenanceManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class ContentKinds
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
public const string FeedbackForms = "feedback-forms";
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
@@ -36,11 +37,15 @@ public static class ContentKinds
|
||||
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
|
||||
public const string FeedbackFormsId = "feedback-forms";
|
||||
|
||||
/// <summary>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
|
||||
public const string MaintenanceId = "maintenance";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind switch
|
||||
{
|
||||
Manifest => SigningScopes.Releases,
|
||||
FeedbackForms => SigningScopes.Forms,
|
||||
Maintenance => SigningScopes.Maintenance,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
@@ -57,7 +62,10 @@ public static class SigningScopes
|
||||
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
|
||||
public const string Forms = "forms";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
|
||||
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
|
||||
|
||||
/// <summary>
|
||||
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
|
||||
@@ -74,6 +82,7 @@ public static class SigningScopes
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
|
||||
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
@@ -82,6 +91,7 @@ public static class SigningScopes
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
Maintenance => "mode maintenance",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
@@ -219,6 +229,7 @@ public static class ContentSigning
|
||||
{
|
||||
ContentKinds.Catalog => ContentKinds.CatalogId,
|
||||
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
|
||||
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
|
||||
_ => id.ToLowerInvariant()
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed record SigningState
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
|
||||
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
|
||||
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
|
||||
|
||||
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public MaintenanceSignatureState? Maintenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
@@ -64,3 +70,38 @@ public sealed record CatalogSignatureState
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
|
||||
public sealed record FeedbackFormsSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
|
||||
public sealed record MaintenanceSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : jamais publié, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence du mode maintenance est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
|
||||
/// des joueurs :
|
||||
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
|
||||
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
|
||||
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
|
||||
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
|
||||
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
|
||||
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
|
||||
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
|
||||
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
|
||||
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
|
||||
/// termine la maintenance.
|
||||
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceService
|
||||
{
|
||||
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private byte[]? _lastAccepted;
|
||||
private bool _unreachableLogged;
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
Url = url;
|
||||
_trust = trust;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Uri? Url { get; }
|
||||
|
||||
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
|
||||
public MaintenanceDocument? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock) return _current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
|
||||
public bool IsActive => Current is { Enabled: true };
|
||||
|
||||
/// <summary>
|
||||
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
|
||||
/// Null : plus de maintenance (fichier absent).
|
||||
/// </summary>
|
||||
public event Action<MaintenanceDocument?>? Changed;
|
||||
|
||||
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
|
||||
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
|
||||
{
|
||||
var baseUrl = keyringUrl ?? catalogUrl;
|
||||
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "maintenance.json") : null;
|
||||
}
|
||||
|
||||
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
|
||||
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
|
||||
public async Task<bool> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Url is null) return false;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
_unreachableLogged = false;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
|
||||
_unreachableLogged = false;
|
||||
_lastAccepted = null;
|
||||
Apply(null);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
|
||||
if (!_unreachableLogged)
|
||||
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
|
||||
_unreachableLogged = true;
|
||||
return _current is { Enabled: true };
|
||||
}
|
||||
|
||||
// Même fichier que la dernière fois : déjà vérifié.
|
||||
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
|
||||
|
||||
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (document is null) return _current is { Enabled: true };
|
||||
_lastAccepted = bytes;
|
||||
Apply(document);
|
||||
return document.Enabled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
|
||||
public async Task RunAsync(CancellationToken lifetime)
|
||||
{
|
||||
if (Url is null) return;
|
||||
try
|
||||
{
|
||||
while (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
|
||||
await RefreshAsync(lifetime).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fermeture du launcher
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Relecture du mode maintenance interrompue", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
|
||||
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
MaintenanceDocument? document;
|
||||
try
|
||||
{
|
||||
document = MaintenanceRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Mode maintenance refusé : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_current is { } current && document!.Revision < current.Revision)
|
||||
{
|
||||
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
private void Apply(MaintenanceDocument? document)
|
||||
{
|
||||
MaintenanceDocument? previous;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previous = _current;
|
||||
_current = document;
|
||||
}
|
||||
if (SameDisplay(previous, document)) return;
|
||||
Log.Info(document is { Enabled: true }
|
||||
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
|
||||
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
|
||||
try
|
||||
{
|
||||
Changed?.Invoke(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Application du mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
|
||||
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
|
||||
{
|
||||
var activeA = a is { Enabled: true };
|
||||
var activeB = b is { Enabled: true };
|
||||
if (!activeA && !activeB) return true;
|
||||
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
|
||||
|
||||
public string GameName { get; set; } = "Mon Jeu";
|
||||
|
||||
public string Publisher { get; set; } = "Xbhost";
|
||||
public string Publisher { get; set; } = "LanisysGames";
|
||||
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
|
||||
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
|
||||
/// </summary>
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
|
||||
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
|
||||
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
|
||||
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
|
||||
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
|
||||
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
|
||||
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
|
||||
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
|
||||
/// </summary>
|
||||
public string? PriorityVersion { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Version ParsedVersion => VersionUtil.Parse(Version);
|
||||
|
||||
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
|
||||
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
|
||||
/// </summary>
|
||||
public bool IsPriorityFor(Version currentVersion) =>
|
||||
TryGetPriorityVersion(out var priority) && priority > currentVersion;
|
||||
|
||||
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
|
||||
public bool TryGetPriorityVersion(out Version priority)
|
||||
{
|
||||
priority = new Version(0, 0, 0);
|
||||
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
|
||||
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
|
||||
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
|
||||
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
|
||||
{
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
|
||||
if (markPriority) return VersionUtil.Display(published);
|
||||
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
|
||||
return null;
|
||||
return VersionUtil.Display(carried);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Format != 1)
|
||||
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
|
||||
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
|
||||
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
|
||||
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
|
||||
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
|
||||
else if (priority > ParsedVersion)
|
||||
throw new UpdateSecurityException(
|
||||
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
|
||||
Notes ??= new List<string>();
|
||||
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
|
||||
Changelog = ChangelogLimits.Normalize(Changelog);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
|
||||
public bool IsAvailable => Release is not null && DownloadUri is not null;
|
||||
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
|
||||
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
|
||||
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
|
||||
/// </summary>
|
||||
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -40,8 +41,23 @@ public sealed class ContentSecurityException : Exception
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
|
||||
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
|
||||
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
|
||||
/// soit vue à la relecture suivante.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
|
||||
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
@@ -81,12 +97,15 @@ public sealed class ContentTrustService
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
|
||||
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
|
||||
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
|
||||
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
|
||||
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
@@ -94,7 +113,8 @@ public sealed class ContentTrustService
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|
||||
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
@@ -150,7 +170,7 @@ public sealed class ContentTrustService
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
@@ -210,6 +230,26 @@ public sealed class ContentTrustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
|
||||
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
|
||||
/// légitime). Faux : à ignorer (état précédent conservé).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
|
||||
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
|
||||
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
@@ -253,8 +293,9 @@ public sealed class ContentTrustService
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
|
||||
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
@@ -301,6 +342,14 @@ public sealed class ContentTrustService
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
|
||||
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
|
||||
{
|
||||
if (signature?.KeyId is not { } keyId) return false;
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("keyring.", "Signature (trousseau)"),
|
||||
new("feedback.", "Demandes des joueurs"),
|
||||
new("maintenance.", "Mode maintenance"),
|
||||
new("audit.", "Journal d'audit (consultations, exports, purges)"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
@@ -504,6 +505,12 @@ public static class AuditTexts
|
||||
"feedback.delete" => "Demande de joueur supprimée",
|
||||
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
|
||||
"feedback.forms" => "Formulaires des demandes publiés",
|
||||
"feedback.form_create" => "Type de demande ajouté",
|
||||
"feedback.form_update" => "Type de demande modifié",
|
||||
"feedback.form_delete" => "Type de demande supprimé",
|
||||
"maintenance.enable" => "Mode maintenance activé",
|
||||
"maintenance.disable" => "Mode maintenance terminé",
|
||||
"maintenance.update" => "Message de la maintenance modifié",
|
||||
"files.delete" => "Fichier du serveur supprimé",
|
||||
"files.prune" => "Dossier « objects » nettoyé",
|
||||
"request.signature" => "Requête signée refusée",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
|
||||
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
|
||||
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "maintenance";
|
||||
|
||||
public string DisplayName => "Maintenance";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
MaintenancePanelViewModel.PanelId,
|
||||
"Maintenance",
|
||||
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
|
||||
"sauf la désinstallation des jeux et ce menu.",
|
||||
"TrafficCone",
|
||||
AdminPermissions.MaintenanceManage,
|
||||
Order: 5,
|
||||
context => new MaintenancePanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
|
||||
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
|
||||
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
|
||||
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
|
||||
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
|
||||
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
|
||||
/// </summary>
|
||||
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "maintenance";
|
||||
|
||||
private MaintenanceState? _state;
|
||||
private string _message = "";
|
||||
private string? _messageError;
|
||||
private string? _statusMessage;
|
||||
|
||||
public MaintenancePanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
|
||||
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
|
||||
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand EnableCommand { get; }
|
||||
public AsyncRelayCommand UpdateMessageCommand { get; }
|
||||
public AsyncRelayCommand DisableCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État publié
|
||||
|
||||
public bool IsLoaded => _state is not null;
|
||||
|
||||
/// <summary>Maintenance en cours (état publié).</summary>
|
||||
public bool IsActive => _state?.Document.Enabled == true;
|
||||
|
||||
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
|
||||
public bool CanPublish => _state is { PublishingEnabled: true };
|
||||
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
|
||||
|
||||
public string StateText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is not { } state) return "";
|
||||
var document = state.Document;
|
||||
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
|
||||
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
|
||||
if (document.Enabled)
|
||||
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
|
||||
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
|
||||
? $" · message modifié le {AccountTexts.Date(updated)}"
|
||||
: "") + ".";
|
||||
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
|
||||
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _message, value ?? "")) return;
|
||||
MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
OnPropertyChanged(nameof(MessageCounter));
|
||||
OnPropertyChanged(nameof(PreviewMessage));
|
||||
OnPropertyChanged(nameof(IsMessageChanged));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public string? MessageError
|
||||
{
|
||||
get => _messageError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMessageError => _messageError is not null;
|
||||
|
||||
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
|
||||
|
||||
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
|
||||
|
||||
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
|
||||
public bool IsMessageChanged
|
||||
{
|
||||
get
|
||||
{
|
||||
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
|
||||
|
||||
public string PreviewTitle => MaintenanceRules.Title;
|
||||
public string PreviewSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
|
||||
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
|
||||
|
||||
// ------------------------------------------------------------------ Bandeau de résultat
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommands();
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
EnableCommand.RaiseCanExecuteChanged();
|
||||
UpdateMessageCommand.RaiseCanExecuteChanged();
|
||||
DisableCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
|
||||
|
||||
private void Apply(MaintenanceState state, bool resetMessage)
|
||||
{
|
||||
_state = state;
|
||||
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
|
||||
foreach (var name in new[]
|
||||
{
|
||||
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
|
||||
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
|
||||
})
|
||||
OnPropertyChanged(name);
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
|
||||
private bool TryCleanMessage(out string? clean)
|
||||
{
|
||||
clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
private async Task EnableAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
|
||||
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
|
||||
"aussitôt), pour tous les jeux :\n\n" +
|
||||
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
|
||||
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
|
||||
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
|
||||
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
|
||||
"launcher lui-même continuent.",
|
||||
"Activer la maintenance", danger: true))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
|
||||
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
|
||||
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
|
||||
}
|
||||
|
||||
private async Task UpdateMessageAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
|
||||
await PublishAsync(enabled: true, message, document.Since, document.Revision,
|
||||
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task DisableAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
|
||||
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
|
||||
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
|
||||
"Terminer la maintenance"))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
|
||||
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
|
||||
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
|
||||
{
|
||||
MaintenanceState? published = null;
|
||||
var ok = await RunAsync(async () =>
|
||||
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
|
||||
if (!ok)
|
||||
{
|
||||
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
|
||||
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
|
||||
return;
|
||||
}
|
||||
Apply(published!, resetMessage: true);
|
||||
if (!enabled) Message = "";
|
||||
StatusMessage = success;
|
||||
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
|
||||
}
|
||||
|
||||
private async Task ReloadKeepingMessageAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
|
||||
/// ligne, catalogue, formulaires des demandes, mode maintenance).
|
||||
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
|
||||
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
|
||||
/// </summary>
|
||||
@@ -193,6 +194,29 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
|
||||
if (state.FeedbackForms is { } forms)
|
||||
items.Add(forms.Unreadable
|
||||
? SignedContentItem.Failed("Formulaires des demandes",
|
||||
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
|
||||
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
|
||||
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
|
||||
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
|
||||
if (state.Maintenance is { } maintenance)
|
||||
items.Add(maintenance.Unreadable
|
||||
? SignedContentItem.Failed("Mode maintenance",
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
maintenance.Revision > 0
|
||||
? $"Mode maintenance (révision {maintenance.Revision}, {(maintenance.Enabled ? "en cours" : "inactif")})"
|
||||
: "Mode maintenance",
|
||||
ContentKinds.Maintenance, ContentKinds.MaintenanceId, maintenance.Digest, maintenance.Signature, maintenance.SignerName,
|
||||
state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Maintenance » (il est signé à chaque activation, modification ou fin).",
|
||||
"Jamais activé : rien à signer tant que rien n'est publié."));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -319,12 +343,12 @@ public sealed class KeyringKeyItem
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
Text = nothingPublished;
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
@@ -342,6 +366,15 @@ public sealed class SignedContentItem
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
private SignedContentItem(string title, string text)
|
||||
{
|
||||
Title = title;
|
||||
Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
|
||||
internal static SignedContentItem Failed(string title, string text) => new(title, text);
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ public sealed class AdminEnvironment
|
||||
/// </summary>
|
||||
public PlayerSigningCheck? PlayerSigning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Appelé après une publication du mode maintenance (page « Maintenance ») : le launcher de ce PC le relit aussitôt.
|
||||
/// Null : rien à faire (tests).
|
||||
/// </summary>
|
||||
public Action? MaintenanceChanged { get; init; }
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
|
||||
|
||||
public void NotifyMaintenanceChanged() => _shell.Environment.MaintenanceChanged?.Invoke();
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
@@ -2,6 +2,7 @@ using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -34,6 +35,45 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
|
||||
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
|
||||
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
|
||||
OpenMaintenanceCommand = new RelayCommand(() => Context.Navigate(MaintenancePanelViewModel.PanelId), () => CanOpenMaintenance);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Page « Maintenance » présente (permission maintenance.manage).</summary>
|
||||
public bool CanOpenMaintenance => Context.Panels.Any(p => p.Id == MaintenancePanelViewModel.PanelId);
|
||||
|
||||
public RelayCommand OpenMaintenanceCommand { get; }
|
||||
|
||||
private string? _maintenanceText;
|
||||
|
||||
/// <summary>Bandeau « Maintenance en cours depuis … » (null : pas de maintenance, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public string? MaintenanceText
|
||||
{
|
||||
get => _maintenanceText;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _maintenanceText, value)) OnPropertyChanged(nameof(IsMaintenanceActive));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMaintenanceActive => _maintenanceText is not null;
|
||||
|
||||
private async Task RefreshMaintenanceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetMaintenanceAsync();
|
||||
MaintenanceText = state.Document is { Enabled: true } document
|
||||
? "Maintenance en cours" + (document.Since is { } since ? $" depuis le {AccountTexts.Date(since)}" : "") +
|
||||
(state.UpdatedBy is { } by ? $" (activée par {by})" : "") +
|
||||
" : les joueurs ne peuvent ni installer, ni mettre à jour, ni lancer les jeux."
|
||||
: null;
|
||||
}
|
||||
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
MaintenanceText = null; // serveur antérieur (404) ou fichier illisible : rien au tableau de bord
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
@@ -182,6 +222,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
if (CanReadFeedback) await RefreshFeedbackAsync();
|
||||
await RefreshMaintenanceAsync();
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,12 @@ public interface IAdminContext
|
||||
/// </summary>
|
||||
PlayerSigningCheck? PlayerSigning => null;
|
||||
|
||||
/// <summary>
|
||||
/// Le mode maintenance vient d'être publié depuis le menu : le launcher de ce PC le relit aussitôt (sans attendre sa
|
||||
/// relecture périodique). Sans effet par défaut (tests).
|
||||
/// </summary>
|
||||
void NotifyMaintenanceChanged() { }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ public sealed class PlayerSigningCheck
|
||||
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
|
||||
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
|
||||
/// </summary>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</param>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/>, <see cref="ContentKinds.FeedbackForms"/>
|
||||
/// ou <see cref="ContentKinds.Maintenance"/>.</param>
|
||||
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
|
||||
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
|
||||
{
|
||||
@@ -111,6 +112,9 @@ public sealed class PlayerSigningCheck
|
||||
? kind == ContentKinds.FeedbackForms
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
|
||||
"les derniers formulaires acceptés, ou ceux d'origine."
|
||||
: kind == ContentKinds.Maintenance
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce changement : ils garderont l'état du mode " +
|
||||
"maintenance qu'ils connaissent (une maintenance ne s'afficherait pas, ou ne se terminerait qu'à leur redémarrage)."
|
||||
: catalog
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
|
||||
"(celui intégré au launcher pour une installation neuve)."
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
@@ -20,6 +21,7 @@ using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Audit;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Files;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Platform;
|
||||
@@ -64,6 +66,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private string? _keyringUrl;
|
||||
private PlayerSigningCheck? _playerSigning;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
|
||||
/// <summary>Mode maintenance publié depuis le menu d'administration (null : aucune adresse de trousseau ni de catalogue).</summary>
|
||||
private MaintenanceService? _maintenance;
|
||||
private Task<bool>? _maintenanceFetch;
|
||||
|
||||
/// <summary>Annulé à la fermeture du launcher (relecture périodique du mode maintenance).</summary>
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
|
||||
@@ -172,6 +181,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
_trust = CreateContentTrust(catalog);
|
||||
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
|
||||
|
||||
// Mode maintenance (publié depuis le menu d'administration) : lu dès maintenant, en parallèle du catalogue et de la
|
||||
// mise à jour du launcher, puis attendu quelques secondes au plus AVANT la recherche des mises à jour des jeux.
|
||||
var maintenanceStarted = DateTime.UtcNow;
|
||||
StartMaintenance(catalog, download: args.CaptureDirectory is null);
|
||||
|
||||
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
|
||||
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
|
||||
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
|
||||
@@ -222,7 +236,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
else if (normalStart && _updates.IsEnabled && (!_updates.AutoUpdate || _updates.SkippedUpdate is not null))
|
||||
_ = NotifyLauncherUpdateAsync();
|
||||
|
||||
// 5. Détection des installations + mises à jour des jeux
|
||||
// 5. Détection des installations + mises à jour des jeux (après le mode maintenance, qui peut les bloquer)
|
||||
if (args.CaptureDirectory is null) await ApplyStartupMaintenanceAsync(maintenanceStarted, wait: !args.Uninstall);
|
||||
try
|
||||
{
|
||||
if (args.CaptureDirectory is not null)
|
||||
@@ -359,12 +374,21 @@ public partial class App : Application, ILauncherRestartHost
|
||||
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
|
||||
launcher.AttachTransition(window);
|
||||
|
||||
// Mode maintenance : chaque changement (relecture périodique, publication depuis le menu de ce PC), puis l'état déjà connu.
|
||||
if (_maintenance is { } maintenance && !readOnly)
|
||||
{
|
||||
maintenance.Changed += state => Dispatcher.InvokeAsync(() => launcher.ApplyMaintenance(state));
|
||||
launcher.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
|
||||
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
|
||||
if (!readOnly)
|
||||
{
|
||||
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
|
||||
// Publication du mode maintenance depuis le menu : ce launcher le relit aussitôt (sans attendre la relecture périodique).
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning,
|
||||
maintenanceChanged: () => { if (_maintenance is { } m) _ = m.RefreshAsync(_lifetime.Token); });
|
||||
window.AdminRequested += _admin.Open;
|
||||
|
||||
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
|
||||
@@ -374,7 +398,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
|
||||
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
|
||||
launcher.IsHelpAvailable = feedback.IsAvailable;
|
||||
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
window.HelpRequested += game =>
|
||||
{
|
||||
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
|
||||
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
};
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
@@ -383,6 +411,53 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return window;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
|
||||
private static readonly TimeSpan MaintenanceStartupBudget = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <summary>
|
||||
/// Lance la lecture du mode maintenance (maintenance.json, à côté du trousseau ou du catalogue). Rien n'est gardé sur le
|
||||
/// disque : hors ligne, le launcher démarre normalement (le jeu installé reste jouable).
|
||||
/// </summary>
|
||||
private void StartMaintenance(LauncherCatalog catalog, bool download)
|
||||
{
|
||||
if (!download || _http is null) return;
|
||||
try
|
||||
{
|
||||
var url = MaintenanceService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
|
||||
if (url is null) return;
|
||||
_maintenance = new MaintenanceService(_http.GetBytesAsync, url, _trust);
|
||||
_maintenanceFetch = _maintenance.RefreshAsync(_lifetime.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avant la recherche des mises à jour des jeux : attend le mode maintenance (quelques secondes au plus), l'applique,
|
||||
/// puis lance sa relecture périodique. <paramref name="wait"/> faux (désinstallation depuis Windows) : pas d'attente.
|
||||
/// </summary>
|
||||
private async Task ApplyStartupMaintenanceAsync(DateTime started, bool wait)
|
||||
{
|
||||
if (_maintenance is not { } maintenance || _maintenanceFetch is not { } fetch) return;
|
||||
try
|
||||
{
|
||||
var remaining = MaintenanceStartupBudget - (DateTime.UtcNow - started);
|
||||
if (wait && !fetch.IsCompleted && remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
|
||||
if (wait && !fetch.IsCompleted)
|
||||
Log.Info("Mode maintenance : serveur lent, démarrage sans attendre (l'état sera appliqué à son arrivée).");
|
||||
_launcher?.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
_ = maintenance.RunAsync(_lifetime.Token);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue des jeux
|
||||
|
||||
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
|
||||
@@ -591,6 +666,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new FilesAdminModule(),
|
||||
new MaintenanceAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
@@ -676,6 +752,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_lifetime.Cancel(); // relecture du mode maintenance
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
|
||||
@@ -822,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
/// <summary>
|
||||
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
|
||||
/// dossier protégé) : simple information dans le bandeau.
|
||||
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
|
||||
/// version prioritaire.
|
||||
/// </summary>
|
||||
private async Task NotifyLauncherUpdateAsync()
|
||||
{
|
||||
@@ -830,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
|
||||
if (check.IsAvailable)
|
||||
if (check.IsPriority)
|
||||
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
|
||||
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
|
||||
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
|
||||
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Warning);
|
||||
else if (check.IsAvailable)
|
||||
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
|
||||
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Info);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
|
||||
une version STRICTEMENT supérieure à la leur.
|
||||
-->
|
||||
<Version>3.2.0</Version>
|
||||
<Version>3.4.2</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
/// <summary>
|
||||
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
|
||||
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
|
||||
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
|
||||
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
|
||||
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
|
||||
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync()
|
||||
{
|
||||
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
|
||||
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
|
||||
if (_client is null || !IsSingleFileBuild) return false;
|
||||
|
||||
LauncherUpdateCheck check;
|
||||
try
|
||||
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
}
|
||||
|
||||
if (!check.IsAvailable) return false;
|
||||
if (!AutoUpdate && !check.IsPriority)
|
||||
{
|
||||
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
if (check.IsPriority)
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
|
||||
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
|
||||
|
||||
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
|
||||
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
|
||||
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
|
||||
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
return await InstallAsync(check, automatic: true);
|
||||
if (await InstallAsync(check, automatic: true)) return true;
|
||||
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
|
||||
if (check.IsPriority) SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Installation
|
||||
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
Log.Error("Mise à jour du launcher impossible", ex);
|
||||
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
|
||||
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
|
||||
if (automatic) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
|
||||
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
|
||||
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
|
||||
var retryAtNextStart = automatic && update.IsPriority;
|
||||
if (automatic && !retryAtNextStart) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
|
||||
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
|
||||
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
|
||||
: automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
}
|
||||
|
||||
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private readonly PlayerSigningCheck? _playerSigning;
|
||||
private readonly Action? _maintenanceChanged;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
|
||||
/// <param name="playerSigning">
|
||||
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
|
||||
/// </param>
|
||||
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
|
||||
PlayerSigningCheck? playerSigning = null)
|
||||
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
_playerSigning = playerSigning;
|
||||
_maintenanceChanged = maintenanceChanged;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys(),
|
||||
PlayerSigning = _playerSigning
|
||||
PlayerSigning = _playerSigning,
|
||||
MaintenanceChanged = _maintenanceChanged
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:afb="clr-namespace:GameLauncher.Modules.Admin.Feedback;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:amnt="clr-namespace:GameLauncher.Modules.Admin.Maintenance;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:conv="clr-namespace:GameLauncher.Converters">
|
||||
|
||||
<!--
|
||||
@@ -367,6 +368,19 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mode maintenance en cours (tous les administrateurs le voient ; la page « Maintenance » exige maintenance.manage) -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}" Background="#26FF8A1F"
|
||||
Visibility="{Binding IsMaintenanceActive, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding OpenMaintenanceCommand}"
|
||||
Visibility="{Binding CanOpenMaintenance, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.TrafficCone}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding MaintenanceText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Compte connecté -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
@@ -2991,7 +3005,7 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
|
||||
Text="D'après les appareils actifs des administrateurs autorisés à publier une version, le catalogue, les formulaires des demandes ou le mode maintenance (permissions de leur groupe). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
|
||||
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
@@ -3014,13 +3028,130 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
|
||||
Text="Versions, catalogue, formulaires des demandes et mode maintenance tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
|
||||
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Maintenance » -->
|
||||
<DataTemplate DataType="{x:Type amnt:MaintenancePanelViewModel}">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12.5"
|
||||
Text="Publication du mode maintenance non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le fichier est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 decies)." />
|
||||
</Border>
|
||||
|
||||
<!-- État publié -->
|
||||
<Border x:Name="StateCard" Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="StateBadge" Style="{StaticResource AdminIconBadgeStyle}" Width="46" Height="46" CornerRadius="15">
|
||||
<Path x:Name="StateIcon" Style="{StaticResource AdminIconStyle}" Width="22" Height="22" Data="{StaticResource Icon.CheckCircle}"
|
||||
Fill="{DynamicResource SuccessBrush}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,12,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StateTitle}" FontSize="17" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding StateText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
<TextBlock FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding PublicUrl, Converter={StaticResource StringToVisibility}}">
|
||||
<Run Text="Fichier lu par les launchers :" />
|
||||
<Run Text="{Binding PublicUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Message (actions : barre du bas, AdminFooter.MaintenancePanelViewModel) -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" IsEnabled="{Binding IsLoaded}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Message affiché aux joueurs" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="Facultatif : une précision sous « Maintenance en cours — merci de revenir plus tard » (raison, heure de retour prévue…). Texte brut, 6 lignes au plus, aucun lien ni adresse de site." />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="80" MaxLength="400"
|
||||
Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}" ToolTip="Précision affichée aux joueurs (facultative)" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding MessageCounter}" FontSize="11.5" Margin="12,0,0,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding MessageError}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
|
||||
Visibility="{Binding HasMessageError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Aperçu : mêmes textes que la carte du launcher -->
|
||||
<Border Margin="0,16,0,0" CornerRadius="16" Padding="22,18" BorderThickness="1" Background="#E61A0D09" BorderBrush="#80FF6A2B">
|
||||
<StackPanel>
|
||||
<DockPanel HorizontalAlignment="Center">
|
||||
<Border DockPanel.Dock="Left" Width="34" Height="34" CornerRadius="11" VerticalAlignment="Center">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FF3B30" Offset="0" />
|
||||
<GradientStop Color="#FF9500" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<Path Data="{StaticResource Icon.TrafficCone}" Fill="White" Width="18" Height="18" Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding PreviewTitle}" FontSize="16" FontWeight="Bold" Foreground="#F4F6FB" />
|
||||
<TextBlock Text="{Binding PreviewSubtitle}" FontSize="12.5" Foreground="#C8B4AC" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding PreviewMessage}" Margin="0,12,0,0" FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Foreground="#F4F6FB" Visibility="{Binding PreviewMessage, Converter={StaticResource StringToVisibility}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
|
||||
Text="Aperçu : dans le launcher, la carte s'affiche au centre de la fenêtre, sur un fond rouge / orange." />
|
||||
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,12,0,0"
|
||||
Text="Publication signée par la clé de ce PC (autorisation « mode maintenance » du trousseau). Les launchers relisent l'état au démarrage, puis toutes les 1 à 2 minutes ; celui de ce PC bascule aussitôt." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Ce que voient les joueurs -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Pendant la maintenance, chez les joueurs" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0"
|
||||
Text="• Bloqués : installation, mises à jour (automatiques ou non), vérification, réparation, localisation et lancement des jeux, raccourcis du Bureau, paramètres du launcher et des jeux, bouton « Aide », site officiel." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Toujours possibles : désinstallation des jeux (menu « … » ou Windows), menu d'administration, réduction et fermeture du launcher." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Non interrompus : téléchargements déjà commencés, parties en cours, mises à jour du launcher lui-même (signées par la clé racine)." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Hors ligne au démarrage : pas de maintenance (le jeu installé reste jouable). C'est un affichage pour les joueurs, pas une protection : si les serveurs du jeu doivent refuser les connexions, c'est à eux de le faire." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<Setter TargetName="StateCard" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
|
||||
<Setter TargetName="StateBadge" Property="Background" Value="#33FF8A1F" />
|
||||
<Setter TargetName="StateIcon" Property="Data" Value="{DynamicResource Icon.TrafficCone}" />
|
||||
<Setter TargetName="StateIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Fichiers du serveur » -->
|
||||
<!-- Élément du fil d'Ariane (www › exovivalzombie › objects) -->
|
||||
<DataTemplate DataType="{x:Type afil:FolderCrumb}">
|
||||
@@ -3326,6 +3457,34 @@
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.MaintenancePanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
|
||||
ToolTip="Afficher l'état actuellement publié" />
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Modifier le message" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding UpdateMessageCommand}"
|
||||
ToolTip="Publie le nouveau message (requête signée par votre clé) ; la maintenance continue"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Terminer la maintenance"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding DisableCommand}"
|
||||
ToolTip="Les launchers des joueurs redeviennent utilisables (requête signée par votre clé)"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource DangerButtonStyle}" Content="Activer la maintenance"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding EnableCommand}"
|
||||
ToolTip="Bloque tous les launchers des joueurs, sauf la désinstallation des jeux (requête signée par votre clé)"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StateTitle}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.MyDevicesPanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
|
||||
<!-- Publication d'une version (phase 5) -->
|
||||
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
|
||||
<!-- Cône de chantier (mode maintenance) : bandes du cône et socle -->
|
||||
<Geometry x:Key="Icon.TrafficCone">M9.6,2H14.4L15.2,5H8.8L9.6,2M8.3,7H15.7L16.7,11H7.3L8.3,7M6.8,13H17.2L18.2,17H5.8L6.8,13M3,19H21V22H3V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
|
||||
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange appliquée par-dessus le thème du
|
||||
jeu, dans la fenêtre principale seulement (MainWindow l'ajoute à ses ressources pendant la maintenance, puis la
|
||||
retire). Mêmes clés que Themes/Default.xaml : les vues, en {DynamicResource …}, changent de couleur à chaud.
|
||||
Les clés « Maintenance* » ne servent qu'à la carte affichée au centre de la fenêtre.
|
||||
-->
|
||||
|
||||
<!-- ============================================================ Palette -->
|
||||
<Color x:Key="AccentColor">#FF3B30</Color>
|
||||
<Color x:Key="Accent2Color">#FF9F0A</Color>
|
||||
<Color x:Key="BackgroundColor">#140806</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#140806" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#FF453A" />
|
||||
<SolidColorBrush x:Key="Accent2Brush" Color="#FF9F0A" />
|
||||
<SolidColorBrush x:Key="OnAccentBrush" Color="White" />
|
||||
|
||||
<LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FF3B30" Offset="0" />
|
||||
<GradientStop Color="#FF9500" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#2EFF7A45" />
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#4DFF7A45" />
|
||||
<SolidColorBrush x:Key="ChipBrush" Color="#991C0C08" />
|
||||
<SolidColorBrush x:Key="ChipIconBoxBrush" Color="#33FF6A2B" />
|
||||
<SolidColorBrush x:Key="BadgeBrush" Color="#24FF6A2B" />
|
||||
<SolidColorBrush x:Key="MenuBrush" Color="#F21C0E0A" />
|
||||
<SolidColorBrush x:Key="ActionBarBrush" Color="#EB170B08" />
|
||||
<SolidColorBrush x:Key="WindowBorderBrush" Color="#66FF6A2B" />
|
||||
<SolidColorBrush x:Key="RailBrush" Color="#CC140806" />
|
||||
|
||||
<!-- ============================================================ Fond : illustration du jeu fortement teintée -->
|
||||
<LinearGradientBrush x:Key="HeroOverlayBrush" StartPoint="0,0" EndPoint="1,0">
|
||||
<GradientStop Color="#F2140806" Offset="0" />
|
||||
<GradientStop Color="#D9140806" Offset="0.5" />
|
||||
<GradientStop Color="#C7140806" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<LinearGradientBrush x:Key="VignetteBrush" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#B3140806" Offset="0" />
|
||||
<GradientStop Color="#00140806" Offset="0.2" />
|
||||
<GradientStop Color="#00140806" Offset="0.6" />
|
||||
<GradientStop Color="#F2140806" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- Lueur rouge / orange derrière la carte de maintenance -->
|
||||
<RadialGradientBrush x:Key="AmbientGlowBrush" Center="0.55,0.45" GradientOrigin="0.55,0.45" RadiusX="0.7" RadiusY="0.8">
|
||||
<GradientStop Color="#66FF3B1F" Offset="0" />
|
||||
<GradientStop Color="#33FF8A00" Offset="0.45" />
|
||||
<GradientStop Color="#00FF8A00" Offset="1" />
|
||||
</RadialGradientBrush>
|
||||
|
||||
<!-- ============================================================ Carte « Maintenance en cours » -->
|
||||
<SolidColorBrush x:Key="MaintenanceCardBrush" Color="#E61A0D09" />
|
||||
<SolidColorBrush x:Key="MaintenanceCardBorderBrush" Color="#80FF6A2B" />
|
||||
|
||||
<!-- Bandes de chantier (diagonales orange / brun), répétées -->
|
||||
<DrawingBrush x:Key="MaintenanceStripesBrush" TileMode="Tile" Viewport="0,0,16,16" ViewportUnits="Absolute"
|
||||
Viewbox="0,0,16,16" ViewboxUnits="Absolute" Stretch="None">
|
||||
<DrawingBrush.Drawing>
|
||||
<DrawingGroup>
|
||||
<GeometryDrawing Brush="#FF2A130B" Geometry="M0,0H16V16H0Z" />
|
||||
<GeometryDrawing Brush="#FFFF8A1F" Geometry="M0,0L8,0L0,8Z M16,0L16,8L8,16L0,16Z" />
|
||||
</DrawingGroup>
|
||||
</DrawingBrush.Drawing>
|
||||
</DrawingBrush>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
@@ -65,6 +66,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private string? _lastInstallDirectory;
|
||||
private int _bannerVersion;
|
||||
private bool _launchWhenReady;
|
||||
private bool _isMaintenance;
|
||||
|
||||
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
|
||||
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
|
||||
@@ -88,8 +90,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
LauncherVersion = launcherVersion;
|
||||
ApplySettings();
|
||||
|
||||
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary);
|
||||
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable);
|
||||
// Mode maintenance : toutes les commandes sont désactivées, sauf la désinstallation, l'annulation d'une opération
|
||||
// déjà commencée et la fermeture du bandeau (Command(…, allowedInMaintenance: true)).
|
||||
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary, allowedInMaintenance: true);
|
||||
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable, allowedInMaintenance: true);
|
||||
CheckUpdatesCommand = Command(() => Fire(() => CheckForUpdatesAsync(userInitiated: true)),
|
||||
() => State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
|
||||
or LauncherState.NeedsRepair or LauncherState.Broken);
|
||||
@@ -101,12 +105,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
() => State is LauncherState.NotInstalled or LauncherState.Broken);
|
||||
ForgetCommand = Command(Forget, () => State == LauncherState.Broken);
|
||||
UninstallCommand = Command(() => Fire(async () => await UninstallAsync()),
|
||||
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken);
|
||||
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken,
|
||||
allowedInMaintenance: true);
|
||||
OpenFolderCommand = Command(() => { if (InstallPath is not null) _window.OpenFolder(InstallPath); },
|
||||
() => InstallPath is not null && State != LauncherState.Broken && Directory.Exists(InstallPath));
|
||||
OpenSettingsCommand = Command(OpenSettings);
|
||||
OpenWebsiteCommand = Command(() => { if (HasWebsite) _window.OpenUrl(_config.WebsiteUrl!); }, () => HasWebsite);
|
||||
DismissBannerCommand = Command(HideBanner);
|
||||
DismissBannerCommand = Command(HideBanner, allowedInMaintenance: true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Propriétés affichées
|
||||
@@ -184,6 +189,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(ShowInstallInfo));
|
||||
OnPropertyChanged(nameof(ShowRailBadge));
|
||||
OnPropertyChanged(nameof(RailToolTip));
|
||||
OnPropertyChanged(nameof(DisplayStatusTitle));
|
||||
OnPropertyChanged(nameof(DisplayStatusDetail));
|
||||
RefreshCommands();
|
||||
|
||||
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
|
||||
@@ -196,12 +203,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
|
||||
public bool IsBusy => State == LauncherState.Busy;
|
||||
public bool ShowLocateHint => State == LauncherState.NotInstalled;
|
||||
public bool ShowLocateHint => State == LauncherState.NotInstalled && !_isMaintenance;
|
||||
public bool ShowBrokenActions => State == LauncherState.Broken;
|
||||
public bool ShowInstallInfo => InstallPath is not null && State != LauncherState.NotInstalled;
|
||||
|
||||
public string PrimaryText => State switch
|
||||
{
|
||||
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "MAINTENANCE",
|
||||
LauncherState.Checking => "CHARGEMENT",
|
||||
LauncherState.NotInstalled => "INSTALLER",
|
||||
LauncherState.Ready => "JOUER",
|
||||
@@ -215,6 +223,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Clé d'icône (ressource "Icon.<clé>" dans Themes/Icons.xaml).</summary>
|
||||
public string PrimaryIcon => State switch
|
||||
{
|
||||
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "TrafficCone",
|
||||
LauncherState.Checking => "Refresh",
|
||||
LauncherState.NotInstalled => "Download",
|
||||
LauncherState.UpdateAvailable => "CloudDownload",
|
||||
@@ -244,10 +253,71 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
};
|
||||
|
||||
private string _statusTitle = "Chargement…";
|
||||
public string StatusTitle { get => _statusTitle; private set => SetProperty(ref _statusTitle, value); }
|
||||
public string StatusTitle
|
||||
{
|
||||
get => _statusTitle;
|
||||
private set { if (SetProperty(ref _statusTitle, value)) OnPropertyChanged(nameof(DisplayStatusTitle)); }
|
||||
}
|
||||
|
||||
private string _statusDetail = "";
|
||||
public string StatusDetail { get => _statusDetail; private set => SetProperty(ref _statusDetail, value); }
|
||||
public string StatusDetail
|
||||
{
|
||||
get => _statusDetail;
|
||||
private set { if (SetProperty(ref _statusDetail, value)) OnPropertyChanged(nameof(DisplayStatusDetail)); }
|
||||
}
|
||||
|
||||
/// <summary>Titre de la barre d'action : pendant une maintenance, celle-ci (sauf pendant une opération déjà commencée).</summary>
|
||||
public string DisplayStatusTitle => _isMaintenance && State != LauncherState.Busy ? MaintenanceRules.Title : StatusTitle;
|
||||
|
||||
public string DisplayStatusDetail => !_isMaintenance || State == LauncherState.Busy
|
||||
? StatusDetail
|
||||
: State == LauncherState.Running
|
||||
? "Le jeu déjà lancé n'est pas fermé. Seule la désinstallation des jeux reste possible."
|
||||
: "Seule la désinstallation des jeux reste possible (menu « … »).";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (commun à tous les jeux, posé par le launcher) : tout est
|
||||
/// bloqué sauf la désinstallation. Une opération déjà commencée se termine (elle reste annulable) ; un jeu déjà lancé
|
||||
/// n'est pas fermé. À la fin de la maintenance, les mises à jour sont recherchées de nouveau.
|
||||
/// </summary>
|
||||
public bool IsMaintenance
|
||||
{
|
||||
get => _isMaintenance;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _isMaintenance, value)) return;
|
||||
if (value) _launchWhenReady = false; // lancement demandé par un raccourci : abandonné
|
||||
OnPropertyChanged(nameof(PrimaryText));
|
||||
OnPropertyChanged(nameof(PrimaryIcon));
|
||||
OnPropertyChanged(nameof(DisplayStatusTitle));
|
||||
OnPropertyChanged(nameof(DisplayStatusDetail));
|
||||
OnPropertyChanged(nameof(ShowLocateHint));
|
||||
OnPropertyChanged(nameof(ShowWebsite));
|
||||
RefreshCommands();
|
||||
Log.Info($"[{GameId}] Mode maintenance {(value ? "activé" : "terminé")}.");
|
||||
|
||||
// Fin de la maintenance : état et mises à jour à jour (mise à jour automatique selon le réglage du jeu).
|
||||
if (!value && State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
|
||||
or LauncherState.NeedsRepair or LauncherState.Broken)
|
||||
Fire(() => CheckForUpdatesAsync(userInitiated: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bouton « Site officiel » de la barre de titre (masqué pendant une maintenance).</summary>
|
||||
public bool ShowWebsite => HasWebsite && !_isMaintenance;
|
||||
|
||||
/// <summary>Action refusée pendant une maintenance : bandeau d'explication. Vrai si l'action doit s'arrêter.</summary>
|
||||
private bool BlockedByMaintenance()
|
||||
{
|
||||
if (!_isMaintenance) return false;
|
||||
Log.Info($"[{GameId}] Action refusée : mode maintenance.");
|
||||
ShowBanner(MaintenanceRules.Title,
|
||||
"Cette action est indisponible pendant la maintenance. Merci de revenir plus tard : seule la désinstallation reste possible.",
|
||||
DialogKind.Warning);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string _stepText = "";
|
||||
public string StepText { get => _stepText; private set => SetProperty(ref _stepText, value); }
|
||||
@@ -334,9 +404,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
public RelayCommand OpenWebsiteCommand { get; }
|
||||
public RelayCommand DismissBannerCommand { get; }
|
||||
|
||||
private RelayCommand Command(Action execute, Func<bool>? canExecute = null)
|
||||
private RelayCommand Command(Action execute, Func<bool>? canExecute = null, bool allowedInMaintenance = false)
|
||||
{
|
||||
var command = new RelayCommand(execute, canExecute);
|
||||
var command = new RelayCommand(execute,
|
||||
allowedInMaintenance ? canExecute : () => !_isMaintenance && (canExecute?.Invoke() ?? true));
|
||||
_commands.Add(command);
|
||||
return command;
|
||||
}
|
||||
@@ -348,6 +419,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private bool CanExecutePrimary() => State switch
|
||||
{
|
||||
LauncherState.Busy when _isMaintenance => _operationCancellable, // seule l'annulation reste possible
|
||||
_ when _isMaintenance => false,
|
||||
LauncherState.Checking or LauncherState.Running => false,
|
||||
LauncherState.Busy => _operationCancellable,
|
||||
_ => true
|
||||
@@ -355,6 +428,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void ExecutePrimary()
|
||||
{
|
||||
if (State != LauncherState.Busy && BlockedByMaintenance()) return;
|
||||
switch (State)
|
||||
{
|
||||
case LauncherState.NotInstalled: Fire(InstallAsync); break;
|
||||
@@ -578,6 +652,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
|
||||
{
|
||||
if (State is LauncherState.Busy or LauncherState.Running) return;
|
||||
// Maintenance : ni recherche ni mise à jour automatique (reprises à la fin de la maintenance).
|
||||
if (_isMaintenance)
|
||||
{
|
||||
if (userInitiated) BlockedByMaintenance();
|
||||
return;
|
||||
}
|
||||
if (_launcherUpdates?.IsInstalling == true) return; // le launcher se met à jour puis redémarre
|
||||
|
||||
if (!background)
|
||||
@@ -599,6 +679,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (State is LauncherState.Busy or LauncherState.Running) return; // une opération ou le jeu a démarré entre-temps
|
||||
if (_launcherUpdates?.IsInstalling == true) return;
|
||||
RefreshInstallState();
|
||||
if (_isMaintenance) return; // maintenance commencée pendant la recherche : pas de mise à jour automatique
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
@@ -649,11 +730,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// Exécute une opération longue : état "Busy", barre de progression, annulation,
|
||||
/// gestion des erreurs, puis nouvelle détection de l'état.
|
||||
/// </summary>
|
||||
/// <param name="allowedInMaintenance">Vrai pour la désinstallation seulement : seule opération permise pendant une maintenance.</param>
|
||||
private async Task<bool> RunOperationAsync(string title,
|
||||
Func<IProgress<OperationProgress>, CancellationToken, Task> work,
|
||||
bool cancellable = true)
|
||||
bool cancellable = true, bool allowedInMaintenance = false)
|
||||
{
|
||||
if (State == LauncherState.Busy) return false;
|
||||
if (!allowedInMaintenance && BlockedByMaintenance()) return false;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
|
||||
_operationCts = cts;
|
||||
@@ -725,6 +808,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var manifest = await TryFetchManifestAsync();
|
||||
if (manifest is null)
|
||||
{
|
||||
@@ -756,6 +840,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task UpdateAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -783,6 +868,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// </param>
|
||||
private async Task RepairAsync(bool cacheIsFresh = false)
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -914,6 +1000,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task VerifyIntegrityAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -983,6 +1070,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task LocateAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var initial = InstallPath is not null ? Path.GetDirectoryName(InstallPath) : null;
|
||||
var folder = _dialogs.PickFolder($"Où se trouve {GameName} ?", initial);
|
||||
if (folder is null) return;
|
||||
@@ -1046,6 +1134,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void Forget()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
if (!_dialogs.Confirm("Oublier l'installation",
|
||||
$"Le launcher ne considérera plus {GameName} comme installé dans « {InstallPath} ».\nAucun fichier ne sera supprimé.",
|
||||
"Oublier", "Annuler", DialogKind.Question))
|
||||
@@ -1124,7 +1213,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_registry.Delete();
|
||||
_shortcuts.RemoveDesktopShortcut();
|
||||
_needsRepair = false;
|
||||
}, cancellable: false);
|
||||
}, cancellable: false, allowedInMaintenance: true);
|
||||
|
||||
if (ok && result is not null)
|
||||
{
|
||||
@@ -1250,6 +1339,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
public void LaunchFromShortcut()
|
||||
{
|
||||
_launchWhenReady = false;
|
||||
if (_isMaintenance && State != LauncherState.Running)
|
||||
{
|
||||
ShowBanner(MaintenanceRules.Title,
|
||||
$"{GameName} ne peut pas être lancé pendant la maintenance. {MaintenanceRules.Subtitle}", DialogKind.Warning, autoHide: false);
|
||||
return;
|
||||
}
|
||||
switch (State)
|
||||
{
|
||||
case LauncherState.Ready:
|
||||
@@ -1283,6 +1378,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void Play()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
var exePath = GetExecutablePath(dir);
|
||||
@@ -1416,6 +1512,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
|
||||
private void OpenSettings()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
|
||||
_config.GameLogsDirectory, _config.GameDataDirectory);
|
||||
var shortcut = new GameShortcutActions(
|
||||
|
||||
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
if (check.IsAvailable)
|
||||
{
|
||||
SetAvailable(check);
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
if (check.IsPriority)
|
||||
{
|
||||
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
|
||||
LauncherStatusKind = DialogKind.Warning;
|
||||
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
|
||||
"Installez-la dès que possible.";
|
||||
}
|
||||
else
|
||||
{
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
|
||||
{
|
||||
Automatic = automatic;
|
||||
IsPriority = update.IsPriority;
|
||||
FromVersion = update.CurrentDisplay;
|
||||
ToVersion = update.AvailableDisplay;
|
||||
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
|
||||
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
|
||||
Subtitle = automatic
|
||||
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
|
||||
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
|
||||
Subtitle = (automatic, IsPriority) switch
|
||||
{
|
||||
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
|
||||
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
|
||||
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
|
||||
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
|
||||
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
|
||||
};
|
||||
|
||||
Progress = new Progress<OperationProgress>(p =>
|
||||
{
|
||||
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
|
||||
public bool Automatic { get; }
|
||||
|
||||
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
|
||||
public bool IsPriority { get; }
|
||||
|
||||
public string FromVersion { get; }
|
||||
public string ToVersion { get; }
|
||||
public string Subtitle { get; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -32,6 +34,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
private readonly string _launcherVersion;
|
||||
private IThemeTransition? _transition;
|
||||
private GameViewModel? _selectedGame;
|
||||
private MaintenanceDocument? _maintenance;
|
||||
|
||||
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
|
||||
LauncherPreferences preferences, string launcherVersion,
|
||||
@@ -52,7 +55,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
|
||||
else Quit();
|
||||
});
|
||||
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
|
||||
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings, () => !IsMaintenance);
|
||||
}
|
||||
|
||||
public ObservableCollection<GameViewModel> Games { get; } = new();
|
||||
@@ -65,9 +68,55 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
public bool IsExitConfirmed { get; set; }
|
||||
|
||||
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
|
||||
/// <summary>Bouton « Aide » disponible (adresse de l'API présente dans launcher.json).</summary>
|
||||
public bool IsHelpAvailable { get; set; }
|
||||
|
||||
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
|
||||
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, vérifié par MaintenanceService) : thème
|
||||
/// rouge / orange, message au centre, tout bloqué sauf la désinstallation des jeux et le menu d'administration.
|
||||
/// </summary>
|
||||
public bool IsMaintenance => _maintenance is { Enabled: true };
|
||||
|
||||
public string MaintenanceTitle => MaintenanceRules.Title;
|
||||
public string MaintenanceSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Précision publiée par l'administrateur (texte brut, sans lien), sinon null.</summary>
|
||||
public string? MaintenanceMessage => IsMaintenance ? _maintenance!.Message : null;
|
||||
|
||||
/// <summary>« Depuis le mardi 10 novembre à 14:05 » (heure de ce PC).</summary>
|
||||
public string? MaintenanceSinceText => IsMaintenance && _maintenance!.Since is { } since
|
||||
? "Depuis le " + since.ToLocalTime().ToString("dddd d MMMM 'à' HH:mm", CultureInfo.GetCultureInfo("fr-FR"))
|
||||
: null;
|
||||
|
||||
/// <summary>Nouvel état du mode maintenance (null : rien de publié). Appelé sur le fil de l'interface.</summary>
|
||||
public void ApplyMaintenance(MaintenanceDocument? maintenance)
|
||||
{
|
||||
var wasActive = IsMaintenance;
|
||||
var next = maintenance is { Enabled: true } ? maintenance : null;
|
||||
|
||||
void Change()
|
||||
{
|
||||
_maintenance = next;
|
||||
var active = IsMaintenance;
|
||||
foreach (var game in Games) game.IsMaintenance = active;
|
||||
OnPropertyChanged(nameof(MaintenanceMessage));
|
||||
OnPropertyChanged(nameof(MaintenanceSinceText));
|
||||
if (wasActive == active) return;
|
||||
OnPropertyChanged(nameof(IsMaintenance));
|
||||
OnPropertyChanged(nameof(ShowHelpButton));
|
||||
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// Début ou fin de la maintenance : même fondu enchaîné qu'un changement de jeu (palette, carte au centre).
|
||||
if (wasActive != (next is not null) && _transition is not null) _transition.Run(Change);
|
||||
else Change();
|
||||
}
|
||||
|
||||
public RelayCommand MinimizeCommand { get; }
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
@@ -124,6 +173,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
public void AddGame(GameViewModel game)
|
||||
{
|
||||
game.IsMaintenance = IsMaintenance;
|
||||
Games.Add(game);
|
||||
OnPropertyChanged(nameof(IsMultiGame));
|
||||
}
|
||||
@@ -253,6 +303,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void OpenLauncherSettings()
|
||||
{
|
||||
if (IsMaintenance) return; // bouton désactivé pendant une maintenance
|
||||
var vm = new LauncherSettingsViewModel(_preferences, LauncherName, _launcherVersion, _window, _launcherUpdates);
|
||||
if (!_dialogs.ShowSettings(vm)) return;
|
||||
|
||||
|
||||
@@ -76,6 +76,16 @@
|
||||
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
|
||||
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
|
||||
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
|
||||
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
|
||||
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
|
||||
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
|
||||
|
||||
@@ -271,14 +271,15 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
|
||||
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json,
|
||||
masqué pendant une maintenance) -->
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
|
||||
Visibility="{Binding DataContext.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Web}"
|
||||
Command="{Binding OpenWebsiteCommand}"
|
||||
Visibility="{Binding HasWebsite, Converter={StaticResource BoolToVisibility}}" />
|
||||
Visibility="{Binding ShowWebsite, Converter={StaticResource BoolToVisibility}}" />
|
||||
<!-- Paramètres du launcher : communs à tous les jeux -->
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Paramètres du launcher" Margin="4,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Cog}"
|
||||
@@ -295,8 +296,9 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Contenu -->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20">
|
||||
<!-- ===================================================== Contenu (masqué pendant une maintenance) -->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20"
|
||||
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
|
||||
@@ -489,6 +491,87 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Mode maintenance (publié depuis le menu d'administration) -->
|
||||
<!--
|
||||
Carte au centre de la fenêtre, à la place de la présentation et des notes de version. La palette rouge / orange
|
||||
(Themes/Maintenance.xaml) est ajoutée aux ressources de la fenêtre par MainWindow.xaml.cs pendant la maintenance.
|
||||
Textes : ceux du launcher (titre, sous-titre) et, au choix, la précision publiée par l'administrateur (texte brut).
|
||||
-->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20"
|
||||
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
|
||||
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="660" Margin="0,8">
|
||||
<!-- Halo de la carte (à part : l'ombre ne s'applique pas aux textes) -->
|
||||
<Border CornerRadius="28" Background="{DynamicResource MaintenanceCardBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="44" ShadowDepth="0" Opacity="0.5" Color="#FF3B1F" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="28" Padding="44,26,44,34" BorderThickness="1"
|
||||
BorderBrush="{DynamicResource MaintenanceCardBorderBrush}">
|
||||
<StackPanel>
|
||||
<!-- Bandes de chantier -->
|
||||
<Border Width="132" Height="8" CornerRadius="4" Margin="0,0,0,26" HorizontalAlignment="Center"
|
||||
Background="{DynamicResource MaintenanceStripesBrush}" />
|
||||
|
||||
<Grid Width="92" Height="92" HorizontalAlignment="Center">
|
||||
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}" Opacity="0.7">
|
||||
<Border.Effect>
|
||||
<BlurEffect Radius="26" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.TrafficCone}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="46" Height="46" Stretch="Uniform" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceTitle, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,26,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontFamily="{DynamicResource HeroTitleFont}" FontSize="38" FontWeight="{DynamicResource HeroTitleWeight}"
|
||||
FontStretch="{DynamicResource HeroTitleStretch}" />
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceSubtitle, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,8,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="18" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
|
||||
<Border Width="72" Height="3" CornerRadius="1.5" Margin="0,22,0,0" HorizontalAlignment="Center"
|
||||
Background="{DynamicResource AccentGradientBrush}" />
|
||||
|
||||
<!-- Précision publiée par l'administrateur : texte brut, sélectionnable, jamais interprété -->
|
||||
<TextBox Text="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}"
|
||||
Margin="0,20,0,0" IsReadOnly="True" IsTabStop="False" BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="Transparent" Cursor="Arrow"
|
||||
FontSize="15" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch"
|
||||
Visibility="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}">
|
||||
<TextBox.Template>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" VerticalScrollBarVisibility="Disabled"
|
||||
HorizontalScrollBarVisibility="Disabled" />
|
||||
</ControlTemplate>
|
||||
</TextBox.Template>
|
||||
</TextBox>
|
||||
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,18,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}" />
|
||||
|
||||
<Border HorizontalAlignment="Center" Margin="0,18,0,0" CornerRadius="14" Padding="14,8"
|
||||
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Data="{StaticResource Icon.Lock}" Fill="{DynamicResource Accent2Brush}"
|
||||
Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" />
|
||||
<TextBlock Margin="9,0,0,0" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Text="Installation, mises à jour et lancement suspendus. La désinstallation reste possible (menu « … »)." />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Barre d'action -->
|
||||
<Border Grid.Row="2" Margin="24,0,24,24" Padding="28,20" CornerRadius="22"
|
||||
Background="{DynamicResource ActionBarBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
@@ -500,7 +583,7 @@
|
||||
|
||||
<StackPanel VerticalAlignment="Center" Margin="0,0,32,0">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding StatusTitle}" FontSize="16" FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding DisplayStatusTitle}" FontSize="16" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,90,0" />
|
||||
<TextBlock Text="{Binding StepText}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
@@ -509,9 +592,9 @@
|
||||
Value="{Binding ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate, Mode=OneWay}"
|
||||
Visibility="{Binding ShowProgress, Converter={StaticResource BoolToVisibility}}" />
|
||||
<TextBlock Text="{Binding StatusDetail}" Margin="0,8,0,0" FontSize="12.5"
|
||||
<TextBlock Text="{Binding DisplayStatusDetail}" Margin="0,8,0,0" FontSize="12.5"
|
||||
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding StatusDetail, Converter={StaticResource StringToVisibility}}" />
|
||||
Visibility="{Binding DisplayStatusDetail, Converter={StaticResource StringToVisibility}}" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
|
||||
Visibility="{Binding ShowLocateHint, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Le jeu est déjà sur votre PC ?" Margin="0,0,10,0" FontSize="12.5"
|
||||
@@ -533,6 +616,12 @@
|
||||
<DataTrigger Binding="{Binding State}" Value="Running">
|
||||
<Setter Property="Background" Value="{DynamicResource NeutralGradientBrush}" />
|
||||
</DataTrigger>
|
||||
<!-- Maintenance : bouton « MAINTENANCE » désactivé, aux couleurs de la maintenance -->
|
||||
<DataTrigger Binding="{Binding IsMaintenance}" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
|
||||
<Setter Property="ToolTip" Value="Maintenance en cours : merci de revenir plus tard." />
|
||||
<Setter Property="ToolTipService.ShowOnDisabled" Value="True" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
|
||||
@@ -22,10 +22,52 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
|
||||
private readonly Queue<DateTime> _logoClicks = new();
|
||||
|
||||
/// <summary>Palette rouge / orange du mode maintenance (Themes/Maintenance.xaml), ajoutée aux ressources de la fenêtre.</summary>
|
||||
private static readonly Uri MaintenancePaletteUri = new("pack://application:,,,/GameLauncher;component/Themes/Maintenance.xaml");
|
||||
private ResourceDictionary? _maintenancePalette;
|
||||
private LauncherViewModel? _observed;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
DataContextChanged += (_, _) => ObserveLauncher(DataContext as LauncherViewModel);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
private void ObserveLauncher(LauncherViewModel? launcher)
|
||||
{
|
||||
if (_observed is not null) _observed.PropertyChanged -= OnLauncherPropertyChanged;
|
||||
_observed = launcher;
|
||||
if (launcher is null) return;
|
||||
launcher.PropertyChanged += OnLauncherPropertyChanged;
|
||||
ApplyMaintenancePalette(launcher.IsMaintenance);
|
||||
}
|
||||
|
||||
private void OnLauncherPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(LauncherViewModel.IsMaintenance) && sender is LauncherViewModel launcher)
|
||||
ApplyMaintenancePalette(launcher.IsMaintenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance : la palette rouge / orange passe devant celle du thème du jeu, pour cette fenêtre seulement (les
|
||||
/// ressources de Root sont trouvées avant celles de l'application). Le fondu enchaîné est fait par LauncherViewModel
|
||||
/// (début et fin de la maintenance passent par <see cref="Run"/>, comme un changement de jeu).
|
||||
/// </summary>
|
||||
private void ApplyMaintenancePalette(bool active)
|
||||
{
|
||||
var merged = Root.Resources.MergedDictionaries;
|
||||
if (active)
|
||||
{
|
||||
_maintenancePalette ??= new ResourceDictionary { Source = MaintenancePaletteUri };
|
||||
if (!merged.Contains(_maintenancePalette)) merged.Add(_maintenancePalette);
|
||||
}
|
||||
else if (_maintenancePalette is not null)
|
||||
{
|
||||
merged.Remove(_maintenancePalette);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -182,7 +182,8 @@ public class FeedbackPanelTests
|
||||
public void Feedback_audit_actions_have_friendly_labels()
|
||||
{
|
||||
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
|
||||
"feedback.purge", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
|
||||
"feedback.purge", "feedback.forms", "feedback.form_create", "feedback.form_update",
|
||||
"feedback.form_delete", "audit.purge", "keyring.publish", "release.sign" })
|
||||
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Maintenance » du menu d'administration et bandeau du tableau de bord.</summary>
|
||||
public class MaintenancePanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new MaintenanceAdminModule() });
|
||||
|
||||
[Fact]
|
||||
public async Task The_owner_enables_the_maintenance_with_a_message_updates_it_then_ends_it()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-maintenance-panel-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json")
|
||||
});
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs();
|
||||
var notified = 0;
|
||||
var environment = AdminTestKit.Environment(api, folder.Path, dialogs, Modules);
|
||||
environment = new AdminEnvironment
|
||||
{
|
||||
Client = environment.Client, Modules = environment.Modules, Games = environment.Games, Themes = environment.Themes,
|
||||
Dialogs = environment.Dialogs, Ui = environment.Ui, SettingsPath = environment.SettingsPath, KeysDirectory = environment.KeysDirectory,
|
||||
LauncherVersion = environment.LauncherVersion, Time = environment.Time, StartTimers = false,
|
||||
MaintenanceChanged = () => notified++
|
||||
};
|
||||
await using var shell = new AdminShellViewModel(environment);
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(MaintenancePanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<MaintenancePanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.False(page.IsActive);
|
||||
Assert.Equal("Launchers accessibles", page.StateTitle);
|
||||
Assert.Contains("jamais", page.StateText);
|
||||
Assert.True(page.EnableCommand.CanExecute(null));
|
||||
Assert.False(page.DisableCommand.CanExecute(null));
|
||||
|
||||
// Message avec un lien : refusé avant tout envoi.
|
||||
page.Message = "Infos sur discord.gg/exovival";
|
||||
Assert.True(page.HasMessageError);
|
||||
await page.EnableCommand.ExecuteAsync();
|
||||
Assert.False(page.IsActive);
|
||||
Assert.Empty(dialogs.Confirmations);
|
||||
|
||||
// Activation (confirmée) : publiée, ce launcher prévenu.
|
||||
page.Message = " Mise à jour des serveurs.\r\nRetour vers 18 h. ";
|
||||
Assert.False(page.HasMessageError);
|
||||
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", page.PreviewMessage);
|
||||
await page.EnableCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.IsActive);
|
||||
Assert.Equal("Maintenance en cours", page.StateTitle);
|
||||
Assert.Contains("Depuis le", page.StateText);
|
||||
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", page.PublishedMessage);
|
||||
Assert.Equal(1, notified);
|
||||
Assert.Single(dialogs.Confirmations);
|
||||
Assert.False(page.UpdateMessageCommand.CanExecute(null)); // message inchangé
|
||||
|
||||
// Tableau de bord : bandeau « Maintenance en cours ».
|
||||
workspace.Navigate(DashboardPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
await dashboard.RefreshCommand.ExecuteAsync(); // déjà affiché à la connexion : « Actualiser »
|
||||
Assert.True(dashboard.IsMaintenanceActive, dashboard.ErrorMessage);
|
||||
Assert.Contains("Maintenance en cours depuis le", dashboard.MaintenanceText);
|
||||
Assert.True(dashboard.CanOpenMaintenance);
|
||||
|
||||
// Nouveau message : même maintenance (même début).
|
||||
workspace.Navigate(MaintenancePanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var since = (await workspace.Session.GetMaintenanceAsync()).Document.Since;
|
||||
page.Message = "Retour vers 19 h.";
|
||||
Assert.True(page.UpdateMessageCommand.CanExecute(null));
|
||||
await page.UpdateMessageCommand.ExecuteAsync();
|
||||
var state = await workspace.Session.GetMaintenanceAsync();
|
||||
Assert.Equal("Retour vers 19 h.", state.Document.Message);
|
||||
Assert.Equal(since, state.Document.Since);
|
||||
Assert.Equal(2, notified);
|
||||
|
||||
// Fin refusée à la confirmation : rien n'est publié.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.DisableCommand.ExecuteAsync();
|
||||
Assert.True(page.IsActive);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.DisableCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.False(page.IsActive);
|
||||
Assert.Equal("", page.Message);
|
||||
Assert.Contains("terminée", page.StateText);
|
||||
Assert.Equal(3, notified);
|
||||
Assert.False((await workspace.Session.GetMaintenanceAsync()).Document.Enabled);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Contracts;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Publishing;
|
||||
using GameLauncher.Tests.Server;
|
||||
@@ -91,6 +92,71 @@ public class SigningPanelTests
|
||||
Assert.Contains("déjà publié", page.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_feedback_forms_signature_is_shown_below_the_catalog()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
|
||||
["LauncherApi:Games:EZR:ManifestFile"] = ChangelogModuleTests.PublishedManifest(),
|
||||
["LauncherApi:Feedback:Enabled"] = "true",
|
||||
["LauncherApi:Feedback:Games:0"] = "EZR"
|
||||
});
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
using var root = SigningKit.NewKey();
|
||||
|
||||
// Trousseau n° 1 : la clé de Camille, sans l'autorisation « formulaires ».
|
||||
using var camilleKey = api.AddOwner("Camille");
|
||||
await using var camille = await api.LoginAsync(camilleKey);
|
||||
KeyringKey Entry(params string[] scopes) => new()
|
||||
{
|
||||
KeyId = camilleKey.KeyId, PublicKey = Convert.ToBase64String(camilleKey.PublicKey), Name = "Camille — PC", Scopes = scopes.ToList()
|
||||
};
|
||||
await camille.PublishKeyringAsync(SigningKit.Keyring(root, 1, Entry(SigningScopes.All.Where(s => s != SigningScopes.Forms).ToArray())));
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules,
|
||||
rootKeys: new[] { SigningKit.Root(root) }));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(SigningPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<SigningPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
|
||||
// Formulaires d'origine : rien à signer ; affichés après les versions en ligne et le catalogue, avant le mode maintenance.
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.StartsWith("Version en ligne de EZR", page.Contents[0].Title);
|
||||
Assert.StartsWith("Catalogue", page.Contents[^3].Title);
|
||||
Assert.Equal("Mode maintenance", page.Contents[^1].Title);
|
||||
var forms = page.Contents[^2];
|
||||
Assert.Equal("Formulaires des demandes", forms.Title);
|
||||
Assert.StartsWith("Formulaires d'origine", forms.Text);
|
||||
Assert.True(forms.IsValid);
|
||||
|
||||
// Publiés par Camille, dont la clé n'a pas l'autorisation « formulaires » dans le trousseau publié : refusés par
|
||||
// les launchers ; le trousseau proposé (propriétaire : autorisation « formulaires ») les rendra valides.
|
||||
await camille.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
|
||||
await page.RefreshCommand.ExecuteAsync();
|
||||
forms = page.Contents[^2];
|
||||
Assert.Equal("Formulaires des demandes (révision 1)", forms.Title);
|
||||
Assert.False(forms.IsValid);
|
||||
Assert.False(forms.WillBreak);
|
||||
Assert.Contains("refusé par les launchers", forms.Text);
|
||||
Assert.Contains("« Formulaires des demandes »", forms.Text);
|
||||
|
||||
// Trousseau n° 2 avec l'autorisation « formulaires » : valides.
|
||||
await camille.PublishKeyringAsync(SigningKit.Keyring(root, 2, Entry(SigningScopes.All.ToArray())));
|
||||
await page.RefreshCommand.ExecuteAsync();
|
||||
forms = page.Contents[^2];
|
||||
Assert.True(forms.IsValid, forms.Text);
|
||||
Assert.Contains("Camille", forms.Text);
|
||||
Assert.Contains("valide pour les launchers", forms.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_names_proposed_for_the_public_keyring_are_cleaned()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Tests.Contracts;
|
||||
|
||||
/// <summary>Règles du mode maintenance (communes au menu, au serveur et au launcher) et autorisation du trousseau.</summary>
|
||||
public class MaintenanceRulesTests
|
||||
{
|
||||
private static readonly DateTimeOffset Since = DateTimeOffset.FromUnixTimeSeconds(1_794_300_000);
|
||||
|
||||
public static MaintenanceDocument Active(long revision = 1, string? message = null) =>
|
||||
new() { Revision = revision, Enabled = true, Message = message, Since = Since };
|
||||
|
||||
[Fact]
|
||||
public void The_message_is_plain_text_without_links_and_bounded()
|
||||
{
|
||||
Assert.Equal("Retour vers 18 h.\nMerci !", MaintenanceRules.CleanMessage(" Retour vers 18 h. \r\nMerci !\t\n\n\n\n", out var problem));
|
||||
Assert.Null(problem);
|
||||
Assert.Null(MaintenanceRules.CleanMessage(" ", out problem));
|
||||
Assert.Null(problem);
|
||||
|
||||
foreach (var linked in new[] { "Infos sur discord.gg/exovival", "Voir https://exemple.fr", "www.site.com", "hxxp://piege", "contact@studio.fr" })
|
||||
{
|
||||
Assert.Null(MaintenanceRules.CleanMessage(linked, out problem));
|
||||
Assert.Contains("lien", problem);
|
||||
}
|
||||
MaintenanceRules.CleanMessage(new string('a', MaintenanceRules.MaxMessageLength + 1), out problem);
|
||||
Assert.Contains("300", problem);
|
||||
MaintenanceRules.CleanMessage(string.Join("\n", Enumerable.Range(1, MaintenanceRules.MaxMessageLines + 1)), out problem);
|
||||
Assert.Contains("lignes", problem);
|
||||
MaintenanceRules.CleanMessage("Texteesrevni", out problem); // inversion du sens d'écriture
|
||||
Assert.NotNull(problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Documents_are_validated_and_signed_on_a_canonical_digest()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
Assert.True(MaintenanceRules.Validate(Active(message: "Mise à jour des serveurs."), errors), string.Join(" ", errors));
|
||||
Assert.True(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false }, errors));
|
||||
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Revision = 0 }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Since = null }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Since = Since.AddMilliseconds(500) }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active(message: " non nettoyé "), errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active(message: "Voir www.site.com"), errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Message = "x" }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Since = Since }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Format = "autre" }, errors = new()));
|
||||
// Début dans le futur (horloge du PC de l'administrateur déréglée) : refusé par le serveur.
|
||||
Assert.False(MaintenanceRules.Validate(Active(), errors = new(), now: Since - TimeSpan.FromHours(1)));
|
||||
|
||||
// Condensé : état, message, début et révision couverts ; date de publication et signature non.
|
||||
var digest = MaintenanceRules.Digest(Active(message: "A"));
|
||||
Assert.Equal(digest, MaintenanceRules.Digest(Active(message: "A") with { UpdatedAt = DateTimeOffset.UtcNow }));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "B")));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(2, "A")));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "A") with { Since = Since.AddSeconds(1) }));
|
||||
Assert.NotEqual(MaintenanceRules.Digest(Active()), MaintenanceRules.Digest(new MaintenanceDocument { Revision = 1 }));
|
||||
|
||||
// Aller-retour du fichier public.
|
||||
var parsed = MaintenanceRules.Parse(System.Text.Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(Active(message: "A"))))!;
|
||||
Assert.Equal(digest, MaintenanceRules.Digest(parsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Maintenance_has_its_own_permission_and_keyring_scope()
|
||||
{
|
||||
Assert.Contains(AdminPermissions.MaintenanceManage, AdminPermissions.All);
|
||||
Assert.Equal("Mode maintenance", AdminPermissions.BuiltInLabel(AdminPermissions.MaintenanceManage));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Owner, AdminPermissions.MaintenanceManage));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.MaintenanceManage));
|
||||
Assert.False(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.MaintenanceManage));
|
||||
|
||||
Assert.Equal(SigningScopes.Maintenance, ContentKinds.ScopeOf(ContentKinds.Maintenance));
|
||||
Assert.Equal(ContentKinds.MaintenanceId, ContentSigning.SignedId(ContentKinds.Maintenance, "autre"));
|
||||
Assert.Equal(new[] { SigningScopes.Maintenance }, SigningScopes.FromPermissions(new[] { AdminPermissions.MaintenanceManage }));
|
||||
Assert.Equal("mode maintenance", SigningScopes.DisplayName(SigningScopes.Maintenance));
|
||||
|
||||
// Une clé autorisée « catalog » seulement ne peut pas signer le mode maintenance.
|
||||
using var key = Signing.SigningKit.NewKey();
|
||||
using var root = Signing.SigningKit.NewKey();
|
||||
var document = Active();
|
||||
var signature = Signing.SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
|
||||
var catalogOnly = Signing.SigningKit.Keyring(root, 1, Signing.SigningKit.Entry(key, "Léo", SigningScopes.Catalog));
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, catalogOnly).Valid);
|
||||
var allowed = Signing.SigningKit.Keyring(root, 2, Signing.SigningKit.Entry(key, "Nicolas", SigningScopes.Maintenance));
|
||||
Assert.True(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, allowed).Valid);
|
||||
// Signature du mode maintenance réutilisée pour un autre contenu : refusée (séparation des types).
|
||||
Assert.False(ContentSigning.VerifyWithKey(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(document), signature,
|
||||
key.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
et attaquée comme le ferait un client réel ou malveillant.
|
||||
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
|
||||
écrans) contre ce même serveur, sans interface graphique.
|
||||
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -35,6 +36,18 @@
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Site vitrine : ses services sont compilés ici plutôt que référencés. Une référence au projet du site
|
||||
copierait son appsettings.json dans le dossier des tests, à la place de celui de la Launcher API
|
||||
(même nom de fichier), et fausserait les tests du serveur.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<Compile Include="..\..\website\GameLauncher.Website\Services\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
|
||||
<Compile Include="..\..\website\GameLauncher.Website\Options\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
|
||||
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Website\Site\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -67,9 +67,22 @@ public class FeedbackFormsModuleTests
|
||||
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
|
||||
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(published),
|
||||
published.Signature!, key.PublicKey));
|
||||
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
|
||||
Assert.Contains("Problème technique", audit.Details);
|
||||
Assert.Contains("+3 champ(s)", audit.Details);
|
||||
// Audit : une ligne de publication, puis une par type modifié, avec son nom et le détail de chaque champ.
|
||||
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
|
||||
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
|
||||
Assert.Contains("3 types modifiés", audit.Details);
|
||||
Assert.Contains("« Problème technique »", audit.Details);
|
||||
string Details(string name) =>
|
||||
string.Join(" ", entries.Where(e => e.Action == FeedbackFormsAudit.Updated && e.Target == $"« {name} »").Select(e => e.Details));
|
||||
var bug = Details("Problème technique");
|
||||
Assert.Contains("nom affiché : « Bug » → « Problème technique »", bug);
|
||||
Assert.Contains("champ supprimé : « Résultat attendu »", bug);
|
||||
Assert.Contains("champ ajouté : « Où cela arrive-t-il ? » (cases à cocher, obligatoire, 3 choix)", bug);
|
||||
Assert.Contains("champ ajouté : « Version affichée en jeu » (texte court, facultatif)", bug);
|
||||
Assert.Contains("champ ajouté : « Le problème revient après un redémarrage » (case oui/non, facultatif)", bug);
|
||||
Assert.Contains("champ « Note » modifié : étoiles 5 → 10", Details("Avis"));
|
||||
Assert.Contains("masqué (plus proposé aux joueurs)", Details("Autre"));
|
||||
Assert.DoesNotContain(entries, e => e.Target == "« Demande d'évolution »"); // inchangé : pas de ligne
|
||||
|
||||
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
|
||||
using var client = api.CreateClient();
|
||||
@@ -166,9 +179,17 @@ public class FeedbackFormsModuleTests
|
||||
Assert.Equal("type_1", state.Document.Forms[0].Kind); // ordre conservé
|
||||
Assert.Equal(icon, state.Document.Forms[0].CustomIcon);
|
||||
Assert.True(new FileInfo(Path.Combine(directory, "feedback-forms.json")).Length > 64 * 1024);
|
||||
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
|
||||
Assert.Contains("type(s) ajouté(s)", audit.Details);
|
||||
Assert.Contains("« Autre »", audit.Details);
|
||||
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
|
||||
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
|
||||
Assert.Contains("5 types ajoutés", audit.Details);
|
||||
Assert.Contains("1 type supprimé : « Autre »", audit.Details);
|
||||
Assert.Equal(5, entries.Count(e => e.Action == FeedbackFormsAudit.Created));
|
||||
var created = entries.Single(e => e.Action == FeedbackFormsAudit.Created && e.Target == "« Suggestion de niveau »");
|
||||
Assert.Contains("icône importée", created.Details);
|
||||
Assert.Contains("captures d'écran non proposées", created.Details);
|
||||
Assert.Contains("champ « Message » (zone de texte, obligatoire)", created.Details);
|
||||
Assert.Contains("icône importée", entries.Single(e => e.Action == FeedbackFormsAudit.Updated && e.Target == "« Bug »").Details);
|
||||
Assert.Equal("« Autre »", entries.Single(e => e.Action == FeedbackFormsAudit.Deleted).Target);
|
||||
|
||||
using var client = api.CreateClient();
|
||||
// Nouveau type : accepté, mais sans capture d'écran.
|
||||
@@ -193,4 +214,54 @@ public class FeedbackFormsModuleTests
|
||||
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
|
||||
Assert.Equal(icon, detail.Form.CustomIcon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_names_every_changed_field_and_spreads_long_details_over_numbered_lines()
|
||||
{
|
||||
var before = FeedbackFormRules.Defaults();
|
||||
var many = Enumerable.Range(1, FeedbackFormRules.MaxOptions).Select(i => new FeedbackOption($"o{i}", new string('x', 50) + i)).ToList();
|
||||
var after = new FeedbackFormsDocument
|
||||
{
|
||||
Revision = 1,
|
||||
Forms = before.Forms.Select(form => form.Kind != FeedbackKinds.Bug ? form : form with
|
||||
{
|
||||
TitleHint = "Le souci en une phrase",
|
||||
TechByDefault = false,
|
||||
Fields = form.Fields.Select(field => field.Id switch
|
||||
{
|
||||
"severity" => field with { Label = "Gravité du problème", Required = false, ShowInList = false, Options = many },
|
||||
"steps" => field with { Hint = "Autre aide." },
|
||||
"actual" => field with { MaxLength = 800 },
|
||||
_ => field
|
||||
}).Reverse().ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var change = Assert.Single(FeedbackFormsAudit.Changes(before, after));
|
||||
Assert.Equal(FeedbackFormsAudit.Updated, change.Action);
|
||||
Assert.Equal("Bug", change.Name);
|
||||
Assert.Equal("1 type modifié : « Bug »", FeedbackFormsAudit.Summary(before, after, new[] { change }));
|
||||
|
||||
// Rien n'est perdu : plusieurs lignes numérotées, chacune sous la limite d'un champ du journal.
|
||||
var lines = FeedbackFormsAudit.Lines(change.Details);
|
||||
Assert.True(lines.Count > 1);
|
||||
Assert.All(lines, line => Assert.True(line.Length <= FeedbackFormsAudit.MaxDetailsLength, line));
|
||||
Assert.StartsWith("(1/", lines[0]);
|
||||
Assert.StartsWith($"({lines.Count}/{lines.Count}) ", lines[^1]);
|
||||
var all = string.Join(" ", lines);
|
||||
Assert.Contains("exemple de titre : « Le problème en une phrase » → « Le souci en une phrase »", all);
|
||||
Assert.Contains("« Joindre les informations techniques » décoché par défaut", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : libellé « Gravité » → « Gravité du problème »", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : rendu facultatif", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : retiré de l'aperçu de la liste des demandes", all);
|
||||
foreach (var option in many) Assert.Contains($"choix ajouté « {option.Label} »", all);
|
||||
Assert.Contains("choix retiré « Mineur »", all);
|
||||
Assert.Contains("champ « Étapes pour reproduire » modifié : aide modifiée", all);
|
||||
Assert.Contains("champ « Résultat obtenu » modifié : longueur maximale 1500 → 800", all);
|
||||
Assert.Contains("ordre des champs modifié", all);
|
||||
|
||||
// Publication sans changement : pas de ligne par type.
|
||||
Assert.Empty(FeedbackFormsAudit.Changes(before, before with { Revision = 1 }));
|
||||
Assert.Equal("aucun changement", FeedbackFormsAudit.Summary(before, before, Array.Empty<FeedbackFormChange>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Net;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Signing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Mode maintenance : publication signée depuis le menu, fichier public, droits, révisions, audit.</summary>
|
||||
public class MaintenanceModuleTests
|
||||
{
|
||||
private static async Task<(TestApi Api, string PublicDirectory)> StartAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-maintenance-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json")
|
||||
});
|
||||
return (api, directory);
|
||||
}
|
||||
|
||||
private static MaintenanceDocument ReadPublic(string directory) =>
|
||||
MaintenanceRules.Parse(File.ReadAllBytes(Path.Combine(directory, "maintenance.json")))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_is_enabled_updated_and_disabled_with_signed_public_files_and_audit()
|
||||
{
|
||||
var (api, directory) = await StartAsync();
|
||||
await using var _ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
var state = await owner.GetMaintenanceAsync();
|
||||
Assert.Equal(0, state.Document.Revision);
|
||||
Assert.False(state.Document.Enabled);
|
||||
Assert.True(state.PublishingEnabled);
|
||||
Assert.EndsWith("/catalog/maintenance.json", state.PublicUrl);
|
||||
Assert.False(File.Exists(Path.Combine(directory, "maintenance.json")));
|
||||
|
||||
// Activation : fichier public signé par la clé de la session, vérifiable par les launchers.
|
||||
state = await owner.PublishMaintenanceAsync(true, "Mise à jour des serveurs.\nRetour vers 18 h.", null, 0);
|
||||
Assert.Equal(1, state.Document.Revision);
|
||||
Assert.True(state.Document.Enabled);
|
||||
Assert.NotNull(state.Document.Since);
|
||||
Assert.NotNull(state.UpdatedBy);
|
||||
var published = ReadPublic(directory);
|
||||
Assert.True(published.Enabled);
|
||||
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", published.Message);
|
||||
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(published),
|
||||
published.Signature!, key.PublicKey));
|
||||
|
||||
// Nouveau message pendant la maintenance : même début.
|
||||
var since = state.Document.Since;
|
||||
state = await owner.PublishMaintenanceAsync(true, "Retour vers 19 h.", since, 1);
|
||||
Assert.Equal(since, ReadPublic(directory).Since);
|
||||
Assert.Equal("Retour vers 19 h.", ReadPublic(directory).Message);
|
||||
|
||||
// Fin : ni message ni début.
|
||||
state = await owner.PublishMaintenanceAsync(false, "ignoré", null, 2);
|
||||
published = ReadPublic(directory);
|
||||
Assert.False(published.Enabled);
|
||||
Assert.Null(published.Message);
|
||||
Assert.Null(published.Since);
|
||||
Assert.Equal(3, published.Revision);
|
||||
|
||||
var actions = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries
|
||||
.Where(e => e.Action.StartsWith("maintenance.", StringComparison.Ordinal)).Select(e => e.Action).Reverse().ToList();
|
||||
Assert.Equal(new[] { "maintenance.enable", "maintenance.update", "maintenance.disable" }, actions);
|
||||
var enable = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "maintenance.enable");
|
||||
Assert.Contains("Retour vers 18 h.", enable.Details);
|
||||
Assert.Contains(key.KeyId, enable.Details);
|
||||
|
||||
// Page « Signature » : signature du mode maintenance en vigueur.
|
||||
var signing = await owner.GetSigningStateAsync();
|
||||
Assert.Equal(3, signing.Maintenance!.Revision);
|
||||
Assert.False(signing.Maintenance.Enabled);
|
||||
Assert.Equal(MaintenanceRules.Digest(published), signing.Maintenance.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publication_needs_the_permission_the_session_key_the_current_revision_and_valid_content()
|
||||
{
|
||||
var (api, _) = await StartAsync();
|
||||
await using var __ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
// Éditeur (sans maintenance.manage) : lecture permise (tableau de bord), publication refusée.
|
||||
var (editorKey, _) = await api.AddAdminAsync(owner, "Léo", AdminRoles.Editor);
|
||||
await using (var editor = await api.LoginAsync(editorKey))
|
||||
{
|
||||
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.PublishMaintenanceAsync(true, null, null, 0));
|
||||
Assert.Equal(403, denied.Status);
|
||||
Assert.False((await editor.GetMaintenanceAsync()).Document.Enabled);
|
||||
}
|
||||
editorKey.Dispose();
|
||||
|
||||
// Sans session : refusé.
|
||||
using (var anonymous = await api.Http.GetAsync(ApiRoutes.Prefix + "/" + ApiRoutes.Maintenance))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
|
||||
var state = await owner.PublishMaintenanceAsync(true, null, null, 0);
|
||||
// Révision dépassée : 409, rien n'est écrasé.
|
||||
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(false, null, null, 0));
|
||||
Assert.Equal(409, conflict.Status);
|
||||
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
|
||||
Assert.True((await owner.GetMaintenanceAsync()).Document.Enabled);
|
||||
|
||||
var login = await api.RawLoginAsync(key);
|
||||
var since = state.Document.Since;
|
||||
|
||||
// Document signé par une autre clé que celle de la session, ou non signé : refusé.
|
||||
using var stranger = SigningKit.NewKey();
|
||||
var next = new MaintenanceDocument { Revision = 2, Enabled = true, Message = "Autre", Since = since };
|
||||
foreach (var document in new[]
|
||||
{
|
||||
next with { Signature = SigningKit.Sign(stranger, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(next)) },
|
||||
next
|
||||
})
|
||||
{
|
||||
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = document });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// Signature de la session, mais contenu hors règles (lien), ou début modifié pendant la maintenance : refusé.
|
||||
foreach (var document in new[] { next with { Message = "Infos sur discord.gg/exovival" }, next with { Since = since!.Value.AddHours(-1) } })
|
||||
{
|
||||
var signature = await owner.SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
|
||||
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = document with { Signature = signature } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// Signature d'un autre type de contenu (catalogue) sur le même condensé : refusée.
|
||||
var mixed = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(next));
|
||||
using (var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = next with { Signature = mixed } }))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
// Le client refuse aussi d'envoyer un message hors règles.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => owner.PublishMaintenanceAsync(true, "Voir www.site.com", since, 1));
|
||||
Assert.Equal(1, (await owner.GetMaintenanceAsync()).Document.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_public_folder_publication_is_not_configured()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
var state = await owner.GetMaintenanceAsync();
|
||||
Assert.False(state.PublishingEnabled);
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(true, null, null, 0));
|
||||
Assert.Equal(503, refused.Status);
|
||||
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,74 @@ public class ContentTrustTests
|
||||
Assert.Equal(2, server.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scope_granted_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases)));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
var catalog = new CatalogDocument { Revision = 1, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Texte" } } };
|
||||
catalog = catalog with
|
||||
{
|
||||
Signature = SigningKit.Sign(nicolas, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog))
|
||||
};
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog)); // clé connue, sans l'autorisation « catalog »
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// Trousseau republié avec l'autorisation (ex. « maintenance » après la mise à jour 3.3) : vu sans redémarrer,
|
||||
// au plus une fois par KeyChangeRetryDelay.
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog)));
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(1, server.Requests);
|
||||
clock.Now += ContentTrustService.KeyChangeRetryDelay + TimeSpan.FromSeconds(1);
|
||||
Assert.True(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(2, server.Requests);
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_launcher_left_open_downloads_the_keyring_again_and_stops_trusting_a_removed_key()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
using var compromised = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", compromised), "EZR");
|
||||
|
||||
// Clé retirée du trousseau (n° 2) : encore acceptée tant que le trousseau en mémoire est récent…
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
clock.Now += TimeSpan.FromMinutes(10);
|
||||
await trust.VerifyManifestAsync(Manifest("1.3", compromised), "EZR");
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// … puis refusée dès qu'il a dépassé son âge maximal (sans redémarrer le launcher).
|
||||
clock.Now += ContentTrustService.KeyringMaxAge;
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", compromised), "EZR"));
|
||||
Assert.Equal(2, server.Requests);
|
||||
await trust.VerifyManifestAsync(Manifest("1.4", nicolas), "EZR");
|
||||
|
||||
// Serveur injoignable au téléchargement suivant : dernier trousseau vu (n° 2) conservé, pas d'erreur.
|
||||
server.Keyring = null;
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await trust.VerifyManifestAsync(Manifest("1.5", nicolas), "EZR");
|
||||
Assert.Equal(3, server.Requests);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.6", compromised), "EZR"));
|
||||
|
||||
// Serveur compromis qui ressert l'ancien trousseau n° 1 : refusé (retour en arrière), la clé reste exclue.
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.7", compromised), "EZR"));
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE du launcher (correctif critique) : installée au démarrage même si le joueur a désactivé la mise
|
||||
/// à jour automatique. Information signée, reprise d'une publication à l'autre, sans effet sur les launchers à jour.
|
||||
/// </summary>
|
||||
public class LauncherPriorityTests
|
||||
{
|
||||
private static LauncherRelease Release(string version, string? priority = null) => new()
|
||||
{
|
||||
Product = "GameLauncher",
|
||||
Version = version,
|
||||
File = $"GameLauncher-{version}.exe",
|
||||
Size = 1234,
|
||||
Sha256 = new string('a', 64),
|
||||
PublishedAt = DateTimeOffset.UnixEpoch,
|
||||
PriorityVersion = priority
|
||||
};
|
||||
|
||||
private static (string Envelope, UpdateSignature.TrustedKey Key) Sign(LauncherRelease release, ECDsa key)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options);
|
||||
var envelope = UpdateSignature.CreateEnvelope(payload, key);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
return (envelope, new UpdateSignature.TrustedKey(UpdateSignature.ComputeKeyId(spki), spki));
|
||||
}
|
||||
|
||||
private static LauncherUpdateCheck Check(string current, LauncherRelease? release) =>
|
||||
new(VersionUtil.Parse(current), release, release is null ? null : new Uri("https://jeux.exemple.test/launcher/" + release.File));
|
||||
|
||||
[Fact]
|
||||
public void The_priority_version_is_part_of_the_signed_content()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, trusted) = Sign(Release("3.4.0", priority: "3.4.0"), key);
|
||||
|
||||
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
|
||||
Assert.Equal("3.4.0", read.PriorityVersion);
|
||||
Assert.True(read.IsPriorityRelease);
|
||||
|
||||
// Serveur compromis : retirer (ou ajouter) la priorité dans le payload casse la signature.
|
||||
var json = JsonSerializer.Deserialize<SignedEnvelope>(envelope, JsonDefaults.Options)!;
|
||||
var tampered = Encoding.UTF8.GetString(Convert.FromBase64String(json.Payload)).Replace("\"3.4.0\"", "\"3.3.9\"");
|
||||
json.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(tampered));
|
||||
var forged = JsonSerializer.Serialize(json, JsonDefaults.Options);
|
||||
Assert.Throws<UpdateSecurityException>(() => UpdateSignature.ReadRelease(forged, new[] { trusted }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_release_without_priority_stays_compatible_with_older_launchers()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var release = Release("3.4.0");
|
||||
var payload = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options));
|
||||
Assert.DoesNotContain("priorityVersion", payload, StringComparison.OrdinalIgnoreCase); // champ absent : rien ne change
|
||||
|
||||
var (envelope, trusted) = Sign(release, key);
|
||||
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
|
||||
Assert.Null(read.PriorityVersion);
|
||||
Assert.False(read.IsPriorityRelease);
|
||||
Assert.False(Check("3.3.1", read).IsPriority);
|
||||
Assert.True(Check("3.3.1", read).IsAvailable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3.3.1", true)] // plus ancien que le correctif : mise à jour imposée
|
||||
[InlineData("3.3.9", true)]
|
||||
[InlineData("3.4.0", false)] // correctif déjà installé : réglage du joueur respecté pour 3.5.0
|
||||
[InlineData("3.4.2", false)]
|
||||
public void Only_launchers_older_than_the_priority_version_are_forced(string current, bool forced)
|
||||
{
|
||||
var release = Release("3.5.0", priority: "3.4.0"); // 3.4.0 prioritaire, puis 3.5.0 normale
|
||||
Assert.Equal(forced, release.IsPriorityFor(VersionUtil.Parse(current)));
|
||||
Assert.Equal(forced, Check(current, release).IsPriority);
|
||||
Assert.False(release.IsPriorityRelease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_update_available_means_no_priority()
|
||||
{
|
||||
Assert.False(Check("3.4.0", null).IsPriority);
|
||||
Assert.False(new LauncherUpdateCheck(VersionUtil.Parse("3.3.0"), Release("3.4.0", "3.4.0"), null).IsPriority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3.6.0")] // supérieure à la version publiée
|
||||
[InlineData("pas-une-version")]
|
||||
public void An_inconsistent_priority_version_is_refused(string priority)
|
||||
{
|
||||
var release = Release("3.5.0", priority);
|
||||
Assert.Throws<UpdateSecurityException>(release.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_priority_version_means_none()
|
||||
{
|
||||
var release = Release("3.5.0", " ");
|
||||
release.Validate();
|
||||
Assert.Null(release.PriorityVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_publisher_marks_carries_over_or_clears_the_priority()
|
||||
{
|
||||
var v350 = VersionUtil.Parse("3.5.0");
|
||||
var previousPriority = Release("3.4.0", priority: "3.4.0");
|
||||
var previousNormal = Release("3.4.0");
|
||||
|
||||
// --priority : la version publiée elle-même
|
||||
Assert.Equal("3.5.0", LauncherRelease.ResolvePriorityVersion(v350, markPriority: true, clearPriority: false, previousNormal));
|
||||
// version normale après un correctif critique : priorité reprise (les launchers < 3.4.0 restent forcés)
|
||||
Assert.Equal("3.4.0", LauncherRelease.ResolvePriorityVersion(v350, false, false, previousPriority));
|
||||
// --clear-priority : plus aucune version imposée
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, true, previousPriority));
|
||||
// rien à reprendre
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, previousNormal));
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, null));
|
||||
// priorité précédente supérieure à la version publiée (--force vers une version inférieure) : non reprise
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(VersionUtil.Parse("3.3.5"), false, false, previousPriority));
|
||||
// options contradictoires
|
||||
Assert.Throws<ArgumentException>(() => LauncherRelease.ResolvePriorityVersion(v350, true, true, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_player_setting_file_has_no_priority_field()
|
||||
{
|
||||
// Effet ponctuel : la priorité vient UNIQUEMENT de la description signée, jamais de self-update.json.
|
||||
var settings = new SelfUpdateSettings { AutoUpdate = false };
|
||||
var json = JsonSerializer.Serialize(settings, JsonDefaults.Options);
|
||||
Assert.DoesNotContain("priority", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(JsonSerializer.Deserialize<SelfUpdateSettings>(json, JsonDefaults.Options)!.AutoUpdate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Tests.Admin;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Mode maintenance lu par le launcher des joueurs : vérification, retour en arrière, hors ligne, changements signalés.</summary>
|
||||
public class MaintenanceServiceTests
|
||||
{
|
||||
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
|
||||
private static readonly Uri MaintenanceUrl = new("https://launcher.example/catalog/maintenance.json");
|
||||
|
||||
/// <summary>Serveur simulé : trousseau et maintenance.json (absent : 404 ; Offline : réseau coupé).</summary>
|
||||
private sealed class FakeServer
|
||||
{
|
||||
public byte[]? Keyring { get; set; }
|
||||
public byte[]? Maintenance { get; set; }
|
||||
public bool Offline { get; set; }
|
||||
public int MaintenanceRequests { get; private set; }
|
||||
|
||||
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
|
||||
{
|
||||
if (Offline) throw new HttpRequestException("hors ligne");
|
||||
if (uri == KeyringUrl) return Keyring is { } k ? Task.FromResult(k) : throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
MaintenanceRequests++;
|
||||
if (Maintenance is not { } bytes) throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
if (bytes.Length > max) throw new InvalidDataException("réponse trop volumineuse");
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
public void Serve(MaintenanceDocument document) => Maintenance = Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(document));
|
||||
}
|
||||
|
||||
private static MaintenanceDocument Signed(ECDsa key, long revision, bool enabled, string? message = null, DateTimeOffset? signedAt = null)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = revision,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds()) : null
|
||||
};
|
||||
return document with
|
||||
{
|
||||
Signature = SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static (MaintenanceService Service, FakeServer Server, List<MaintenanceDocument?> Changes) Create(
|
||||
string folder, ECDsa root, ECDsa admin, ContentSigningMode mode, params string[] scopes)
|
||||
{
|
||||
var server = new FakeServer();
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", scopes.Length == 0 ? new[] { SigningScopes.Maintenance } : scopes))));
|
||||
var trust = mode == ContentSigningMode.Off
|
||||
? null
|
||||
: new ContentTrustService(server.Download, folder, KeyringUrl, new[] { SigningKit.Root(root) }, mode);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
return (service, server, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_maintenance_is_applied_then_ended_and_each_change_is_reported_once()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
|
||||
// Rien de publié (404) : pas de maintenance, rien à signaler.
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h.", now.AddMinutes(-3)));
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
|
||||
// Même fichier : rien de nouveau.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
|
||||
// Nouveau message : signalé.
|
||||
server.Serve(Signed(admin, 2, true, "Retour vers 19 h.", now.AddMinutes(-2)));
|
||||
await service.RefreshAsync();
|
||||
Assert.Equal(2, changes.Count);
|
||||
Assert.Equal("Retour vers 19 h.", changes[^1]!.Message);
|
||||
|
||||
// Coupure réseau pendant la maintenance : dernier état vérifié conservé.
|
||||
server.Offline = true;
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Equal(2, changes.Count);
|
||||
server.Offline = false;
|
||||
|
||||
// Fin de la maintenance.
|
||||
server.Serve(Signed(admin, 3, false, signedAt: now.AddMinutes(-1)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.False(service.IsActive);
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.False(changes[^1]!.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task In_require_mode_forged_unsigned_or_replayed_maintenance_is_ignored()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
using var attacker = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Non signé, signé par une clé absente du trousseau, ou contenu modifié après signature : ignorés.
|
||||
var unsigned = Signed(admin, 1, true) with { Signature = null };
|
||||
var forged = Signed(attacker, 1, true);
|
||||
var tampered = Signed(admin, 1, true, "Message d'origine") with { Message = "Message modifié" };
|
||||
foreach (var document in new[] { unsigned, forged, tampered })
|
||||
{
|
||||
server.Serve(document);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
Assert.Empty(changes);
|
||||
|
||||
// Légitime : maintenance, puis fin (signée plus tard).
|
||||
var old = Signed(admin, 1, true, signedAt: now.AddMinutes(-10));
|
||||
server.Serve(old);
|
||||
Assert.True(await service.RefreshAsync());
|
||||
server.Serve(Signed(admin, 2, false, signedAt: now.AddMinutes(-5)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Un serveur compromis ressert l'ancienne maintenance légitime : refusée (révision et date de signature).
|
||||
server.Serve(old);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Même après un redémarrage du launcher (date de la dernière signature acceptée gardée dans l'état des signatures).
|
||||
var (restarted, restartedServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
restartedServer.Serve(old);
|
||||
Assert.False(await restarted.RefreshAsync());
|
||||
|
||||
// Hors limites, même bien signé : ignoré.
|
||||
var linked = new MaintenanceDocument { Revision = 3, Enabled = true, Message = "Infos : discord.gg/x", Since = old.Since };
|
||||
server.Serve(linked with
|
||||
{
|
||||
Signature = SigningKit.Sign(admin, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(linked))
|
||||
});
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_key_without_the_maintenance_scope_cannot_start_a_maintenance()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require, SigningScopes.Catalog, SigningScopes.Releases);
|
||||
server.Serve(Signed(admin, 1, true));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
}
|
||||
|
||||
/// <summary>Horloge avancée à la main (trousseau).</summary>
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
|
||||
{
|
||||
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
|
||||
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
|
||||
// redémarrage : chaque relecture refusait la maintenance.
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var server = new FakeServer
|
||||
{
|
||||
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
|
||||
};
|
||||
var clock = new ManualClock();
|
||||
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
|
||||
ContentSigningMode.Require, time: clock);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
|
||||
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
|
||||
Assert.NotNull(await trust.GetKeyringAsync());
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
|
||||
clock.Now += TimeSpan.FromMinutes(3);
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
|
||||
|
||||
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
|
||||
// Hors ligne au démarrage : jamais de maintenance (le jeu installé reste jouable).
|
||||
var (offline, offlineServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
offlineServer.Offline = true;
|
||||
Assert.False(await offline.RefreshAsync());
|
||||
Assert.Null(offline.Current);
|
||||
|
||||
// Sans adresse (ni trousseau ni catalogue) : rien n'est lu.
|
||||
Assert.Null(MaintenanceService.PublishedUrl(null, null));
|
||||
Assert.Equal(MaintenanceUrl, MaintenanceService.PublishedUrl(KeyringUrl.AbsoluteUri, null));
|
||||
var none = new MaintenanceService((_, _, _) => throw new InvalidOperationException("aucune requête attendue"), null, null);
|
||||
Assert.False(await none.RefreshAsync());
|
||||
|
||||
// « warn » : document non signé appliqué (signalé dans le journal) ; « off » : appliqué sans vérification.
|
||||
foreach (var mode in new[] { ContentSigningMode.Warn, ContentSigningMode.Off })
|
||||
{
|
||||
using var modeFolder = new TempFolder();
|
||||
var (service, server, changes) = Create(modeFolder.Path, root, admin, mode);
|
||||
server.Serve(Signed(admin, 1, true) with { Signature = null });
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
// Fichier trop volumineux : ignoré.
|
||||
var (big, bigServer, bigChanges) = Create(folder.Path, root, admin, ContentSigningMode.Off);
|
||||
bigServer.Maintenance = new byte[MaintenanceRules.MaxBytes + 1];
|
||||
Assert.False(await big.RefreshAsync());
|
||||
Assert.Empty(bigChanges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Website.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GameLauncher.Tests.Website;
|
||||
|
||||
/// <summary>
|
||||
/// Site vitrine : lecture des fichiers du serveur du launcher (signature, adresses, bornes) et liseuse
|
||||
/// Markdown des actualités (aucun HTML ni lien dangereux ne doit passer).
|
||||
/// </summary>
|
||||
public sealed class WebsiteRemoteContentTests : IDisposable
|
||||
{
|
||||
private static readonly Uri UpdateUrl = new("https://launcher.example/launcher/launcher-update.json");
|
||||
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _trusted;
|
||||
|
||||
public WebsiteRemoteContentTests()
|
||||
{
|
||||
_trusted = UpdateSignature.ParsePublicKeys(_key.ExportSubjectPublicKeyInfoPem());
|
||||
}
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
|
||||
private static LauncherRelease Release(string file = "GameLauncher-3.4.0.exe", string product = "EZRLauncher") => new()
|
||||
{
|
||||
Product = product,
|
||||
Version = "3.4.0",
|
||||
File = file,
|
||||
Size = 1234,
|
||||
Sha256 = new string('A', 64),
|
||||
PublishedAt = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero),
|
||||
PriorityVersion = "3.4.0",
|
||||
Changelog = new()
|
||||
{
|
||||
new ChangelogEntry
|
||||
{
|
||||
Version = "3.4.0",
|
||||
Title = "Launcher 3.4.0",
|
||||
Sections = new()
|
||||
{
|
||||
new ChangelogSection { Title = "EN BREF", Items = { new ChangelogItem { Text = "Nouveauté" } } },
|
||||
new ChangelogSection { Title = "DÉVELOPPEMENT", Items = { new ChangelogItem { Text = "Interne" } } },
|
||||
new ChangelogSection { Title = "Serveur", Items = { new ChangelogItem { Text = "Rien" } } }
|
||||
}
|
||||
},
|
||||
new ChangelogEntry { Version = "3.3.1", Title = "Ancienne" }
|
||||
}
|
||||
};
|
||||
|
||||
private byte[] Envelope(LauncherRelease release, ECDsa? key = null) =>
|
||||
Encoding.UTF8.GetBytes(UpdateSignature.CreateEnvelope(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options), key ?? _key));
|
||||
|
||||
private static readonly string[] Hidden = { "Serveur", "Developpement" };
|
||||
|
||||
[Fact]
|
||||
public void Launcher_signature_valide_donne_la_version_l_adresse_et_les_notes_filtrees()
|
||||
{
|
||||
var snapshot = RemoteContentReader.ReadLauncher(Envelope(Release()), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("3.4.0", snapshot.Version);
|
||||
Assert.Equal("https://launcher.example/launcher/GameLauncher-3.4.0.exe", snapshot.DownloadUrl.ToString());
|
||||
Assert.Equal("GameLauncher-3.4.0.exe", snapshot.FileName);
|
||||
Assert.Equal(new string('a', 64), snapshot.Sha256);
|
||||
Assert.True(snapshot.IsPriority);
|
||||
Assert.NotNull(snapshot.LatestNotes);
|
||||
Assert.Equal("Launcher 3.4.0", snapshot.LatestNotes!.Title);
|
||||
// Sections réservées à l'équipe retirées, sans tenir compte des accents ni de la casse.
|
||||
Assert.Equal(new[] { "EN BREF" }, snapshot.LatestNotes.Sections.Select(s => s.Title).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Launcher_contenu_modifie_est_refuse()
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Envelope(Release()));
|
||||
var envelope = JsonSerializer.Deserialize<SignedEnvelope>(json, JsonDefaults.Options)!;
|
||||
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)).Replace("GameLauncher-3.4.0.exe", "Piege-3.4.0.exe");
|
||||
envelope.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
|
||||
var tampered = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options);
|
||||
|
||||
Assert.Throws<RemoteContentException>(() =>
|
||||
RemoteContentReader.ReadLauncher(tampered, UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Launcher_signe_par_une_autre_cle_est_refuse()
|
||||
{
|
||||
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
Assert.Throws<RemoteContentException>(() =>
|
||||
RemoteContentReader.ReadLauncher(Envelope(Release(), other), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://ailleurs.example/GameLauncher.exe")]
|
||||
[InlineData("http://launcher.example/launcher/GameLauncher.exe")]
|
||||
[InlineData("https://launcher.example:8443/launcher/GameLauncher.exe")]
|
||||
[InlineData("GameLauncher.exe?x=1")]
|
||||
[InlineData("notes.txt")]
|
||||
public void Launcher_executable_hors_du_serveur_ou_inattendu_est_refuse(string file)
|
||||
{
|
||||
// Même signé, un fichier ne peut pas envoyer les visiteurs vers un autre serveur.
|
||||
Assert.Throws<RemoteContentException>(() =>
|
||||
RemoteContentReader.ReadLauncher(Envelope(Release(file)), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Launcher_produit_inattendu_est_refuse()
|
||||
{
|
||||
Assert.Throws<RemoteContentException>(() =>
|
||||
RemoteContentReader.ReadLauncher(Envelope(Release(product: "AutreLauncher")), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Launcher_cle_publique_integree_au_site()
|
||||
{
|
||||
var keys = RemoteContentReader.LoadEmbeddedKeys();
|
||||
Assert.NotEmpty(keys);
|
||||
Assert.All(keys, k => Assert.Equal(16, k.KeyId.Length));
|
||||
}
|
||||
|
||||
private static byte[] Manifest(string gameId, int entries, string extraChangelogText = "Texte")
|
||||
{
|
||||
var manifest = new
|
||||
{
|
||||
formatVersion = 1,
|
||||
gameId,
|
||||
version = "1.8",
|
||||
releaseDate = new DateTimeOffset(2026, 11, 1, 18, 0, 0, TimeSpan.FromHours(1)),
|
||||
archive = new { url = "jeu.zip", size = 1000L, sha256 = new string('b', 64) },
|
||||
files = new[] { new { path = "a.bin", size = 600L }, new { path = "b.bin", size = 400L } },
|
||||
changelog = Enumerable.Range(0, entries).Select(i => new
|
||||
{
|
||||
version = $"1.{entries - i}",
|
||||
title = "Version " + i,
|
||||
sections = new[] { new { title = "NOUVEAU", items = new[] { new { text = extraChangelogText } } } }
|
||||
})
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jeu_manifest_lu_borne_et_nettoye_comme_dans_le_launcher()
|
||||
{
|
||||
var snapshot = RemoteContentReader.ReadGame(Manifest("EZR", 50, new string('x', 5000)), "EZR", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("1.8", snapshot.Version);
|
||||
Assert.Equal(1000, snapshot.DownloadSize);
|
||||
Assert.Equal(1000, snapshot.InstalledSize);
|
||||
Assert.Equal(ChangelogLimits.MaxEntries, snapshot.Changelog.Count);
|
||||
Assert.DoesNotContain('', snapshot.Changelog[0].Title!);
|
||||
Assert.True(snapshot.Changelog[0].Sections[0].Items[0].Text.Length <= ChangelogLimits.MaxTextLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jeu_manifest_d_un_autre_jeu_est_refuse()
|
||||
{
|
||||
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Manifest("AUTRE", 1), "EZR", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jeu_manifest_illisible_est_refuse()
|
||||
{
|
||||
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{ pas du json"), "EZR", DateTimeOffset.UtcNow));
|
||||
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{\"gameId\":\"EZR\",\"version\":\"abc\"}"), "EZR", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://launcher.example/manifest.json", false, true)]
|
||||
[InlineData("http://launcher.example/manifest.json", true, false)]
|
||||
[InlineData("http://127.0.0.1:8099/manifest.json", false, false)]
|
||||
[InlineData("http://127.0.0.1:8099/manifest.json", true, true)]
|
||||
[InlineData("https://user:secret@launcher.example/manifest.json", false, false)]
|
||||
[InlineData("file:///etc/passwd", true, false)]
|
||||
[InlineData("pas une adresse", false, false)]
|
||||
public void Adresses_lues_https_seulement(string url, bool allowLoopbackHttp, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RemoteFetcher.IsAllowedUrl(url, allowLoopbackHttp, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Erreur_reseau_detaillee_certificat_refuse()
|
||||
{
|
||||
var ex = new HttpRequestException("The SSL connection could not be established, see inner exception.",
|
||||
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid: PartialChain"));
|
||||
var text = RemoteFetcher.Describe(ex);
|
||||
Assert.StartsWith("certificat HTTPS refusé", text);
|
||||
Assert.Contains("PartialChain", text);
|
||||
Assert.StartsWith("serveur injoignable", RemoteFetcher.Describe(new HttpRequestException("Name or service not known")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lecture_bornee_au_dela_de_la_limite()
|
||||
{
|
||||
using var small = new MemoryStream(new byte[100]);
|
||||
Assert.NotNull(await RemoteFetcher.ReadBoundedAsync(small, 100, CancellationToken.None));
|
||||
using var big = new MemoryStream(new byte[101]);
|
||||
Assert.Null(await RemoteFetcher.ReadBoundedAsync(big, 100, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebsiteMarkdownTests
|
||||
{
|
||||
[Fact]
|
||||
public void Html_brut_affiche_jamais_interprete()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("Bonjour <script>alert(1)</script> <img src=x onerror=alert(1)>");
|
||||
Assert.DoesNotContain("<script", html);
|
||||
Assert.DoesNotContain("<img", html);
|
||||
Assert.Contains("<script>", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[clic](javascript:alert(1))")]
|
||||
[InlineData("[clic](JaVaScRiPt:alert(1))")]
|
||||
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
|
||||
[InlineData("[clic](//evil.example/x)")]
|
||||
[InlineData("[clic](vbscript:msgbox)")]
|
||||
public void Liens_dangereux_retires(string markdown)
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml(markdown);
|
||||
Assert.DoesNotContain("href", html);
|
||||
Assert.Contains("clic", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lien_externe_sans_acces_a_la_page_d_origine()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
|
||||
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
|
||||
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
|
||||
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attribut_impossible_a_injecter_dans_un_lien()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
|
||||
Assert.DoesNotContain("\"onmouseover", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("")]
|
||||
[InlineData("")]
|
||||
public void Images_hors_du_site_refusees(string markdown)
|
||||
{
|
||||
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Syntaxe_prise_en_charge()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n");
|
||||
Assert.Contains("<h2>Titre</h2>", html);
|
||||
Assert.Contains("<strong>gras</strong>", html);
|
||||
Assert.Contains("<em>italique</em>", html);
|
||||
Assert.Contains("<code>du code</code>", html);
|
||||
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
|
||||
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
|
||||
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
|
||||
Assert.Contains("<hr>", html);
|
||||
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebsiteNewsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Article_lu_avec_son_en_tete()
|
||||
{
|
||||
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
|
||||
Assert.NotNull(article);
|
||||
Assert.Equal("Titre : avec deux-points", article!.Title);
|
||||
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
|
||||
Assert.Equal("exovival", article.Theme);
|
||||
Assert.Equal("/img/exovival/couloir.webp", article.Image);
|
||||
Assert.Contains("<strong>gras</strong>", article.Html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mon_Article")]
|
||||
[InlineData("../secret")]
|
||||
[InlineData("")]
|
||||
public void Nom_de_fichier_incorrect_ignore(string slug)
|
||||
{
|
||||
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
|
||||
{
|
||||
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
|
||||
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
|
||||
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
|
||||
Assert.Equal("launcher", article!.Theme);
|
||||
Assert.Null(article.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Brouillons_masques_hors_developpement()
|
||||
{
|
||||
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
|
||||
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
|
||||
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
|
||||
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
|
||||
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
|
||||
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
|
||||
/// [--version 1.2.0] [--notes notes-launcher.txt]
|
||||
/// [--public-key src\GameLauncher\Security\update-signing.pub]
|
||||
/// [--priority | --clear-priority] [--yes]
|
||||
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
|
||||
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
|
||||
///
|
||||
@@ -49,6 +50,11 @@ public static class Program
|
||||
default: return Usage();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Warn(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error($"Erreur : {ex.Message}");
|
||||
@@ -136,6 +142,22 @@ public static class Program
|
||||
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
|
||||
}
|
||||
|
||||
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
|
||||
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
|
||||
var markPriority = o.Has("--priority");
|
||||
var clearPriority = o.Has("--clear-priority");
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
|
||||
if (markPriority && !o.Has("--yes"))
|
||||
{
|
||||
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
|
||||
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
|
||||
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
|
||||
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
|
||||
if (answer is not ("o" or "oui"))
|
||||
throw new OperationCanceledException("Abandon : rien n'a été publié.");
|
||||
}
|
||||
|
||||
using var key = LoadPrivateKey(keyPath);
|
||||
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
@@ -172,10 +194,20 @@ public static class Program
|
||||
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
|
||||
}
|
||||
|
||||
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
|
||||
var history = ReadHistory(previousJson, key, keyId, display);
|
||||
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
|
||||
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
|
||||
var history = ReadHistory(previousRelease, display);
|
||||
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
|
||||
|
||||
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
|
||||
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
|
||||
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
|
||||
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
|
||||
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
|
||||
"relancez avec --priority si cette version doit rester imposée.");
|
||||
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
|
||||
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
|
||||
|
||||
var release = new LauncherRelease
|
||||
{
|
||||
Product = product,
|
||||
@@ -187,7 +219,8 @@ public static class Program
|
||||
// Format historique, lu par les launchers antérieurs à 1.1.3
|
||||
Title = entry.Title,
|
||||
Notes = entry.Changes.ToList(),
|
||||
Changelog = changelog
|
||||
Changelog = changelog,
|
||||
PriorityVersion = priorityVersion
|
||||
};
|
||||
release.Validate();
|
||||
|
||||
@@ -219,6 +252,7 @@ public static class Program
|
||||
? " Notes : aucune (--notes non fourni ou fichier vide)"
|
||||
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
|
||||
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
|
||||
Console.WriteLine($" Priorité : {DescribePriority(release)}");
|
||||
Console.WriteLine($" Produit : {product}");
|
||||
Console.WriteLine($" Clé : {keyId}");
|
||||
Console.WriteLine($" SHA-256 : {sha256}");
|
||||
@@ -245,6 +279,7 @@ public static class Program
|
||||
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
|
||||
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
|
||||
Console.WriteLine($" SHA-256 : {release.Sha256}");
|
||||
Console.WriteLine($" Priorité: {DescribePriority(release)}");
|
||||
|
||||
var exe = o.Get("--exe");
|
||||
if (exe is null) return true;
|
||||
@@ -295,25 +330,30 @@ public static class Program
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
|
||||
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
|
||||
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
|
||||
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
|
||||
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
|
||||
{
|
||||
if (previousJson is null) return Array.Empty<ChangelogEntry>();
|
||||
LauncherRelease previous;
|
||||
if (previousJson is null) return null;
|
||||
try
|
||||
{
|
||||
previous = UpdateSignature.ReadRelease(previousJson, new[]
|
||||
return UpdateSignature.ReadRelease(previousJson, new[]
|
||||
{
|
||||
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
|
||||
});
|
||||
}
|
||||
catch (UpdateSecurityException ex)
|
||||
{
|
||||
Warn($"Historique des notes ignoré ({ex.Message}).");
|
||||
return Array.Empty<ChangelogEntry>();
|
||||
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
|
||||
{
|
||||
if (previous is null) return Array.Empty<ChangelogEntry>();
|
||||
|
||||
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
|
||||
var entries = previous.Changelog.Count > 0
|
||||
@@ -322,6 +362,16 @@ public static class Program
|
||||
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
|
||||
private static string DescribePriority(LauncherRelease release)
|
||||
{
|
||||
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
|
||||
var display = VersionUtil.Display(priority);
|
||||
return release.IsPriorityRelease
|
||||
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
|
||||
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
|
||||
}
|
||||
|
||||
private static LauncherRelease? TryReadUnverified(string envelopeJson)
|
||||
{
|
||||
try
|
||||
@@ -509,7 +559,14 @@ public static class Program
|
||||
(--config <launcher.json> | --product <launcherId>)
|
||||
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
|
||||
[--public-key <update-signing.pub>] [--force]
|
||||
[--priority | --clear-priority] [--yes]
|
||||
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
|
||||
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
|
||||
chez les joueurs qui ont désactivé la mise à jour automatique (leur
|
||||
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
|
||||
Reprise par les publications suivantes : un launcher plus ancien que
|
||||
cette version se met à jour quoi qu'il arrive.
|
||||
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
|
||||
|
||||
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
|
||||
Vérifie la signature (et l'exécutable).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace UiSnapshot;
|
||||
|
||||
@@ -54,7 +56,7 @@ internal interface ISceneSet
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
|
||||
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
|
||||
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
|
||||
/// </summary>
|
||||
internal sealed class Capture
|
||||
@@ -222,7 +224,7 @@ internal sealed class Capture
|
||||
root.UpdateLayout();
|
||||
|
||||
var image = Render(root);
|
||||
Save(image, record.Id + ".png", record);
|
||||
Save(image, record.Id + _options.ImageExtension, record);
|
||||
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
|
||||
|
||||
Diagnose(root, record);
|
||||
@@ -267,16 +269,58 @@ internal sealed class Capture
|
||||
|
||||
private void Save(BitmapSource image, string fileName, SceneRecord record)
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
|
||||
encoder.Save(stream);
|
||||
{
|
||||
if (_options.Format == SnapshotOptions.FormatWebp)
|
||||
{
|
||||
SaveWebp(image, stream, _options.Quality);
|
||||
}
|
||||
else
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
encoder.Save(stream);
|
||||
}
|
||||
}
|
||||
record.Files.Add(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
|
||||
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
|
||||
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
|
||||
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
|
||||
/// </summary>
|
||||
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
|
||||
{
|
||||
if (image.Format != PixelFormats.Pbgra32)
|
||||
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
|
||||
|
||||
var width = image.PixelWidth;
|
||||
var height = image.PixelHeight;
|
||||
var stride = width * 4;
|
||||
var pixels = new byte[stride * height];
|
||||
image.CopyPixels(pixels, stride, 0);
|
||||
|
||||
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
||||
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
|
||||
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
|
||||
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
|
||||
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
|
||||
data.SaveTo(stream);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
|
||||
/// (<scène>.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
|
||||
/// (<scène>.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
|
||||
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
|
||||
/// </summary>
|
||||
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
|
||||
@@ -295,7 +339,7 @@ internal sealed class Capture
|
||||
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
|
||||
scroller.ScrollToVerticalOffset(target);
|
||||
await SettleAsync(160);
|
||||
Save(Render(root), $"{record.Id}.p{page}.png", record);
|
||||
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
|
||||
if (target >= scroller.ScrollableHeight) break;
|
||||
}
|
||||
scroller.ScrollToVerticalOffset(original);
|
||||
|
||||
@@ -8,6 +8,7 @@ using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
@@ -173,6 +174,12 @@ internal sealed class FakeApi : HttpMessageHandler
|
||||
return Json(_world.FormsState());
|
||||
case ["v1", "feedback", "forms"] when method == HttpMethod.Put:
|
||||
return Json(_world.FormsPublished(Read<FeedbackFormsPublishRequest>(body)));
|
||||
// ---- Mode maintenance (en cours dans le monde de démonstration ; publication acceptée sans rien enregistrer)
|
||||
case ["v1", "maintenance"] when get:
|
||||
return Json(_world.Maintenance());
|
||||
case ["v1", "maintenance"] when method == HttpMethod.Put:
|
||||
return Json(_world.MaintenancePublished(Read<MaintenancePublishRequest>(body)));
|
||||
|
||||
case ["v1", "games", var gameId, "feedback"] when get:
|
||||
return Json(_world.FeedbackList(gameId, QueryValue(query, "kind"), QueryValue(query, "status")));
|
||||
case ["v1", "games", var gameId, "feedback", var feedbackId] when get:
|
||||
@@ -614,6 +621,15 @@ internal sealed class SampleWorld
|
||||
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
|
||||
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
|
||||
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
|
||||
// Publication des formulaires : une ligne de résumé, puis une par type de demande (nom et détail des champs).
|
||||
Add(12, "feedback.forms", AuditResults.Ok, "Camille", "révision 4",
|
||||
$"1 type ajouté : « Suggestion de niveau » ; 1 type modifié : « Problème technique » ; signé (clé {OwnerKeyId})");
|
||||
Add(12, "feedback.form_create", AuditResults.Ok, "Camille", "« Suggestion de niveau »",
|
||||
"icône importée ; captures d'écran non proposées ; 1 champ(s) ; champ « Message » (zone de texte, obligatoire)");
|
||||
Add(12, "feedback.form_update", AuditResults.Ok, "Camille", "« Problème technique »",
|
||||
"nom affiché : « Bug » → « Problème technique » ; champ supprimé : « Résultat attendu » ; champ « Gravité » modifié : " +
|
||||
"rendu facultatif, choix ajouté « Très grave », choix renommé « Plantage » → « Plantage du jeu » ; " +
|
||||
"champ ajouté : « Version affichée en jeu » (texte court, facultatif)");
|
||||
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
|
||||
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
|
||||
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
|
||||
@@ -709,10 +725,46 @@ internal sealed class SampleWorld
|
||||
Digest = SampleData.Digest("catalogue"),
|
||||
Signature = Signature(-3),
|
||||
SignerName = "Camille — PC-STUDIO"
|
||||
},
|
||||
// Formulaires des demandes de démonstration (révision 3, comme la page « Formulaires des demandes »).
|
||||
FeedbackForms = new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = 3,
|
||||
Digest = FeedbackFormRules.Digest(SampleData.SampleFeedbackForms()),
|
||||
Signature = Signature(-1),
|
||||
SignerName = $"{OwnerName} — PC-BUREAU"
|
||||
},
|
||||
// Mode maintenance de démonstration (en cours, comme la page « Maintenance »).
|
||||
Maintenance = new MaintenanceSignatureState
|
||||
{
|
||||
Revision = 4,
|
||||
Enabled = true,
|
||||
Digest = MaintenanceRules.Digest(SampleData.SampleMaintenance(SampleMaintenanceMessage)),
|
||||
Signature = Signature(0),
|
||||
SignerName = $"{OwnerName} — PC-BUREAU"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
public const string SampleMaintenanceMessage = "Mise à jour des serveurs de jeu.\nRetour prévu vers 18 h.";
|
||||
|
||||
/// <summary>Maintenance en cours (révision 4, activée par le propriétaire).</summary>
|
||||
public MaintenanceState Maintenance() => new()
|
||||
{
|
||||
Document = SampleData.SampleMaintenance(SampleMaintenanceMessage) with { Signature = Signature(0) },
|
||||
PublishingEnabled = true,
|
||||
PublicUrl = FakeApi.Origin + "/catalog/maintenance.json",
|
||||
UpdatedBy = $"{OwnerName} (PC-BUREAU)"
|
||||
};
|
||||
|
||||
/// <summary>Publication acceptée sans rien enregistrer : document renvoyé tel quel (révision suivante).</summary>
|
||||
public MaintenanceState MaintenancePublished(MaintenancePublishRequest request) => Maintenance() with
|
||||
{
|
||||
Document = request.Document with { UpdatedAt = DateTimeOffset.Now }
|
||||
};
|
||||
|
||||
private ContentSignature Signature(int days) => new()
|
||||
{
|
||||
KeyId = OwnerKeyId,
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Windows.Media.Imaging;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
|
||||
@@ -264,6 +265,16 @@ internal static class SampleData
|
||||
/// Formulaires publiés de démonstration (révision 3) : bug renommé, avec cases à cocher, note sur 10, texte court et
|
||||
/// case oui/non ; « autre » masqué. Fenêtre « Aide » (feedback-custom) et page « Formulaires des demandes » du menu.
|
||||
/// </summary>
|
||||
/// <summary>Mode maintenance de démonstration : en cours depuis 40 minutes (révision 4), message facultatif.</summary>
|
||||
public static MaintenanceDocument SampleMaintenance(string? message) => new()
|
||||
{
|
||||
Revision = 4,
|
||||
Enabled = true,
|
||||
Message = message,
|
||||
Since = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-40).ToUnixTimeSeconds()),
|
||||
UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-40).ToUnixTimeSeconds())
|
||||
};
|
||||
|
||||
public static FeedbackFormsDocument SampleFeedbackForms() => new()
|
||||
{
|
||||
Revision = 3,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# UiSnapshot — captures de toutes les fenêtres du launcher
|
||||
|
||||
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
|
||||
Outil de développement : il rend **chaque écran du launcher en PNG** (ou en WebP, `--format webp`), avec des données de démonstration,
|
||||
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
|
||||
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
|
||||
|
||||
@@ -8,6 +8,7 @@ partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launch
|
||||
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
|
||||
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
|
||||
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
|
||||
tools\ui-snapshot.bat --filter "main-*" --format webp captures pour le site vitrine (WebP)
|
||||
tools\ui-snapshot.bat --list liste des scènes
|
||||
tools\ui-snapshot.bat --help toutes les options
|
||||
```
|
||||
@@ -19,22 +20,45 @@ arguments éventuels dans Propriétés › Déboguer.
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
|
||||
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
|
||||
| `<scène>.png` (ou `.webp`) | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
|
||||
| `<scène>.p2.png`… (ou `.p2.webp`) | Pages suivantes quand une zone défile (jusqu'à 4) |
|
||||
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
|
||||
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
|
||||
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
|
||||
| `launcher.log` | Journal du launcher pendant les captures |
|
||||
|
||||
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
|
||||
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels des images).
|
||||
|
||||
## Captures pour le site vitrine (WebP)
|
||||
|
||||
Le site (`website\GameLauncher.Website\wwwroot\img\launcher\`) utilise des images **WebP** : plus légères que le PNG
|
||||
à qualité égale. `--format webp` les produit directement :
|
||||
|
||||
```
|
||||
tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp --quality 85
|
||||
```
|
||||
|
||||
* `--quality` : 1 à 100 (défaut 85, très bon compromis pour des captures d'interface) ; **100 = sans perte**.
|
||||
Ignorée en PNG, toujours sans perte.
|
||||
* `--backdrop transparent` : la transparence (coins arrondis, ombre) est conservée en WebP.
|
||||
* `--scale 1` (défaut) donne la taille de la fenêtre (environ 1200 px de large), adaptée au site ; `--scale 2`
|
||||
double la taille et le poids.
|
||||
* Copier ensuite les fichiers voulus de `artifacts\ui-snapshots` dans `wwwroot\img\launcher\` sous les noms
|
||||
utilisés par les pages (`apercu-exovival.webp`, `apercu-menu.webp`, `apercu-telechargement.webp`,
|
||||
`apercu-multijeux.webp`, `mise-a-jour-verifiee.webp`), puis republier le site.
|
||||
* Encodage par **SkiaSharp** (paquet NuGet maintenu par Microsoft, licence MIT, restauré automatiquement par
|
||||
Visual Studio) : Windows sait lire le WebP mais pas l'écrire. Seul cet outil l'utilise.
|
||||
|
||||
## Scènes
|
||||
|
||||
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
|
||||
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
|
||||
bloquée par sécurité, démarrage ; `main-multi` : plusieurs jeux (exemple `launcher.multi-jeux.example.json`).
|
||||
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
|
||||
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
|
||||
`launcher.multi-jeux.example.json`).
|
||||
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
|
||||
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
|
||||
d'installation, mise à jour du launcher (téléchargement, version vérifiée, version prioritaire
|
||||
`launcher-update-priority`, erreur), boîtes de dialogue
|
||||
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
|
||||
de notification.
|
||||
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
|
||||
@@ -45,13 +69,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
|
||||
démonstration (ni fichier choisi, ni presse-papiers).
|
||||
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
|
||||
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
|
||||
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), une
|
||||
version prête à publier, la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
|
||||
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil ; la page
|
||||
« Signature », `admin-page-signing`, montre les versions en ligne, le catalogue et les formulaires des
|
||||
demandes), une version prête à publier, la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
|
||||
lien signalé, captures d'écran) et un formulaire des demandes en cours de modification
|
||||
(`admin-page-feedback-forms-edit` : champ ajouté et déplié, erreur « lien » et avertissement « donnée
|
||||
personnelle », aperçu tel que les joueurs le verront), puis un type de demande ajouté
|
||||
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé) ; rien
|
||||
n'est publié.
|
||||
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé), la page
|
||||
« Maintenance » (`admin-page-maintenance` : maintenance en cours ; `admin-page-maintenance-edit` : message avec
|
||||
un lien, refusé, aperçu) ; rien n'est publié.
|
||||
|
||||
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ namespace UiSnapshot;
|
||||
/// <summary>report.md (problèmes détectés, à lire en premier) et index.html (planche de toutes les captures).</summary>
|
||||
internal static class Report
|
||||
{
|
||||
/// <summary>Image de capture (PNG ou WebP, selon --format), par opposition aux autres fichiers produits.</summary>
|
||||
private static bool IsImage(string file) =>
|
||||
file.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static void Write(Capture capture, SnapshotOptions options, TimeSpan duration)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(capture.OutputDirectory, "report.md"), Markdown(capture, options, duration), new UTF8Encoding(false));
|
||||
@@ -21,7 +25,7 @@ internal static class Report
|
||||
sb.AppendLine($"Échelle {options.Scale:0.##} · thème {(options.Theme ?? "du jeu")} · attente {options.WaitMilliseconds} ms · " +
|
||||
$"{(options.Filters.Count == 0 ? "toutes les scènes" : "filtre " + string.Join(", ", options.Filters))} · " +
|
||||
$"{duration.TotalSeconds:0} s");
|
||||
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels du PNG). Données de démonstration, aucun serveur réel.");
|
||||
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels des images). Données de démonstration, aucun serveur réel.");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("| Scène | Image | Taille | Problèmes | Remarques |");
|
||||
@@ -29,7 +33,7 @@ internal static class Report
|
||||
foreach (var r in capture.Records)
|
||||
{
|
||||
var status = r.Error is not null ? "**ÉCHEC**" : r.Skipped is not null ? "ignorée" : (r.Issues.Count + r.Bindings.Count).ToString();
|
||||
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)))} | {r.Size} | {status} | {r.Notes.Count} |");
|
||||
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(IsImage))} | {r.Size} | {status} | {r.Notes.Count} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -72,12 +76,12 @@ internal static class Report
|
||||
sb.AppendLine(".pages a{color:var(--muted);margin-right:8px;font-size:12px}");
|
||||
sb.AppendLine("ul{margin:6px 0 0;padding-left:18px;font-size:12px}li.warn{color:var(--warn)}li.bad{color:var(--bad)}li.note{color:var(--muted)}");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · " +
|
||||
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · {options.Format.ToUpperInvariant()} · " +
|
||||
$"{capture.Records.Count} scène(s) · données de démonstration · détails dans report.md</p>");
|
||||
sb.AppendLine("<div class=\"grid\">");
|
||||
foreach (var r in capture.Records)
|
||||
{
|
||||
var pngs = r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)).ToList();
|
||||
var pngs = r.Files.Where(IsImage).ToList();
|
||||
sb.AppendLine("<div class=\"card\">");
|
||||
if (pngs.Count > 0) sb.AppendLine($"<a href=\"{E(pngs[0])}\"><img loading=\"lazy\" src=\"{E(pngs[0])}\" alt=\"{E(r.Title)}\"></a>");
|
||||
sb.AppendLine($"<div class=\"meta\"><div class=\"id\">{E(r.Id)}</div><div class=\"title\">{E(r.Title)} {E(r.Size)}</div>");
|
||||
|
||||
@@ -12,6 +12,7 @@ using GameLauncher.Modules.Admin.Audit;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
@@ -48,6 +49,7 @@ internal sealed class AdminScenes : ISceneSet
|
||||
(PagePrefix + "feedback-requests-detail", "Administration · demande d'un joueur (lien signalé, captures d'écran)"),
|
||||
(PagePrefix + "feedback-forms-edit", "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)"),
|
||||
(PagePrefix + "feedback-forms-new-type", "Administration · type de demande ajouté (icône importée, sans captures d'écran)"),
|
||||
(PagePrefix + "maintenance-edit", "Administration · maintenance : message en cours de modification (lien refusé)"),
|
||||
("admin-login-locked", "Administration · session verrouillée")
|
||||
};
|
||||
|
||||
@@ -283,6 +285,21 @@ internal sealed class AdminScenes : ISceneSet
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Maintenance : la page automatique montre la maintenance en cours ; ici, message modifié avec un lien (refusé,
|
||||
// bouton « Modifier le message » désactivé) et aperçu tel que les joueurs le verraient. Rien n'est publié.
|
||||
var maintenanceId = PagePrefix + "maintenance-edit";
|
||||
if (capture.WantsAny(capture.Themes.Select(t => maintenanceId + Capture.Suffix(t))) &&
|
||||
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == MaintenancePanelViewModel.PanelId) is { } maintenanceItem)
|
||||
{
|
||||
workspace.SelectedPanel = maintenanceItem;
|
||||
await WaitForPanelAsync(capture, maintenanceItem, api);
|
||||
if (maintenanceItem.ViewModel is MaintenancePanelViewModel maintenance)
|
||||
maintenance.Message = "Mise à jour des serveurs de jeu.\nInfos en direct sur discord.gg/exemple";
|
||||
else
|
||||
capture.GlobalNotes.Add("⚠ Maintenance : page introuvable (module MaintenanceAdminModule déclaré dans App.CreateModules ?).");
|
||||
await ShootAllThemesAsync(maintenanceId, "Administration · maintenance : message en cours de modification (lien refusé)");
|
||||
}
|
||||
|
||||
// ---- Verrouillage
|
||||
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
|
||||
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Windows;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -23,7 +24,8 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
|
||||
public PlayerScenes(Fixture fixture) => _fixture = fixture;
|
||||
|
||||
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply);
|
||||
/// <param name="Launcher">Facultatif : état commun à tous les jeux, posé après celui du jeu (mode maintenance…).</param>
|
||||
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply, Action<LauncherViewModel>? Launcher = null);
|
||||
|
||||
/// <summary>États de la fenêtre principale (textes repris de GameViewModel).</summary>
|
||||
private static readonly MainState[] MainStates =
|
||||
@@ -104,7 +106,22 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont ignorées. Vous pouvez tout de même jouer.",
|
||||
DialogKind.Error);
|
||||
}),
|
||||
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { })
|
||||
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { }),
|
||||
// Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange, carte au centre, tout bloqué
|
||||
// sauf la désinstallation. Avec une précision de l'administrateur, puis sans (jeu non installé).
|
||||
new("main-maintenance", "Fenêtre principale · mode maintenance (avec message)", (g, f) =>
|
||||
{
|
||||
Installed(g, f, "1.4.2", "1.4.2");
|
||||
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
|
||||
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(
|
||||
"Mise à jour des serveurs de jeu et sauvegarde des mondes.\nRetour prévu vers 18 h : merci de votre patience !"))),
|
||||
new("main-maintenance-plain", "Fenêtre principale · mode maintenance (sans message, jeu non installé)", (g, f) =>
|
||||
{
|
||||
Poke.Set(g, "LatestVersion", "1.4.2");
|
||||
Notes(g);
|
||||
State(g, LauncherState.NotInstalled, "Prêt à installer",
|
||||
$"Téléchargement : {Format.Bytes(DownloadSize)} • Espace requis : {Format.Bytes(InstalledSize)}");
|
||||
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(null)))
|
||||
};
|
||||
|
||||
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes)
|
||||
@@ -120,6 +137,7 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
yield return new SceneInfo("install-dialog" + suffix, "Installation : choix du dossier");
|
||||
yield return new SceneInfo("launcher-update" + suffix, "Mise à jour du launcher · téléchargement");
|
||||
yield return new SceneInfo("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée");
|
||||
yield return new SceneInfo("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire");
|
||||
yield return new SceneInfo("launcher-update-error" + suffix, "Mise à jour du launcher · erreur");
|
||||
foreach (var (id, title, _) in Dialogs) yield return new SceneInfo(id + suffix, title);
|
||||
yield return new SceneInfo("tray-menu" + suffix, "Menu de la zone de notification");
|
||||
@@ -132,7 +150,7 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
{
|
||||
var suffix = Capture.Suffix(theme);
|
||||
foreach (var state in MainStates)
|
||||
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply);
|
||||
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply, state.Launcher);
|
||||
|
||||
if (_fixture.MultiCatalog is { } multi)
|
||||
await MainAsync(capture, "main-multi" + suffix, "Fenêtre principale · plusieurs jeux (liste à gauche)", multi, theme,
|
||||
@@ -153,12 +171,13 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
// ------------------------------------------------------------------ Fenêtre principale
|
||||
|
||||
private Task MainAsync(Capture capture, string id, string title, LauncherCatalog catalog, string? theme,
|
||||
Action<GameViewModel, Fixture> apply) =>
|
||||
Action<GameViewModel, Fixture> apply, Action<LauncherViewModel>? applyLauncher = null) =>
|
||||
capture.ShootAsync(id, title, () =>
|
||||
{
|
||||
var launcher = _fixture.CreateLauncher(catalog, new RecordingDialogs(capture.Problem));
|
||||
var game = launcher.SelectedGame ?? throw new InvalidOperationException("Aucun jeu dans launcher.json.");
|
||||
apply(game, _fixture);
|
||||
applyLauncher?.Invoke(launcher);
|
||||
Poke.Call(game, "RefreshCommands");
|
||||
|
||||
var window = new MainWindow();
|
||||
@@ -272,6 +291,21 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
|
||||
}, CloseUpdateWindowAsync);
|
||||
|
||||
// Version prioritaire (correctif critique) : pastille « Prioritaire » et texte dédié
|
||||
var priorityRelease = SampleData.CreateLauncherRelease(_fixture.CurrentVersion);
|
||||
priorityRelease.PriorityVersion = priorityRelease.Version;
|
||||
var priorityCheck = new LauncherUpdateCheck(_fixture.CurrentVersion, priorityRelease,
|
||||
new Uri("https://jeux.exemple.test/launcher/GameLauncher.exe"));
|
||||
await capture.ShootAsync("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire", () =>
|
||||
{
|
||||
_fixture.ApplyTheme(theme);
|
||||
var viewModel = new LauncherUpdateViewModel(priorityCheck, automatic: true);
|
||||
viewModel.Progress.Report(new OperationProgress("Téléchargement de la nouvelle version",
|
||||
$"{Format.Bytes(25_800_000)} / {Format.Bytes(priorityRelease.Size)} • {Format.Speed(6_300_000)} • " +
|
||||
$"{Format.Duration(TimeSpan.FromSeconds(2))} restantes", 67.5));
|
||||
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
|
||||
}, CloseUpdateWindowAsync);
|
||||
|
||||
await capture.ShootAsync("launcher-update-error" + suffix, "Mise à jour du launcher · erreur", () =>
|
||||
{
|
||||
_fixture.ApplyTheme(theme);
|
||||
|
||||
@@ -29,6 +29,18 @@ internal sealed class SnapshotOptions
|
||||
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
|
||||
public bool Scroll { get; private set; } = true;
|
||||
|
||||
/// <summary>Format des images : « png » (défaut) ou « webp » (format des images du site vitrine).</summary>
|
||||
public string Format { get; private set; } = FormatPng;
|
||||
|
||||
/// <summary>Qualité WebP, 1 à 100 (100 : sans perte). Ignorée en PNG (toujours sans perte).</summary>
|
||||
public int Quality { get; private set; } = 85;
|
||||
|
||||
public const string FormatPng = "png";
|
||||
public const string FormatWebp = "webp";
|
||||
|
||||
/// <summary>Extension des images produites, avec le point (« .png », « .webp »).</summary>
|
||||
public string ImageExtension => "." + Format;
|
||||
|
||||
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
|
||||
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
|
||||
|
||||
@@ -52,11 +64,14 @@ internal sealed class SnapshotOptions
|
||||
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
|
||||
--no-scroll pas de pages supplémentaires pour les zones qui défilent
|
||||
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
|
||||
--format <png|webp> format des images : « png » (défaut, sans perte) ou « webp » (format des
|
||||
images du site vitrine, website\GameLauncher.Website\wwwroot\img)
|
||||
--quality <1-100> qualité WebP (défaut 85 ; 100 = sans perte) ; ignorée en PNG
|
||||
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
|
||||
--open ouvre index.html à la fin
|
||||
--help cette aide
|
||||
|
||||
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
|
||||
Résultat : une image par scène (PNG ou WebP), index.html (planche de toutes les captures) et report.md
|
||||
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
|
||||
""";
|
||||
|
||||
@@ -117,6 +132,19 @@ internal sealed class SnapshotOptions
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "--format":
|
||||
var format = Next().Trim().TrimStart('.').ToLowerInvariant();
|
||||
if (format is not (FormatPng or FormatWebp))
|
||||
throw new ArgumentException($"Format inconnu : « {format} » (png ou webp).");
|
||||
options.Format = format;
|
||||
break;
|
||||
case "--quality":
|
||||
var qualityText = Next();
|
||||
if (!int.TryParse(qualityText, NumberStyles.None, CultureInfo.InvariantCulture, out var quality) ||
|
||||
quality < 1 || quality > 100)
|
||||
throw new ArgumentException($"Qualité invalide : « {qualityText} » (1 à 100).");
|
||||
options.Quality = quality;
|
||||
break;
|
||||
case "--out":
|
||||
case "-o":
|
||||
options.OutputDirectory = Next();
|
||||
|
||||
@@ -113,7 +113,7 @@ internal sealed class SnapshotRunner
|
||||
var marker = Path.Combine(output, MarkerFile);
|
||||
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
|
||||
{
|
||||
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
|
||||
foreach (var pattern in new[] { "*.png", "*.webp", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Option « format webp » de l'outil : Windows (WIC) lit le WebP mais ne sait pas l'écrire. SkiaSharp (maintenu par Microsoft,
|
||||
licence MIT) encode les captures en WebP. Outil de développement uniquement : le launcher, la Launcher API
|
||||
et le site ne dépendent pas de ce paquet.
|
||||
-->
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="3.119.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
|
||||
|
||||
+41
-115
@@ -1,140 +1,66 @@
|
||||
# Launcher 3.2.0
|
||||
# Launcher 3.4.2
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Formulaires des demandes modifiables - Mise à jour du 10 novembre 2026
|
||||
==================================================================
|
||||
Version de maintenance du launcher - Mise à jour du 10 novembre 2026
|
||||
=========================================================================
|
||||
|
||||
Les formulaires du bouton « Aide » se gèrent désormais depuis le menu d'administration : de 1 à 8 types de demande (ajout, suppression, ordre d'affichage, icône, captures d'écran autorisées ou non) et leurs champs, avec un aperçu identique à la fenêtre des joueurs.
|
||||
Version de maintenance : pour les joueurs, le launcher fonctionne exactement comme en 3.4.1. Le nom de l'éditeur par défaut est désormais LanisysGames, et le journal d'audit du menu d'administration détaille les modifications des formulaires des demandes.
|
||||
|
||||
Les formulaires sont communs à tous les jeux, signés et vérifiés par le launcher, et ne peuvent contenir aucun lien. Le serveur est à redéployer, avec deux blocs nginx à ajouter ; le trousseau est à re-signer une fois cette version diffusée.
|
||||
Le launcher se met à jour automatiquement. Administrateurs : mettre à jour la Launcher API (voir SERVEUR).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : page « Formulaires des demandes » dans le menu :
|
||||
types de demande, champs, aperçu, contrôle en direct, publication
|
||||
signée.
|
||||
- NOUVEAU : 1 à 8 types de demande : ajout, suppression, ordre
|
||||
d'affichage, type masqué.
|
||||
- NOUVEAU : icône d'un type : l'une des quatre d'origine, ou un
|
||||
fichier .ico importé (image 64 × 64 obligatoire).
|
||||
- NOUVEAU : captures d'écran autorisées ou non par type de demande.
|
||||
- NOUVEAU : champs texte court, cases à cocher, case oui/non, notes
|
||||
de 3, 5 ou 10 étoiles.
|
||||
- AMÉLIORÉ : fenêtre « Aide » : formulaires publiés, saisie gardée
|
||||
si les formulaires changent pendant la saisie.
|
||||
- AMÉLIORÉ : chaque demande garde le formulaire de son envoi (nom,
|
||||
icône, libellés).
|
||||
- SÉCURITÉ : formulaires signés et revérifiés par le launcher ;
|
||||
aucun lien accepté dans les textes.
|
||||
- SERVEUR : à redéployer, avec deux blocs nginx ; trousseau à
|
||||
re-signer après la diffusion de la 3.2.0.
|
||||
- QUALITÉ : 13 nouveaux tests automatisés (355 au total).
|
||||
- INFO : changement du nom de l'éditeur par défaut. Les propriétés
|
||||
de GameLauncher.exe (clic droit > Propriétés > Détails)
|
||||
indiquent désormais LanisysGames.
|
||||
- INFO : aucun changement dans l'utilisation du launcher :
|
||||
installation, mises à jour, vérification et réparation des
|
||||
jeux restent identiques. Vos jeux, vos paramètres et vos
|
||||
dossiers d'installation sont conservés.
|
||||
- SÉCURITÉ : comme pour chaque version, la signature numérique
|
||||
et l'empreinte SHA-256 sont vérifiées avant l'installation.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PAGE « FORMULAIRES DES DEMANDES » (ADMINISTRATION)
|
||||
MENU D'ADMINISTRATION
|
||||
------------------------------------------------------------------
|
||||
- Permission « Formulaires des demandes » (feedback.forms).
|
||||
- Types de demande : ajouter (8 au plus), monter, descendre,
|
||||
supprimer (avec confirmation ; les demandes reçues restent
|
||||
consultables), masquer.
|
||||
- Réglages d'un type : nom, description, exemple de titre,
|
||||
informations techniques cochées par défaut, captures d'écran
|
||||
autorisées (3 au plus), icône.
|
||||
- Champs : ajouter, monter, descendre, retirer ; libellé, aide,
|
||||
obligatoire, aperçu dans la liste, longueur, nombre d'étoiles,
|
||||
choix (2 à 20).
|
||||
- Types de champs : zone de texte, texte court, liste déroulante,
|
||||
cases à cocher, case oui/non, étoiles (3, 5 ou 10).
|
||||
- Aperçu identique à la fenêtre « Aide » ; contrôle en direct, la
|
||||
publication reste bloquée tant qu'il reste une erreur.
|
||||
- Avertissement si un libellé semble demander une donnée
|
||||
personnelle (e-mail, mot de passe, téléphone…).
|
||||
- « Publier », « Annuler les modifications », « Revenir à
|
||||
l'origine », « Recharger » ; une publication d'un autre
|
||||
administrateur n'est jamais écrasée.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
ICÔNES IMPORTÉES
|
||||
------------------------------------------------------------------
|
||||
- Fichier .ico seulement (1 Mo au plus), contenant une image de
|
||||
64 × 64 pixels ; les autres tailles du fichier sont ignorées.
|
||||
- PNG intégré ou bitmap 24/32 bits ; 256 couleurs et moins refusés.
|
||||
- Image réécrite en PNG neuf, sans métadonnées, intégrée aux
|
||||
formulaires et couverte par leur signature.
|
||||
- Un fichier refusé affiche un message qui explique pourquoi.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
FENÊTRE « AIDE » ET DEMANDES DES JOUEURS
|
||||
------------------------------------------------------------------
|
||||
- Formulaires publiés lus à l'ouverture de la fenêtre, puis
|
||||
vérifiés (règles, signature, révision).
|
||||
- Types dans l'ordre choisi, avec leur icône ; types masqués
|
||||
absents ; section « Captures d'écran » masquée si le type ne les
|
||||
autorise pas.
|
||||
- Formulaires republiés pendant la saisie : formulaire reconstruit,
|
||||
saisie des champs inchangés gardée.
|
||||
- Hors ligne ou formulaires refusés : formulaires d'origine.
|
||||
- Menu : chaque demande s'affiche avec le formulaire de son envoi ;
|
||||
filtre par type publié, « (masqué) » ou « (supprimé) ».
|
||||
- Journal d'audit : « Formulaires des demandes publiés », avec le
|
||||
détail des changements par type.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SÉCURITÉ
|
||||
------------------------------------------------------------------
|
||||
- Formulaires signés par une clé autorisée « formulaires » dans le
|
||||
trousseau ; vérifiés par chaque launcher, comme le catalogue.
|
||||
- Aucun lien dans les noms, descriptions, libellés, aides et choix :
|
||||
refusé par le menu, le serveur et le launcher.
|
||||
- Ancienne révision refusée par le launcher.
|
||||
- Publication par requête signée, avec la permission
|
||||
feedback.forms ; révision attendue (rien n'est écrasé).
|
||||
- Taille limitée : 256 Ko de formulaires, icônes comprises.
|
||||
- Le serveur refuse une demande avec captures pour un type qui ne
|
||||
les autorise pas, et une demande pour un type masqué ou supprimé.
|
||||
- Une autorisation inconnue du trousseau est désormais acceptée sans
|
||||
effet.
|
||||
- Journal d'audit : une ligne par type de demande ajouté, modifié
|
||||
ou supprimé lors d'une publication des formulaires, avec son nom
|
||||
(« Type de demande ajouté », « modifié », « supprimé »).
|
||||
- Type modifié : tous ses changements (nom affiché, description,
|
||||
exemple de titre, masqué, icône, captures d'écran,
|
||||
informations techniques) et chaque champ ajouté, supprimé ou
|
||||
modifié, avec ce qui a changé (libellé, type, obligatoire,
|
||||
aide, longueur, étoiles, choix, aperçu, ordre).
|
||||
- Ligne de la publication : révision, types ajoutés, modifiés et
|
||||
supprimés par leur nom, ordre des types, signature.
|
||||
- Détail trop long : réparti sur plusieurs lignes numérotées
|
||||
(1/2, 2/2...), rien n'est tronqué.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR
|
||||
------------------------------------------------------------------
|
||||
- À redéployer : server/deploy/LISEZMOI.md, étape 8, puis
|
||||
étape 3 nonies.
|
||||
- nginx : ajouter « location = /catalog/feedback-forms.json »
|
||||
(nginx-catalog.conf) et « location = /api/v1/feedback/forms »
|
||||
(nginx-api.conf, 300 Ko).
|
||||
- Publier ensuite le launcher 3.2.0, puis donner la permission
|
||||
feedback.forms et re-signer le trousseau.
|
||||
- Les launchers 3.0 / 3.1 envoient toujours leurs demandes avec les
|
||||
formulaires d'origine, acceptées sauf type masqué ou supprimé.
|
||||
- Launcher API à mettre à jour (server/deploy/LISEZMOI.md,
|
||||
étape 8) pour obtenir le détail des formulaires dans le
|
||||
journal d'audit. Aucun réglage ni bloc nginx à ajouter.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- Contrats : FeedbackFormsDocument (document signé et règles),
|
||||
FeedbackIcons (lecture des .ico, sans bibliothèque).
|
||||
- Signature : type de contenu feedback-forms, autorisation « forms »
|
||||
du trousseau.
|
||||
- Serveur : FeedbackFormsStore (référence privée, historique de 20
|
||||
publications, copie publique) ; route v1/feedback/forms.
|
||||
- Launcher : FeedbackFormsService (lecture et vérification),
|
||||
Themes/FeedbackForms.xaml (modèles partagés avec l'aperçu).
|
||||
- Menu : FeedbackFormsPanelViewModel, FeedbackFormEditors.
|
||||
- Outil ui-snapshot : scènes admin-page-feedback-forms-edit,
|
||||
admin-page-feedback-forms-new-type, feedback-custom-kinds et
|
||||
feedback-custom.
|
||||
- Tests : FeedbackFormsTests.cs, FeedbackFormsModuleTests.cs,
|
||||
FeedbackFormsPanelTests.cs (nouveaux), ContentSigningTests.cs,
|
||||
FeedbackPanelTests.cs.
|
||||
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
|
||||
(§ 11 sexies) et server/deploy/LISEZMOI.md mis à jour.
|
||||
- Changement du nom de l'éditeur par défaut (LauncherConfig) et
|
||||
des propriétés communes des projets (Directory.Build.props :
|
||||
société, auteurs, copyright).
|
||||
- Documentation : captures docs/preview.png et
|
||||
docs/preview-update.png mises à jour (changement de
|
||||
l'éditeur), avec une version WebP de chacune.
|
||||
- Serveur : FeedbackFormsAudit (détail des publications des
|
||||
formulaires) remplace FeedbackFormsStore.Describe.
|
||||
- UiSnapshot : exemples des nouvelles lignes dans la page
|
||||
« Journal d'audit ».
|
||||
- Tests : 1 nouveau test, 3 tests complétés.
|
||||
|
||||
|
||||
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
|
||||
@@ -7,8 +7,41 @@ REM 3. (optionnel) l'envoie sur le VPS avec WinSCP
|
||||
REM
|
||||
REM Avant de lancer : augmentez <Version> dans src\GameLauncher\GameLauncher.csproj
|
||||
REM et ecrivez les nouveautes dans tools\notes-launcher.txt.
|
||||
REM
|
||||
REM Parametres (facultatifs, depuis une invite de commandes) :
|
||||
REM publish-launcher-update.bat version normale (double-clic)
|
||||
REM publish-launcher-update.bat --priority version PRIORITAIRE (correctif critique) :
|
||||
REM installee au demarrage MEME chez les joueurs qui ont desactive la mise a jour
|
||||
REM automatique du launcher. Leur reglage (self-update.json) n'est pas modifie.
|
||||
REM Reprise par les publications suivantes : un launcher plus ancien que cette
|
||||
REM version se met a jour quoi qu'il arrive. Confirmation demandee.
|
||||
REM publish-launcher-update.bat --clear-priority ne reprend pas la version prioritaire
|
||||
REM deja publiee (priorite publiee par erreur).
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set PRIORITY=
|
||||
|
||||
:args
|
||||
if "%~1"=="" goto :argsdone
|
||||
if /i "%~1"=="--priority" goto :opt_priority
|
||||
if /i "%~1"=="--clear-priority" goto :opt_clear
|
||||
if /i "%~1"=="--help" goto :usage
|
||||
if "%~1"=="/?" goto :usage
|
||||
echo Parametre inconnu : %~1
|
||||
goto :usage
|
||||
:opt_priority
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--priority
|
||||
REM shift /1 : %0 (donc %~dp0) reste inchange
|
||||
shift /1
|
||||
goto :args
|
||||
:opt_clear
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--clear-priority
|
||||
shift /1
|
||||
goto :args
|
||||
:argsdone
|
||||
|
||||
set ROOT=%~dp0..
|
||||
set KEY=%USERPROFILE%\.launcher-keys\launcher-signing.key
|
||||
set OUTPUT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\Updates
|
||||
@@ -16,6 +49,18 @@ REM Script WinSCP (laisser vide pour envoyer les fichiers a la main) :
|
||||
set WINSCP_SCRIPT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\publier-launcher-update.txt
|
||||
set WINSCP="C:\Program Files (x86)\WinSCP\WinSCP.com"
|
||||
|
||||
if /i "%PRIORITY%"=="--priority" (
|
||||
echo ***************************************************************************
|
||||
echo Version PRIORITAIRE : installee au demarrage meme chez les joueurs qui ont
|
||||
echo desactive la mise a jour automatique du launcher. Leur reglage ne change pas.
|
||||
echo ***************************************************************************
|
||||
echo.
|
||||
)
|
||||
if /i "%PRIORITY%"=="--clear-priority" (
|
||||
echo Version normale : la version prioritaire deja publiee n'est PAS reprise.
|
||||
echo.
|
||||
)
|
||||
|
||||
echo === 1/3 Compilation du launcher ===
|
||||
dotnet publish "%ROOT%\src\GameLauncher\GameLauncher.csproj" -c Release -p:PublishProfile=win-x64
|
||||
if errorlevel 1 goto :error
|
||||
@@ -28,6 +73,7 @@ dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -
|
||||
--config "%ROOT%\src\GameLauncher\launcher.json" ^
|
||||
--public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub" ^
|
||||
--notes "%~dp0notes-launcher.txt" ^
|
||||
%PRIORITY% ^
|
||||
--output "%OUTPUT%"
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
@@ -52,3 +98,15 @@ echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:conflict
|
||||
echo --priority et --clear-priority sont incompatibles.
|
||||
exit /b 1
|
||||
|
||||
:usage
|
||||
echo.
|
||||
echo Utilisation : publish-launcher-update.bat [--priority ^| --clear-priority]
|
||||
echo --priority version prioritaire (correctif critique) : installee meme si le
|
||||
echo joueur a desactive la mise a jour automatique (reglage conserve)
|
||||
echo --clear-priority ne reprend pas la version prioritaire deja publiee
|
||||
exit /b 1
|
||||
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Publie le SITE VITRINE pour le VPS (Linux x64, autonome : aucun .NET a
|
||||
REM installer sur le serveur) et prepare les fichiers a envoyer :
|
||||
REM Publish\Site\lanisys-site.tar.gz site + service systemd
|
||||
REM Publish\Site\installer-site.sh script d'installation / mise a jour
|
||||
REM Guide complet : website\deploy\LISEZMOI.md
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set ROOT=%~dp0..
|
||||
set OUT=%ROOT%\Publish\Site
|
||||
set STAGE=%OUT%\lanisys-site
|
||||
|
||||
if exist "%STAGE%" rmdir /s /q "%STAGE%"
|
||||
if exist "%OUT%\lanisys-site.tar.gz" del /q "%OUT%\lanisys-site.tar.gz"
|
||||
set HASH=
|
||||
|
||||
echo === 1/2 Compilation (linux-x64) ===
|
||||
dotnet publish "%ROOT%\website\GameLauncher.Website\GameLauncher.Website.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
|
||||
if errorlevel 1 goto :error
|
||||
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
|
||||
|
||||
echo.
|
||||
echo === 2/2 Archive ===
|
||||
mkdir "%STAGE%\deploy"
|
||||
copy /Y "%ROOT%\website\deploy\lanisys-site.service" "%STAGE%\deploy\" >nul
|
||||
copy /Y "%ROOT%\website\deploy\installer-site.sh" "%OUT%\" >nul
|
||||
tar -czf "%OUT%\lanisys-site.tar.gz" -C "%STAGE%" .
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
REM Empreinte SHA-256 : verifiee par installer-site.sh (archive alteree = refusee)
|
||||
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\lanisys-site.tar.gz').Hash.ToLower()"') do set HASH=%%h
|
||||
if "%HASH%"=="" goto :error
|
||||
> "%OUT%\lanisys-site.tar.gz.sha256" echo %HASH%
|
||||
|
||||
echo.
|
||||
echo Pret. Envoi sur le VPS du site (compte admin) :
|
||||
echo scp "%OUT%\lanisys-site.tar.gz" "%OUT%\installer-site.sh" admin@^<VPS du site^>:/tmp/
|
||||
echo Puis, sur le VPS :
|
||||
echo sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz %HASH%
|
||||
echo (empreinte aussi enregistree dans lanisys-site.tar.gz.sha256)
|
||||
explorer "%OUT%"
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -2,7 +2,7 @@
|
||||
rem ============================================================================
|
||||
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
|
||||
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
|
||||
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
|
||||
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
|
||||
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
|
||||
rem - index.html : planche de toutes les captures ;
|
||||
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
|
||||
@@ -12,6 +12,7 @@ rem Exemples :
|
||||
rem tools\ui-snapshot.bat
|
||||
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
|
||||
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
|
||||
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
|
||||
rem tools\ui-snapshot.bat --list
|
||||
rem ============================================================================
|
||||
setlocal
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Bienvenue sur le site de LanisysGames
|
||||
date: 2026-11-10
|
||||
summary: Nos jeux, le launcher et les nouvelles du studio ont maintenant leur adresse.
|
||||
topic: Studio
|
||||
image: /img/launcher/planete.webp
|
||||
imageAlt: Planète aux anneaux sur un ciel violet, l'illustration du launcher.
|
||||
---
|
||||
Jusqu'ici, il fallait passer par le launcher pour suivre nos jeux. Ce site rassemble désormais tout au même endroit :
|
||||
|
||||
- **le launcher**, à télécharger en un clic, avec les nouveautés de sa dernière version ;
|
||||
- **Exovival Zombie Reborn**, son histoire, ce qui vous attend dans l'école Jean-Moulin, et toutes ses [notes de version](/jeux/exovival-zombie-reborn/notes-de-version) ;
|
||||
- **les actualités** du studio, comme celle-ci.
|
||||
|
||||
## Un site discret
|
||||
|
||||
Pas de compte, pas de cookie, pas de mesure d'audience : le site ne vous suit pas. Les polices et les images sont servies par le site lui-même, sans passer par un service externe. Tout est expliqué sur la page [Confidentialité](/confidentialite).
|
||||
|
||||
## Une question, une idée ?
|
||||
|
||||
Le bouton « Nous contacter » ouvre votre messagerie. Pour un bug dans un jeu, le bouton **Aide** du launcher reste le plus pratique : il joint automatiquement la version du jeu, et vous pouvez ajouter des captures d'écran.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Exovival Zombie Reborn 1.8 : les zombies vous entendent"
|
||||
date: 2026-11-01
|
||||
summary: Huit zombies rôdent dès le début de la partie, ils réagissent au bruit de vos pas, et de petits soins à fabriquer soi-même font leur apparition.
|
||||
topic: Exovival Zombie Reborn
|
||||
theme: exovival
|
||||
image: /img/exovival/vhs-gymnase.webp
|
||||
imageAlt: Le gymnase de l'école, image extraite d'une cassette VHS du jeu.
|
||||
---
|
||||
La version 1.8 remplit l'école de zombies, leur donne de l'oreille et rééquilibre les armes. Merci aux bêta-testeurs pour leurs retours !
|
||||
|
||||
## L'école n'est plus vide
|
||||
|
||||
Huit zombies rôdent dès le début de la partie : autour du car scolaire, dans la cour de service, sur le toit bas, au gymnase et à l'étage. Plus lents et moins forts que ceux qui vous traquent pendant l'histoire, ils se distancent en marchant. Mais attention aux groupes : quand l'un d'eux attaque, ceux d'à côté accourent.
|
||||
|
||||
## Faites moins de bruit
|
||||
|
||||
- Marcher fait du bruit, courir s'entend de beaucoup plus loin, même dans le dos d'un zombie.
|
||||
- Un mur ou une porte fermée étouffe vos pas.
|
||||
- **Accroupi** (touche Ctrl), vous êtes silencieux : de quoi approcher un zombie par derrière.
|
||||
|
||||
## Des armes plus lisibles
|
||||
|
||||
Chaque arme fait toujours les mêmes dégâts, et viser la tête les double. La hache reste la plus puissante : un seul coup à la tête suffit.
|
||||
|
||||
## Se soigner à la Resident Evil
|
||||
|
||||
Des herbes et des huiles médicinales sont cachées dans l'école. Mélangez-les dans l'inventaire pour obtenir une potion bien plus efficace. Une note de l'infirmière, quelque part dans les toilettes, vous explique tout.
|
||||
|
||||
Le détail complet est dans les [notes de version](/jeux/exovival-zombie-reborn/notes-de-version). La mise à jour s'installe automatiquement avec le launcher.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Launcher 3.4 : les correctifs importants arrivent chez tout le monde"
|
||||
date: 2026-11-10
|
||||
summary: Une mise à jour critique du launcher s'installe désormais même si vous avez désactivé la mise à jour automatique. Votre réglage, lui, ne change pas.
|
||||
topic: Launcher
|
||||
image: /img/launcher/apercu-exovival.webp
|
||||
imageAlt: Le launcher ouvert sur Exovival Zombie Reborn.
|
||||
---
|
||||
Vous pouvez choisir, dans les paramètres du launcher, de ne pas le mettre à jour automatiquement. C'est votre droit, et ça le reste. Mais si une faille de sécurité devait un jour être corrigée, il faut que le correctif arrive chez tout le monde.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
- Une version du launcher peut être publiée comme **prioritaire**. Elle s'installe au démarrage, même avec la mise à jour automatique désactivée.
|
||||
- **Votre réglage n'est pas modifié** : une fois le correctif installé, les versions normales suivantes ne s'installeront toujours pas toutes seules.
|
||||
- La fenêtre de mise à jour affiche une pastille « Prioritaire ». « Plus tard » reste possible : le launcher réessaiera au prochain démarrage.
|
||||
|
||||
## Et la sécurité ?
|
||||
|
||||
L'indication « prioritaire » fait partie du contenu signé de la mise à jour : un serveur piraté ne pourrait ni imposer une mise à jour, ni retirer la priorité d'un correctif. Comme avant, seule une version signée et plus récente peut s'installer.
|
||||
|
||||
Le launcher se met à jour tout seul. Pour une première installation, il se [télécharge ici](/launcher#telecharger).
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Site vitrine (LanisysGames) : présentation des jeux et du launcher, téléchargement du launcher,
|
||||
notes de version, actualités. ASP.NET Core Razor Pages, sans base de données, sans formulaire,
|
||||
sans cookie, sans paquet NuGet tiers.
|
||||
|
||||
Isolé de la Launcher API : le site ne l'appelle jamais. Il lit seulement, côté serveur et en
|
||||
arrière-plan, les fichiers publics launcher-update.json (signature vérifiée avec la même clé
|
||||
publique que le launcher) et manifest.json de chaque jeu (notes de version).
|
||||
Voir website\deploy\LISEZMOI.md.
|
||||
|
||||
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
|
||||
tools\publish-site.bat
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Website</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Website</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>Site vitrine LanisysGames</Product>
|
||||
<!-- Pas de dépendance à libicu sur le VPS (comme la Launcher API) : les dates sont mises en forme
|
||||
en français par le site lui-même (Services\FrenchText.cs). -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<!-- Pas de web.config (IIS) dans la publication : le site tourne derrière nginx. -->
|
||||
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Notes de version : même modèle, mêmes bornes et même nettoyage que le launcher. -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
<!-- launcher-update.json : même vérification de signature que le launcher (UpdateSignature). -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Clé publique de mise à jour du launcher, INTÉGRÉE à l'exécutable (comme dans le launcher) :
|
||||
elle ne peut pas être remplacée par un fichier déposé sur le serveur. -->
|
||||
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Security\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Actualités (Markdown) : publiées avec le site, lues au démarrage. -->
|
||||
<None Update="Content\**\*.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// En-têtes de sécurité de toutes les réponses, et refus de tout ce qui n'est pas une lecture.
|
||||
///
|
||||
/// Politique de sécurité du contenu (CSP) stricte : scripts, styles, images et polices du site
|
||||
/// uniquement, aucun script en ligne, aucune ressource externe (ni police Google, ni statistiques),
|
||||
/// aucun formulaire, aucune intégration du site dans un cadre. Le site n'a pas de cookie.
|
||||
/// </summary>
|
||||
public static class SecurityHeaders
|
||||
{
|
||||
public const string ContentSecurityPolicy =
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; " +
|
||||
"manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; " +
|
||||
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||
|
||||
public static IApplicationBuilder UseSiteSecurityHeaders(this IApplicationBuilder app) => app.Use(async (context, next) =>
|
||||
{
|
||||
var request = context.Request;
|
||||
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
|
||||
{
|
||||
// Site en lecture seule : ni formulaire, ni API.
|
||||
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
|
||||
context.Response.Headers.Allow = "GET, HEAD";
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers.ContentSecurityPolicy = ContentSecurityPolicy;
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
headers["Permissions-Policy"] =
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), " +
|
||||
"payment=(), usb=(), interest-cohort=(), browsing-topics=()";
|
||||
headers["Cross-Origin-Opener-Policy"] = "same-origin";
|
||||
headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||
headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
||||
headers.Remove("Server");
|
||||
headers.Remove("X-Powered-By");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Xml.Linq;
|
||||
using GameLauncher.Website.Options;
|
||||
using GameLauncher.Website.Services;
|
||||
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>Plan du site (sitemap.xml) pour les moteurs de recherche.</summary>
|
||||
public static class Sitemap
|
||||
{
|
||||
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
||||
|
||||
public static string Build(SiteOptions options, NewsService news)
|
||||
{
|
||||
var root = options.PublicUrl.TrimEnd('/');
|
||||
var paths = new List<(string Path, string? LastModified)>
|
||||
{
|
||||
("/", null), ("/launcher", null), ("/jeux", null), ("/actualites", null),
|
||||
("/mentions-legales", null), ("/confidentialite", null)
|
||||
};
|
||||
foreach (var game in options.VisibleGames)
|
||||
{
|
||||
paths.Add(($"/jeux/{game.Slug}", null));
|
||||
if (!string.IsNullOrWhiteSpace(game.ManifestUrl)) paths.Add(($"/jeux/{game.Slug}/notes-de-version", null));
|
||||
}
|
||||
paths.AddRange(news.All.Select(a => ($"/actualites/{a.Slug}", (string?)FrenchText.IsoDate(a.Date))));
|
||||
|
||||
var document = new XDocument(new XDeclaration("1.0", "utf-8", null),
|
||||
new XElement(Ns + "urlset",
|
||||
paths.Select(p => new XElement(Ns + "url",
|
||||
new XElement(Ns + "loc", root + p.Path),
|
||||
p.LastModified is null ? null : new XElement(Ns + "lastmod", p.LastModified)))));
|
||||
return document.Declaration + "\n" + document.Root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle du certificat du serveur du launcher : EXACTEMENT la règle par défaut de .NET (aucune erreur
|
||||
/// acceptée), avec en plus, en cas de refus, le détail dans le journal du site (nom attendu, certificat
|
||||
/// présenté, maillons de la chaîne et leurs erreurs). Sans ce détail, .NET ne dit que « The SSL connection
|
||||
/// could not be established ».
|
||||
/// </summary>
|
||||
public static class TlsDiagnostics
|
||||
{
|
||||
public static RemoteCertificateValidationCallback Create(ILogger logger) => (sender, certificate, chain, errors) =>
|
||||
{
|
||||
if (errors == SslPolicyErrors.None) return true;
|
||||
|
||||
var host = (sender as SslStream)?.TargetHostName ?? "?";
|
||||
var links = chain?.ChainElements.Select((e, i) =>
|
||||
$"[{i}] {e.Certificate.Subject} (émis par {e.Certificate.Issuer}, jusqu'au {e.Certificate.NotAfter:yyyy-MM-dd})" +
|
||||
(e.ChainElementStatus.Length == 0 ? "" : " : " + string.Join(", ", e.ChainElementStatus.Select(s => s.Status))))
|
||||
.ToList() ?? new List<string>();
|
||||
var chainStatus = chain?.ChainStatus.Select(s => s.Status.ToString()).Distinct().ToList() ?? new List<string>();
|
||||
|
||||
logger.LogError(
|
||||
"Certificat de {Host} REFUSÉ ({Errors}). Chaîne : {Status}. Maillons reçus et reconstruits :{Links}{Hint}",
|
||||
host, errors, chainStatus.Count == 0 ? "—" : string.Join(", ", chainStatus),
|
||||
links.Count == 0 ? " aucun" : "\n " + string.Join("\n ", links),
|
||||
chainStatus.Contains(nameof(X509ChainStatusFlags.PartialChain))
|
||||
? "\n → Chaîne incomplète : le serveur n'envoie pas le certificat intermédiaire. Sur le VPS du launcher, " +
|
||||
"nginx doit utiliser fullchain.pem (et non cert.pem) dans ssl_certificate (voir website/deploy/LISEZMOI.md, Dépannage)."
|
||||
: "");
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace GameLauncher.Website.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Réglages du site (section « Site » d'appsettings.json, complétée sur le VPS par
|
||||
/// appsettings.Production.json, conservé d'une mise à jour à l'autre).
|
||||
/// </summary>
|
||||
public sealed class SiteOptions
|
||||
{
|
||||
public const string Section = "Site";
|
||||
|
||||
/// <summary>Adresse publique du site (https), sans « / » final : liens absolus (Open Graph, plan du site).</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Nom du studio, affiché dans l'en-tête et le pied de page.</summary>
|
||||
public string StudioName { get; set; } = "LanisysGames";
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du bouton « Nous contacter » (lien mailto: : le joueur écrit avec sa propre messagerie,
|
||||
/// le site ne reçoit ni ne stocke rien). Vide : bouton masqué.
|
||||
/// </summary>
|
||||
public string ContactEmail { get; set; } = "";
|
||||
|
||||
public LauncherOptions Launcher { get; set; } = new();
|
||||
|
||||
public List<GameOptions> Games { get; set; } = new();
|
||||
|
||||
public RemoteOptions Remote { get; set; } = new();
|
||||
|
||||
public LegalOptions Legal { get; set; } = new();
|
||||
|
||||
/// <summary>Jeux affichés (masqués exclus), dans l'ordre du fichier.</summary>
|
||||
public IEnumerable<GameOptions> VisibleGames => Games.Where(g => g.Visible);
|
||||
|
||||
public GameOptions? FindGame(string? slug) =>
|
||||
string.IsNullOrEmpty(slug) ? null : VisibleGames.FirstOrDefault(g => string.Equals(g.Slug, slug, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public sealed class LauncherOptions
|
||||
{
|
||||
/// <summary>Nom du launcher affiché sur le site.</summary>
|
||||
public string Name { get; set; } = "Launcher LanisysGames";
|
||||
|
||||
/// <summary>
|
||||
/// launcher-update.json (https) : version, exécutable à télécharger et notes de version, SIGNÉS.
|
||||
/// Le site n'affiche le bouton de téléchargement que si la signature est valide.
|
||||
/// </summary>
|
||||
public string UpdateUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de produit attendu dans launcher-update.json (« launcherId », ligne « Produit » de
|
||||
/// LauncherPublisher verify). Vide : non contrôlé (le produit lu est inscrit au journal du site).
|
||||
/// </summary>
|
||||
public string Product { get; set; } = "";
|
||||
|
||||
/// <summary>Page « Publications » de Gitea : historique complet des versions du launcher. Vide : lien masqué.</summary>
|
||||
public string ReleasesUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sections des notes du launcher qui ne concernent pas les joueurs (serveur, développement…),
|
||||
/// retirées de l'affichage. Comparaison sans tenir compte de la casse ni des accents.
|
||||
/// </summary>
|
||||
public List<string> HiddenSections { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GameOptions
|
||||
{
|
||||
/// <summary>Adresse de la page : /jeux/<slug> (lettres minuscules, chiffres, tirets).</summary>
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Thème de la page : « exovival », « multi » ou « launcher ».</summary>
|
||||
public string Theme { get; set; } = "launcher";
|
||||
|
||||
/// <summary>« available » (disponible dans le launcher) ou « upcoming » (en préparation).</summary>
|
||||
public string Status { get; set; } = "upcoming";
|
||||
|
||||
public bool Visible { get; set; } = true;
|
||||
|
||||
/// <summary>Identifiant du jeu (« gameId » du manifest), contrôlé à la lecture du manifest.</summary>
|
||||
public string GameId { get; set; } = "";
|
||||
|
||||
/// <summary>manifest.json du jeu (https). Vide : pas de notes de version.</summary>
|
||||
public string ManifestUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Accroche (cartes de l'accueil et de la liste des jeux).</summary>
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
/// <summary>Présentation courte d'un jeu en préparation (page générique).</summary>
|
||||
public string Teaser { get; set; } = "";
|
||||
|
||||
/// <summary>Image de la carte (chemin local commençant par /img/).</summary>
|
||||
public string CardImage { get; set; } = "";
|
||||
|
||||
public string CardImageAlt { get; set; } = "";
|
||||
|
||||
public bool IsAvailable => string.Equals(Status, "available", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Page dédiée (Pages/Jeux/<…>.cshtml) plutôt que la page générique.</summary>
|
||||
public bool HasDedicatedPage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RemoteOptions
|
||||
{
|
||||
/// <summary>Intervalle entre deux lectures des fichiers JSON (minutes).</summary>
|
||||
public int RefreshMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Délai maximal d'une lecture (secondes).</summary>
|
||||
public int TimeoutSeconds { get; set; } = 20;
|
||||
|
||||
/// <summary>Taille maximale d'un manifest.json (Mo) : il contient la liste de tous les fichiers du jeu.</summary>
|
||||
public int MaxManifestMegabytes { get; set; } = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Développement uniquement : autorise http://localhost (fichiers de test servis en local).
|
||||
/// Ignoré en production (https obligatoire).
|
||||
/// </summary>
|
||||
public bool AllowLoopbackHttp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LegalOptions
|
||||
{
|
||||
/// <summary>Éditeur du site (nom ou raison sociale).</summary>
|
||||
public string Publisher { get; set; } = "";
|
||||
|
||||
/// <summary>Statut et coordonnées de l'éditeur (adresse, SIRET… ou mention « éditeur non professionnel »).</summary>
|
||||
public List<string> PublisherDetails { get; set; } = new();
|
||||
|
||||
/// <summary>Directeur de la publication.</summary>
|
||||
public string Director { get; set; } = "";
|
||||
|
||||
/// <summary>Hébergeur du site (nom, adresse, téléphone).</summary>
|
||||
public List<string> Host { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de conservation des journaux du serveur web, en jours (page « Confidentialité »).</summary>
|
||||
public int LogRetentionDays { get; set; } = 14;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Options;
|
||||
|
||||
/// <summary>Contrôle des réglages au démarrage : un réglage incorrect arrête le site avec un message clair.</summary>
|
||||
public sealed partial class SiteOptionsValidator : IValidateOptions<SiteOptions>
|
||||
{
|
||||
public static readonly string[] Themes = { "launcher", "exovival", "multi" };
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, SiteOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.PublicUrl) &&
|
||||
(!Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var site) || site.Scheme is not ("https" or "http") ||
|
||||
options.PublicUrl.EndsWith('/')))
|
||||
errors.Add("Site:PublicUrl doit être une adresse absolue, sans « / » final (ex. https://www.lanisys.fr).");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ContactEmail) && !EmailRegex().IsMatch(options.ContactEmail))
|
||||
errors.Add("Site:ContactEmail n'est pas une adresse e-mail simple (ex. contact@lanisys.fr).");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Launcher.ReleasesUrl) &&
|
||||
(!Uri.TryCreate(options.Launcher.ReleasesUrl, UriKind.Absolute, out var releases) || releases.Scheme != "https"))
|
||||
errors.Add("Site:Launcher:ReleasesUrl doit être une adresse https.");
|
||||
|
||||
var slugs = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var game in options.Games)
|
||||
{
|
||||
game.Slug ??= "";
|
||||
if (!SlugRegex().IsMatch(game.Slug))
|
||||
errors.Add($"Site:Games : « {game.Slug} » n'est pas un slug valide (minuscules, chiffres, tirets).");
|
||||
else if (!slugs.Add(game.Slug))
|
||||
errors.Add($"Site:Games : slug « {game.Slug} » en double.");
|
||||
if (string.IsNullOrWhiteSpace(game.Name))
|
||||
errors.Add($"Site:Games : nom manquant pour « {game.Slug} ».");
|
||||
if (!Themes.Contains(game.Theme))
|
||||
errors.Add($"Site:Games : thème inconnu « {game.Theme} » pour « {game.Slug} » ({string.Join(", ", Themes)}).");
|
||||
if (!string.IsNullOrWhiteSpace(game.CardImage) && !game.CardImage.StartsWith("/img/", StringComparison.Ordinal))
|
||||
errors.Add($"Site:Games : l'image de « {game.Slug} » doit être un fichier du site (/img/…).");
|
||||
}
|
||||
|
||||
if (options.Remote.RefreshMinutes < 1)
|
||||
errors.Add("Site:Remote:RefreshMinutes doit valoir au moins 1.");
|
||||
|
||||
return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
|
||||
private static partial Regex SlugRegex();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")]
|
||||
private static partial Regex EmailRegex();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@page "/actualites/{slug:maxlength(80)}"
|
||||
@model ArticleModel
|
||||
@{
|
||||
var article = Model.Article;
|
||||
ViewData["Nav"] = "actualites";
|
||||
ViewData["Theme"] = article.Theme;
|
||||
ViewData["Title"] = article.Title;
|
||||
ViewData["Description"] = article.Summary;
|
||||
ViewData["OgType"] = "article";
|
||||
if (article.Image is not null && article.Image.EndsWith(".jpg", StringComparison.Ordinal)) ViewData["OgImage"] = article.Image;
|
||||
}
|
||||
|
||||
<article>
|
||||
<header class="article-head">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p class="news-date">
|
||||
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
|
||||
{<text>, </text><span class="news-topic">@article.Topic</span>}
|
||||
</p>
|
||||
<h1>@article.Title</h1>
|
||||
<p class="lead">@article.Summary</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap wrap-narrow">
|
||||
@if (article.Image is not null)
|
||||
{
|
||||
<figure class="article-cover"><img src="@article.Image" alt="@(article.ImageAlt ?? "")" width="1280" height="720"></figure>
|
||||
}
|
||||
<div class="prose">
|
||||
@* HTML produit par SafeMarkdown : tout le texte de l'article y est encodé, seules ses balises existent. *@
|
||||
@Html.Raw(article.Html)
|
||||
</div>
|
||||
<a class="article-back" href="/actualites">Toutes les actualités</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Website.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Actualites;
|
||||
|
||||
public sealed class ArticleModel : PageModel
|
||||
{
|
||||
private readonly NewsService _news;
|
||||
|
||||
public ArticleModel(NewsService news) => _news = news;
|
||||
|
||||
public NewsArticle Article { get; private set; } = null!;
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
// Recherche dans les articles chargés au démarrage : l'adresse n'atteint jamais le disque.
|
||||
var article = _news.Find(slug);
|
||||
if (article is null) return NotFound();
|
||||
Article = article;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@page "/actualites"
|
||||
@inject NewsService News
|
||||
@{
|
||||
ViewData["Nav"] = "actualites";
|
||||
ViewData["Title"] = "Actualités";
|
||||
ViewData["Description"] = "Les nouvelles des jeux et du launcher : sorties, mises à jour, coulisses.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap">
|
||||
<h1>Actualités</h1>
|
||||
<p class="lead">Sorties, mises à jour et coulisses de nos jeux et du launcher.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap">
|
||||
@if (News.All.Count == 0)
|
||||
{
|
||||
<p class="notice">Aucune actualité pour le moment. Les nouveautés des jeux sont aussi dans leurs notes de version.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<partial name="_NewsList" model="News.All" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
@page "/confidentialite"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Title"] = "Confidentialité";
|
||||
ViewData["Description"] = "Ce que le site enregistre (presque rien) et pourquoi.";
|
||||
Uri.TryCreate(site.Launcher.UpdateUrl, UriKind.Absolute, out var launcherServer);
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap wrap-narrow legal">
|
||||
<h1>Confidentialité</h1>
|
||||
<p class="lead">Ce site ne vous demande rien et ne vous suit pas : pas de compte, pas de formulaire, pas de cookie, pas de mesure d'audience, pas de publicité, aucun service externe (les polices et les images sont servies par le site lui-même).</p>
|
||||
|
||||
<h2>Journaux du serveur</h2>
|
||||
<p>Comme tout serveur web, le nôtre note chaque requête : adresse IP, date, page demandée, navigateur. Ces journaux servent uniquement à la sécurité du site (repérer une attaque, un abus) et au diagnostic des pannes. Ils sont effacés au bout de @site.Legal.LogRetentionDays jours et ne sont ni croisés avec d'autres données, ni transmis à qui que ce soit, sauf obligation légale.</p>
|
||||
<p>Base légale : notre intérêt légitime à protéger le site (article 6.1.f du RGPD).</p>
|
||||
|
||||
<h2>Téléchargement du launcher</h2>
|
||||
<p>Le bouton « Télécharger le launcher » vous envoie directement vers notre serveur de téléchargement@(launcherServer is null ? "" : $" ({launcherServer.Host})"), qui tient lui aussi un journal technique dans les mêmes conditions.</p>
|
||||
|
||||
<h2>Nous contacter</h2>
|
||||
<p>Le bouton « Nous contacter » ouvre votre propre messagerie. Votre message et votre adresse e-mail ne passent pas par ce site : nous ne les utilisons que pour vous répondre, et les supprimons quand l'échange est terminé.</p>
|
||||
|
||||
<h2>Vos droits</h2>
|
||||
<p>Vous pouvez demander l'accès à vos données, leur rectification ou leur effacement@(string.IsNullOrWhiteSpace(site.ContactEmail) ? "" : " en écrivant à ")@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
|
||||
{<a href="mailto:@site.ContactEmail">@site.ContactEmail</a>}. Vous pouvez aussi adresser une réclamation à la CNIL (<a href="https://www.cnil.fr" rel="noopener noreferrer">cnil.fr</a>).</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/erreur/{code:int?}"
|
||||
@model ErreurModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Code == 404 ? "Page introuvable" : "Erreur";
|
||||
}
|
||||
|
||||
<section class="error-page">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p class="error-code" aria-hidden="true">@Model.Code</p>
|
||||
@if (Model.Code == 404)
|
||||
{
|
||||
<h1>Cette page n'existe pas.</h1>
|
||||
<p class="lead">L'adresse est peut-être mal saisie, ou la page a été déplacée.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Le site a rencontré un problème.</h1>
|
||||
<p class="lead">Réessayez dans un instant. Si le problème continue, écrivez-nous.</p>
|
||||
}
|
||||
<p><a class="btn btn-primary" href="/">Revenir à l'accueil</a></p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace GameLauncher.Website.Pages;
|
||||
|
||||
/// <summary>Page d'erreur (404, 500…) : aucun détail technique n'est montré au visiteur.</summary>
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public sealed class ErreurModel : PageModel
|
||||
{
|
||||
public int Code { get; private set; } = 500;
|
||||
|
||||
public void OnGet(int? code)
|
||||
{
|
||||
Code = code is >= 400 and <= 599 ? code.Value : 500;
|
||||
// Accès direct à /erreur/… : garder le code d'erreur dans la réponse.
|
||||
if (HttpContext.Features.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>() is null)
|
||||
Response.StatusCode = Code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@page
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject NewsService News
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Nav"] = "accueil";
|
||||
ViewData["Theme"] = "launcher";
|
||||
ViewData["Description"] = $"{site.StudioName}, studio indépendant : Exovival Zombie Reborn et le launcher pour installer, mettre à jour et lancer nos jeux sur Windows.";
|
||||
var available = site.VisibleGames.Where(g => g.IsAvailable).ToList();
|
||||
var upcoming = site.VisibleGames.Where(g => !g.IsAvailable).ToList();
|
||||
var latest = News.Latest(3).ToList();
|
||||
}
|
||||
|
||||
<section class="home-hero">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<h1>Nos jeux s'installent en un clic.</h1>
|
||||
<p class="lead">Le launcher @site.StudioName installe nos jeux, les garde à jour et vérifie chaque fichier. Téléchargez-le une fois : il s'occupe du reste, et de lui-même.</p>
|
||||
<partial name="_LauncherDownload" />
|
||||
</div>
|
||||
<figure class="hero-shot">
|
||||
<img src="/img/launcher/apercu-exovival.webp" width="1168" height="708"
|
||||
alt="Le launcher ouvert sur Exovival Zombie Reborn : bouton Installer, taille du téléchargement et notes de version.">
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Nos jeux</h2>
|
||||
<a href="/jeux">Tous les jeux</a>
|
||||
</div>
|
||||
|
||||
@foreach (var game in available)
|
||||
{
|
||||
<a class="game-feature" href="/jeux/@game.Slug">
|
||||
<div class="game-feature-media">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CardImage))
|
||||
{
|
||||
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
|
||||
}
|
||||
</div>
|
||||
<div class="game-feature-body">
|
||||
<span class="status">Disponible dans le launcher</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
<span class="game-feature-link">Découvrir le jeu</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@foreach (var game in upcoming)
|
||||
{
|
||||
<a class="game-teaser" href="/jeux/@game.Slug">
|
||||
<div>
|
||||
<span class="status">En préparation</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (latest.Count > 0)
|
||||
{
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Actualités</h2>
|
||||
<a href="/actualites">Toutes les actualités</a>
|
||||
</div>
|
||||
<partial name="_NewsList" model="latest" />
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@page "/jeux/exovival-zombie-reborn"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
const string Slug = "exovival-zombie-reborn";
|
||||
var game = SiteOptionsMonitor.CurrentValue.FindGame(Slug);
|
||||
var snapshot = Store.GetGame(Slug);
|
||||
var latest = snapshot?.Changelog.FirstOrDefault();
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = "exovival";
|
||||
ViewData["Title"] = "Exovival Zombie Reborn";
|
||||
ViewData["Description"] = "Exovival Zombie Reborn : survival horror à la première personne. La ville a été évacuée, votre fille Léa manque à l'appel. Retournez seul dans l'école plongée dans le noir.";
|
||||
}
|
||||
|
||||
<section class="exo-hero" data-uv-hero>
|
||||
<div class="exo-hero-photo">
|
||||
<img src="/img/exovival/couloir.webp" srcset="/img/exovival/couloir-960.webp 960w, /img/exovival/couloir.webp 1920w" sizes="100vw"
|
||||
width="1920" height="1080" alt="" fetchpriority="high">
|
||||
</div>
|
||||
<div class="exo-uv" role="img" aria-label="Écrit à l'encre invisible sur le mur : Léa ♥ Papa."></div>
|
||||
<div class="exo-torch"></div>
|
||||
<div class="wrap">
|
||||
<p class="exo-mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#biohazard" /></svg>
|
||||
Survival horror à la première personne
|
||||
</p>
|
||||
<h1 class="exo-title">Exovival Zombie Reborn</h1>
|
||||
<p class="lead">La ville a été évacuée en pleine nuit. À l'appel du point de rassemblement, une élève manquait : Léa, dix ans, votre fille. Vous retournez seul à l'école Jean-Moulin. Plus de courant, des portes condamnées, et quelque chose qui gratte dans les couloirs.</p>
|
||||
<div class="exo-actions">
|
||||
<a class="btn btn-primary btn-large" href="/launcher#telecharger">@Html.Icon("Download") Jouer avec le launcher</a>
|
||||
<a class="btn btn-ghost btn-large" href="/jeux/@Slug/notes-de-version">Notes de version</a>
|
||||
</div>
|
||||
<p class="uv-hint">@Html.Icon("Flashlight") Promenez votre lampe UV sur le mur : Léa vous a laissé un message.</p>
|
||||
</div>
|
||||
<svg width="0" height="0" class="visually-hidden" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<mask id="biohazard-crescents" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||
<rect width="24" height="24" fill="#fff" />
|
||||
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
|
||||
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="12" cy="12.8" r="3.9" fill="#000" />
|
||||
</mask>
|
||||
<mask id="biohazard-ring" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||
<rect width="24" height="24" fill="#fff" />
|
||||
<circle cx="12" cy="12.8" r="2.2" fill="#000" />
|
||||
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
|
||||
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
|
||||
</mask>
|
||||
<symbol id="biohazard" viewBox="0 0 24 24">
|
||||
<g fill="currentColor" mask="url(#biohazard-crescents)">
|
||||
<circle cx="12" cy="7.8" r="5.3" />
|
||||
<circle cx="16.33" cy="15.3" r="5.3" />
|
||||
<circle cx="7.67" cy="15.3" r="5.3" />
|
||||
</g>
|
||||
<circle cx="12" cy="12.8" r="3.3" fill="currentColor" mask="url(#biohazard-ring)" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap exo-story">
|
||||
<div class="prose">
|
||||
<p>Le soir de l'épidémie, tout le quartier a été évacué. Les cars sont partis, les portes ont été condamnées. Au point de rassemblement, on a fait l'appel classe par classe. En CM1, il manquait une élève : Léa.</p>
|
||||
<p>Vous revenez seul, de nuit, avec une lampe torche et ce que vous trouverez sur place. L'école Jean-Moulin n'a plus de courant. Des pas résonnent quelque part à l'étage.</p>
|
||||
<p>Léa et vous avez un jeu secret : des messages à l'encre invisible, écrits avec le stylo espion que vous lui avez offert pour son anniversaire. Elle vous a laissé une piste. Il faut la suivre jusqu'au bout.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Ceux qui étaient là</h2>
|
||||
<ul class="cast">
|
||||
<li><b>Léa</b><span>Dix ans, en CM1. Votre fille.</span></li>
|
||||
<li><b>Mme Roussel</b><span>Sa maîtresse, qui filme la classe au caméscope.</span></li>
|
||||
<li><b>Mme Garnier</b><span>La directrice. Elle a laissé un mot dans la classe.</span></li>
|
||||
<li><b>M. Bernard</b><span>Le concierge. Il connaît chaque recoin et en garde les clés.</span></li>
|
||||
<li><b>L'agent de sécurité</b><span>De garde la nuit de l'évacuation.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Ce qui vous attend</h2>
|
||||
<div class="exo-features">
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Flashlight") Le noir, et peu de piles</h3>
|
||||
<p>Lampe torche, briquet, bougie, lanterne à pétrole : chaque source de lumière s'épuise. Pour rallumer l'école, il faudra trouver des fusibles, régler une armoire électrique et remettre en marche un groupe électrogène qui consomme son carburant.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Puzzle") Des énigmes à l'ancienne</h3>
|
||||
<p>Digicodes, cadenas, coffres à combinaison, crochetage, labyrinthe à bille, circuit électrique à réparer, leviers à actionner dans le bon ordre. Les indices se cachent dans les papiers, sur les cassettes, et sur les murs, à la lampe UV.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Cassette") Cassettes, caméscope et caméras</h3>
|
||||
<p>Le magnétoscope de la salle des profs relit les cassettes de l'école. Le caméscope de Mme Roussel voit dans le noir. Une fois le courant revenu, les caméras de surveillance montrent des pièces où l'on ne peut pas entrer.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Ear") Les zombies vous entendent</h3>
|
||||
<p>Marcher fait du bruit, courir s'entend de loin, même dans leur dos ; un mur ou une porte fermée étouffe vos pas. Accroupi, vous êtes silencieux. Cachez-vous dans un casier, observez en vous penchant, contournez. Et attention aux groupes : un zombie qui attaque attire ceux d'à côté.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Axe") Couteau, hache, pistolet</h3>
|
||||
<p>Chaque arme a ses dégâts, et viser la tête les double. Un coup de hache bien placé suffit ; au pistolet, on vise avec les organes de visée et on compte ses balles.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Medical") Se soigner à l'ancienne</h3>
|
||||
<p>Herbes et huiles médicinales à ramasser, à mélanger en potion dans l'inventaire. La trousse de secours reste le meilleur remède. Rien n'est gaspillé : un soin ne s'utilise pas quand vous êtes en pleine forme.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("History") Sauvegardes</h3>
|
||||
<p>Jusqu'à dix sauvegardes, plus des sauvegardes automatiques aux moments clés. Après une mort, « Recommencer » reprend à la dernière. Le jeu est en français et en anglais.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Cassettes retrouvées</h2>
|
||||
<p class="lead">Des images enregistrées dans l'école, avant et pendant la nuit de l'évacuation. Elles sont dans le jeu, sur les cassettes à glisser dans le magnétoscope.</p>
|
||||
<div class="tapes">
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-couloir-nuit.webp" width="640" height="480" loading="lazy" alt="Couloir de l'école filmé en vision nocturne verte, horodatage du 12 octobre 2026 à 21 h 47."></div>
|
||||
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">Le couloir, en vision nocturne.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-escalier.webp" width="640" height="480" loading="lazy" alt="Cage d'escalier filmée en vision nocturne, vue plongeante."></div>
|
||||
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">L'escalier, caméra à la main.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-bibliotheque.webp" width="640" height="480" loading="lazy" alt="La bibliothèque de l'école éclairée, rayonnages et tables, image VHS datée du 19 décembre 2025."></div>
|
||||
<figcaption><span class="tape-label">Spectacle de Noël</span><span class="tape-text">La bibliothèque, quand tout allait bien.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-gymnase.webp" width="640" height="480" loading="lazy" alt="Le gymnase de l'école en plein jour, parquet et paniers de basket, image VHS datée du 3 mars 2026."></div>
|
||||
<figcaption><span class="tape-label">EPS</span><span class="tape-text">Le gymnase, un matin de mars.</span></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="abri">
|
||||
<div class="abri-head">
|
||||
<span class="abri-tag">En développement</span>
|
||||
<h2>Sous l'école, l'abri</h2>
|
||||
<p>La prochaine extension du niveau ouvre ce que la ville avait muré : un abri de défense passive inauguré en 1962, le parking souterrain de l'ancien commissariat sur trois niveaux, et les égouts de 1890 qui courent sous le quartier. Au programme : de nouvelles énigmes, le stand de tir de la police, des caméras de surveillance et les enregistrements laissés par ceux qui sont passés avant vous.</p>
|
||||
<p class="small">Images de développement : elles peuvent encore changer avant la sortie.</p>
|
||||
</div>
|
||||
<div class="abri-grid">
|
||||
<figure><img src="/img/exovival/abri-collecteur.webp" width="1280" height="720" loading="lazy" alt="Galerie d'égout voûtée, cerclée de conduites métalliques."><figcaption>Le collecteur</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-camera.webp" width="1280" height="720" loading="lazy" alt="Image d'une caméra de surveillance : CAM 11, égouts, grand collecteur."><figcaption>Caméra 11</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-decantation.webp" width="1280" height="720" loading="lazy" alt="Salle de décantation circulaire avec une fosse noyée au centre."><figcaption>La salle de décantation</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-parking.webp" width="1280" height="720" loading="lazy" alt="Parking souterrain vide, piliers de béton, éclairage de secours."><figcaption>Le parking, niveau -1</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-grand-collecteur.webp" width="1280" height="720" loading="lazy" alt="Grand tunnel d'égout en briques avec une rigole d'eau au centre."><figcaption>Le grand collecteur</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Dernière mise à jour</h2>
|
||||
<a href="/jeux/@Slug/notes-de-version">Toutes les notes de version</a>
|
||||
</div>
|
||||
@if (latest is not null)
|
||||
{
|
||||
<div class="changelog">
|
||||
<partial name="_Release" model="latest" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Pour jouer</h2>
|
||||
<dl class="specs">
|
||||
@if (snapshot is not null)
|
||||
{
|
||||
<div class="spec"><dt>Version</dt><dd>@snapshot.Version</dd></div>
|
||||
@if (snapshot.DownloadSize > 0)
|
||||
{
|
||||
<div class="spec"><dt>Téléchargement</dt><dd>@FrenchText.Size(snapshot.DownloadSize)</dd></div>
|
||||
}
|
||||
@if (snapshot.InstalledSize > 0)
|
||||
{
|
||||
<div class="spec"><dt>Espace disque</dt><dd>@FrenchText.Size(snapshot.InstalledSize)</dd></div>
|
||||
}
|
||||
}
|
||||
<div class="spec"><dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd></div>
|
||||
<div class="spec"><dt>Graphismes</dt><dd>DirectX 11</dd></div>
|
||||
<div class="spec"><dt>Langues</dt><dd>Français, anglais</dd></div>
|
||||
</dl>
|
||||
<div class="section-tight">
|
||||
<p class="lead">Exovival Zombie Reborn s'installe et se met à jour avec le launcher. Téléchargez-le, choisissez le jeu, cliquez sur Installer.</p>
|
||||
<partial name="_LauncherDownload" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
@page "/jeux/{slug:maxlength(80)}"
|
||||
@model GameModel
|
||||
@* Page générique d'un jeu sans page dédiée (jeu en préparation, par exemple). *@
|
||||
@{
|
||||
var game = Model.Game;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = game.Theme;
|
||||
ViewData["Title"] = game.Name;
|
||||
ViewData["Description"] = string.IsNullOrWhiteSpace(game.Tagline) ? game.Name : game.Tagline;
|
||||
}
|
||||
|
||||
<section class="upcoming">
|
||||
<div class="wrap">
|
||||
<span class="status">@(game.IsAvailable ? "Disponible dans le launcher" : "En préparation")</span>
|
||||
<h1>@game.Name</h1>
|
||||
<p class="lead">@(string.IsNullOrWhiteSpace(game.Teaser) ? game.Tagline : game.Teaser)</p>
|
||||
@if (game.IsAvailable)
|
||||
{
|
||||
<partial name="_LauncherDownload" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">En attendant, <a href="/actualites">suivez les actualités</a> ou installez le launcher : le jeu y apparaîtra dès sa sortie.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Website.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Jeux;
|
||||
|
||||
public sealed class GameModel : PageModel
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteOptions> _options;
|
||||
|
||||
public GameModel(IOptionsMonitor<SiteOptions> options) => _options = options;
|
||||
|
||||
public GameOptions Game { get; private set; } = null!;
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
var game = _options.CurrentValue.FindGame(slug);
|
||||
if (game is null) return NotFound();
|
||||
// Les jeux qui ont leur propre page (Pages/Jeux/<Jeu>.cshtml) y sont servis directement.
|
||||
if (game.HasDedicatedPage) return NotFound();
|
||||
Game = game;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@page "/jeux"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Title"] = "Nos jeux";
|
||||
ViewData["Description"] = $"Les jeux de {site.StudioName}, à installer avec le launcher.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap">
|
||||
<h1>Nos jeux</h1>
|
||||
<p class="lead">Tous nos jeux s'installent et se mettent à jour avec le launcher. Un seul téléchargement, pour tous.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap">
|
||||
@foreach (var game in site.VisibleGames.Where(g => g.IsAvailable))
|
||||
{
|
||||
var snapshot = Store.GetGame(game.Slug);
|
||||
<a class="game-feature" href="/jeux/@game.Slug">
|
||||
<div class="game-feature-media">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CardImage))
|
||||
{
|
||||
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
|
||||
}
|
||||
</div>
|
||||
<div class="game-feature-body">
|
||||
<span class="status">@(snapshot is null ? "Disponible dans le launcher" : $"Version {snapshot.Version} dans le launcher")</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
<span class="game-feature-link">Découvrir le jeu</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@foreach (var game in site.VisibleGames.Where(g => !g.IsAvailable))
|
||||
{
|
||||
<a class="game-teaser" href="/jeux/@game.Slug">
|
||||
<div>
|
||||
<span class="status">En préparation</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
@page "/jeux/{slug:maxlength(80)}/notes-de-version"
|
||||
@model NotesModel
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
var game = Model.Game;
|
||||
var snapshot = Model.Snapshot;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = game.Theme;
|
||||
ViewData["Title"] = $"Notes de version – {game.Name}";
|
||||
ViewData["Description"] = $"Toutes les nouveautés et corrections de {game.Name}, version par version.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p><a href="/jeux/@game.Slug">@game.Name</a></p>
|
||||
<h1>Notes de version</h1>
|
||||
@if (snapshot is not null)
|
||||
{
|
||||
<p class="lead">Version actuelle : <strong>@snapshot.Version</strong>@(snapshot.ReleaseDate != default ? $", publiée le {FrenchText.Date(snapshot.ReleaseDate)}" : "").
|
||||
Le launcher affiche les mêmes notes et installe la mise à jour pour vous.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap wrap-narrow">
|
||||
@if (snapshot is null)
|
||||
{
|
||||
<p class="notice">@(Store.FirstRefreshDone ? "Les notes de version sont momentanément indisponibles. Réessayez dans quelques minutes." : "Les notes de version se chargent. Rechargez la page dans quelques secondes.")</p>
|
||||
}
|
||||
else if (snapshot.Changelog.Count == 0)
|
||||
{
|
||||
<p class="notice">Aucune note de version publiée pour le moment.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="changelog">
|
||||
@foreach (var entry in snapshot.Changelog)
|
||||
{
|
||||
<partial name="_Release" model="entry" view-data='new ViewDataDictionary(ViewData) { ["Anchor"] = "v" + System.Text.RegularExpressions.Regex.Replace(entry.Version, "[^A-Za-z0-9._-]", "") }' />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
using GameLauncher.Website.Options;
|
||||
using GameLauncher.Website.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Jeux;
|
||||
|
||||
/// <summary>Toutes les notes de version d'un jeu (manifest.json lu par le site, jamais par le navigateur).</summary>
|
||||
public sealed class NotesModel : PageModel
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteOptions> _options;
|
||||
private readonly RemoteContentStore _store;
|
||||
|
||||
public NotesModel(IOptionsMonitor<SiteOptions> options, RemoteContentStore store)
|
||||
{
|
||||
_options = options;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public GameOptions Game { get; private set; } = null!;
|
||||
public GameSnapshot? Snapshot { get; private set; }
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
var game = _options.CurrentValue.FindGame(slug);
|
||||
if (game is null || string.IsNullOrWhiteSpace(game.ManifestUrl)) return NotFound();
|
||||
Game = game;
|
||||
Snapshot = _store.GetGame(game.Slug);
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user