Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c | ||
|
|
89775bd73b | ||
|
|
944e47dea7 | ||
|
|
c44c9fe3ef | ||
|
|
6d44b5680a | ||
|
|
a6b2f2d6bb | ||
|
|
e888b4037b | ||
|
|
417dd78df5 | ||
|
|
c8836a9baf | ||
|
|
e887a91d9b | ||
|
|
c1ad053861 | ||
|
|
5195398943 | ||
|
|
fd90c436e7 | ||
|
|
ca194c2bf8 | ||
|
|
4b7b7bb83c | ||
|
|
270e311fb4 | ||
|
|
80d0eb93c5 | ||
|
|
6d4219228e | ||
|
|
20991ddc61 | ||
|
|
fa01962d03 | ||
|
|
53d87f3cc6 | ||
|
|
dc18e01540 | ||
|
|
723da51c65 | ||
|
|
cf3e3f5b57 | ||
|
|
7c7e12f694 | ||
|
|
950d6acc4d | ||
|
|
39dd8300e4 |
No files matched your search
@@ -40,8 +40,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
|
||||
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
@@ -56,6 +59,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\Admin
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -136,6 +143,22 @@ Global
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -151,6 +174,8 @@ Global
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
|
||||
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,11 +32,13 @@ 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 |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `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, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue et notes de version (modèle et limites) (voir §8) |
|
||||
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
|
||||
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux ») et « Notes de version » (`Changelog/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -47,14 +49,17 @@ src/GameLauncher/
|
||||
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
|
||||
├─ 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)
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux » et « Notes de version »)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification)
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
|
||||
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
|
||||
```
|
||||
|
||||
## 3. Publier une version du jeu (ManifestBuilder)
|
||||
@@ -67,12 +72,20 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
|
||||
--exe MonJeu.exe ^
|
||||
--game-id MonJeu ^
|
||||
--notes notes.txt ^
|
||||
--remote-manifest https://launcher.example/monjeu/manifest.json ^
|
||||
--exclude "*.pdb;*.log"
|
||||
```
|
||||
|
||||
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
|
||||
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
|
||||
|
||||
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
|
||||
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
|
||||
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
|
||||
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
|
||||
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
|
||||
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -328,7 +341,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/)) |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
|
||||
| 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` |
|
||||
|
||||
@@ -371,7 +384,7 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|
||||
|---|---|
|
||||
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
|
||||
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, ses `apiUrl` et `catalogUrl` sont ignorées (seules celles intégrées à l'exe comptent) |
|
||||
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
|
||||
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
|
||||
|
||||
@@ -467,9 +480,53 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
## 8. Administration (Launcher API)
|
||||
|
||||
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils ;
|
||||
le menu d'administration du launcher arrive en phase 2 (rien ne change pour les joueurs d'ici là).
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
|
||||
**Notes de version**. Les autres pages de gestion arrivent dans les phases suivantes.
|
||||
|
||||
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
|
||||
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
|
||||
vérifiée par le serveur, protège l'administration.
|
||||
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
|
||||
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
|
||||
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
|
||||
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
|
||||
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
|
||||
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
|
||||
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
|
||||
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
|
||||
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
|
||||
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
|
||||
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
|
||||
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
|
||||
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
|
||||
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
|
||||
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
|
||||
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
|
||||
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
|
||||
|
||||
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
|
||||
YouTube…), site, thème intégré, icône et fond, fréquence des recherches de mise à jour, jeu masqué,
|
||||
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
|
||||
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
|
||||
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
|
||||
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
|
||||
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
|
||||
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
|
||||
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
|
||||
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
|
||||
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
|
||||
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
|
||||
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
|
||||
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
|
||||
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 ter.
|
||||
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
|
||||
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
|
||||
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
|
||||
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
|
||||
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
@@ -484,5 +541,5 @@ le menu d'administration du launcher arrive en phase 2 (rien ne change pour les
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
+175
-45
@@ -51,9 +51,9 @@ Conséquences :
|
||||
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
|
||||
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
|
||||
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
|
||||
│ │ clé privée (Windows │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ Hello/TPM ou fichier │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ chiffré) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ │ clé privée : fichier │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ chiffré, ou puce TPM │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ du PC (non exportable) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ └─ GameLauncher.ApiClient │ │ ASP.NET Core, service systemd durci │
|
||||
└──────────────────────────────┘ │ données : /var/lib/launcher-api (700) │
|
||||
└─────────────────────────────────────────────┘
|
||||
@@ -64,9 +64,11 @@ Conséquences :
|
||||
|
||||
| Projet | Cible | Rôle |
|
||||
|---|---|---|
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; module Administration (phase 2) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
|
||||
| `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 |
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
|
||||
| `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.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `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 |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
@@ -74,7 +76,9 @@ Conséquences :
|
||||
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(+ `Core` à partir de la phase 3) ; `GameLauncher` → `Core`, `Contracts`, `ApiClient`.
|
||||
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
|
||||
`Modules.Admin`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
|
||||
## 5. Modules
|
||||
@@ -96,35 +100,44 @@ Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion,
|
||||
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
|
||||
`.RequireSignedRequest()`.
|
||||
|
||||
### 5.2 Côté launcher (phase 2)
|
||||
### 5.2 Côté launcher (phase 2 ✅)
|
||||
|
||||
La logique d'un module (services, ViewModels) est une bibliothèque **net8.0 sans WPF**, testée sans
|
||||
interface ; le launcher WPF n'en fournit que l'**affichage** (un `DataTemplate` par type de page).
|
||||
|
||||
```csharp
|
||||
public interface ILauncherModule
|
||||
{
|
||||
string Id { get; } // "admin", "news", "friends"…
|
||||
string Id { get; } // "admin", puis "news", "friends"…
|
||||
string DisplayName { get; }
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
void AddResources(Uri resourceDictionary); // modèles WPF du module (DataTemplate)
|
||||
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
|
||||
void AddPage(PageDescriptor page); // section de la fenêtre principale (futur)
|
||||
void AddService<T>(T service) where T : class; // services partagés
|
||||
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
|
||||
}
|
||||
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id, string Title, string IconKey, string RequiredPermission, int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
string Id, string Title, string Description, string IconKey,
|
||||
string? RequiredPermission, // null : toute personne connectée
|
||||
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
```
|
||||
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : navigation à gauche (pages fournies
|
||||
par les modules, **filtrées selon les permissions** renvoyées par le serveur), en-tête avec
|
||||
l'administrateur connecté, durée de session restante et bouton « Verrouiller ».
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), les jeux
|
||||
déclarés dans `launcher.json`, les boîtes de dialogue, le journal.
|
||||
* Les modules futurs ajoutent leurs pages joueurs (`AddPage`) **et** leurs pages d'administration
|
||||
(`AddAdminPanel`) : ils s'intègrent au menu admin sans le modifier.
|
||||
* `ModuleCatalog` rassemble les modules (déclarés dans `App.CreateModules()`), vérifie l'unicité des
|
||||
identifiants et le format des permissions, et donne les pages **autorisées par les permissions**
|
||||
renvoyées par le serveur (le serveur reste seul juge : masquer une page n'est qu'un confort).
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : à gauche l'administrateur connecté, les
|
||||
pages des modules, le temps restant avant verrouillage et le bouton « Verrouiller » ; à droite la
|
||||
page choisie.
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), le client de
|
||||
l'API, les jeux déclarés dans `launcher.json`, les boîtes de dialogue (`IModuleDialogs`), la
|
||||
navigation entre pages et le signalement d'une session perdue (retour à la connexion).
|
||||
`AdminPanelViewModel.RunAsync` gère « occupé », les messages d'erreur et la session expirée.
|
||||
* Ajouter une page à un module futur : une classe `AdminPanelViewModel` + son descripteur dans le
|
||||
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
|
||||
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
@@ -135,11 +148,39 @@ public sealed record AdminPanelDescriptor(
|
||||
* `keyId` = 16 premiers caractères hexadécimaux du SHA-256 de la clé publique (SPKI).
|
||||
* **Empreinte** affichée aux humains : 5 groupes de 4 caractères (`AB12 CD34 EF56 7890 1A2B`, 80 bits),
|
||||
calculée avec une séparation de domaine : elle ne se déduit pas du `keyId`, visible dans les listes.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur (phase 2) :
|
||||
* **Windows Hello / TPM** si disponible : clé non exportable, liée au PC, déverrouillée par le
|
||||
code PIN Windows (un PC = une clé ; un administrateur peut enregistrer plusieurs PC) ;
|
||||
* sinon **fichier chiffré** (PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la clé de
|
||||
signature du launcher) déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur :
|
||||
* **fichier chiffré** (phase 2 ✅) : PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la
|
||||
clé de signature du launcher — déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
Dossier proposé : `%USERPROFILE%\.launcher-keys` (celui de `tools\admin-key.bat`). La clé de
|
||||
signature du launcher (`launcher-signing*.key`), rangée dans le même dossier, n'est jamais
|
||||
proposée ni acceptée. Une clé n'écrase jamais un fichier existant ;
|
||||
* **puce de sécurité TPM 2.0** (phase 2b ✅, au choix lors de « J'ai une invitation ») : la clé
|
||||
ECDSA P-256 est **générée dans la puce** par le fournisseur Windows « Microsoft Platform Crypto
|
||||
Provider » et n'en sort jamais (non exportable) ; la puce signe elle-même. Clé de l'utilisateur
|
||||
Windows courant, protégée par un **code PIN** (6 caractères minimum, saisi dans le launcher,
|
||||
vérifié par la puce, qui se bloque temporairement après plusieurs codes faux). Sur le disque,
|
||||
seul un fichier de référence `*.tpmkey` (nom de la clé dans la puce + clé publique, aucun
|
||||
secret) et le `.pub` habituel. À la connexion, la clé publique de la puce est comparée à celle
|
||||
de la référence. Le choix n'est **dégrisé qu'après une vérification réelle de la puce**
|
||||
(création, signature et suppression d'une clé de test). Rien ne change côté serveur : même
|
||||
format de clé publique P-256, mêmes signatures. Limites : la clé ne peut pas être sauvegardée
|
||||
et disparaît avec la puce (carte mère, réinitialisation du TPM, Windows réinstallé) — garder
|
||||
une clé « fichier » de secours ; le serveur ne peut pas prouver qu'une clé est dans une puce
|
||||
(pas d'attestation) : c'est une protection pour l'administrateur, pas une règle du serveur ;
|
||||
le blocage anti-essais est commun à toute la puce (les codes faux comptent aussi pour le code
|
||||
PIN Windows Hello) ; avec un profil Windows itinérant, la clé n'est utilisable que sur le PC
|
||||
qui l'a créée. Le fichier `.tpmkey` ne désigne que des clés nommées `GameLauncher.Admin.*`
|
||||
du fournisseur de la puce, et sa clé publique est comparée à celle de la puce avant tout
|
||||
essai du code PIN.
|
||||
Écran de connexion : **« Supprimer cette clé de la puce »** (clés TPM uniquement, après
|
||||
confirmation ; efface la clé de la puce, le `.tpmkey` et le `.pub` — à révoquer aussi sur le
|
||||
serveur ; les clés fichier se suppriment à la main). Après un code faux, le nombre d'essais
|
||||
restants est affiché ; si la puce est bloquée, un **décompte** indique quand elle acceptera de
|
||||
nouveau un code (état lu dans la puce par TPM2_GetCapability via le service TBS : compteur,
|
||||
maximum, intervalle de « guérison » ; estimation, PC allumé). Si cet état n'est pas lisible sur
|
||||
le PC, une explication générale est affichée et un nouvel essai reste possible.
|
||||
* Windows Hello (visage, empreinte) : écarté pour l'instant, son API ne crée que des clés RSA,
|
||||
ce qui demanderait de faire évoluer le serveur.
|
||||
|
||||
### 6.2 Connexion (défi-réponse)
|
||||
|
||||
@@ -174,6 +215,17 @@ empêchent de réutiliser une signature auprès d'un autre serveur.
|
||||
d'inactivité, 8 h au maximum**, 5 sessions par administrateur.
|
||||
* À chaque requête : session valide **et** administrateur actif **et** clé active (une révocation
|
||||
prend effet immédiatement, y compris depuis l'outil en ligne de commande).
|
||||
* **Côté launcher** (phase 2) : la clé déchiffrée n'existe en mémoire que pendant la session. Le menu
|
||||
se **verrouille** (session fermée, clé effacée, retour à la connexion) après **10 min sans activité**
|
||||
dans la fenêtre, à la fin des 8 h, quand la **session Windows est verrouillée** (Win+L, veille),
|
||||
sur `Ctrl+L` ou le bouton « Verrouiller », et si le serveur refuse la session (révocation). Tant
|
||||
que l'administrateur est actif, la session est prolongée sur le serveur toutes les 4 min. Fermer
|
||||
la fenêtre ou le launcher efface la clé, puis déconnecte la session. La phrase de passe n'est
|
||||
jamais conservée (champ effacé dès la tentative de connexion).
|
||||
* L'adresse de l'API (`apiUrl`) n'est lue, dans la version Release, que dans le `launcher.json`
|
||||
**intégré à l'exe** : un fichier posé à côté (ou `--config`) ne peut pas envoyer les
|
||||
administrateurs vers un autre serveur. Une valeur invalide est ignorée (le launcher des joueurs
|
||||
démarre toujours) et expliquée à l'ouverture du menu.
|
||||
|
||||
### 6.4 Requêtes signées
|
||||
|
||||
@@ -236,6 +288,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
|
||||
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
|
||||
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
|
||||
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
|
||||
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
|
||||
|
||||
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
|
||||
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
|
||||
@@ -252,8 +306,9 @@ sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas"
|
||||
## 9. Informations fixes et informations modifiables des jeux (phase 3)
|
||||
|
||||
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Les champs actuels de `launcher.json` se
|
||||
répartissent ainsi :
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Comme `apiUrl`, la liste des jeux que le
|
||||
menu d'administration peut modifier est lue, en Release, dans le `launcher.json` intégré à l'exe.
|
||||
Les champs de `launcher.json` se répartissent ainsi :
|
||||
|
||||
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
|
||||
|---|:-:|:-:|---|
|
||||
@@ -276,20 +331,94 @@ validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\
|
||||
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
|
||||
seulement via l'API (permission `games.edit`, requête signée).
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
### 9.1 Mise en œuvre (phase 3 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
**Format** (`Contracts/Catalog`) : `{ "format": "gamelauncher-catalog", "version": 1, "revision", "updatedAt",
|
||||
"launcher": { "launcherName", "defaultGame", "gameOrder" }, "games": { "<gameId>": { "gameName", "tagline",
|
||||
"description", "websiteUrl", "links": [{ "kind", "label", "url" }], "theme", "icon", "background",
|
||||
"updateCheckIntervalMinutes", "hidden" } } }`. Un champ absent = valeur de `launcher.json`.
|
||||
`CatalogRules` (partagé) borne tout : 48 Ko (sous la limite de 64 Ko des requêtes de l'API), 50 jeux,
|
||||
noms 80 caractères, accroche 200, description 2000 caractères / 40 lignes, 8 liens, adresses https
|
||||
(500 caractères, sans identifiants), images `.png`/`.jpg` du **même hôte que le catalogue**, thème =
|
||||
identifiant (`[a-z0-9-]`, jamais un chemin), intervalle 0 ou 5 à 1440. Le serveur **refuse** une
|
||||
valeur invalide (400) ; le launcher l'**ignore** (valeur de `launcher.json`).
|
||||
|
||||
**Serveur** (module `Catalog`, `Storage/CatalogStore`) :
|
||||
|
||||
* `GET v1/catalog` (administrateur connecté) : catalogue publié, publication activée ou non, adresse publique.
|
||||
* `PUT v1/catalog` (`games.edit` + requête signée) : `{ baseRevision, catalog }`. Révision différente
|
||||
de celle publiée → 409 `revision_conflict` (rien n'est écrasé) ; contrôle `CatalogRules` ; écriture
|
||||
de la référence privée (`/var/lib/launcher-api/catalog.json` + `.bak`), puis du **fichier public**
|
||||
(fichier temporaire du même dossier, droits 644 fixés explicitement malgré `UMask=0077`, renommage
|
||||
atomique) ; en cas d'échec du fichier public, la référence privée est remise en état (503) ;
|
||||
historique des 20 dernières versions ; audit `catalog.publish` (jeux modifiés).
|
||||
* Réglages `LauncherApi:Catalog:PublicFile` (vide = publication désactivée, 503 `not_configured`) et
|
||||
`PublicUrl` (défaut : `PublicOrigin` + `/catalog/catalog.json`). Au démarrage : chemin absolu `.json`,
|
||||
dossier existant, dossier public et données privées séparés (aucun dans l'autre, liens résolus).
|
||||
* VPS : dossier public (ex. `/srv/exovival/www/catalog`) autorisé en écriture par un complément
|
||||
systemd (`ReadWritePaths`), `catalog.json` servi par nginx sans cache (`server/deploy/LISEZMOI.md`, étape 3 bis).
|
||||
|
||||
**Launcher (joueur)** (`Core/Catalog`) :
|
||||
|
||||
* `catalogUrl` lue, en Release, **uniquement** dans le `launcher.json` intégré (comme `apiUrl`).
|
||||
* Démarrage : le cache est appliqué immédiatement ; le catalogue à jour est téléchargé en parallèle
|
||||
de la vérification de mise à jour du launcher et appliqué s'il arrive en 2,5 s (nouvelles images :
|
||||
1,5 s de plus) ; sinon il est mis en cache pour le démarrage suivant.
|
||||
* Téléchargements bornés (48 Ko, images 4 Mo), redirections suivies **uniquement** vers le même
|
||||
serveur (aucune requête vers un autre hôte), images contrôlées (signature PNG/JPEG), mises en cache
|
||||
sous l'empreinte de leur adresse (changer d'image = nouveau nom de fichier), fonds réduits à
|
||||
3840 px au chargement.
|
||||
* `CatalogMerger` : valeurs de `launcher.json` restaurées avant chaque application ; jeux inconnus
|
||||
ignorés ; thèmes intégrés uniquement ; images du cache uniquement ; jeu masqué toujours affiché s'il
|
||||
est installé ou demandé (raccourci) ; au moins un jeu affiché. Le nom du catalogue est un **nom
|
||||
affiché** : registre, raccourci, dossier d'installation proposé et dossier de données gardent le
|
||||
`gameName` de `launcher.json`.
|
||||
* Fiche du jeu : description et boutons de liens (icônes génériques : site, forum, vidéo…).
|
||||
|
||||
**Menu d'administration** : module `CatalogAdminModule` (permission `games.edit`), page « Jeux » :
|
||||
liste « Launcher » + jeux (ordre ↑ ↓), champs vides = valeur de `launcher.json` (rappelée sous le
|
||||
champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles que le serveur), saisie
|
||||
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
|
||||
publication n'est pas configurée.
|
||||
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
|
||||
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
|
||||
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
|
||||
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
|
||||
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
|
||||
sans titre et enregistrée au format enrichi.
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
|
||||
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
|
||||
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
|
||||
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
|
||||
* **API** :
|
||||
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
|
||||
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
|
||||
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
|
||||
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
|
||||
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
|
||||
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
|
||||
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
|
||||
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
|
||||
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
|
||||
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
|
||||
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
|
||||
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
|
||||
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
|
||||
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
|
||||
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
|
||||
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
|
||||
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
|
||||
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
|
||||
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
|
||||
n'est modifiable et le dossier reste en lecture seule pour le service.
|
||||
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
|
||||
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
|
||||
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
|
||||
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
|
||||
@@ -324,15 +453,16 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
|---|---|---|
|
||||
| **0** | Architecture (ce document) | ✅ |
|
||||
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
|
||||
| **2** | Launcher : hôte de modules, entrée cachée (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), stockage de clé (Windows Hello/TPM, fichier chiffré), connexion, « J'ai une invitation », tableau de bord | à faire |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | à faire |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
|
||||
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
|
||||
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable : tant que la phase 2 n'est pas
|
||||
livrée, rien ne change pour les joueurs.
|
||||
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.
|
||||
|
||||
## 14. Choix écartés
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ public static class ApiModules
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule()
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
/// </summary>
|
||||
public Dictionary<string, GameSettings> Games { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
@@ -53,6 +61,32 @@ public sealed class LauncherApiOptions
|
||||
public int MaxTotal { get; set; } = 500;
|
||||
}
|
||||
|
||||
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
|
||||
public sealed class CatalogSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
|
||||
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
|
||||
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
|
||||
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
|
||||
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
|
||||
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string ManifestFile { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
@@ -79,6 +113,22 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
|
||||
|
||||
/// <summary>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? CatalogPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
|
||||
public string CatalogPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
|
||||
/// launcher n'accepte que des images du même hôte que le catalogue).
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -105,6 +155,9 @@ public sealed class LauncherApiOptions
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateGames();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
|
||||
@@ -116,6 +169,97 @@ public sealed class LauncherApiOptions
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
}
|
||||
|
||||
private void ValidateCatalog()
|
||||
{
|
||||
CatalogPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
|
||||
{
|
||||
var file = Catalog.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
|
||||
CatalogPublicFileFullPath = full;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
|
||||
{
|
||||
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
|
||||
}
|
||||
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
CatalogPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (gameId, game) in Games)
|
||||
{
|
||||
var name = $"Games:{gameId}:ManifestFile";
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
|
||||
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
|
||||
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
|
||||
var file = game.ManifestFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, name);
|
||||
if (files.Values.Contains(full, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
|
||||
files[gameId] = full;
|
||||
}
|
||||
GameManifestFiles = files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
/// </summary>
|
||||
private void EnsureSeparatedFromData(string directory, string name)
|
||||
{
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
|
||||
private static string RealPath(string directory)
|
||||
{
|
||||
var full = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar);
|
||||
try
|
||||
{
|
||||
var target = new DirectoryInfo(full).ResolveLinkTarget(returnFinalTarget: true);
|
||||
return target is null ? full : Path.GetFullPath(target.FullName).TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameOrInside(string path, string parent)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
return string.Equals(path, parent, comparison) ||
|
||||
path.StartsWith(parent + Path.DirectorySeparatorChar, comparison);
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
|
||||
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
|
||||
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
|
||||
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
|
||||
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// </summary>
|
||||
public sealed class CatalogModule : IApiModule
|
||||
{
|
||||
public string Name => "Catalog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<CatalogStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
|
||||
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
|
||||
|
||||
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
|
||||
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
|
||||
{
|
||||
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
|
||||
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
|
||||
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(request.Catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = 0
|
||||
}, options.Value.CatalogImageHost, errors);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
CatalogDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du catalogue");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", Describe(previous, published));
|
||||
return Results.Json(State(published, store, options.Value), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
|
||||
{
|
||||
Catalog = catalog,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = options.CatalogPublicUrl
|
||||
};
|
||||
|
||||
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
|
||||
private static string Describe(CatalogDocument before, CatalogDocument after)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
|
||||
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
before.Games.TryGetValue(id, out var a);
|
||||
after.Games.TryGetValue(id, out var b);
|
||||
if (Serialize(a) != Serialize(b)) changed.Add(id);
|
||||
}
|
||||
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
|
||||
}
|
||||
|
||||
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
|
||||
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
|
||||
/// l'affaire de ManifestBuilder (et de la publication).
|
||||
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
|
||||
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
|
||||
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogModule : IApiModule
|
||||
{
|
||||
public string Name => "Changelog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<ChangelogStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
try
|
||||
{
|
||||
return Results.Json(store.Read(gameId), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
|
||||
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
|
||||
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
|
||||
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
|
||||
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
|
||||
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
|
||||
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
|
||||
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
|
||||
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
|
||||
|
||||
var errors = ChangelogLimits.Check(request.Entry);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
ChangelogState state;
|
||||
ChangelogEntry previous;
|
||||
try
|
||||
{
|
||||
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
|
||||
{
|
||||
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
|
||||
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
|
||||
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
|
||||
});
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var after = state.Entries.FirstOrDefault(e => e.Version == version);
|
||||
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{state.GameId} {version}",
|
||||
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations modifiables).
|
||||
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
|
||||
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
|
||||
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
|
||||
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
|
||||
/// </summary>
|
||||
public sealed class CatalogStore
|
||||
{
|
||||
public const string FileName = "catalog.json";
|
||||
private const string HistoryFolder = "catalog-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
private const UnixFileMode PublicFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public CatalogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
|
||||
|
||||
public CatalogStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
|
||||
public CatalogDocument Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return new CatalogDocument();
|
||||
var text = File.ReadAllText(_path);
|
||||
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
var errors = new List<string>();
|
||||
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
|
||||
/// fichier public, puis référence privée et historique.
|
||||
/// </summary>
|
||||
/// <returns>Le catalogue publié et le précédent.</returns>
|
||||
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current.Revision != baseRevision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
var published = catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = current.Revision + 1,
|
||||
UpdatedAt = now
|
||||
};
|
||||
var json = JsonSerializer.Serialize(published, Json);
|
||||
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
|
||||
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
|
||||
|
||||
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
|
||||
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
|
||||
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
|
||||
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
|
||||
try
|
||||
{
|
||||
WritePublic(json);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
RestorePrivate(previousText);
|
||||
throw;
|
||||
}
|
||||
|
||||
// 3. Historique (aide uniquement).
|
||||
WriteHistory(published.Revision, json);
|
||||
return (published, current);
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePublic(string json)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_publicFile!)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PublicFileMode);
|
||||
File.Move(temp, _publicFile!, overwrite: true); // renommage atomique dans le même dossier
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique)
|
||||
}
|
||||
throw new IOException($"Écriture du catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void RestorePrivate(string? previousText)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (previousText is null) File.Delete(_path);
|
||||
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
|
||||
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:<id>:ManifestFile).
|
||||
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
|
||||
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
|
||||
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
|
||||
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
|
||||
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
|
||||
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
|
||||
/// </summary>
|
||||
public sealed class ChangelogStore
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
|
||||
private const string HistoryFolder = "manifest-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
|
||||
private static readonly JsonSerializerOptions ManifestJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
|
||||
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public ChangelogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Notes de version publiées du jeu.</summary>
|
||||
public ChangelogState Read(string gameId)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
var bytes = ReadManifest(path);
|
||||
var root = Parse(bytes, key);
|
||||
return State(key, root, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
|
||||
/// </summary>
|
||||
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
|
||||
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(Path.Combine(_dataDirectory, $"manifest-{key.ToLowerInvariant()}.lock"), LockTimeout))
|
||||
{
|
||||
var bytes = ReadManifest(path);
|
||||
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
|
||||
var root = Parse(bytes, key);
|
||||
var changelog = ChangelogArray(root)
|
||||
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
|
||||
var index = -1;
|
||||
for (var i = 0; i < changelog.Count; i++)
|
||||
if (changelog[i] is JsonObject candidate && string.Equals(StringValue(candidate, "version"), version, StringComparison.Ordinal))
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
if (index < 0)
|
||||
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
|
||||
|
||||
var old = (JsonObject)changelog[index]!;
|
||||
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
|
||||
|
||||
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
|
||||
// anciens launchers ; numéro et date de la version repris du manifest.
|
||||
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
|
||||
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
|
||||
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
|
||||
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
|
||||
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
|
||||
var replacement = old.DeepClone().AsObject();
|
||||
var edited = JsonSerializer.SerializeToNode(clean, ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields)
|
||||
{
|
||||
foreach (var existing in replacement.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
replacement.Remove(existing);
|
||||
if (edited[name] is { } value) replacement[name] = value.DeepClone();
|
||||
}
|
||||
changelog[index] = replacement;
|
||||
|
||||
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
|
||||
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
|
||||
if (total > ChangelogLimits.MaxTotalItems)
|
||||
throw ApiRejection.BadRequest(
|
||||
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
|
||||
|
||||
var json = root.ToJsonString(ManifestJson);
|
||||
var newBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json);
|
||||
if (newBytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
|
||||
|
||||
Backup(key, bytes);
|
||||
WriteManifest(path, newBytes, baseSha256);
|
||||
return (State(key, root, newBytes), previous);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Notes de version non modifiables pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
private static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
JsonObject root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(StripBom(bytes), documentOptions: ReadOptions) as JsonObject
|
||||
?? throw new InvalidDataException("le manifest n'est pas un objet JSON");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = StringValue(root, "gameId");
|
||||
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
|
||||
{
|
||||
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
|
||||
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
|
||||
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
|
||||
return new ChangelogState
|
||||
{
|
||||
GameId = gameId,
|
||||
ManifestVersion = StringValue(root, "version") ?? "",
|
||||
ManifestSha256 = Sha256(bytes),
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
|
||||
private static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(ManifestJson) ?? new List<ChangelogEntry?>();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
|
||||
private static JsonArray? ChangelogArray(JsonObject root)
|
||||
{
|
||||
foreach (var (name, value) in root)
|
||||
if (string.Equals(name, "changelog", StringComparison.OrdinalIgnoreCase))
|
||||
return value as JsonArray;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? StringValue(JsonObject node, string name)
|
||||
{
|
||||
foreach (var (key, value) in node)
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return value is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
|
||||
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
|
||||
|
||||
public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static ApiRejection Conflict() => ApiRejection.Conflict(
|
||||
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
// ------------------------------------------------------------------ Écriture
|
||||
|
||||
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération).</summary>
|
||||
private void Backup(string gameId, byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.Combine(_dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
|
||||
SecureFiles.EnsureDirectory(Path.Combine(_dataDirectory, HistoryFolder));
|
||||
SecureFiles.EnsureDirectory(directory);
|
||||
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// La copie est une aide : son échec n'empêche pas la modification.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace le manifest publié : fichier temporaire caché (refusé par nginx) dans le même dossier,
|
||||
/// droits du manifest existant, empreinte revérifiée, puis renommage atomique.
|
||||
/// </summary>
|
||||
private static void WriteManifest(string path, byte[] bytes, string baseSha256)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
if (new FileInfo(path).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// Le fichier remplaçant appartient au compte du service, avec le groupe du dossier SEULEMENT si
|
||||
// celui-ci a le bit setgid : sinon nginx (groupe www-data) pourrait perdre l'accès au manifest.
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) &&
|
||||
!File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : le manifest remplacé ne serait plus lisible par nginx.");
|
||||
}
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temp, new FileStreamOptions
|
||||
{
|
||||
Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None
|
||||
}))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, File.GetUnixFileMode(path));
|
||||
|
||||
// Dernier contrôle : un envoi (WinSCP…) pendant la préparation n'est pas écrasé.
|
||||
if (!string.Equals(Sha256(File.ReadAllBytes(path)), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique, caché)
|
||||
}
|
||||
if (ex is ApiRejection) throw;
|
||||
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Remplacement du manifest refusé par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
|
||||
public sealed class ManifestWriteRefusedException : IOException
|
||||
{
|
||||
public ManifestWriteRefusedException(string message) : base(message) { }
|
||||
}
|
||||
@@ -24,6 +24,17 @@
|
||||
},
|
||||
"ChallengeLifetimeSeconds": 60,
|
||||
"InvitationLifetimeHours": 24,
|
||||
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
|
||||
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
|
||||
"Catalog": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
|
||||
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
|
||||
"Games": {},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
"AuthPerMinute": 20,
|
||||
|
||||
+132
-5
@@ -65,6 +65,114 @@ Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), l
|
||||
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
|
||||
signatures seront refusées.
|
||||
|
||||
## 3 bis. Catalogue des jeux (VPS)
|
||||
|
||||
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
|
||||
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
|
||||
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
|
||||
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
|
||||
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
|
||||
|
||||
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
|
||||
|
||||
```
|
||||
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
|
||||
```
|
||||
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
|
||||
```
|
||||
|
||||
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
|
||||
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
|
||||
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
|
||||
`/var/lib/launcher-api` sont l'un dans l'autre.
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
|
||||
lecture seule) :
|
||||
|
||||
```
|
||||
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
|
||||
puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
|
||||
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
|
||||
```
|
||||
|
||||
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
|
||||
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
|
||||
|
||||
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
|
||||
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
|
||||
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
|
||||
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
|
||||
serveur sont refusées.
|
||||
|
||||
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
|
||||
|
||||
## 3 ter. Notes de version modifiables en ligne (VPS)
|
||||
|
||||
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
|
||||
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
|
||||
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
|
||||
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
|
||||
|
||||
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
|
||||
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
|
||||
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
|
||||
|
||||
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
|
||||
remplacé garde le groupe `www-data` lu par nginx :
|
||||
|
||||
```
|
||||
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
|
||||
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
|
||||
```
|
||||
|
||||
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
|
||||
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
|
||||
```
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui :
|
||||
|
||||
```
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
|
||||
une par jeu), puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
|
||||
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
|
||||
corrigées en ligne avec celles du `manifest.json` local.
|
||||
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
|
||||
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
|
||||
|
||||
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
|
||||
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
|
||||
|
||||
## 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 :
|
||||
@@ -76,7 +184,13 @@ Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères
|
||||
|
||||
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
|
||||
|
||||
> Phase 2 : le launcher saura créer la clé lui-même (Windows Hello / TPM) et importer ce fichier.
|
||||
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
|
||||
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
|
||||
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
|
||||
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
|
||||
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
|
||||
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
|
||||
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
|
||||
|
||||
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
|
||||
|
||||
@@ -102,12 +216,15 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
|
||||
|
||||
## 7. Ajouter un administrateur
|
||||
|
||||
En attendant le menu d'administration du launcher (phases 2 et 6), avec `tools\admin-key.bat` :
|
||||
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
|
||||
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
|
||||
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
|
||||
|
||||
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
|
||||
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
|
||||
2. **Paul** : `admin-key.bat` (création de sa clé), puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>` ; il te communique
|
||||
2. **Paul** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
|
||||
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
|
||||
@@ -123,7 +240,8 @@ d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
|
||||
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
## 9. Récupération (VPS)
|
||||
@@ -132,6 +250,7 @@ pas, le script remet la précédente en place.
|
||||
|---|---|
|
||||
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
|
||||
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
|
||||
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
|
||||
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
|
||||
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
|
||||
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
|
||||
@@ -171,6 +290,14 @@ recréer les administrateurs.
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
|
||||
├─ catalog-history/ 20 dernières versions publiées
|
||||
├─ manifest-history/<jeu>/ manifests remplacés par la page Notes de version (20 derniers)
|
||||
└─ 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)
|
||||
└─ 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é)
|
||||
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
|
||||
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
|
||||
# launcher-api.service, pas ce dossier).
|
||||
#
|
||||
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
|
||||
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/catalog
|
||||
@@ -0,0 +1,17 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
|
||||
# /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
|
||||
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
|
||||
# entier qui doit être autorisé (pas le seul fichier).
|
||||
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
|
||||
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
|
||||
#
|
||||
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
|
||||
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
|
||||
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/exovivalzombie
|
||||
@@ -6,6 +6,10 @@
|
||||
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
|
||||
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
|
||||
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
|
||||
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
|
||||
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
|
||||
# (refusés).
|
||||
# =============================================================================
|
||||
|
||||
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
|
||||
location = /catalog/catalog.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;
|
||||
}
|
||||
@@ -36,8 +36,26 @@ public static class AdminKeyFile
|
||||
using (var writer = new StreamWriter(stream))
|
||||
writer.Write(pem);
|
||||
|
||||
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
|
||||
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
|
||||
File.WriteAllText(publicPath, AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// clé chiffrée laissée en place : sans danger
|
||||
}
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
@@ -32,6 +34,9 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
|
||||
public TimeSpan ClockOffset => _clockOffset;
|
||||
public string SessionId { get; }
|
||||
public AdminProfile Profile { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
@@ -104,6 +109,37 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
|
||||
public Task<CatalogState> GetCatalogAsync(CancellationToken ct = default) =>
|
||||
GetAsync<CatalogState>(ApiRoutes.Catalog, ct);
|
||||
|
||||
/// <summary>Publie le catalogue (409 « revision_conflict » si quelqu'un a publié depuis <paramref name="baseRevision"/>).</summary>
|
||||
public Task<CatalogState> PublishCatalogAsync(CatalogDocument catalog, long baseRevision, CancellationToken ct = default) =>
|
||||
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
|
||||
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
|
||||
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
|
||||
/// </summary>
|
||||
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
|
||||
{
|
||||
if (!ChangelogLimits.IsEditableVersion(entry.Version))
|
||||
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
|
||||
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
|
||||
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
|
||||
}
|
||||
|
||||
private static string CheckGameId(string gameId) =>
|
||||
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
|
||||
|
||||
// ------------------------------------------------------------------ Requêtes génériques (modules)
|
||||
|
||||
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace GameLauncher.ApiClient;
|
||||
/// <summary>
|
||||
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
|
||||
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
|
||||
/// chiffré) ; Windows Hello / TPM dans le launcher (phase 2).
|
||||
/// chiffré) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
|
||||
/// </summary>
|
||||
public interface IAdminSigner
|
||||
{
|
||||
|
||||
@@ -36,4 +36,16 @@ public static class ApiRoutes
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
public const string Catalog = "v1/catalog";
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
|
||||
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
|
||||
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
|
||||
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
|
||||
|
||||
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
|
||||
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
|
||||
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
|
||||
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
|
||||
/// </summary>
|
||||
public sealed record CatalogDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-catalog";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public CatalogLauncher? Launcher { get; init; }
|
||||
|
||||
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
|
||||
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
|
||||
public sealed record CatalogLauncher
|
||||
{
|
||||
public string? LauncherName { get; init; }
|
||||
|
||||
/// <summary>Jeu affiché au premier lancement.</summary>
|
||||
public string? DefaultGame { get; init; }
|
||||
|
||||
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
|
||||
public List<string>? GameOrder { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
|
||||
public sealed record CatalogGame
|
||||
{
|
||||
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
|
||||
public string? GameName { get; init; }
|
||||
|
||||
public string? Tagline { get; init; }
|
||||
|
||||
/// <summary>Texte de présentation (plusieurs lignes, texte brut).</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? WebsiteUrl { get; init; }
|
||||
|
||||
public List<CatalogLink>? Links { get; init; }
|
||||
|
||||
/// <summary>Thème INTÉGRÉ au launcher (« default », « exovival »…), jamais un fichier.</summary>
|
||||
public string? Theme { get; init; }
|
||||
|
||||
/// <summary>Image https (PNG ou JPEG) du même serveur que le catalogue.</summary>
|
||||
public string? Icon { get; init; }
|
||||
|
||||
/// <summary>Image de fond https (PNG ou JPEG) du même serveur que le catalogue.</summary>
|
||||
public string? Background { get; init; }
|
||||
|
||||
/// <summary>0 (désactivé) ou 5 à 1440 minutes.</summary>
|
||||
public int? UpdateCheckIntervalMinutes { get; init; }
|
||||
|
||||
/// <summary>Masqué de la liste (un jeu installé sur le PC reste toujours affiché).</summary>
|
||||
public bool? Hidden { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation du jeu (Discord, YouTube…).</summary>
|
||||
public sealed record CatalogLink
|
||||
{
|
||||
/// <summary>Voir <see cref="CatalogLinkKinds"/> (choisit l'icône intégrée).</summary>
|
||||
public string Kind { get; init; } = CatalogLinkKinds.Website;
|
||||
|
||||
public string? Label { get; init; }
|
||||
|
||||
public string Url { get; init; } = "";
|
||||
}
|
||||
|
||||
public static class CatalogLinkKinds
|
||||
{
|
||||
public const string Website = "website";
|
||||
public const string Discord = "discord";
|
||||
public const string YouTube = "youtube";
|
||||
public const string X = "x";
|
||||
public const string Twitch = "twitch";
|
||||
public const string Steam = "steam";
|
||||
public const string Other = "other";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Website, Discord, YouTube, X, Twitch, Steam, Other };
|
||||
|
||||
/// <summary>Libellé par défaut d'un lien.</summary>
|
||||
public static string DefaultLabel(string kind) => kind switch
|
||||
{
|
||||
Website => "Site web",
|
||||
Discord => "Discord",
|
||||
YouTube => "YouTube",
|
||||
X => "X (Twitter)",
|
||||
Twitch => "Twitch",
|
||||
Steam => "Steam",
|
||||
_ => "Lien"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Publication du catalogue depuis le menu d'administration (requête signée, permission games.edit).</summary>
|
||||
public sealed record CatalogUpdateRequest
|
||||
{
|
||||
/// <summary>Révision sur laquelle les modifications ont été faites (refus si quelqu'un a publié entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
public CatalogDocument Catalog { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Catalogue tel que connu du serveur, pour le menu d'administration.</summary>
|
||||
public sealed record CatalogState
|
||||
{
|
||||
public CatalogDocument Catalog { get; init; } = new();
|
||||
|
||||
/// <summary>Faux : la publication n'est pas configurée sur le serveur (LauncherApi:Catalog:PublicFile).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du catalogue (pour information).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
|
||||
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
|
||||
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
|
||||
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
|
||||
/// </summary>
|
||||
public static partial class CatalogRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
|
||||
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxFileBytes = 48 * 1024;
|
||||
|
||||
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
|
||||
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
|
||||
public static int PublishedSize(CatalogDocument catalog) =>
|
||||
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
|
||||
public const int MaxGames = 50;
|
||||
public const int MaxGameNameLength = 80;
|
||||
public const int MaxLauncherNameLength = 80;
|
||||
public const int MaxTaglineLength = 200;
|
||||
public const int MaxDescriptionLength = 2000;
|
||||
public const int MaxDescriptionLines = 40;
|
||||
public const int MaxLinks = 8;
|
||||
public const int MaxLinkLabelLength = 40;
|
||||
public const int MaxUrlLength = 500;
|
||||
public const int MinUpdateInterval = 5;
|
||||
public const int MaxUpdateInterval = 1440;
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
|
||||
private static partial Regex GameIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
|
||||
private static partial Regex ThemeRegex();
|
||||
|
||||
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
|
||||
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
|
||||
/// </summary>
|
||||
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
|
||||
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
|
||||
{
|
||||
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
|
||||
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
|
||||
if (input.Revision < 0) errors.Add("Révision invalide.");
|
||||
|
||||
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (games.ContainsKey(id))
|
||||
{
|
||||
errors.Add($"Jeu « {id} » présent plusieurs fois.");
|
||||
continue;
|
||||
}
|
||||
if (games.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Trop de jeux (au plus {MaxGames}).");
|
||||
break;
|
||||
}
|
||||
if (game is null) continue;
|
||||
var cleaned = NormalizeGame(id, game, imageHost, errors);
|
||||
if (!IsEmpty(cleaned)) games[id] = cleaned;
|
||||
}
|
||||
|
||||
return new CatalogDocument
|
||||
{
|
||||
Revision = Math.Max(0, input.Revision),
|
||||
UpdatedAt = input.UpdatedAt,
|
||||
Launcher = NormalizeLauncher(input.Launcher, errors),
|
||||
Games = games
|
||||
};
|
||||
}
|
||||
|
||||
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
|
||||
{
|
||||
if (launcher is null) return null;
|
||||
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
|
||||
string? defaultGame = null;
|
||||
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
|
||||
{
|
||||
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
|
||||
else errors.Add("Jeu par défaut : identifiant invalide.");
|
||||
}
|
||||
|
||||
List<string>? order = null;
|
||||
if (launcher.GameOrder is { Count: > 0 })
|
||||
{
|
||||
order = new List<string>();
|
||||
foreach (var id in launcher.GameOrder)
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (order.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
|
||||
break;
|
||||
}
|
||||
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
|
||||
}
|
||||
if (order.Count == 0) order = null;
|
||||
}
|
||||
|
||||
return name is null && defaultGame is null && order is null
|
||||
? null
|
||||
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
|
||||
}
|
||||
|
||||
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
|
||||
{
|
||||
var prefix = $"Jeu « {id} »";
|
||||
int? interval = null;
|
||||
if (game.UpdateCheckIntervalMinutes is { } minutes)
|
||||
{
|
||||
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
|
||||
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
|
||||
}
|
||||
|
||||
string? theme = null;
|
||||
if (!string.IsNullOrWhiteSpace(game.Theme))
|
||||
{
|
||||
var value = game.Theme.Trim();
|
||||
if (IsValidTheme(value)) theme = value;
|
||||
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
|
||||
}
|
||||
|
||||
return new CatalogGame
|
||||
{
|
||||
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
|
||||
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
|
||||
Description = Text(game.Description, $"{prefix} : présentation", errors),
|
||||
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
|
||||
Links = Links(game.Links, prefix, errors),
|
||||
Theme = theme,
|
||||
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
|
||||
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
|
||||
UpdateCheckIntervalMinutes = interval,
|
||||
Hidden = game.Hidden == true ? true : null
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
|
||||
{
|
||||
if (links is not { Count: > 0 }) return null;
|
||||
var result = new List<CatalogLink>();
|
||||
foreach (var link in links)
|
||||
{
|
||||
if (link is null) continue;
|
||||
if (result.Count >= MaxLinks)
|
||||
{
|
||||
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
|
||||
break;
|
||||
}
|
||||
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
|
||||
if (kind is null)
|
||||
{
|
||||
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
|
||||
continue;
|
||||
}
|
||||
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
|
||||
if (url is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
|
||||
continue;
|
||||
}
|
||||
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
|
||||
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
|
||||
}
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs
|
||||
|
||||
private static string? Line(string? value, int maxLength, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var cleaned = ApiText.CleanLine(value, maxLength);
|
||||
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
|
||||
private static string? Text(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append('\n');
|
||||
var line = lines[i].TrimEnd();
|
||||
if (line.Length == 0) continue;
|
||||
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
|
||||
var indent = line.Length - line.TrimStart(' ').Length;
|
||||
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
|
||||
if (cleaned is null)
|
||||
{
|
||||
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
|
||||
return null;
|
||||
}
|
||||
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
|
||||
}
|
||||
var text = sb.ToString().Trim('\n');
|
||||
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
|
||||
{
|
||||
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
|
||||
return null;
|
||||
}
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
|
||||
public static string? Url(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var text = value.Trim();
|
||||
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
|
||||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
|
||||
{
|
||||
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
|
||||
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
|
||||
{
|
||||
var url = Url(value, field, errors);
|
||||
if (url is null) return null;
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath;
|
||||
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
|
||||
return null;
|
||||
}
|
||||
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static bool IsEmpty(CatalogGame game) =>
|
||||
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
|
||||
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
|
||||
|
||||
private static string Truncate(string? value) =>
|
||||
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
|
||||
}
|
||||
+73
-4
@@ -1,11 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version du jeu (manifest.json → "changelog").
|
||||
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
|
||||
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
|
||||
///
|
||||
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
|
||||
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
|
||||
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
|
||||
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
|
||||
public List<string> Changes { get; set; } = new();
|
||||
|
||||
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
[JsonIgnore]
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||
@@ -142,6 +147,69 @@ public static class ChangelogLimits
|
||||
public const int MaxTitleLength = 200;
|
||||
public const int MaxTagLength = 24;
|
||||
public const int MaxTextLength = 2000;
|
||||
public const int MaxVersionLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
|
||||
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxEditRequestBytes = 60 * 1024;
|
||||
|
||||
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
|
||||
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
|
||||
|
||||
/// <summary>
|
||||
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
|
||||
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
|
||||
/// </summary>
|
||||
public static List<string> Check(ChangelogEntry entry)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
void Length(string? value, int max, string what)
|
||||
{
|
||||
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
|
||||
}
|
||||
|
||||
Length(entry.Title, MaxTitleLength, "Titre");
|
||||
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
|
||||
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
|
||||
if (introduction.Count > MaxIntroductionParagraphs)
|
||||
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
|
||||
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
|
||||
|
||||
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
|
||||
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
|
||||
var items = 0;
|
||||
for (var s = 0; s < sections.Count; s++)
|
||||
{
|
||||
var section = sections[s];
|
||||
var where = $"Section {s + 1}";
|
||||
Length(section.Title, MaxTitleLength, $"{where} (titre)");
|
||||
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
|
||||
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
|
||||
items += sectionItems.Count;
|
||||
for (var i = 0; i < sectionItems.Count; i++)
|
||||
{
|
||||
var item = sectionItems[i];
|
||||
var at = $"{where}, élément {i + 1}";
|
||||
Length(item.Text, MaxTextLength, at);
|
||||
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
|
||||
Length(item.Note, MaxTextLength, $"{at} (note)");
|
||||
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
|
||||
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
|
||||
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
|
||||
}
|
||||
}
|
||||
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
|
||||
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
|
||||
errors.Add($"{MaxFlatChanges} modifications au plus.");
|
||||
return errors.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
|
||||
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
|
||||
|
||||
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
|
||||
{
|
||||
@@ -229,7 +297,8 @@ public static class ChangelogLimits
|
||||
|
||||
var text = sb.ToString().Trim();
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
|
||||
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
|
||||
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
|
||||
/// l'éditeur du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed record ChangelogState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
|
||||
public string ManifestVersion { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
|
||||
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// </summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
|
||||
public List<ChangelogEntry> Entries { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
|
||||
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
|
||||
/// </summary>
|
||||
public sealed record ChangelogUpdateRequest
|
||||
{
|
||||
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
|
||||
public string BaseSha256 { get; init; } = "";
|
||||
|
||||
public ChangelogEntry? Entry { get; init; }
|
||||
}
|
||||
@@ -53,4 +53,10 @@ public static class ApiErrorCodes
|
||||
public const string SelfAction = "self_action";
|
||||
public const string Busy = "busy";
|
||||
public const string HttpsRequired = "https_required";
|
||||
|
||||
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
|
||||
public const string RevisionConflict = "revision_conflict";
|
||||
|
||||
/// <summary>Fonction non configurée sur le serveur.</summary>
|
||||
public const string NotConfigured = "not_configured";
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
|
||||
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
|
||||
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
|
||||
/// • thème : seulement un thème intégré au launcher ;
|
||||
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
|
||||
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
|
||||
/// et au moins un jeu reste affiché.
|
||||
/// Peut être appliqué plusieurs fois (cache au démarrage, puis version à jour) : les valeurs de
|
||||
/// launcher.json sont rétablies avant chaque application.
|
||||
/// </summary>
|
||||
public static class CatalogMerger
|
||||
{
|
||||
/// <summary>Valeurs de launcher.json avant toute application du catalogue.</summary>
|
||||
private sealed record Baseline(string Tagline, string? WebsiteUrl, string Theme, string? Icon, string? Background, int UpdateCheckIntervalMinutes);
|
||||
|
||||
private static readonly ConditionalWeakTable<LauncherConfig, Baseline> Baselines = new();
|
||||
|
||||
/// <param name="builtInThemes">Thèmes intégrés au launcher (les autres valeurs sont ignorées).</param>
|
||||
/// <param name="localImage">Fichier local d'une image du catalogue (null : pas encore en cache → image par défaut).</param>
|
||||
/// <param name="mustStayVisible">Jeu installé sur ce PC, ou demandé en ligne de commande : jamais masqué.</param>
|
||||
public static void Apply(LauncherCatalog launcher, CatalogDocument catalog, IReadOnlyCollection<string> builtInThemes,
|
||||
Func<string?, string?> localImage, Func<LauncherConfig, bool> mustStayVisible)
|
||||
{
|
||||
var declared = launcher.DeclaredGames;
|
||||
foreach (var game in declared)
|
||||
{
|
||||
Restore(game);
|
||||
if (!catalog.Games.TryGetValue(game.GameId, out var entry)) continue;
|
||||
|
||||
if (entry.GameName is { } name) game.DisplayName = name;
|
||||
if (entry.Tagline is { } tagline) game.Tagline = tagline;
|
||||
if (entry.Description is { } description) game.Description = description;
|
||||
if (entry.WebsiteUrl is { } website) game.WebsiteUrl = website;
|
||||
if (entry.Links is { Count: > 0 } links)
|
||||
game.Links = links.Select(l => new GameLink(l.Kind, l.Label ?? CatalogLinkKinds.DefaultLabel(l.Kind), l.Url)).ToList();
|
||||
if (entry.Theme is { } theme)
|
||||
{
|
||||
if (builtInThemes.Contains(theme, StringComparer.OrdinalIgnoreCase)) game.Theme = theme;
|
||||
else Log.Warn($"Catalogue : thème « {theme} » inconnu de cette version du launcher ({game.GameId}), ignoré.");
|
||||
}
|
||||
if (localImage(entry.Icon) is { } icon) game.Icon = icon;
|
||||
if (localImage(entry.Background) is { } background) game.Background = background;
|
||||
if (entry.UpdateCheckIntervalMinutes is { } interval) game.UpdateCheckIntervalMinutes = interval;
|
||||
}
|
||||
|
||||
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
|
||||
var ordered = new List<LauncherConfig>();
|
||||
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
|
||||
if (declared.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
|
||||
ordered.Add(game);
|
||||
ordered.AddRange(declared.Where(g => !ordered.Contains(g)));
|
||||
|
||||
// Visibilité.
|
||||
var visible = ordered.Where(g => !(catalog.Games.TryGetValue(g.GameId, out var entry) && entry.Hidden == true) || mustStayVisible(g))
|
||||
.ToList();
|
||||
if (visible.Count == 0) visible.Add(ordered[0]);
|
||||
|
||||
var launcherName = catalog.Launcher?.LauncherName ?? launcher.DeclaredLauncherName;
|
||||
var defaultGame = catalog.Launcher?.DefaultGame is { } requested && visible.Any(g => string.Equals(g.GameId, requested, StringComparison.OrdinalIgnoreCase))
|
||||
? requested
|
||||
: launcher.DeclaredDefaultGameId;
|
||||
launcher.ApplyDisplay(launcherName, defaultGame, visible);
|
||||
}
|
||||
|
||||
/// <summary>Rétablit les valeurs de launcher.json (enregistrées à la première application).</summary>
|
||||
private static void Restore(LauncherConfig game)
|
||||
{
|
||||
var baseline = Baselines.GetValue(game, g => new Baseline(g.Tagline, g.WebsiteUrl, g.Theme, g.Icon, g.Background, g.UpdateCheckIntervalMinutes));
|
||||
game.DisplayName = null;
|
||||
game.Description = null;
|
||||
game.Links = Array.Empty<GameLink>();
|
||||
game.Tagline = baseline.Tagline;
|
||||
game.WebsiteUrl = baseline.WebsiteUrl;
|
||||
game.Theme = baseline.Theme;
|
||||
game.Icon = baseline.Icon;
|
||||
game.Background = baseline.Background;
|
||||
game.UpdateCheckIntervalMinutes = baseline.UpdateCheckIntervalMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
|
||||
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
|
||||
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
|
||||
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
|
||||
/// téléchargées une fois puis conservées en cache.
|
||||
/// </summary>
|
||||
public sealed class CatalogService
|
||||
{
|
||||
public const long MaxImageBytes = 4 * 1024 * 1024;
|
||||
private const string FolderName = "catalog";
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri _catalogUrl;
|
||||
|
||||
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
Directory = Path.Combine(dataDirectory, FolderName);
|
||||
CachePath = Path.Combine(Directory, "catalog.json");
|
||||
ImagesDirectory = Path.Combine(Directory, "images");
|
||||
}
|
||||
|
||||
public string Directory { get; }
|
||||
public string CachePath { get; }
|
||||
public string ImagesDirectory { get; }
|
||||
|
||||
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
return Parse(File.ReadAllBytes(CachePath), out _);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
|
||||
{
|
||||
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
|
||||
/// le fichier est inutilisable (le cache est alors conservé tel quel).
|
||||
/// </summary>
|
||||
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
|
||||
var catalog = Parse(bytes, out var errors);
|
||||
if (catalog is null) return null;
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
|
||||
return catalog;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
|
||||
InvalidDataException or JsonException or OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
|
||||
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
|
||||
?? throw new InvalidDataException("catalogue vide");
|
||||
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
|
||||
{
|
||||
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
|
||||
return null;
|
||||
}
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images
|
||||
|
||||
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
|
||||
public string? CachedImage(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return null;
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
|
||||
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
|
||||
/// </summary>
|
||||
public Task<string?> GetImageAsync(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
|
||||
if (File.Exists(path)) return Task.FromResult<string?>(path);
|
||||
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
|
||||
return download.Value;
|
||||
}
|
||||
|
||||
private async Task<string?> DownloadImageAsync(string url, string path)
|
||||
{
|
||||
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
|
||||
System.IO.Directory.CreateDirectory(ImagesDirectory);
|
||||
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
|
||||
InvalidDataException or OperationCanceledException)
|
||||
{
|
||||
FileUtil.TryDeleteFile(temp);
|
||||
Log.Warn($"Image du catalogue indisponible ({url}) : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_imageDownloads.TryRemove(path, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Supprime les images du cache qui ne sont plus utilisées par le catalogue.</summary>
|
||||
public void PruneImages(CatalogDocument catalog)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.Directory.Exists(ImagesDirectory)) return;
|
||||
var used = catalog.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
|
||||
.Select(ImagePath).Where(p => p is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in System.IO.Directory.GetFiles(ImagesDirectory))
|
||||
{
|
||||
if (used.Contains(file)) continue;
|
||||
// Fichier temporaire : peut-être un téléchargement en cours (supprimé s'il est ancien).
|
||||
if (file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) &&
|
||||
DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < TimeSpan.FromHours(1))
|
||||
continue;
|
||||
FileUtil.TryDeleteFile(file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Nettoyage des images du catalogue : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier du cache pour une image (nom = empreinte de l'adresse) ; null si l'adresse est refusée.</summary>
|
||||
private string? ImagePath(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, _catalogUrl.Host, StringComparison.OrdinalIgnoreCase) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||
return null;
|
||||
var extension = uri.AbsolutePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ".png" : ".jpg";
|
||||
var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32].ToLowerInvariant();
|
||||
return Path.Combine(ImagesDirectory, name + extension);
|
||||
}
|
||||
|
||||
internal static bool IsPngOrJpeg(ReadOnlySpan<byte> data) =>
|
||||
data.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) ||
|
||||
data.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF });
|
||||
}
|
||||
@@ -13,4 +13,9 @@
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Catalogue des jeux (catalog.json) : format et règles partagés avec la Launcher API. -->
|
||||
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -34,21 +34,34 @@ public sealed class LauncherCatalog
|
||||
Publisher = publisher;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
DeclaredGames = games;
|
||||
DeclaredLauncherName = launcherName;
|
||||
DeclaredDefaultGameId = defaultGameId;
|
||||
IsMultiGameFormat = isMultiGameFormat;
|
||||
}
|
||||
|
||||
/// <summary>Identifiant technique du launcher (dossier de données, instance unique).</summary>
|
||||
public string LauncherId { get; }
|
||||
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés.</summary>
|
||||
public string LauncherName { get; }
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés (modifiable par le catalogue).</summary>
|
||||
public string LauncherName { get; private set; }
|
||||
|
||||
public string Publisher { get; }
|
||||
|
||||
/// <summary>Jeu sélectionné au premier lancement (sinon le premier de la liste).</summary>
|
||||
public string? DefaultGameId { get; }
|
||||
public string? DefaultGameId { get; private set; }
|
||||
|
||||
public IReadOnlyList<LauncherConfig> Games { get; }
|
||||
/// <summary>Jeux affichés, dans l'ordre (le catalogue peut changer l'ordre et masquer des jeux non installés).</summary>
|
||||
public IReadOnlyList<LauncherConfig> Games { get; private set; }
|
||||
|
||||
/// <summary>Tous les jeux déclarés dans launcher.json, y compris ceux masqués par le catalogue.</summary>
|
||||
public IReadOnlyList<LauncherConfig> DeclaredGames { get; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json (avant le catalogue).</summary>
|
||||
public string DeclaredLauncherName { get; }
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json (avant le catalogue).</summary>
|
||||
public string? DeclaredDefaultGameId { get; }
|
||||
|
||||
/// <summary>Vrai si launcher.json utilise le tableau "games" (même avec un seul jeu).</summary>
|
||||
public bool IsMultiGameFormat { get; }
|
||||
@@ -62,6 +75,37 @@ public sealed class LauncherCatalog
|
||||
/// </summary>
|
||||
public string? LauncherUpdateUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse de la Launcher API (menu d'administration), ex. « https://launcher.lanisys.fr/api/ ».
|
||||
/// Facultatif : sans cette valeur, le menu d'administration est désactivé. Le launcher publié ne
|
||||
/// lit cette valeur que dans le launcher.json INTÉGRÉ à l'exe (voir App) : un fichier posé à côté
|
||||
/// ne peut pas rediriger les administrateurs vers un autre serveur.
|
||||
/// Une valeur invalide ne bloque jamais le launcher des joueurs : elle est ignorée
|
||||
/// (<see cref="ApiUrl"/> null) et expliquée dans <see cref="ApiUrlError"/>.
|
||||
/// </summary>
|
||||
public string? ApiUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? ApiUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du catalogue des jeux (catalog.json : informations modifiables publiées depuis le menu
|
||||
/// d'administration), ex. « https://launcher.lanisys.fr/catalog/catalog.json ». Facultatif. Comme
|
||||
/// <see cref="ApiUrl"/>, le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? CatalogUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? CatalogUrlError { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
LauncherName = launcherName;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
|
||||
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
|
||||
@@ -202,10 +246,11 @@ public sealed class LauncherCatalog
|
||||
? $@"Local\{Publisher}.{Games[0].GameId}.Launcher"
|
||||
: $@"Local\{Publisher}.{LauncherId}";
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json (même s'il est masqué par le catalogue).</summary>
|
||||
public LauncherConfig? Find(string? gameId) =>
|
||||
string.IsNullOrWhiteSpace(gameId)
|
||||
? null
|
||||
: Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
: DeclaredGames.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static LauncherCatalog Load(string path)
|
||||
{
|
||||
@@ -240,6 +285,10 @@ public sealed class LauncherCatalog
|
||||
new[] { single }, isMultiGameFormat: false)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
|
||||
ApiUrlError = singleApiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
@@ -291,6 +340,10 @@ public sealed class LauncherCatalog
|
||||
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var apiUrlError),
|
||||
ApiUrlError = apiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
@@ -315,6 +368,42 @@ public sealed class LauncherCatalog
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
|
||||
/// <summary>Adresse https absolue, sans paramètres ni identifiants (http autorisé seulement en local) ; null si absente ou invalide.</summary>
|
||||
private static string? ReadTrustedUrl(JsonObject root, string name, string example, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var node = Get(root, name);
|
||||
if (node is null) return null;
|
||||
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = $"launcher.json : {name} ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"{example}\".";
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
private static string? ReadApiUrl(JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var node = Get(root, "apiUrl");
|
||||
if (node is null) return null;
|
||||
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = "launcher.json : apiUrl ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"https://launcher.exemple.fr/api/\".";
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri.EndsWith('/') ? uri.AbsoluteUri : uri.AbsoluteUri + "/";
|
||||
}
|
||||
|
||||
/// <summary>Lecture d'une propriété sans tenir compte de la casse (gameId / GameId…).</summary>
|
||||
private static JsonNode? Get(JsonObject obj, string name)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -47,6 +48,27 @@ public sealed class LauncherConfig
|
||||
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
|
||||
public string? Background { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
|
||||
|
||||
/// <summary>
|
||||
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
|
||||
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>Nom à afficher : celui du catalogue, sinon celui de launcher.json.</summary>
|
||||
[JsonIgnore]
|
||||
public string ShownName => string.IsNullOrWhiteSpace(DisplayName) ? GameName : DisplayName;
|
||||
|
||||
/// <summary>Présentation du jeu (catalogue, texte brut de plusieurs lignes).</summary>
|
||||
[JsonIgnore]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Liens affichés sous la présentation (catalogue).</summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<GameLink> Links { get; set; } = Array.Empty<GameLink>();
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
|
||||
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
|
||||
@@ -127,3 +149,7 @@ public sealed class LauncherConfig
|
||||
part.Trim() == part && part.IndexOfAny(invalid) < 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation d'un jeu (Discord, YouTube…), adresse https contrôlée.</summary>
|
||||
/// <param name="Kind">Type de lien (icône intégrée) : website, discord, youtube, x, twitch, steam, other.</param>
|
||||
public sealed record GameLink(string Kind, string Label, string Url);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
|
||||
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
|
||||
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
|
||||
/// </summary>
|
||||
public static class ChangelogHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
|
||||
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
|
||||
/// </summary>
|
||||
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
|
||||
{
|
||||
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
|
||||
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
|
||||
?? throw new InvalidDataException("manifest vide");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
|
||||
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
|
||||
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
|
||||
/// </summary>
|
||||
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
|
||||
{
|
||||
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
|
||||
if (!entry.IsEmpty)
|
||||
{
|
||||
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
|
||||
list.Insert(0, entry);
|
||||
}
|
||||
return ChangelogLimits.Normalize(list);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
|
||||
public sealed class HttpDownloader : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
|
||||
private const int MaxSameHostRedirects = 3;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
/// <summary>
|
||||
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
|
||||
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
|
||||
/// </summary>
|
||||
private readonly HttpClient _strictHttp;
|
||||
|
||||
public HttpDownloader(string userAgent)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
|
||||
};
|
||||
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
_strictHttp = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
|
||||
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
|
||||
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
|
||||
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var current = uri;
|
||||
HttpResponseMessage response;
|
||||
for (var redirects = 0; ; redirects++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
|
||||
request.Headers.Pragma.ParseAdd("no-cache");
|
||||
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
var status = (int)response.StatusCode;
|
||||
if (status is < 300 or > 399 || status == 304) break;
|
||||
|
||||
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
|
||||
var location = response.Headers.Location;
|
||||
response.Dispose();
|
||||
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
if (next is null || redirects >= MaxSameHostRedirects ||
|
||||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
|
||||
next.Scheme != uri.Scheme || next.Port != uri.Port)
|
||||
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
|
||||
current = next;
|
||||
}
|
||||
|
||||
using var _ = response;
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
|
||||
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
|
||||
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
|
||||
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_strictHttp.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Administration » : la fenêtre d'administration elle-même (connexion, invitation,
|
||||
/// verrouillage) et sa page d'accueil. Les autres pages (Jeux, Notes de version, Publication,
|
||||
/// Administrateurs, Audit) seront déclarées par leurs propres modules.
|
||||
/// </summary>
|
||||
public sealed class AdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "admin";
|
||||
|
||||
public string DisplayName => "Administration";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
DashboardPanelViewModel.PanelId,
|
||||
"Tableau de bord",
|
||||
"Votre compte, la session en cours et l'état du serveur.",
|
||||
"ViewDashboard",
|
||||
RequiredPermission: null,
|
||||
Order: 0,
|
||||
context => new DashboardPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Catalogue des jeux » : page « Jeux » du menu d'administration (informations modifiables
|
||||
/// des jeux déclarés dans launcher.json, publiées dans catalog.json). Permission games.edit.
|
||||
/// </summary>
|
||||
public sealed class CatalogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "catalog";
|
||||
|
||||
public string DisplayName => "Catalogue des jeux";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
GamesPanelViewModel.PanelId,
|
||||
"Jeux",
|
||||
"Ce que le launcher affiche pour chaque jeu : nom, accroche, présentation, liens, apparence, ordre. " +
|
||||
"Les jeux eux-mêmes restent déclarés dans launcher.json.",
|
||||
"Gamepad",
|
||||
AdminPermissions.GamesEdit,
|
||||
Order: 10,
|
||||
context => new GamesPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>Choix d'une liste (null : valeur par défaut de launcher.json).</summary>
|
||||
public sealed record Choice(string? Value, string Label)
|
||||
{
|
||||
/// <summary>Texte affiché par les listes déroulantes.</summary>
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
/// <summary>Partie modifiable de la page « Jeux » (réglages du launcher ou un jeu).</summary>
|
||||
public abstract class CatalogEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
|
||||
protected CatalogEditor(Action changed) => _changed = changed;
|
||||
|
||||
public abstract string Title { get; }
|
||||
public abstract string Subtitle { get; }
|
||||
public abstract string IconKey { get; }
|
||||
|
||||
/// <summary>Modifié depuis la dernière publication (pastille dans la liste).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
internal set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
private bool _isDirty;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
_changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void Changed() => _changed();
|
||||
|
||||
protected static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Réglages du launcher : nom, jeu affiché au premier lancement.</summary>
|
||||
public sealed class LauncherEditor : CatalogEditor
|
||||
{
|
||||
private string _launcherName = "";
|
||||
private Choice _defaultGame;
|
||||
|
||||
public LauncherEditor(string declaredName, string? declaredDefault, IReadOnlyList<LauncherConfig> games, Action changed) : base(changed)
|
||||
{
|
||||
DeclaredName = declaredName;
|
||||
GameChoices = new[] { new Choice(null, declaredDefault is null ? "Par défaut (premier jeu)" : $"Par défaut ({declaredDefault})") }
|
||||
.Concat(games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})"))).ToList();
|
||||
_defaultGame = GameChoices[0];
|
||||
}
|
||||
|
||||
public override string Title => "Launcher";
|
||||
public override string Subtitle => "Nom, jeu affiché au démarrage, ordre des jeux";
|
||||
public override string IconKey => "Tune";
|
||||
|
||||
public string DeclaredName { get; }
|
||||
public string DefaultNameHint => $"Vide : « {DeclaredName} » (launcher.json).";
|
||||
|
||||
public string LauncherName
|
||||
{
|
||||
get => _launcherName;
|
||||
set => Set(ref _launcherName, value ?? "");
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
|
||||
public Choice DefaultGame
|
||||
{
|
||||
get => _defaultGame;
|
||||
set
|
||||
{
|
||||
if (value is not null) Set(ref _defaultGame, value); // null : liste de l'écran en cours de remplacement
|
||||
}
|
||||
}
|
||||
|
||||
internal void Load(CatalogLauncher? launcher)
|
||||
{
|
||||
_launcherName = launcher?.LauncherName ?? "";
|
||||
_defaultGame = GameChoices.FirstOrDefault(c => c.Value is not null &&
|
||||
string.Equals(c.Value, launcher?.DefaultGame, StringComparison.OrdinalIgnoreCase)) ?? GameChoices[0];
|
||||
OnPropertyChanged(nameof(LauncherName));
|
||||
OnPropertyChanged(nameof(DefaultGame));
|
||||
}
|
||||
|
||||
internal CatalogLauncher? ToCatalog(IReadOnlyList<string> order, IReadOnlyList<string> declaredOrder)
|
||||
{
|
||||
var name = Clean(LauncherName);
|
||||
var gameOrder = order.SequenceEqual(declaredOrder, StringComparer.OrdinalIgnoreCase) ? null : order.ToList();
|
||||
return name is null && DefaultGame.Value is null && gameOrder is null
|
||||
? null
|
||||
: new CatalogLauncher { LauncherName = name, DefaultGame = DefaultGame.Value, GameOrder = gameOrder };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Un jeu déclaré dans launcher.json, et ses informations modifiables.</summary>
|
||||
public sealed class GameEditor : CatalogEditor
|
||||
{
|
||||
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
|
||||
private Choice _theme;
|
||||
private bool _hidden;
|
||||
|
||||
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
|
||||
{
|
||||
Declared = declared;
|
||||
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
|
||||
.Concat(themes.Select(t => new Choice(t, t))).ToList();
|
||||
ThemeChoices = _builtInThemeChoices;
|
||||
_theme = ThemeChoices[0];
|
||||
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
|
||||
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
|
||||
Links.CollectionChanged += (_, _) =>
|
||||
{
|
||||
AddLinkCommand.RaiseCanExecuteChanged();
|
||||
Changed();
|
||||
};
|
||||
}
|
||||
|
||||
public LauncherConfig Declared { get; }
|
||||
public string GameId => Declared.GameId;
|
||||
|
||||
public override string Title => string.IsNullOrWhiteSpace(GameName) ? Declared.GameName : GameName.Trim();
|
||||
public override string Subtitle => Hidden ? $"{GameId} · masqué" : GameId;
|
||||
public override string IconKey => Hidden ? "EyeOffOutline" : "Gamepad";
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs (vide : launcher.json)
|
||||
|
||||
public string GameName
|
||||
{
|
||||
get => _gameName;
|
||||
set
|
||||
{
|
||||
if (Set(ref _gameName, value ?? "")) OnPropertyChanged(nameof(Title));
|
||||
}
|
||||
}
|
||||
|
||||
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
|
||||
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
|
||||
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
|
||||
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
|
||||
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
|
||||
|
||||
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
|
||||
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
|
||||
|
||||
private readonly IReadOnlyList<Choice> _builtInThemeChoices;
|
||||
|
||||
/// <summary>Thèmes intégrés (+ celui du catalogue publié s'il est inconnu de ce launcher, pour ne pas l'effacer en silence).</summary>
|
||||
public IReadOnlyList<Choice> ThemeChoices { get; private set; }
|
||||
|
||||
public Choice Theme
|
||||
{
|
||||
get => _theme;
|
||||
set
|
||||
{
|
||||
// null : liste des thèmes remplacée à l'écran (rechargement), pas un choix de l'administrateur.
|
||||
if (value is not null) Set(ref _theme, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Hidden
|
||||
{
|
||||
get => _hidden;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _hidden, value)) return;
|
||||
OnPropertyChanged(nameof(Subtitle));
|
||||
OnPropertyChanged(nameof(IconKey));
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<LinkEditor> Links { get; } = new();
|
||||
public IReadOnlyList<Choice> LinkKinds { get; }
|
||||
public RelayCommand AddLinkCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
|
||||
|
||||
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
|
||||
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
|
||||
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
|
||||
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
|
||||
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
|
||||
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
|
||||
|
||||
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
|
||||
|
||||
private void AddLink()
|
||||
{
|
||||
if (Links.Count >= CatalogRules.MaxLinks) return;
|
||||
Links.Add(new LinkEditor(LinkKinds, CatalogLinkKinds.Website, "", "", Changed, RemoveLink));
|
||||
}
|
||||
|
||||
private void RemoveLink(LinkEditor link) => Links.Remove(link);
|
||||
|
||||
internal void Load(CatalogGame? game)
|
||||
{
|
||||
_gameName = game?.GameName ?? "";
|
||||
_tagline = game?.Tagline ?? "";
|
||||
_description = game?.Description ?? "";
|
||||
_websiteUrl = game?.WebsiteUrl ?? "";
|
||||
_icon = game?.Icon ?? "";
|
||||
_background = game?.Background ?? "";
|
||||
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
_hidden = game?.Hidden == true;
|
||||
ThemeChoices = _builtInThemeChoices;
|
||||
_theme = ThemeChoices.FirstOrDefault(c => c.Value is not null && string.Equals(c.Value, game?.Theme, StringComparison.OrdinalIgnoreCase))
|
||||
?? ThemeChoices[0];
|
||||
if (_theme.Value is null && game?.Theme is { } unknown)
|
||||
{
|
||||
// Publié par une autre version du launcher : conservé (les launchers qui ne le connaissent pas l'ignorent).
|
||||
_theme = new Choice(unknown, $"{unknown} (inconnu de ce launcher)");
|
||||
ThemeChoices = _builtInThemeChoices.Append(_theme).ToList();
|
||||
}
|
||||
Links.Clear();
|
||||
foreach (var link in game?.Links ?? new List<CatalogLink>())
|
||||
Links.Add(new LinkEditor(LinkKinds, link.Kind, link.Label ?? "", link.Url, Changed, RemoveLink));
|
||||
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
|
||||
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
|
||||
internal CatalogGame? ToCatalog(List<string> errors)
|
||||
{
|
||||
int? interval = null;
|
||||
if (!string.IsNullOrWhiteSpace(UpdateInterval))
|
||||
{
|
||||
if (int.TryParse(UpdateInterval.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) interval = minutes;
|
||||
else errors.Add($"Jeu « {GameId} » : intervalle de mise à jour : nombre de minutes attendu.");
|
||||
}
|
||||
var links = Links.Where(l => !l.IsEmpty)
|
||||
.Select(l => new CatalogLink { Kind = l.Kind.Value ?? CatalogLinkKinds.Other, Label = Clean(l.Label), Url = l.Url.Trim() })
|
||||
.ToList();
|
||||
var game = new CatalogGame
|
||||
{
|
||||
GameName = Clean(GameName),
|
||||
Tagline = Clean(Tagline),
|
||||
Description = Clean(Description),
|
||||
WebsiteUrl = Clean(WebsiteUrl),
|
||||
Links = links.Count == 0 ? null : links,
|
||||
Theme = Theme.Value,
|
||||
Icon = Clean(Icon),
|
||||
Background = Clean(Background),
|
||||
UpdateCheckIntervalMinutes = interval,
|
||||
Hidden = Hidden ? true : null
|
||||
};
|
||||
return game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
|
||||
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null }
|
||||
? null
|
||||
: game;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien d'un jeu (Discord, YouTube…).</summary>
|
||||
public sealed class LinkEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private Choice _kind;
|
||||
private string _label, _url;
|
||||
|
||||
public LinkEditor(IReadOnlyList<Choice> kinds, string kind, string label, string url, Action changed, Action<LinkEditor> remove)
|
||||
{
|
||||
Kinds = kinds;
|
||||
_kind = kinds.FirstOrDefault(k => k.Value == kind) ?? kinds[^1];
|
||||
_label = label;
|
||||
_url = url;
|
||||
_changed = changed;
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> Kinds { get; }
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _kind, value)) _changed(); // null : liste de l'écran en cours de remplacement
|
||||
}
|
||||
}
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _label, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Url
|
||||
{
|
||||
get => _url;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _url, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Jeux » : informations modifiables des jeux déclarés dans launcher.json, publiées dans le
|
||||
/// catalogue (catalog.json) que les launchers des joueurs lisent au démarrage.
|
||||
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
|
||||
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
|
||||
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
|
||||
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
|
||||
/// </summary>
|
||||
public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "catalog.games";
|
||||
|
||||
private CatalogState? _state;
|
||||
private CatalogEditor? _selected;
|
||||
private string? _statusMessage;
|
||||
private bool _loading;
|
||||
private string _publishedSnapshot = "";
|
||||
private bool _hasChanges;
|
||||
private bool _hasConflict;
|
||||
|
||||
public GamesPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
|
||||
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
|
||||
RebuildItems(Launcher);
|
||||
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
|
||||
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
|
||||
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
|
||||
}
|
||||
|
||||
public LauncherEditor Launcher { get; }
|
||||
|
||||
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
|
||||
public ObservableCollection<GameEditor> Games { get; } = new();
|
||||
|
||||
/// <summary>Liste affichée : « Launcher », puis les jeux.</summary>
|
||||
public ObservableCollection<CatalogEditor> Items { get; } = new();
|
||||
|
||||
public CatalogEditor? Selected
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selected, value)) return;
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _state?.PublishingEnabled == true;
|
||||
public bool ShowPublishingDisabled => _state is not null && !_state.PublishingEnabled;
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
/// <summary>« Révision 12, publiée le 31/10/2026 à 08:42 » / « Jamais publié ».</summary>
|
||||
public string RevisionText => _state?.Catalog is not { } catalog ? "" :
|
||||
catalog.Revision == 0 ? "Jamais publié : le launcher utilise les valeurs de launcher.json."
|
||||
: $"Révision {catalog.Revision}" + (catalog.UpdatedAt is { } at ? $", publiée le {at.ToLocalTime():dd/MM/yyyy à HH:mm}" : "") + ".";
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _hasChanges, value)) return;
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Quelqu'un a publié entre-temps : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set => SetProperty(ref _hasConflict, value);
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
|
||||
/// <summary>Faux pendant un chargement ou une publication : la saisie attend la réponse du serveur.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
var state = await Context.Session.GetCatalogAsync();
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les modifications non publiées seront perdues : la page affichera le catalogue actuellement publié.",
|
||||
"Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Affiche un catalogue (chargé ou publié) et en fait la référence « sans modification ».</summary>
|
||||
private void Apply(CatalogState state)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_state = state;
|
||||
var catalog = state.Catalog;
|
||||
Launcher.Load(catalog.Launcher);
|
||||
foreach (var game in Games) game.Load(catalog.Games.TryGetValue(game.GameId, out var entry) ? entry : null);
|
||||
|
||||
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
|
||||
var ordered = new List<GameEditor>();
|
||||
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
|
||||
if (Games.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
|
||||
ordered.Add(game);
|
||||
ordered.AddRange(Context.Games.Select(d => Games.First(g => g.Declared == d)).Where(g => !ordered.Contains(g)));
|
||||
var selected = Selected;
|
||||
Games.Clear();
|
||||
foreach (var game in ordered) Games.Add(game);
|
||||
RebuildItems(selected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
_publishedSnapshot = Snapshot(BuildCatalog(new List<string>()));
|
||||
HasConflict = false;
|
||||
RefreshDirty();
|
||||
foreach (var name in new[] { nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(PublicUrl), nameof(RevisionText) })
|
||||
OnPropertyChanged(name);
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication
|
||||
|
||||
private Task PublishAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
var errors = new List<string>();
|
||||
var draft = BuildCatalog(errors);
|
||||
var imageHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var url) ? url.Host : null;
|
||||
var normalized = CatalogRules.Normalize(draft, imageHost, errors);
|
||||
if (errors.Count == 0 && CatalogRules.PublishedSize(normalized) > CatalogRules.MaxFileBytes)
|
||||
errors.Add($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko) : raccourcissez les descriptions.");
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
|
||||
Apply(published);
|
||||
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
|
||||
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
Context.Dialogs.ShowMessage("Publié entre-temps",
|
||||
"Quelqu'un a publié le catalogue pendant vos modifications. Rechargez la page pour partir de sa version " +
|
||||
"(vos modifications non publiées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
|
||||
private CatalogDocument BuildCatalog(List<string> errors)
|
||||
{
|
||||
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var game in Games)
|
||||
if (game.ToCatalog(errors) is { } entry) games[game.GameId] = entry;
|
||||
return new CatalogDocument
|
||||
{
|
||||
Launcher = Launcher.ToCatalog(Games.Select(g => g.GameId).ToList(), Context.Games.Select(g => g.GameId).ToList()),
|
||||
Games = games
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
private void OnEdited()
|
||||
{
|
||||
if (_loading) return;
|
||||
RefreshDirty();
|
||||
}
|
||||
|
||||
private void RefreshDirty()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var current = BuildCatalog(errors);
|
||||
HasChanges = Snapshot(current) != _publishedSnapshot || errors.Count > 0;
|
||||
|
||||
var published = string.IsNullOrEmpty(_publishedSnapshot) ? null : JsonSerializer.Deserialize<CatalogDocument>(_publishedSnapshot, ApiJson.Options);
|
||||
Launcher.IsDirty = Snapshot(current.Launcher) != Snapshot(published?.Launcher);
|
||||
foreach (var game in Games)
|
||||
{
|
||||
current.Games.TryGetValue(game.GameId, out var now);
|
||||
CatalogGame? before = null;
|
||||
published?.Games.TryGetValue(game.GameId, out before);
|
||||
game.IsDirty = Snapshot(now) != Snapshot(before);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanMove(int delta) =>
|
||||
Selected is GameEditor game && Games.IndexOf(game) is var index and >= 0 && index + delta >= 0 && index + delta < Games.Count;
|
||||
|
||||
private void Move(int delta)
|
||||
{
|
||||
if (Selected is not GameEditor game || !CanMove(delta)) return;
|
||||
var index = Games.IndexOf(game);
|
||||
Games.Move(index, index + delta);
|
||||
RebuildItems(game);
|
||||
OnEdited();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstruit la liste affichée puis resélectionne <paramref name="select"/> : la liste de l'écran
|
||||
/// vide sa sélection quand ses éléments sont retirés, la notification la rétablit.
|
||||
/// </summary>
|
||||
private void RebuildItems(CatalogEditor? select)
|
||||
{
|
||||
Items.Clear();
|
||||
Items.Add(Launcher);
|
||||
foreach (var game in Games) Items.Add(game);
|
||||
_selected = select is not null && Items.Contains(select) ? select : Items.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(Selected));
|
||||
MoveUpCommand?.RaiseCanExecuteChanged();
|
||||
MoveDownCommand?.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Snapshot(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
|
||||
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "changelog";
|
||||
|
||||
public string DisplayName => "Notes de version";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
ChangelogPanelViewModel.PanelId,
|
||||
"Notes de version",
|
||||
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
|
||||
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
|
||||
"FileDocument",
|
||||
AdminPermissions.ChangelogEdit,
|
||||
Order: 20,
|
||||
context => new ChangelogPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
|
||||
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
|
||||
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
|
||||
/// </summary>
|
||||
public sealed class VersionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly Func<string, string, bool> _confirm;
|
||||
private string _title = "", _subtitle = "", _introduction = "";
|
||||
private string _snapshot = "";
|
||||
private bool _isDirty, _showPreview, _loading;
|
||||
private ChangelogEntry _preview;
|
||||
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
|
||||
|
||||
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
|
||||
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
|
||||
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
|
||||
{
|
||||
Version = published.Version;
|
||||
Date = published.Date;
|
||||
_confirm = confirm;
|
||||
_changed = changed;
|
||||
_preview = new ChangelogEntry { Version = Version, Date = Date };
|
||||
Sections.CollectionChanged += OnSectionsChanged;
|
||||
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
|
||||
Load(published);
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
public DateTimeOffset Date { get; }
|
||||
public string DateText => Date == default ? "" : _preview.DateText;
|
||||
|
||||
// ------------------------------------------------------------------ En-tête
|
||||
|
||||
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
|
||||
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
|
||||
|
||||
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
|
||||
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
|
||||
|
||||
public ObservableCollection<SectionEditor> Sections { get; } = new();
|
||||
public RelayCommand AddSectionCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
private set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
|
||||
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
|
||||
public bool ShowPreview
|
||||
{
|
||||
get => _showPreview;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showPreview, value)) return;
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(IsPreviewMode));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
|
||||
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
|
||||
|
||||
/// <summary>Notes telles que le launcher les affichera.</summary>
|
||||
public ChangelogEntry Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set => SetProperty(ref _preview, value);
|
||||
}
|
||||
|
||||
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
|
||||
public IReadOnlyList<string> LimitErrors
|
||||
{
|
||||
get => _limitErrors;
|
||||
private set
|
||||
{
|
||||
_limitErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasLimitErrors));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasLimitErrors => _limitErrors.Count > 0;
|
||||
|
||||
/// <summary>« 3 sections · 14 éléments ».</summary>
|
||||
public string CountText
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = Sections.Sum(s => s.Items.Count);
|
||||
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
|
||||
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement, conversion
|
||||
|
||||
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
|
||||
public void Load(ChangelogEntry entry)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_title = entry.Title ?? "";
|
||||
_subtitle = entry.Subtitle ?? "";
|
||||
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
|
||||
Sections.Clear();
|
||||
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
|
||||
var sections = entry.Sections.Count > 0
|
||||
? entry.Sections
|
||||
: (entry.Changes ?? new List<string>())
|
||||
.Chunk(ChangelogLimits.MaxItemsPerSection)
|
||||
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
|
||||
.ToList();
|
||||
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
|
||||
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
_snapshot = Snapshot(Normalized(ToEntry()));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
|
||||
public ChangelogEntry ToEntry()
|
||||
{
|
||||
var entry = new ChangelogEntry
|
||||
{
|
||||
Version = Version,
|
||||
Date = Date,
|
||||
Title = Clean(Title),
|
||||
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
|
||||
Introduction = Paragraphs(Introduction),
|
||||
Sections = Sections.Select(s => s.ToSection()).ToList()
|
||||
};
|
||||
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
|
||||
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
if (_loading) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var entry = ToEntry();
|
||||
var preview = Normalized(entry);
|
||||
var errors = ChangelogLimits.Check(entry);
|
||||
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
|
||||
LimitErrors = errors;
|
||||
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
|
||||
IsDirty = Snapshot(preview) != _snapshot;
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddSectionCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddSection()
|
||||
{
|
||||
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
|
||||
var section = new SectionEditor(new ChangelogSection(), this);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
Sections.Add(section);
|
||||
}
|
||||
|
||||
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
|
||||
|
||||
internal void RemoveSection(SectionEditor section)
|
||||
{
|
||||
if (section.Items.Any(i => !i.IsEmpty) &&
|
||||
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
|
||||
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
|
||||
return;
|
||||
Sections.Remove(section);
|
||||
}
|
||||
|
||||
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
foreach (var section in Sections) section.RefreshMoves();
|
||||
Changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
|
||||
|
||||
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
|
||||
|
||||
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
|
||||
private static List<string> Paragraphs(string text) =>
|
||||
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
|
||||
.Select(p => Clean(p.Replace('\n', ' ')))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
/// <summary>Une ligne par élément (sous-points).</summary>
|
||||
internal static List<string> Lines(string text) =>
|
||||
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
|
||||
public sealed class SectionEditor : ObservableObject
|
||||
{
|
||||
private readonly VersionEditor _version;
|
||||
private string _title;
|
||||
|
||||
public SectionEditor(ChangelogSection section, VersionEditor version)
|
||||
{
|
||||
_version = version;
|
||||
_title = section.Title ?? "";
|
||||
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
|
||||
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
|
||||
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
|
||||
Items.CollectionChanged += (_, _) =>
|
||||
{
|
||||
foreach (var item in Items) item.RefreshMoves();
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddChangeCommand.RaiseCanExecuteChanged();
|
||||
AddCommentCommand.RaiseCanExecuteChanged();
|
||||
_version.Changed();
|
||||
};
|
||||
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
|
||||
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
|
||||
}
|
||||
|
||||
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _title, value ?? "")) _version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ItemEditor> Items { get; } = new();
|
||||
|
||||
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
|
||||
|
||||
public RelayCommand AddChangeCommand { get; }
|
||||
public RelayCommand AddCommentCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal VersionEditor Version => _version;
|
||||
|
||||
internal ChangelogSection ToSection() => new()
|
||||
{
|
||||
Title = VersionEditor.Clean(Title),
|
||||
Items = Items.Select(i => i.ToItem()).ToList()
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
|
||||
|
||||
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
|
||||
|
||||
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
|
||||
|
||||
private void Add(bool comment)
|
||||
{
|
||||
if (!CanAdd()) return;
|
||||
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
|
||||
public sealed class ItemEditor : ObservableObject
|
||||
{
|
||||
public static readonly IReadOnlyList<Choice> Kinds = new[]
|
||||
{
|
||||
new Choice(ChangelogItem.ChangeType, "Modification"),
|
||||
new Choice(ChangelogItem.CommentType, "Commentaire")
|
||||
};
|
||||
|
||||
private readonly SectionEditor _section;
|
||||
private Choice _kind;
|
||||
private string _text, _tag, _details, _note;
|
||||
|
||||
public ItemEditor(ChangelogItem item, SectionEditor section)
|
||||
{
|
||||
_section = section;
|
||||
_kind = item.IsComment ? Kinds[1] : Kinds[0];
|
||||
_text = item.Text ?? "";
|
||||
_tag = item.Tag ?? "";
|
||||
_details = string.Join("\n", item.Details ?? new List<string>());
|
||||
_note = item.Note ?? "";
|
||||
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
|
||||
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> KindChoices => Kinds;
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
|
||||
if (value is null || !SetProperty(ref _kind, value)) return;
|
||||
OnPropertyChanged(nameof(IsComment));
|
||||
OnPropertyChanged(nameof(IsChange));
|
||||
_section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
|
||||
public bool IsChange => !IsComment;
|
||||
|
||||
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
|
||||
|
||||
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
|
||||
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
|
||||
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
|
||||
|
||||
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
|
||||
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
|
||||
|
||||
internal ChangelogItem ToItem() => IsComment
|
||||
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
|
||||
: new ChangelogItem
|
||||
{
|
||||
Text = Text.Trim(),
|
||||
Tag = VersionEditor.Clean(Tag),
|
||||
Details = VersionEditor.Lines(Details),
|
||||
Note = VersionEditor.Clean(Note)
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (SetProperty(ref field, value, name)) _section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ListMoves
|
||||
{
|
||||
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
|
||||
{
|
||||
var index = list.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= list.Count) return;
|
||||
list.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
|
||||
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
|
||||
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
|
||||
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
|
||||
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
|
||||
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
|
||||
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
|
||||
/// </summary>
|
||||
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "changelog.notes";
|
||||
|
||||
private ChangelogState? _state;
|
||||
private Choice? _selectedGame;
|
||||
private VersionEditor? _selectedVersion;
|
||||
private bool _hasChanges, _hasConflict;
|
||||
private string? _statusMessage;
|
||||
private int _skippedVersions;
|
||||
|
||||
public ChangelogPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
|
||||
_ = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Versions
|
||||
|
||||
public ObservableCollection<VersionEditor> Versions { get; } = new();
|
||||
|
||||
public VersionEditor? SelectedVersion
|
||||
{
|
||||
get => _selectedVersion;
|
||||
set
|
||||
{
|
||||
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
|
||||
if (value is not null) SetProperty(ref _selectedVersion, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasVersions => Versions.Count > 0;
|
||||
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
|
||||
|
||||
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
|
||||
public string ManifestText => _state is null ? "" :
|
||||
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
|
||||
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void Apply(ChangelogState state)
|
||||
{
|
||||
var selected = _selectedVersion?.Version;
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
Versions.Clear();
|
||||
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
|
||||
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
|
||||
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
|
||||
_skippedVersions = state.Entries.Count - editable.Count;
|
||||
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
|
||||
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Enregistrement
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is null || _selectedGame?.Value is not { } gameId) return;
|
||||
var dirty = Versions.Where(v => v.IsDirty).ToList();
|
||||
|
||||
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var entry = version.ToEntry();
|
||||
var errors = version.LimitErrors; // limites + notes vides
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
|
||||
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
|
||||
if (size > ChangelogLimits.MaxEditRequestBytes)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
|
||||
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
|
||||
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var saved = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
|
||||
_state = state;
|
||||
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
|
||||
saved.Add(version.Version);
|
||||
}
|
||||
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
|
||||
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
|
||||
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
|
||||
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
|
||||
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
|
||||
ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
_state = null;
|
||||
HasConflict = false;
|
||||
StatusMessage = null;
|
||||
Versions.Clear();
|
||||
_selectedVersion = null;
|
||||
_skippedVersions = 0;
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
|
||||
|
||||
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Module « Administration » du launcher (sans WPF) : clés de l'administrateur, connexion,
|
||||
invitation, verrouillage de session, tableau de bord. Les écrans correspondants sont dans le
|
||||
launcher (Views\Admin, Themes\Admin.xaml). Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules.Admin</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules.Admin</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion ouverte : session + clé déverrouillée (en mémoire, ou ouverte dans la puce TPM). La fermer
|
||||
/// déconnecte la session sur le serveur et libère la clé : il faudra de nouveau la phrase de passe (ou le PIN).
|
||||
/// </summary>
|
||||
public sealed class AdminConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly IDisposable _key;
|
||||
private int _disposed;
|
||||
|
||||
/// <param name="key">Ce qui retient la clé (clé en mémoire, poignée de la clé TPM) : libéré à la fermeture.</param>
|
||||
internal AdminConnection(LauncherApiClient client, AdminSession session, IAdminSigner signer, IDisposable key, string keyPath,
|
||||
bool isTpm = false)
|
||||
{
|
||||
Client = client;
|
||||
Session = session;
|
||||
_signer = signer;
|
||||
_key = key;
|
||||
KeyPath = keyPath;
|
||||
IsTpm = isTpm;
|
||||
}
|
||||
|
||||
public LauncherApiClient Client { get; }
|
||||
public AdminSession Session { get; }
|
||||
public string KeyPath { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
/// <summary>Clé conservée dans la puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool IsTpm { get; }
|
||||
|
||||
/// <summary>Fin de session (durée maximale), à l'heure du PC.</summary>
|
||||
public DateTimeOffset LocalExpiresAt => Session.ExpiresAt - Session.ClockOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Libère d'abord la clé (la déconnexion n'est pas signée : elle n'en a pas besoin), puis ferme la
|
||||
/// session sur le serveur — au mieux, sans bloquer : injoignable, elle expirera d'elle-même.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_key.Dispose();
|
||||
using var timeout = new CancellationTokenSource(LogoutTimeout);
|
||||
try
|
||||
{
|
||||
await Session.LogoutAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : déconnexion du serveur impossible ({ex.GetType().Name}) ; la session expirera d'elle-même.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la création d'une clé (et de son enregistrement auprès du serveur avec une invitation).</summary>
|
||||
/// <param name="Enrollment">Réponse du serveur ; null si aucune invitation n'a été utilisée (clé seulement) ou sans réponse.</param>
|
||||
/// <param name="EnrollmentUnconfirmed">
|
||||
/// Invitation envoyée mais réponse du serveur perdue (délai dépassé, coupure, erreur 5xx) : le serveur a
|
||||
/// peut-être enregistré la clé et consommé l'invitation. La clé est donc CONSERVÉE (message expliquant quoi faire).
|
||||
/// </param>
|
||||
/// <param name="IsTpm">Clé créée dans la puce de sécurité (TPM) de ce PC ; KeyPath est alors son fichier de référence.</param>
|
||||
public sealed record AdminKeyCreation(string KeyPath, string PublicKeyPath, string KeyId, string Fingerprint,
|
||||
EnrollmentResponse? Enrollment, string? EnrollmentUnconfirmed = null, bool IsTpm = false);
|
||||
|
||||
/// <summary>Clé fichier en mémoire, vue comme une clé « libérable » (même contrat que la clé TPM).</summary>
|
||||
internal sealed class FileKeySigner : IDisposableAdminSigner
|
||||
{
|
||||
private readonly EcdsaAdminSigner _inner;
|
||||
|
||||
public FileKeySigner(EcdsaAdminSigner inner) => _inner = inner;
|
||||
|
||||
public byte[] PublicKey => _inner.PublicKey;
|
||||
public string KeyId => _inner.KeyId;
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _inner.SignAsync(message, ct);
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Création d'une clé d'administrateur sur ce PC — fichier chiffré par une phrase de passe, ou clé
|
||||
/// non exportable dans la puce TPM protégée par un code PIN — et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur (preuve de possession). Si le serveur REFUSE (erreur 4xx), la clé
|
||||
/// est supprimée (fichiers, et clé de la puce) ; sans réponse sûre, elle est conservée.
|
||||
/// </summary>
|
||||
public sealed class AdminEnrollmentService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminEnrollmentService(LauncherApiClient client, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou, avec <paramref name="useTpm"/>, fichier de référence .tpmkey.</param>
|
||||
/// <param name="secret">Phrase de passe du fichier (12 caractères au moins) ou code PIN de la clé TPM (6 au moins).</param>
|
||||
/// <param name="useTpm">Vrai : clé créée dans la puce de sécurité (TPM) de ce PC.</param>
|
||||
public async Task<AdminKeyCreation> CreateKeyAsync(string keyPath, string secret, string? invitationCode, string? keyLabel,
|
||||
CancellationToken ct = default, bool useTpm = false)
|
||||
{
|
||||
var minLength = useTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
if (secret.Length < minLength)
|
||||
throw new ArgumentException(useTpm
|
||||
? $"Le code PIN doit contenir au moins {minLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {minLength} caractères.");
|
||||
string? code = null;
|
||||
if (!string.IsNullOrWhiteSpace(invitationCode))
|
||||
code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ArgumentException("Code d'invitation mal saisi : 32 caractères (lettres A à Z, chiffres 2 à 7), tirets facultatifs.");
|
||||
var label = string.IsNullOrWhiteSpace(keyLabel) ? null : ApiText.CleanLine(keyLabel, ApiText.MaxKeyLabelLength)
|
||||
?? throw new ArgumentException($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus, sans caractère spécial invisible).");
|
||||
|
||||
var fullPath = Path.GetFullPath(keyPath);
|
||||
if (AdminKeyLocator.IsReserved(fullPath))
|
||||
throw new InvalidOperationException($"« {AdminKeyLocator.ReservedFileName} » est réservé à la clé de signature du launcher.");
|
||||
var extension = useTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
if (!fullPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Le fichier de clé doit avoir l'extension {extension}.");
|
||||
var publicPath = Path.ChangeExtension(fullPath, AdminKeyFile.PublicKeyExtension);
|
||||
// Les deux types de clés partagent le même .pub : aucun des trois fichiers ne doit exister.
|
||||
var otherPath = Path.ChangeExtension(fullPath, useTpm ? AdminKeyFile.PrivateKeyExtension : AdminTpmKeyFile.Extension);
|
||||
if (File.Exists(fullPath) || File.Exists(publicPath) || File.Exists(otherPath))
|
||||
throw new IOException($"Une clé existe déjà à cet emplacement : {Path.ChangeExtension(fullPath, null)}. Choisissez un autre nom.");
|
||||
var tpm = useTpm ? _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.") : null;
|
||||
|
||||
// Génération (et chiffrement, ou accès à la puce : lents) hors du fil de l'interface.
|
||||
string? tpmKeyName = null;
|
||||
IDisposableAdminSigner signer;
|
||||
if (tpm is not null)
|
||||
{
|
||||
var keyName = AdminTpmKeyFile.NewKeyName();
|
||||
signer = await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
var created = tpm.CreateKey(keyName, secret);
|
||||
try
|
||||
{
|
||||
AdminTpmKeyFile.Save(fullPath, keyName, created.PublicKey);
|
||||
return created;
|
||||
}
|
||||
catch
|
||||
{
|
||||
created.Dispose();
|
||||
DeleteTpmKey(tpm, keyName);
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
tpmKeyName = keyName;
|
||||
}
|
||||
else
|
||||
{
|
||||
signer = await BackgroundWork.RunAsync<IDisposableAdminSigner>(() =>
|
||||
{
|
||||
var key = AdminKeyFile.Generate();
|
||||
try
|
||||
{
|
||||
AdminKeyFile.Save(key, fullPath, secret);
|
||||
return new FileKeySigner(new EcdsaAdminSigner(key));
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnrollmentResponse? enrollment = null;
|
||||
string? unconfirmed = null;
|
||||
if (code is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
enrollment = await _client.EnrollAsync(code, signer, label, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsDefinitiveRefusal(ex) || ex is CryptographicException or ObjectDisposedException)
|
||||
{
|
||||
// Refus certain (invitation invalide, expirée, déjà utilisée…), ou signature de la preuve
|
||||
// impossible (puce bloquée…) avant tout envoi : rien n'a été enregistré.
|
||||
signer.Dispose();
|
||||
if (tpmKeyName is not null)
|
||||
{
|
||||
AdminTpmKeyFile.DeleteFiles(fullPath);
|
||||
DeleteTpmKey(tpm!, tpmKeyName);
|
||||
}
|
||||
else
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
TryDelete(publicPath);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or OperationCanceledException or HttpRequestException or IOException)
|
||||
{
|
||||
unconfirmed = $"Le serveur n'a pas confirmé l'enregistrement ({ApiErrorText.Describe(ex)}). " +
|
||||
"Votre clé est conservée : communiquez son empreinte à la personne qui vous a invité. " +
|
||||
"Si elle ne voit aucune clé en attente à votre nom, demandez-lui une nouvelle invitation.";
|
||||
}
|
||||
}
|
||||
var publicKey = signer.PublicKey;
|
||||
return new AdminKeyCreation(fullPath, publicPath, AdminKeys.ComputeKeyId(publicKey), AdminKeys.ComputeFingerprint(publicKey),
|
||||
enrollment, unconfirmed, IsTpm: tpmKeyName is not null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
signer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteTpmKey(IAdminTpm tpm, string keyName)
|
||||
{
|
||||
try
|
||||
{
|
||||
tpm.DeleteKey(keyName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Core.Utils.Log.Warn($"Administration : clé {keyName} non supprimée de la puce ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le serveur a répondu par un refus (4xx), ou la requête n'a pas pu partir (horloge) : rien d'enregistré.</summary>
|
||||
private static bool IsDefinitiveRefusal(Exception ex) => ex is ApiException api &&
|
||||
((api.Status is >= 400 and < 500 && api.Code != "invalid_response") || api.Code == "clock_skew");
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans danger (clé chiffrée, jamais enregistrée sur le serveur)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé d'administrateur trouvée sur ce PC.</summary>
|
||||
/// <param name="Path">Chemin complet du fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="Fingerprint">Empreinte (null si inconnue : fichier .pub absent).</param>
|
||||
/// <param name="IsTpm">Clé conservée dans la puce de sécurité (TPM) de ce PC : déverrouillée par un code PIN.</param>
|
||||
public sealed record AdminKeyEntry(string Path, string? KeyId, string? Fingerprint, bool IsTpm = false)
|
||||
{
|
||||
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
|
||||
public string Detail => Fingerprint is null ? "Empreinte inconnue (fichier .pub absent)" : $"Empreinte {Fingerprint}";
|
||||
|
||||
/// <summary>Libellé du secret demandé à la connexion.</summary>
|
||||
public string SecretLabel => IsTpm ? "Code PIN de la clé (puce TPM)" : "Phrase de passe de la clé";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recherche des clés d'administrateur : dossier %USERPROFILE%\.launcher-keys (celui de
|
||||
/// tools\admin-key.bat) et fichiers déjà utilisés. La clé de SIGNATURE DU LAUNCHER, rangée dans le
|
||||
/// même dossier, n'est jamais proposée : elle sert à autre chose et ne doit pas circuler.
|
||||
/// </summary>
|
||||
public static class AdminKeyLocator
|
||||
{
|
||||
/// <summary>Clé de signature des mises à jour du launcher (tools\keygen-launcher.bat).</summary>
|
||||
public const string ReservedFileName = "launcher-signing.key";
|
||||
|
||||
private const long MaxKeyFileBytes = 16 * 1024;
|
||||
|
||||
public static string DefaultDirectory =>
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys");
|
||||
|
||||
/// <summary>
|
||||
/// Clé de signature du launcher, copies comprises (« launcher-signing - Copie.key »…) : ne doit jamais
|
||||
/// être déverrouillée par le menu d'administration. Chemin normalisé (« launcher-signing.key. »…).
|
||||
/// </summary>
|
||||
public static bool IsReserved(string path)
|
||||
{
|
||||
string name;
|
||||
try
|
||||
{
|
||||
name = System.IO.Path.GetFileName(System.IO.Path.GetFullPath(path)).TrimEnd('.', ' ');
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return true; // chemin invalide : refusé
|
||||
}
|
||||
return name.StartsWith(System.IO.Path.GetFileNameWithoutExtension(ReservedFileName), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Clés du dossier <paramref name="directory"/> puis des chemins mémorisés (sans doublon).</summary>
|
||||
public static IReadOnlyList<AdminKeyEntry> Find(string directory, IEnumerable<string>? rememberedPaths = null)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
paths.AddRange(Directory.EnumerateFiles(directory, "*" + AdminKeyFile.PrivateKeyExtension, SearchOption.TopDirectoryOnly)
|
||||
.Concat(Directory.EnumerateFiles(directory, "*" + AdminTpmKeyFile.Extension, SearchOption.TopDirectoryOnly))
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// dossier illisible : seulement les chemins mémorisés
|
||||
}
|
||||
paths.AddRange(rememberedPaths ?? Enumerable.Empty<string>());
|
||||
|
||||
var result = new List<AdminKeyEntry>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
// Describe normalise le chemin et ignore les chemins invalides (admin.json modifié à la main…).
|
||||
if (Describe(path) is not { } entry) continue;
|
||||
if (result.Any(e => string.Equals(e.Path, entry.Path, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
result.Add(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Décrit un fichier de clé chiffré ; null s'il n'en est pas un (ou s'il est réservé).</summary>
|
||||
public static AdminKeyEntry? Describe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = System.IO.Path.GetFullPath(path);
|
||||
var info = new FileInfo(full);
|
||||
if (!info.Exists || info.Length > MaxKeyFileBytes || IsReserved(full)) return null;
|
||||
|
||||
// Référence d'une clé TPM : empreinte calculée depuis la clé publique qu'elle contient
|
||||
// (comparée à celle de la puce à la connexion).
|
||||
if (AdminTpmKeyFile.IsTpmKeyPath(full))
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(full);
|
||||
return new AdminKeyEntry(full, AdminKeys.ComputeKeyId(reference.PublicKey),
|
||||
AdminKeys.ComputeFingerprint(reference.PublicKey), IsTpm: true);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clé fichier : reconnue à son contenu (extension .key conseillée, mais pas exigée : admin-key.bat --out).
|
||||
if (!File.ReadAllText(full).Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal)) return null;
|
||||
|
||||
string? keyId = null, fingerprint = null;
|
||||
var publicPath = System.IO.Path.ChangeExtension(full, AdminKeyFile.PublicKeyExtension);
|
||||
if (File.Exists(publicPath) && new FileInfo(publicPath).Length <= MaxKeyFileBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
|
||||
keyId = AdminKeys.ComputeKeyId(spki);
|
||||
fingerprint = AdminKeys.ComputeFingerprint(spki);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// .pub illisible : empreinte inconnue (la clé reste utilisable)
|
||||
}
|
||||
}
|
||||
return new AdminKeyEntry(full, keyId, fingerprint);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emplacement libre pour une nouvelle clé : admin, sinon admin-2, admin-3… Le nom est libre pour
|
||||
/// les deux types de clés (.key, .tpmkey) et pour le .pub qu'ils partageraient.
|
||||
/// </summary>
|
||||
/// <param name="tpm">Vrai : chemin d'un fichier de référence de clé TPM (.tpmkey).</param>
|
||||
public static string SuggestNewKeyPath(string directory, bool tpm = false)
|
||||
{
|
||||
var extension = tpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
for (var i = 1; i < 1000; i++)
|
||||
{
|
||||
var name = System.IO.Path.Combine(directory, i == 1 ? "admin" : $"admin-{i}");
|
||||
if (!File.Exists(name + AdminKeyFile.PrivateKeyExtension) && !File.Exists(name + AdminTpmKeyFile.Extension) &&
|
||||
!File.Exists(name + AdminKeyFile.PublicKeyExtension))
|
||||
return name + extension;
|
||||
}
|
||||
return System.IO.Path.Combine(directory, $"admin-{Guid.NewGuid():N}{extension}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : ouvre la clé (fichier chiffré déverrouillé en arrière-plan, ou clé de la puce TPM avec
|
||||
/// son code PIN), puis défi signé auprès du serveur.
|
||||
/// </summary>
|
||||
public sealed class AdminLoginService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly string _clientName;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
|
||||
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_clientName = clientName;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="secret">Phrase de passe du fichier, ou code PIN de la clé TPM.</param>
|
||||
public async Task<AdminConnection> LoginAsync(string keyPath, string secret, CancellationToken ct = default)
|
||||
{
|
||||
if (AdminKeyLocator.IsReserved(keyPath))
|
||||
throw new InvalidOperationException("Ce fichier est la clé de signature du launcher : utilisez votre clé d'administrateur.");
|
||||
keyPath = Path.GetFullPath(keyPath);
|
||||
var isTpm = AdminTpmKeyFile.IsTpmKeyPath(keyPath);
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
throw new ArgumentException(isTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.");
|
||||
|
||||
IAdminSigner signer;
|
||||
IDisposable key;
|
||||
if (isTpm)
|
||||
{
|
||||
var tpm = _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.");
|
||||
var reference = AdminTpmKeyFile.Load(keyPath);
|
||||
var opened = await BackgroundWork.RunAsync(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, secret), ct)
|
||||
.ConfigureAwait(false);
|
||||
// Le fichier de référence ne doit pas pouvoir faire utiliser une AUTRE clé que celle annoncée
|
||||
// (déjà vérifié par la puce avant le code PIN ; contrôlé de nouveau ici).
|
||||
if (!opened.PublicKey.AsSpan().SequenceEqual(reference.PublicKey))
|
||||
{
|
||||
opened.Dispose();
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
|
||||
}
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Déchiffrement volontairement lent (PBKDF2, 600 000 itérations) : hors du fil de l'interface.
|
||||
var opened = await BackgroundWork.RunAsync(() => new EcdsaAdminSigner(AdminKeyFile.Load(keyPath, secret)), ct).ConfigureAwait(false);
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
|
||||
return new AdminConnection(_client, session, signer, key, keyPath, isTpm);
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surveille la session ouverte :
|
||||
/// • verrouillage après <see cref="LockAfter"/> sans activité dans la fenêtre d'administration ;
|
||||
/// • verrouillage à la fin de la durée maximale de la session (8 h) ;
|
||||
/// • tant que l'administrateur est actif, la session est prolongée sur le serveur (toutes les
|
||||
/// <see cref="KeepAliveEvery"/>) ; si le serveur la refuse (expirée, révoquée), verrouillage.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionGuard : IDisposable
|
||||
{
|
||||
public static readonly TimeSpan DefaultLockAfter = TimeSpan.FromMinutes(10);
|
||||
public static readonly TimeSpan KeepAliveEvery = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan TickEvery = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly AdminConnection _connection;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ITimer? _timer;
|
||||
private DateTimeOffset _lastActivity;
|
||||
private DateTimeOffset _lastKeepAlive;
|
||||
private bool _refreshing;
|
||||
private bool _locked;
|
||||
private bool _disposed;
|
||||
|
||||
public AdminSessionGuard(AdminConnection connection, TimeProvider time, IUiDispatcher ui, TimeSpan? lockAfter = null,
|
||||
bool startTimer = true)
|
||||
{
|
||||
_connection = connection;
|
||||
_time = time;
|
||||
LockAfter = lockAfter ?? DefaultLockAfter;
|
||||
_lastActivity = _lastKeepAlive = time.GetUtcNow();
|
||||
if (startTimer) _timer = time.CreateTimer(_ => ui.Post(() => _ = TickAsync()), null, TickEvery, TickEvery);
|
||||
}
|
||||
|
||||
public TimeSpan LockAfter { get; }
|
||||
|
||||
/// <summary>Verrouillage demandé (le message explique pourquoi) ; attendu par <see cref="TickAsync"/>.</summary>
|
||||
public Func<string, Task>? LockRequested { get; set; }
|
||||
|
||||
/// <summary>Le temps restant a changé (affichage).</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>Moment du verrouillage prévu (inactivité ou fin de session).</summary>
|
||||
public DateTimeOffset LockAt
|
||||
{
|
||||
get
|
||||
{
|
||||
var idle = _lastActivity + LockAfter;
|
||||
return idle < _connection.LocalExpiresAt ? idle : _connection.LocalExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan Remaining
|
||||
{
|
||||
get
|
||||
{
|
||||
var remaining = LockAt - _time.GetUtcNow();
|
||||
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
|
||||
public void NotifyActivity()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _lastActivity < TimeSpan.FromSeconds(1)) return;
|
||||
_lastActivity = now;
|
||||
}
|
||||
|
||||
/// <summary>Vérification périodique (minuterie) ; publique pour les tests.</summary>
|
||||
public async Task TickAsync()
|
||||
{
|
||||
if (_locked || _disposed) return;
|
||||
var now = _time.GetUtcNow();
|
||||
if (now >= _connection.LocalExpiresAt)
|
||||
{
|
||||
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
if (now - _lastActivity >= LockAfter)
|
||||
{
|
||||
await LockAsync($"Verrouillé après {LockAfter.TotalMinutes:0} minutes d'inactivité.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_refreshing && now - _lastKeepAlive >= KeepAliveEvery && _lastActivity > _lastKeepAlive)
|
||||
{
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
await _connection.Session.RefreshAsync();
|
||||
_lastKeepAlive = _time.GetUtcNow();
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
await LockAsync("Session expirée ou révoquée sur le serveur : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serveur momentanément injoignable, délai dépassé… : nouvel essai au prochain passage.
|
||||
// L'inactivité et la durée maximale restent contrôlées localement.
|
||||
Log.Warn($"Administration : prolongation de la session impossible ({ex.GetType().Name}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private async Task LockAsync(string reason)
|
||||
{
|
||||
if (_locked) return;
|
||||
_locked = true;
|
||||
if (LockRequested is { } handler) await handler(reason);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_timer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du menu d'administration sur ce PC (%AppData%\…\GameLauncher\admin.json) : chemins
|
||||
/// des fichiers de clé utilisés. Aucun secret (les clés restent chiffrées dans leurs fichiers).
|
||||
/// </summary>
|
||||
public sealed class AdminSettings
|
||||
{
|
||||
public const string FileName = "admin.json";
|
||||
private const int MaxRememberedKeys = 10;
|
||||
|
||||
public string? LastKeyPath { get; set; }
|
||||
|
||||
public List<string> KeyPaths { get; set; } = new();
|
||||
|
||||
public static AdminSettings Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > 64 * 1024) return new AdminSettings();
|
||||
var settings = JsonSerializer.Deserialize<AdminSettings>(File.ReadAllText(path), JsonDefaults.Options) ?? new AdminSettings();
|
||||
settings.KeyPaths = (settings.KeyPaths ?? new()).Where(p => !string.IsNullOrWhiteSpace(p)).Take(MaxRememberedKeys).ToList();
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration illisibles ({path}) : {ex.Message}");
|
||||
return new AdminSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Oublie une clé (supprimée).</summary>
|
||||
public void Forget(string keyPath)
|
||||
{
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, keyPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (string.Equals(LastKeyPath, keyPath, StringComparison.OrdinalIgnoreCase)) LastKeyPath = null;
|
||||
}
|
||||
|
||||
/// <summary>Mémorise la clé utilisée (en tête de liste).</summary>
|
||||
public void Remember(string keyPath)
|
||||
{
|
||||
var full = Path.GetFullPath(keyPath);
|
||||
LastKeyPath = full;
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase));
|
||||
KeyPaths.Insert(0, full);
|
||||
if (KeyPaths.Count > MaxRememberedKeys) KeyPaths.RemoveRange(MaxRememberedKeys, KeyPaths.Count - MaxRememberedKeys);
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
FileUtil.WriteAllTextAtomic(path, JsonSerializer.Serialize(this, JsonDefaults.Options));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration non enregistrées ({path}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Libellés affichés (français).</summary>
|
||||
public static class AdminTexts
|
||||
{
|
||||
public static string Permission(string permission) => permission switch
|
||||
{
|
||||
AdminPermissions.AdminsManage => "Gestion des administrateurs",
|
||||
AdminPermissions.AuditRead => "Journal d'audit",
|
||||
AdminPermissions.GamesEdit => "Informations des jeux",
|
||||
AdminPermissions.ChangelogEdit => "Notes de version",
|
||||
AdminPermissions.ReleasesPublish => "Publication des versions",
|
||||
_ => permission
|
||||
};
|
||||
|
||||
public static string Role(string? role) => AdminRoles.DisplayName(role);
|
||||
|
||||
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
|
||||
public static string Initials(string? name)
|
||||
{
|
||||
var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var initials = string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
return initials.Length == 0 ? "?" : initials;
|
||||
}
|
||||
|
||||
/// <summary>Durée lisible : « 9 min », « moins d'une minute », « 2 h 05 ».</summary>
|
||||
public static string Duration(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1)) return "moins d'une minute";
|
||||
if (duration < TimeSpan.FromHours(1)) return $"{(int)duration.TotalMinutes} min";
|
||||
return $"{(int)duration.TotalHours} h {duration.Minutes:00}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé conservée dans la puce TPM, telle que décrite par son fichier de référence.</summary>
|
||||
/// <param name="KeyName">Nom de la clé dans la puce de ce PC.</param>
|
||||
/// <param name="PublicKey">Clé publique (SPKI DER) attendue : comparée à celle de la puce à l'ouverture.</param>
|
||||
public sealed record AdminTpmKeyReference(string KeyName, byte[] PublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de référence d'une clé TPM (« admin.tpmkey ») : il ne contient AUCUN secret, seulement le
|
||||
/// nom de la clé dans la puce et sa clé publique. Copié sur un autre PC, il ne sert à rien. Un fichier
|
||||
/// .pub est écrit à côté, comme pour une clé fichier (déclaration du premier propriétaire : add-owner).
|
||||
/// </summary>
|
||||
public static partial class AdminTpmKeyFile
|
||||
{
|
||||
public const string Extension = ".tpmkey";
|
||||
|
||||
/// <summary>
|
||||
/// Longueur minimale du code PIN. Plus courte que la phrase de passe d'un fichier (12) : le code
|
||||
/// ne peut être essayé que sur la puce elle-même, qui se bloque après quelques erreurs.
|
||||
/// </summary>
|
||||
public const int MinPinLength = 6;
|
||||
|
||||
/// <summary>Préfixe des noms de clés créées par le launcher (seules ces clés peuvent être ouvertes).</summary>
|
||||
public const string KeyNamePrefix = "GameLauncher.Admin.";
|
||||
|
||||
private const string Format = "gamelauncher-admin-tpm-key";
|
||||
private const int MaxFileBytes = 16 * 1024;
|
||||
|
||||
[GeneratedRegex(@"\AGameLauncher\.Admin\.[0-9a-f]{32}\z")]
|
||||
private static partial Regex KeyNameRegex();
|
||||
|
||||
public static string NewKeyName() => KeyNamePrefix + Guid.NewGuid().ToString("N");
|
||||
|
||||
public static bool IsValidKeyName(string? keyName) => keyName is not null && KeyNameRegex().IsMatch(keyName);
|
||||
|
||||
public static bool IsTpmKeyPath(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Écrit le fichier de référence et le .pub, sans jamais écraser un fichier existant.</summary>
|
||||
/// <returns>Chemin du fichier .pub.</returns>
|
||||
public static string Save(string path, string keyName, byte[] publicKey)
|
||||
{
|
||||
if (!IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(publicKey)); // P-256 attendue
|
||||
if (!IsTpmKeyPath(path)) throw new ArgumentException($"Le fichier de référence doit avoir l'extension {Extension}.");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var json = new JsonObject
|
||||
{
|
||||
["format"] = Format,
|
||||
["version"] = 1,
|
||||
["provider"] = "Microsoft Platform Crypto Provider",
|
||||
["keyName"] = keyName,
|
||||
["publicKey"] = Convert.ToBase64String(publicKey)
|
||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
WriteNew(path, json);
|
||||
|
||||
var publicPath = Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension);
|
||||
try
|
||||
{
|
||||
WriteNew(publicPath, AdminKeys.ToPem(publicKey));
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(path);
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/// <summary>Lit et contrôle un fichier de référence (<see cref="CryptographicException"/> s'il est invalide).</summary>
|
||||
public static AdminTpmKeyReference Load(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > MaxFileBytes) throw new CryptographicException("Fichier de clé TPM inattendu (trop volumineux).");
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject
|
||||
?? throw new CryptographicException("Fichier de clé TPM illisible.");
|
||||
if (root["format"]?.GetValue<string>() != Format || root["version"]?.GetValue<int>() != 1)
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé TPM du launcher.");
|
||||
var keyName = root["keyName"]?.GetValue<string>();
|
||||
if (!IsValidKeyName(keyName)) throw new CryptographicException("Fichier de clé TPM invalide (nom de clé).");
|
||||
var publicKey = AdminKeys.ParsePublicKey(root["publicKey"]?.GetValue<string>() ?? "");
|
||||
return new AdminTpmKeyReference(keyName!, publicKey);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
|
||||
{
|
||||
throw new CryptographicException("Fichier de clé TPM illisible.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Supprime le fichier de référence et son .pub (clé refusée, création annulée, suppression demandée).</summary>
|
||||
/// <returns>Les fichiers qui n'ont pas pu être supprimés (à supprimer à la main).</returns>
|
||||
internal static IReadOnlyList<string> DeleteFiles(string path)
|
||||
{
|
||||
var left = new List<string>();
|
||||
foreach (var file in new[] { path, Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension) })
|
||||
if (!TryDelete(file)) left.Add(file);
|
||||
return left;
|
||||
}
|
||||
|
||||
private static void WriteNew(string path, string text)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return false; // laissé en place : sans danger (aucun secret dans ces fichiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Runtime.ExceptionServices;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Travail lent (chiffrement de clé) hors du fil de l'interface. L'erreur éventuelle est capturée
|
||||
/// DANS le travail puis relancée ici, dans la méthode asynchrone appelante : une erreur attendue
|
||||
/// (phrase de passe incorrecte…) ne traverse pas Task.Run, ce qui évite que le débogueur de Visual
|
||||
/// Studio ne s'arrête sur une « exception non gérée par l'utilisateur » alors qu'elle est bien gérée
|
||||
/// (message affiché dans le menu d'administration).
|
||||
/// </summary>
|
||||
internal static class BackgroundWork
|
||||
{
|
||||
public static async Task<T> RunAsync<T>(Func<T> work, CancellationToken ct = default)
|
||||
{
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return work();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex);
|
||||
return default;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la vérification de la puce de sécurité (TPM) de ce PC.</summary>
|
||||
/// <param name="IsAvailable">Vrai si une clé ECDSA P-256 a pu être créée, utilisée puis supprimée dans la puce.</param>
|
||||
/// <param name="Message">Explication affichée (disponible, ou raison de l'indisponibilité).</param>
|
||||
public sealed record TpmAvailability(bool IsAvailable, string Message)
|
||||
{
|
||||
public static readonly TpmAvailability Unsupported =
|
||||
new(false, "Puce de sécurité (TPM) non prise en charge par ce launcher sur ce système.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protection anti-essais de la puce (compteur COMMUN à toute la puce : codes PIN du launcher, de
|
||||
/// Windows Hello…). Au-delà de <see cref="MaxTries"/> erreurs, la puce refuse tout code ; elle
|
||||
/// « oublie » une erreur à chaque <see cref="RecoveryInterval"/> écoulé, PC allumé.
|
||||
/// </summary>
|
||||
public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan RecoveryInterval, bool InLockout)
|
||||
{
|
||||
public bool IsLockedOut => InLockout || (MaxTries > 0 && Failures >= MaxTries);
|
||||
|
||||
/// <summary>Codes faux encore permis avant le blocage.</summary>
|
||||
public int RemainingTries => Math.Max(0, MaxTries - Failures);
|
||||
|
||||
/// <summary>Faux : la puce ne se débloque pas seule (un administrateur du PC doit la réinitialiser).</summary>
|
||||
public bool HealsWithTime => RecoveryInterval > TimeSpan.Zero;
|
||||
|
||||
/// <summary>Attente MAXIMALE avant que la puce accepte de nouveau un code (estimation, PC allumé).</summary>
|
||||
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
|
||||
? TimeSpan.Zero
|
||||
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
|
||||
}
|
||||
|
||||
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
|
||||
public sealed class TpmPinException : CryptographicException
|
||||
{
|
||||
public TpmPinException(string message, bool isLockedOut, Exception? inner = null) : base(message, inner) =>
|
||||
IsLockedOut = isLockedOut;
|
||||
|
||||
public bool IsLockedOut { get; }
|
||||
}
|
||||
|
||||
/// <summary>Clé ouverte (dans la puce ou en mémoire) : elle signe, et se libère à la fermeture de la session.</summary>
|
||||
public interface IDisposableAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clés d'administrateur conservées dans la puce de sécurité (TPM 2.0) de ce PC : la clé privée est
|
||||
/// CRÉÉE dans la puce et n'en sort jamais (non exportable) ; la puce signe elle-même. Chaque usage
|
||||
/// exige le code PIN de la clé, vérifié par la puce (blocage temporaire après plusieurs erreurs).
|
||||
/// Implémentation Windows dans le launcher (Microsoft Platform Crypto Provider) ; les méthodes sont
|
||||
/// lentes (accès à la puce) et sont appelées hors du fil de l'interface.
|
||||
/// </summary>
|
||||
public interface IAdminTpm
|
||||
{
|
||||
/// <summary>Vérifie RÉELLEMENT la puce : création, signature et suppression d'une clé de test.</summary>
|
||||
TpmAvailability Check();
|
||||
|
||||
/// <summary>Crée une clé (non exportable) protégée par <paramref name="pin"/> et l'ouvre.</summary>
|
||||
/// <param name="keyName">Nom de la clé dans la puce (<see cref="AdminTpmKeyFile.NewKeyName"/>).</param>
|
||||
IDisposableAdminSigner CreateKey(string keyName, string pin);
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre une clé existante, vérifie qu'elle a bien la clé publique attendue (AVANT tout essai du
|
||||
/// code, qui consommerait un essai de la protection anti-essais), puis vérifie le code PIN
|
||||
/// (signature de test). <see cref="System.Security.Cryptography.CryptographicException"/> avec un
|
||||
/// message clair si la clé diffère, si le code est faux, si la puce est bloquée ou si la clé
|
||||
/// n'existe pas sur ce PC.
|
||||
/// </summary>
|
||||
IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin);
|
||||
|
||||
/// <summary>Supprime la clé de la puce (sans erreur si elle n'existe plus).</summary>
|
||||
void DeleteKey(string keyName);
|
||||
|
||||
/// <summary>
|
||||
/// État de la protection anti-essais (lu dans la puce sans rien modifier), ou null s'il n'est pas
|
||||
/// lisible sur ce PC (Windows peut réserver cette lecture aux administrateurs).
|
||||
/// </summary>
|
||||
TpmLockoutInfo? GetLockoutInfo();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Dépendances de la fenêtre d'administration (fournies par le launcher).</summary>
|
||||
public sealed class AdminEnvironment
|
||||
{
|
||||
/// <summary>Client de l'API (adresse intégrée à l'exe).</summary>
|
||||
public required LauncherApiClient Client { get; init; }
|
||||
|
||||
public required ModuleCatalog Modules { get; init; }
|
||||
|
||||
/// <summary>Jeux déclarés dans launcher.json (valeurs par défaut, avant le catalogue).</summary>
|
||||
public required IReadOnlyList<LauncherConfig> Games { get; init; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json.</summary>
|
||||
public string LauncherName { get; init; } = "Launcher";
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json.</summary>
|
||||
public string? DefaultGameId { get; init; }
|
||||
|
||||
/// <summary>Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</summary>
|
||||
public IReadOnlyList<string> Themes { get; init; } = new[] { "default" };
|
||||
|
||||
public required IModuleDialogs Dialogs { get; init; }
|
||||
|
||||
public required IUiDispatcher Ui { get; init; }
|
||||
|
||||
/// <summary>Fichier des préférences d'administration (%AppData%\…\GameLauncher\admin.json).</summary>
|
||||
public required string SettingsPath { get; init; }
|
||||
|
||||
public required string LauncherVersion { get; init; }
|
||||
|
||||
public TimeProvider Time { get; init; } = TimeProvider.System;
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
/// <summary>Dossier des clés proposé par défaut (%USERPROFILE%\.launcher-keys).</summary>
|
||||
public string KeysDirectory { get; init; } = AdminKeyLocator.DefaultDirectory;
|
||||
|
||||
/// <summary>Verrouillage après inactivité (10 min par défaut).</summary>
|
||||
public TimeSpan? LockAfter { get; init; }
|
||||
|
||||
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
|
||||
public bool StartTimers { get; init; } = true;
|
||||
|
||||
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
|
||||
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre d'administration : un seul écran à la fois — connexion (<see cref="LoginViewModel"/>),
|
||||
/// création de clé / invitation (<see cref="EnrollViewModel"/>) ou espace d'administration
|
||||
/// (<see cref="AdminWorkspaceViewModel"/>). Verrouiller ferme la session et libère la clé.
|
||||
/// </summary>
|
||||
public sealed class AdminShellViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private object _screen;
|
||||
private AdminWorkspaceViewModel? _workspace;
|
||||
private bool _disposed;
|
||||
private Task<TpmAvailability>? _tpmCheck;
|
||||
|
||||
public AdminShellViewModel(AdminEnvironment environment)
|
||||
{
|
||||
Environment = environment;
|
||||
Settings = AdminSettings.Load(environment.SettingsPath);
|
||||
_screen = new LoginViewModel(this, null, null);
|
||||
}
|
||||
|
||||
public AdminEnvironment Environment { get; }
|
||||
|
||||
internal AdminSettings Settings { get; }
|
||||
|
||||
/// <summary>Écran affiché (le launcher choisit la vue selon son type).</summary>
|
||||
public object Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set
|
||||
{
|
||||
var previous = _screen;
|
||||
if (!SetProperty(ref _screen, value)) return;
|
||||
OnPropertyChanged(nameof(IsLoggedIn));
|
||||
if (previous is LoginViewModel login) login.Dispose(); // arrêt de ses minuteries (décompte de la puce)
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoggedIn => Screen is AdminWorkspaceViewModel;
|
||||
|
||||
public string ServerHost => Environment.Client.BaseUrl.Host;
|
||||
|
||||
public string WindowTitle => $"Administration — {ServerHost}";
|
||||
|
||||
/// <summary>Souris ou clavier dans la fenêtre : repousse le verrouillage automatique.</summary>
|
||||
public void NotifyActivity() => _workspace?.Guard.NotifyActivity();
|
||||
|
||||
public void ShowLogin(string? status = null, string? selectKeyPath = null)
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new LoginViewModel(this, status, selectKeyPath);
|
||||
}
|
||||
|
||||
/// <summary>Vérification de la puce TPM (une fois par ouverture de la fenêtre, hors du fil de l'interface).</summary>
|
||||
internal Task<TpmAvailability> CheckTpmAsync() => _tpmCheck ??= RunTpmCheckAsync();
|
||||
|
||||
private async Task<TpmAvailability> RunTpmCheckAsync()
|
||||
{
|
||||
if (Environment.Tpm is not { } tpm) return TpmAvailability.Unsupported;
|
||||
try
|
||||
{
|
||||
var availability = await BackgroundWork.RunAsync(tpm.Check);
|
||||
Log.Info($"Administration : puce TPM {(availability.IsAvailable ? "disponible" : "indisponible")} ({availability.Message})");
|
||||
return availability;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : vérification de la puce TPM impossible ({ex.Message}).");
|
||||
return new TpmAvailability(false, $"Puce de sécurité (TPM) inutilisable : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowEnroll()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new EnrollViewModel(this);
|
||||
}
|
||||
|
||||
internal async Task OpenWorkspaceAsync(AdminConnection connection)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
AdminWorkspaceViewModel workspace;
|
||||
try
|
||||
{
|
||||
workspace = new AdminWorkspaceViewModel(this, connection);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync(); // clé effacée, session fermée
|
||||
throw;
|
||||
}
|
||||
_workspace = workspace;
|
||||
Screen = workspace;
|
||||
await workspace.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session (serveur + clé en mémoire) et revient à l'écran de connexion.</summary>
|
||||
public Task LockAsync(string? reason) => _workspace is { } workspace ? LockAsync(workspace, reason) : Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Verrouillage demandé par un espace donné (minuterie, session perdue) : sans effet s'il n'est plus
|
||||
/// l'espace courant — une requête tardive d'une session fermée ne doit pas verrouiller la suivante.
|
||||
/// </summary>
|
||||
internal async Task LockAsync(AdminWorkspaceViewModel source, string? reason)
|
||||
{
|
||||
if (!ReferenceEquals(_workspace, source)) return;
|
||||
_workspace = null;
|
||||
try
|
||||
{
|
||||
if (!_disposed) ShowLogin(reason, source.KeyPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await source.DisposeAsync(); // dans tous les cas : clé effacée, session fermée
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fermeture de la fenêtre : déconnexion.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
(_screen as LoginViewModel)?.Dispose();
|
||||
var workspace = _workspace;
|
||||
_workspace = null;
|
||||
if (workspace is not null) await workspace.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Page du menu d'administration dans la navigation (ViewModel créé à la première ouverture).</summary>
|
||||
public sealed class AdminPanelItem : ObservableObject
|
||||
{
|
||||
private AdminPanelViewModel? _viewModel;
|
||||
|
||||
public AdminPanelItem(AdminPanelDescriptor descriptor) => Descriptor = descriptor;
|
||||
|
||||
public AdminPanelDescriptor Descriptor { get; }
|
||||
public string Title => Descriptor.Title;
|
||||
public string Description => Descriptor.Description;
|
||||
public string IconKey => Descriptor.IconKey;
|
||||
|
||||
public AdminPanelViewModel? ViewModel
|
||||
{
|
||||
get => _viewModel;
|
||||
internal set => SetProperty(ref _viewModel, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Espace d'administration (connecté) : navigation entre les pages des modules autorisées par les
|
||||
/// permissions, identité, temps restant avant verrouillage, bouton « Verrouiller ».
|
||||
/// </summary>
|
||||
public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, IAsyncDisposable
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private readonly AdminConnection _connection;
|
||||
private AdminPanelItem? _selectedPanel;
|
||||
private Task _activation = Task.CompletedTask;
|
||||
private bool _disposed;
|
||||
|
||||
internal AdminWorkspaceViewModel(AdminShellViewModel shell, AdminConnection connection)
|
||||
{
|
||||
_shell = shell;
|
||||
_connection = connection;
|
||||
var environment = shell.Environment;
|
||||
|
||||
Guard = new AdminSessionGuard(connection, environment.Time, environment.Ui, environment.LockAfter, environment.StartTimers);
|
||||
Guard.LockRequested = reason => shell.LockAsync(this, reason);
|
||||
Guard.Changed += () => OnPropertyChanged(nameof(SessionText));
|
||||
|
||||
Panels = environment.Modules.AdminPanelsFor(Session.Profile.Permissions);
|
||||
PanelItems = new ObservableCollection<AdminPanelItem>(Panels.Select(p => new AdminPanelItem(p)));
|
||||
LockCommand = new AsyncRelayCommand(() => shell.LockAsync(this, "Session verrouillée."));
|
||||
}
|
||||
|
||||
internal AdminSessionGuard Guard { get; }
|
||||
|
||||
internal string KeyPath => _connection.KeyPath;
|
||||
|
||||
// ------------------------------------------------------------------ Affichage
|
||||
|
||||
public ObservableCollection<AdminPanelItem> PanelItems { get; }
|
||||
|
||||
public AdminPanelItem? SelectedPanel
|
||||
{
|
||||
get => _selectedPanel;
|
||||
set
|
||||
{
|
||||
if (value is null || ReferenceEquals(value, _selectedPanel)) return;
|
||||
_selectedPanel = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(PanelTitle));
|
||||
OnPropertyChanged(nameof(PanelDescription));
|
||||
_activation = ActivateAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public string PanelTitle => SelectedPanel?.Title ?? "";
|
||||
public string PanelDescription => SelectedPanel?.Description ?? "";
|
||||
|
||||
public string DisplayName => Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Session.Profile.Role);
|
||||
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
|
||||
(_connection.IsTpm ? " · puce TPM" : "");
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
/// <summary>« Verrouillage dans 9 min ».</summary>
|
||||
public string SessionText => $"Verrouillage dans {AdminTexts.Duration(Guard.Remaining)}";
|
||||
|
||||
public AsyncRelayCommand LockCommand { get; }
|
||||
|
||||
/// <summary>Ouvre la première page et attend son chargement initial.</summary>
|
||||
internal Task StartAsync()
|
||||
{
|
||||
SelectedPanel = PanelItems.FirstOrDefault();
|
||||
return _activation;
|
||||
}
|
||||
|
||||
/// <summary>Chargement de la page affichée (tests).</summary>
|
||||
internal Task Activation => _activation;
|
||||
|
||||
private async Task ActivateAsync(AdminPanelItem item)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
item.ViewModel ??= item.Descriptor.CreateViewModel(this);
|
||||
await item.ViewModel.ActivateAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Administration : ouverture de la page {item.Descriptor.Id}", ex);
|
||||
Dialogs.ShowMessage("Page indisponible", ApiErrorText.Describe(ex), ModuleMessageKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ IAdminContext
|
||||
|
||||
public AdminSession Session => _connection.Session;
|
||||
public LauncherApiClient Client => _connection.Client;
|
||||
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
|
||||
public string LauncherName => _shell.Environment.LauncherName;
|
||||
public string? DefaultGameId => _shell.Environment.DefaultGameId;
|
||||
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
|
||||
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public void Navigate(string panelId)
|
||||
{
|
||||
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
|
||||
SelectedPanel = item;
|
||||
}
|
||||
|
||||
public void ReportSessionLost(string reason) => _ = _shell.LockAsync(this, reason);
|
||||
|
||||
// ------------------------------------------------------------------ Fermeture
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Guard.Dispose();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json, tel qu'affiché au tableau de bord.</summary>
|
||||
public sealed record DashboardGame(string Name, string GameId, string ManifestHost);
|
||||
|
||||
/// <summary>
|
||||
/// Page d'accueil : compte connecté et permissions, session, serveur, jeux déclarés et, pour les
|
||||
/// propriétaires, les clés en attente d'approbation.
|
||||
/// </summary>
|
||||
public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "admin.dashboard";
|
||||
|
||||
private string _serverVersion = "…";
|
||||
private string _serverId = "…";
|
||||
private int _pendingApprovals;
|
||||
private int _activeInvitations;
|
||||
private string? _lastRefresh;
|
||||
|
||||
public DashboardPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
var profile = context.Session.Profile;
|
||||
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
|
||||
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
|
||||
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||
}
|
||||
|
||||
public string DisplayName => Context.Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
|
||||
public IReadOnlyList<string> Permissions { get; }
|
||||
|
||||
public string KeyText => $"{Context.Session.Profile.KeyLabel ?? "Clé"} · {Context.Session.Profile.KeyId}";
|
||||
|
||||
/// <summary>« Session ouverte jusqu'à 23:14 au plus tard » (heure du PC).</summary>
|
||||
public string SessionText =>
|
||||
$"Ouverte au plus tard jusqu'à {(Context.Session.ExpiresAt - Context.Session.ClockOffset).ToLocalTime():HH:mm}";
|
||||
|
||||
public string ServerOrigin => Context.Client.Origin;
|
||||
|
||||
public string ServerVersion
|
||||
{
|
||||
get => _serverVersion;
|
||||
private set => SetProperty(ref _serverVersion, value);
|
||||
}
|
||||
|
||||
/// <summary>Identifiant du serveur (début), pour le support.</summary>
|
||||
public string ServerId
|
||||
{
|
||||
get => _serverId;
|
||||
private set => SetProperty(ref _serverId, value);
|
||||
}
|
||||
|
||||
public string LauncherVersion => Context.LauncherVersion;
|
||||
|
||||
public IReadOnlyList<DashboardGame> Games { get; }
|
||||
|
||||
public bool CanManageAdmins => Context.Session.Has(AdminPermissions.AdminsManage);
|
||||
|
||||
public int PendingApprovals
|
||||
{
|
||||
get => _pendingApprovals;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _pendingApprovals, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPendingApprovals));
|
||||
OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ActiveInvitations
|
||||
{
|
||||
get => _activeInvitations;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _activeInvitations, value)) OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingApprovals => PendingApprovals > 0;
|
||||
|
||||
public string PendingText => PendingApprovals switch
|
||||
{
|
||||
0 => ActiveInvitations == 0 ? "Aucune clé en attente, aucune invitation en cours." : $"Aucune clé en attente · {ActiveInvitations} invitation(s) en cours.",
|
||||
1 => $"1 clé attend votre approbation · {ActiveInvitations} invitation(s) en cours.",
|
||||
var n => $"{n} clés attendent votre approbation · {ActiveInvitations} invitation(s) en cours."
|
||||
};
|
||||
|
||||
public string? LastRefresh
|
||||
{
|
||||
get => _lastRefresh;
|
||||
private set => SetProperty(ref _lastRefresh, value);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommand.RaiseCanExecuteChanged();
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshAsync() : Task.CompletedTask;
|
||||
|
||||
public Task RefreshAsync() => RunAsync(async () =>
|
||||
{
|
||||
var info = await Context.Client.GetInfoAsync();
|
||||
ServerVersion = info.ServerVersion;
|
||||
ServerId = info.ServerId.Length > 8 ? info.ServerId[..8] : info.ServerId;
|
||||
|
||||
if (CanManageAdmins)
|
||||
{
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
var invitations = await Context.Session.ListInvitationsAsync();
|
||||
PendingApprovals = admins.Where(a => a.Status != AdminStatus.Revoked)
|
||||
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// « J'ai une invitation » : création d'une clé sur ce PC et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur. Deux rangements au choix :
|
||||
/// • fichier chiffré par une phrase de passe (copiable, sauvegardable) — par défaut ;
|
||||
/// • puce de sécurité (TPM) de ce PC, protégée par un code PIN (non copiable) — proposé seulement
|
||||
/// une fois la puce RÉELLEMENT vérifiée (création, signature et suppression d'une clé de test).
|
||||
/// Affiche ensuite l'EMPREINTE à communiquer à la personne qui approuve. Sans code : clé seule
|
||||
/// (premier propriétaire, à déclarer sur le VPS).
|
||||
/// </summary>
|
||||
public sealed class EnrollViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private string _invitationCode = "";
|
||||
private string _keyLabel;
|
||||
private string _keyPath;
|
||||
private bool _useTpm;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private AdminKeyCreation? _result;
|
||||
private TpmAvailability? _tpm;
|
||||
|
||||
public EnrollViewModel(AdminShellViewModel shell)
|
||||
{
|
||||
_shell = shell;
|
||||
_keyLabel = System.Environment.MachineName;
|
||||
_keyPath = AdminKeyLocator.SuggestNewKeyPath(shell.Environment.KeysDirectory);
|
||||
BackCommand = new RelayCommand(() => shell.ShowLogin(), () => !IsBusy);
|
||||
ChangePathCommand = new RelayCommand(ChangePath, () => !IsBusy && !IsDone);
|
||||
CopyFingerprintCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (_result is not null) shell.Environment.Dialogs.CopyToClipboard(_result.Fingerprint);
|
||||
});
|
||||
OpenKeyFolderCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (Path.GetDirectoryName(_result?.KeyPath ?? KeyPath) is { } folder && Directory.Exists(folder))
|
||||
shell.Environment.Dialogs.OpenFolder(folder);
|
||||
});
|
||||
ContinueCommand = new RelayCommand(() => shell.ShowLogin(
|
||||
_result is null or { Enrollment: null, EnrollmentUnconfirmed: null } ? null : "Connectez-vous dès que votre clé a été approuvée.",
|
||||
_result?.KeyPath));
|
||||
TpmCheck = CheckTpmAsync();
|
||||
}
|
||||
|
||||
public string InvitationCode
|
||||
{
|
||||
get => _invitationCode;
|
||||
set => SetProperty(ref _invitationCode, value ?? "");
|
||||
}
|
||||
|
||||
/// <summary>Nom de ce PC, visible par les propriétaires (ex. « PC bureau »).</summary>
|
||||
public string KeyLabel
|
||||
{
|
||||
get => _keyLabel;
|
||||
set => SetProperty(ref _keyLabel, value ?? "");
|
||||
}
|
||||
|
||||
public string KeyPath
|
||||
{
|
||||
get => _keyPath;
|
||||
private set => SetProperty(ref _keyPath, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Rangement de la clé
|
||||
|
||||
/// <summary>Vérification de la puce en cours (le choix « puce » reste grisé).</summary>
|
||||
public bool IsCheckingTpm => _tpm is null;
|
||||
|
||||
/// <summary>Puce vérifiée et utilisable.</summary>
|
||||
public bool IsTpmAvailable => _tpm?.IsAvailable == true;
|
||||
|
||||
/// <summary>« Vérification de la puce de sécurité… », « Puce TPM 2.0 disponible… » ou la raison de l'indisponibilité.</summary>
|
||||
public string TpmStatus => _tpm?.Message ?? "Vérification de la puce de sécurité (TPM) de ce PC…";
|
||||
|
||||
/// <summary>Le choix « puce de sécurité » n'est dégrisé qu'après une vérification réussie.</summary>
|
||||
public bool CanChooseTpm => IsTpmAvailable && !IsBusy && !IsDone;
|
||||
|
||||
public bool CanChooseFile => !IsBusy && !IsDone;
|
||||
|
||||
/// <summary>Faux : fichier chiffré (par défaut). Vrai : puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool UseTpm
|
||||
{
|
||||
get => _useTpm;
|
||||
set
|
||||
{
|
||||
if ((value && !IsTpmAvailable) || IsBusy || IsDone || value == _useTpm)
|
||||
{
|
||||
// Refusé (jamais la puce sans vérification réussie) : l'affichage revient à l'état réel.
|
||||
OnPropertyChanged(nameof(UseTpm));
|
||||
OnPropertyChanged(nameof(UseFile));
|
||||
return;
|
||||
}
|
||||
_useTpm = value;
|
||||
ErrorMessage = null;
|
||||
KeyPath = AdminKeyLocator.SuggestNewKeyPath(Path.GetDirectoryName(KeyPath) ?? _shell.Environment.KeysDirectory, value);
|
||||
foreach (var name in new[] { nameof(UseTpm), nameof(UseFile), nameof(MinSecretLength), nameof(SecretLabel),
|
||||
nameof(ConfirmationLabel), nameof(SecretHint), nameof(KeyFileLabel) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
}
|
||||
|
||||
public bool UseFile
|
||||
{
|
||||
get => !_useTpm;
|
||||
set
|
||||
{
|
||||
if (value) UseTpm = false;
|
||||
}
|
||||
}
|
||||
|
||||
public int MinSecretLength => UseTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
|
||||
public string SecretLabel => UseTpm
|
||||
? $"Code PIN de la clé (au moins {AdminTpmKeyFile.MinPinLength} caractères)"
|
||||
: $"Phrase de passe (au moins {AdminKeyFile.MinPassphraseLength} caractères)";
|
||||
|
||||
public string ConfirmationLabel => UseTpm ? "Confirmation du code PIN" : "Confirmation";
|
||||
|
||||
public string SecretHint => UseTpm
|
||||
? "Le code PIN est vérifié par la puce elle-même, qui se bloque temporairement après plusieurs erreurs " +
|
||||
"(ces erreurs comptent aussi pour les autres usages de la puce, comme le code PIN Windows Hello). " +
|
||||
"Cette clé ne peut être ni copiée ni sauvegardée : gardez aussi une clé « fichier » de secours."
|
||||
: "Une phrase de plusieurs mots est plus sûre et plus facile à retenir. Elle ne peut pas être récupérée : " +
|
||||
"en cas d'oubli, il faudra une nouvelle invitation.";
|
||||
|
||||
public string KeyFileLabel => UseTpm
|
||||
? "Fichier de référence (aucun secret : la clé reste dans la puce)"
|
||||
: "Fichier de la clé";
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
BackCommand.RaiseCanExecuteChanged();
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(CanChooseTpm));
|
||||
OnPropertyChanged(nameof(CanChooseFile));
|
||||
}
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
public bool IsDone => _result is not null;
|
||||
public bool IsForm => _result is null;
|
||||
|
||||
/// <summary>Clé enregistrée auprès du serveur (sinon : clé seule).</summary>
|
||||
public bool IsEnrolled => _result?.Enrollment is not null;
|
||||
public bool IsKeyOnly => _result is { Enrollment: null, EnrollmentUnconfirmed: null };
|
||||
|
||||
/// <summary>Invitation envoyée sans réponse sûre du serveur : clé conservée, marche à suivre affichée.</summary>
|
||||
public bool IsUnconfirmed => _result?.EnrollmentUnconfirmed is not null;
|
||||
public string UnconfirmedText => _result?.EnrollmentUnconfirmed ?? "";
|
||||
|
||||
/// <summary>Clé créée dans la puce de sécurité (TPM).</summary>
|
||||
public bool IsTpmKey => _result?.IsTpm == true;
|
||||
|
||||
public string Fingerprint => _result?.Fingerprint ?? "";
|
||||
public string CreatedKeyPath => _result?.KeyPath ?? "";
|
||||
public string PublicKeyPath => _result?.PublicKeyPath ?? "";
|
||||
public string AccountName => _result?.Enrollment?.DisplayName ?? "";
|
||||
|
||||
public RelayCommand BackCommand { get; }
|
||||
public RelayCommand ChangePathCommand { get; }
|
||||
public RelayCommand CopyFingerprintCommand { get; }
|
||||
public RelayCommand OpenKeyFolderCommand { get; }
|
||||
public RelayCommand ContinueCommand { get; }
|
||||
|
||||
/// <summary>Vérification de la puce lancée à l'ouverture de l'écran (tests).</summary>
|
||||
internal Task TpmCheck { get; }
|
||||
|
||||
/// <summary>Crée la clé (et l'enregistre si un code est saisi). La vue efface les secrets après l'appel.</summary>
|
||||
public async Task SubmitAsync(string secret, string confirmation)
|
||||
{
|
||||
if (IsBusy || IsDone) return;
|
||||
if (UseTpm && !IsTpmAvailable)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas utilisable sur ce PC : choisissez « Fichier chiffré ».";
|
||||
return;
|
||||
}
|
||||
if (secret.Length < MinSecretLength)
|
||||
{
|
||||
ErrorMessage = UseTpm
|
||||
? $"Le code PIN doit contenir au moins {MinSecretLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {MinSecretLength} caractères.";
|
||||
return;
|
||||
}
|
||||
if (!string.Equals(secret, confirmation, StringComparison.Ordinal))
|
||||
{
|
||||
ErrorMessage = UseTpm ? "Les deux codes PIN sont différents." : "Les deux phrases de passe sont différentes.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var result = await new AdminEnrollmentService(_shell.Environment.Client, _shell.Environment.Tpm)
|
||||
.CreateKeyAsync(KeyPath, secret, InvitationCode, KeyLabel, useTpm: UseTpm);
|
||||
_shell.Settings.Remember(result.KeyPath);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
var storage = result.IsTpm ? "puce TPM" : "fichier";
|
||||
Log.Info(result switch
|
||||
{
|
||||
{ Enrollment: { } enrollment } =>
|
||||
$"Administration : clé {result.KeyId} ({storage}) enregistrée pour {enrollment.DisplayName}, en attente d'approbation.",
|
||||
{ EnrollmentUnconfirmed: not null } => $"Administration : clé {result.KeyId} ({storage}) créée, enregistrement non confirmé par le serveur.",
|
||||
_ => $"Administration : clé créée ({result.KeyId}, {storage})."
|
||||
});
|
||||
_result = result;
|
||||
foreach (var name in new[] { nameof(IsDone), nameof(IsForm), nameof(IsEnrolled), nameof(IsKeyOnly), nameof(IsUnconfirmed),
|
||||
nameof(UnconfirmedText), nameof(IsTpmKey), nameof(Fingerprint), nameof(CreatedKeyPath),
|
||||
nameof(PublicKeyPath), nameof(AccountName), nameof(CanChooseTpm), nameof(CanChooseFile) })
|
||||
OnPropertyChanged(name);
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : enregistrement refusé ({api.Status} {api.Code}).");
|
||||
else if (UseTpm) Log.Warn($"Administration : création de la clé TPM impossible ({ex.Message}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckTpmAsync()
|
||||
{
|
||||
var availability = await _shell.CheckTpmAsync();
|
||||
_tpm = availability;
|
||||
foreach (var name in new[] { nameof(IsCheckingTpm), nameof(IsTpmAvailable), nameof(TpmStatus), nameof(CanChooseTpm) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void ChangePath()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(KeyPath);
|
||||
var extension = UseTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
var filter = UseTpm ? "Référence de clé TPM (*.tpmkey)|*.tpmkey" : "Clé d'administrateur (*.key)|*.key";
|
||||
var path = _shell.Environment.Dialogs.PickSaveFile("Emplacement de la nouvelle clé", filter,
|
||||
Path.GetFileName(KeyPath), directory is not null && Directory.Exists(directory) ? directory : null);
|
||||
if (path is null) return;
|
||||
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) path += extension;
|
||||
KeyPath = path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Écran de connexion : choix de la clé, phrase de passe ou code PIN (transmis par la vue, jamais
|
||||
/// stocké dans le ViewModel), connexion par défi signé. Pour les clés de la puce TPM : suppression
|
||||
/// de la clé, et aide quand la puce est bloquée par sa protection anti-essais (temps d'attente).
|
||||
/// </summary>
|
||||
public sealed class LoginViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan LockoutTick = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private AdminKeyEntry? _selectedKey;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private string? _statusMessage;
|
||||
|
||||
// Protection anti-essais de la puce (commune à toutes les clés TPM de ce PC).
|
||||
private bool _isTpmLocked;
|
||||
private DateTimeOffset? _lockoutUntil;
|
||||
private TimeSpan _lockoutInterval;
|
||||
private bool _lockoutHeals = true;
|
||||
private string? _lockoutText;
|
||||
private ITimer? _lockoutTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public LoginViewModel(AdminShellViewModel shell, string? statusMessage, string? selectKeyPath)
|
||||
{
|
||||
_shell = shell;
|
||||
_statusMessage = statusMessage;
|
||||
BrowseKeyCommand = new RelayCommand(BrowseKey, () => !IsBusy);
|
||||
ShowEnrollCommand = new RelayCommand(shell.ShowEnroll, () => !IsBusy);
|
||||
RefreshCommand = new RelayCommand(() => LoadKeys(SelectedKey?.Path), () => !IsBusy);
|
||||
OpenKeysFolderCommand = new RelayCommand(OpenKeysFolder);
|
||||
DeleteTpmKeyCommand = new AsyncRelayCommand(DeleteTpmKeyAsync, () => IsTpmKey && !IsBusy);
|
||||
RecheckLockoutCommand = new AsyncRelayCommand(() => CheckLockoutAsync(knownLocked: false), () => !IsBusy);
|
||||
LoadKeys(selectKeyPath ?? shell.Settings.LastKeyPath);
|
||||
}
|
||||
|
||||
public ObservableCollection<AdminKeyEntry> Keys { get; } = new();
|
||||
|
||||
public AdminKeyEntry? SelectedKey
|
||||
{
|
||||
get => _selectedKey;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selectedKey, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
OnPropertyChanged(nameof(SecretLabel));
|
||||
OnPropertyChanged(nameof(IsTpmKey));
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
// Clé de la puce : la puce est-elle bloquée ? (affiché avant même de saisir le code)
|
||||
if (value?.IsTpm == true && !_isTpmLocked) _ = CheckLockoutAsync(knownLocked: false, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasKeys => Keys.Count > 0;
|
||||
|
||||
/// <summary>« Phrase de passe de la clé » ou « Code PIN de la clé (puce TPM) », selon la clé choisie.</summary>
|
||||
public string SecretLabel => SelectedKey?.SecretLabel ?? "Phrase de passe de la clé";
|
||||
|
||||
public bool IsTpmKey => SelectedKey?.IsTpm == true;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
BrowseKeyCommand.RaiseCanExecuteChanged();
|
||||
ShowEnrollCommand.RaiseCanExecuteChanged();
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
RecheckLockoutCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connexion possible. Avec une clé de la puce pendant un blocage dont la fin est connue : non,
|
||||
/// jusqu'à la fin du décompte (un essai serait refusé de toute façon).
|
||||
/// </summary>
|
||||
public bool CanLogin => SelectedKey is not null && !IsBusy && !(IsTpmKey && LockoutBlocksLogin);
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Information (ex. « Verrouillé après 10 minutes d'inactivité », clé supprimée…).</summary>
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
public string KeysDirectory => _shell.Environment.KeysDirectory;
|
||||
|
||||
// ------------------------------------------------------------------ Blocage de la puce
|
||||
|
||||
/// <summary>La puce refuse les codes PIN (trop d'erreurs) : bandeau d'aide affiché pour les clés TPM.</summary>
|
||||
public bool IsTpmLocked
|
||||
{
|
||||
get => _isTpmLocked;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isTpmLocked, value)) return;
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowTpmLockout => IsTpmLocked && IsTpmKey;
|
||||
|
||||
/// <summary>« De nouveau utilisable dans 9 min 30 s au plus… », ou l'explication si la durée est inconnue.</summary>
|
||||
public string? LockoutText
|
||||
{
|
||||
get => _lockoutText;
|
||||
private set => SetProperty(ref _lockoutText, value);
|
||||
}
|
||||
|
||||
private bool LockoutBlocksLogin => _isTpmLocked && _lockoutUntil is { } until && until > _shell.Environment.Time.GetUtcNow();
|
||||
|
||||
public RelayCommand BrowseKeyCommand { get; }
|
||||
public RelayCommand ShowEnrollCommand { get; }
|
||||
public RelayCommand RefreshCommand { get; }
|
||||
public RelayCommand OpenKeysFolderCommand { get; }
|
||||
|
||||
/// <summary>Supprime définitivement la clé de la puce sélectionnée (clés TPM uniquement).</summary>
|
||||
public AsyncRelayCommand DeleteTpmKeyCommand { get; }
|
||||
|
||||
/// <summary>Relit l'état de la protection anti-essais de la puce.</summary>
|
||||
public AsyncRelayCommand RecheckLockoutCommand { get; }
|
||||
|
||||
/// <summary>Dernière lecture de l'état de la puce (tests).</summary>
|
||||
internal Task LockoutCheck { get; private set; } = Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
/// <summary>Connexion avec la clé choisie. La vue efface la phrase de passe (ou le code) juste après l'appel.</summary>
|
||||
public async Task LoginAsync(string passphrase)
|
||||
{
|
||||
if (SelectedKey is not { } key || IsBusy) return;
|
||||
if (string.IsNullOrEmpty(passphrase))
|
||||
{
|
||||
ErrorMessage = key.IsTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.";
|
||||
return;
|
||||
}
|
||||
if (!CanLogin) return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
AdminConnection? connection = null;
|
||||
TpmPinException? pinError = null;
|
||||
try
|
||||
{
|
||||
var environment = _shell.Environment;
|
||||
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex, duringLogin: true);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : connexion refusée ({api.Status} {api.Code}).");
|
||||
pinError = ex as TpmPinException;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
if (connection is null)
|
||||
{
|
||||
if (pinError is not null) await OnTpmPinRefusedAsync(pinError);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Administration : connexion de {connection.Session.Profile.DisplayName} ({connection.Session.Profile.Role}).");
|
||||
_shell.Settings.Remember(key.Path); // chemin déjà normalisé (AdminKeyLocator)
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath); // erreurs d'écriture journalisées, sans effet
|
||||
try
|
||||
{
|
||||
await _shell.OpenWorkspaceAsync(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Administration : ouverture de l'espace d'administration", ex);
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Code refusé : essais restants, ou décompte jusqu'à la fin du blocage.</summary>
|
||||
private async Task OnTpmPinRefusedAsync(TpmPinException error)
|
||||
{
|
||||
var info = await CheckLockoutAsync(error.IsLockedOut);
|
||||
if (_isTpmLocked)
|
||||
{
|
||||
ErrorMessage = null; // le bandeau du blocage explique tout
|
||||
return;
|
||||
}
|
||||
if (info is { MaxTries: > 0 })
|
||||
ErrorMessage = $"Code PIN incorrect. Encore {info.RemainingTries} essai(s) avant le blocage temporaire de la puce " +
|
||||
"(compteur commun à la puce : les codes faux de Windows Hello comptent aussi).";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protection anti-essais
|
||||
|
||||
/// <summary>
|
||||
/// Lit l'état de la puce et met à jour le bandeau : bloquée avec une fin estimée (décompte),
|
||||
/// bloquée sans estimation possible, ou débloquée.
|
||||
/// </summary>
|
||||
/// <param name="knownLocked">La puce vient de répondre « bloquée » (même si l'état n'est pas lisible).</param>
|
||||
/// <param name="quiet">Vérification de fond : rien n'est affiché si la puce n'est pas bloquée.</param>
|
||||
private Task<TpmLockoutInfo?> CheckLockoutAsync(bool knownLocked, bool quiet = false)
|
||||
{
|
||||
// Une lecture à la fois, dans l'ordre des demandes (une lecture de fond ne masque pas un blocage signalé).
|
||||
var task = CheckAfterAsync(LockoutCheck, knownLocked, quiet);
|
||||
LockoutCheck = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckAfterAsync(Task previous, bool knownLocked, bool quiet)
|
||||
{
|
||||
try
|
||||
{
|
||||
await previous;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// erreur déjà journalisée par la lecture précédente
|
||||
}
|
||||
return await CheckCoreAsync(knownLocked, quiet);
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckCoreAsync(bool knownLocked, bool quiet)
|
||||
{
|
||||
if (_shell.Environment.Tpm is not { } tpm || _disposed) return null;
|
||||
TpmLockoutInfo? info = null;
|
||||
try
|
||||
{
|
||||
info = await BackgroundWork.RunAsync(tpm.GetLockoutInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : état de la protection anti-essais de la puce illisible ({ex.Message}).");
|
||||
}
|
||||
if (_disposed) return info;
|
||||
|
||||
var wasLocked = _isTpmLocked;
|
||||
var locked = info?.IsLockedOut ?? knownLocked;
|
||||
if (!locked)
|
||||
{
|
||||
StopLockout();
|
||||
if (wasLocked && !quiet) StatusMessage = "La puce de sécurité accepte de nouveau les codes PIN : vous pouvez réessayer.";
|
||||
return info;
|
||||
}
|
||||
|
||||
var time = _shell.Environment.Time;
|
||||
_lockoutHeals = info?.HealsWithTime ?? true;
|
||||
_lockoutInterval = info?.RecoveryInterval ?? TimeSpan.Zero;
|
||||
_lockoutUntil = info is { HealsWithTime: true } ? time.GetUtcNow() + info.MaxWait : null;
|
||||
IsTpmLocked = true;
|
||||
if (_lockoutUntil is not null && _lockoutTimer is null && _shell.Environment.StartTimers)
|
||||
_lockoutTimer = time.CreateTimer(_ => _shell.Environment.Ui.Post(() => _ = TickLockoutAsync()), null, LockoutTick, LockoutTick);
|
||||
UpdateLockoutText();
|
||||
Log.Info(info is null
|
||||
? "Administration : puce TPM bloquée (protection anti-essais), durée inconnue."
|
||||
: $"Administration : puce TPM bloquée ({info.Failures}/{info.MaxTries} erreurs, une erreur oubliée toutes les {info.RecoveryInterval}).");
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Décompte (chaque seconde ; appelé directement par les tests).</summary>
|
||||
internal async Task TickLockoutAsync()
|
||||
{
|
||||
if (!_isTpmLocked || _disposed || !LockoutCheck.IsCompleted) return;
|
||||
UpdateLockoutText();
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
// Fin estimée atteinte : la puce le confirme (sinon, nouvelle estimation).
|
||||
if (_lockoutUntil is { } until && _shell.Environment.Time.GetUtcNow() >= until) await CheckLockoutAsync(knownLocked: false);
|
||||
}
|
||||
|
||||
private void UpdateLockoutText()
|
||||
{
|
||||
if (!_lockoutHeals)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est bloquée après trop de codes PIN faux, et ne se débloque pas d'elle-même sur ce PC : " +
|
||||
"un administrateur de ce PC doit réinitialiser sa protection anti-essais. En attendant, utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
if (_lockoutUntil is not { } until)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est temporairement bloquée après trop de codes PIN faux. Elle se débloque d'elle-même " +
|
||||
"au bout d'un moment (de quelques minutes à quelques heures selon le PC), uniquement PC allumé. " +
|
||||
"Réessayez plus tard, ou utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
var remaining = until - _shell.Environment.Time.GetUtcNow();
|
||||
LockoutText = remaining > TimeSpan.Zero
|
||||
? $"La puce de sécurité est bloquée après trop de codes PIN faux. Elle sera de nouveau utilisable dans " +
|
||||
$"{FormatWait(remaining)} au plus (une erreur est oubliée toutes les {FormatWait(_lockoutInterval)}, PC allumé)."
|
||||
: "Vérification de la puce…";
|
||||
}
|
||||
|
||||
private void StopLockout()
|
||||
{
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
_lockoutUntil = null;
|
||||
LockoutText = null;
|
||||
IsTpmLocked = false;
|
||||
}
|
||||
|
||||
/// <summary>« 9 min 05 s », « 45 s », « 1 h 30 min ».</summary>
|
||||
internal static string FormatWait(TimeSpan wait)
|
||||
{
|
||||
if (wait < TimeSpan.Zero) wait = TimeSpan.Zero;
|
||||
var seconds = (long)Math.Ceiling(wait.TotalSeconds);
|
||||
if (seconds < 60) return $"{seconds} s";
|
||||
if (seconds < 3600) return seconds % 60 == 0 ? $"{seconds / 60} min" : $"{seconds / 60} min {seconds % 60:00} s";
|
||||
var minutes = (seconds + 59) / 60;
|
||||
return minutes % 60 == 0 ? $"{minutes / 60} h" : $"{minutes / 60} h {minutes % 60:00} min";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Suppression d'une clé TPM
|
||||
|
||||
private async Task DeleteTpmKeyAsync()
|
||||
{
|
||||
if (SelectedKey is not { IsTpm: true } key || IsBusy) return;
|
||||
if (_shell.Environment.Tpm is not { } tpm)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas accessible sur ce PC : la clé ne peut pas être supprimée ici.";
|
||||
return;
|
||||
}
|
||||
|
||||
var dialogs = _shell.Environment.Dialogs;
|
||||
if (!dialogs.Confirm("Supprimer la clé de la puce ?",
|
||||
$"La clé « {key.Name} » ({key.Detail}) sera effacée DÉFINITIVEMENT de la puce de sécurité de ce PC, " +
|
||||
$"avec ses fichiers {Path.GetFileName(key.Path)} et {Path.GetFileNameWithoutExtension(key.Path)}.pub.\n\n" +
|
||||
"Elle ne pourra plus jamais servir à se connecter, sur aucun PC. Si c'était votre seule clé, il faudra une " +
|
||||
"nouvelle invitation (ou la commande add-key sur le serveur).\n\n" +
|
||||
$"Pensez aussi à la révoquer sur le serveur (identifiant de clé {key.KeyId ?? "inconnu"}).",
|
||||
"Supprimer définitivement", "Annuler", danger: true))
|
||||
return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(key.Path);
|
||||
await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
tpm.DeleteKey(reference.KeyName);
|
||||
return true;
|
||||
});
|
||||
var left = AdminTpmKeyFile.DeleteFiles(key.Path);
|
||||
_shell.Settings.Forget(key.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
Log.Info($"Administration : clé TPM {key.KeyId} supprimée de la puce ({key.Path}).");
|
||||
|
||||
StatusMessage = $"Clé « {key.Name} » supprimée de la puce de ce PC. Révoquez-la aussi sur le serveur " +
|
||||
$"(identifiant de clé {key.KeyId ?? "inconnu"} : commande revoke-key sur le VPS)." +
|
||||
(left.Count == 0 ? "" : $" Fichier(s) à supprimer à la main : {string.Join(", ", left)}.");
|
||||
LoadKeys(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : suppression de la clé TPM impossible ({ex.Message}).");
|
||||
ErrorMessage = $"Suppression impossible : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
private void LoadKeys(string? select)
|
||||
{
|
||||
Keys.Clear();
|
||||
foreach (var entry in AdminKeyLocator.Find(KeysDirectory, _shell.Settings.KeyPaths)) Keys.Add(entry);
|
||||
SelectedKey = Keys.FirstOrDefault(k => string.Equals(k.Path, select, StringComparison.OrdinalIgnoreCase)) ?? Keys.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(HasKeys));
|
||||
}
|
||||
|
||||
private void BrowseKey()
|
||||
{
|
||||
var path = _shell.Environment.Dialogs.PickOpenFile("Choisir votre clé d'administrateur",
|
||||
"Clé d'administrateur (*.key, *.tpmkey)|*.key;*.tpmkey", Directory.Exists(KeysDirectory) ? KeysDirectory : null);
|
||||
if (path is null) return;
|
||||
|
||||
if (AdminKeyLocator.IsReserved(path))
|
||||
{
|
||||
ErrorMessage = "Ce fichier est la clé de signature du launcher : choisissez votre clé d'administrateur.";
|
||||
return;
|
||||
}
|
||||
if (AdminKeyLocator.Describe(path) is not { } entry)
|
||||
{
|
||||
ErrorMessage = "Ce fichier n'est pas une clé d'administrateur (fichier .key chiffré créé par le launcher ou tools\\admin-key.bat, " +
|
||||
"ou référence .tpmkey d'une clé de la puce de ce PC).";
|
||||
return;
|
||||
}
|
||||
ErrorMessage = null;
|
||||
_shell.Settings.Remember(entry.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
LoadKeys(entry.Path);
|
||||
}
|
||||
|
||||
private void OpenKeysFolder()
|
||||
{
|
||||
Directory.CreateDirectory(KeysDirectory);
|
||||
_shell.Environment.Dialogs.OpenFolder(KeysDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Écran quitté : arrêt du décompte.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Page du menu d'administration déclarée par un module.</summary>
|
||||
/// <param name="Id">Identifiant unique, ex. « admin.dashboard », « catalog.games ».</param>
|
||||
/// <param name="Title">Titre affiché dans la navigation.</param>
|
||||
/// <param name="Description">Sous-titre affiché en haut de la page.</param>
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> » de Themes/Icons.xaml).</param>
|
||||
/// <param name="RequiredPermission">Permission nécessaire (null : tout administrateur connecté).</param>
|
||||
/// <param name="Order">Position dans la navigation (croissante).</param>
|
||||
/// <param name="CreateViewModel">Crée le ViewModel de la page (son affichage est un DataTemplate du launcher).</param>
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id,
|
||||
string Title,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string? RequiredPermission,
|
||||
int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
@@ -0,0 +1,78 @@
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Base des pages d'administration : état « occupé », message d'erreur, gestion de la session perdue.</summary>
|
||||
public abstract class AdminPanelViewModel : ObservableObject
|
||||
{
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private bool _activated;
|
||||
|
||||
protected AdminPanelViewModel(IAdminContext context) => Context = context;
|
||||
|
||||
public IAdminContext Context { get; }
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value)) OnBusyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Occupé » a changé : réévaluer les commandes de la page (RaiseCanExecuteChanged).</summary>
|
||||
protected virtual void OnBusyChanged()
|
||||
{
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
|
||||
public Task ActivateAsync()
|
||||
{
|
||||
var first = !_activated;
|
||||
_activated = true;
|
||||
return OnActivatedAsync(first);
|
||||
}
|
||||
|
||||
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
|
||||
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
|
||||
/// </summary>
|
||||
protected async Task<bool> RunAsync(Func<Task> action)
|
||||
{
|
||||
if (IsBusy) return false;
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ApiErrorText.IsSessionLost(ex))
|
||||
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
|
||||
else
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Ce qu'une page d'administration reçoit : session, jeux déclarés, dialogues, navigation.</summary>
|
||||
public interface IAdminContext
|
||||
{
|
||||
/// <summary>Session ouverte : lectures avec GetAsync, modifications signées avec SendSignedAsync.</summary>
|
||||
AdminSession Session { get; }
|
||||
|
||||
LauncherApiClient Client { get; }
|
||||
|
||||
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier), valeurs par défaut.</summary>
|
||||
IReadOnlyList<LauncherConfig> Games { get; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json.</summary>
|
||||
string LauncherName { get; }
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json.</summary>
|
||||
string? DefaultGameId { get; }
|
||||
|
||||
/// <summary>Thèmes intégrés au launcher.</summary>
|
||||
IReadOnlyList<string> Themes { get; }
|
||||
|
||||
IModuleDialogs Dialogs { get; }
|
||||
|
||||
string LauncherVersion { get; }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
void Navigate(string panelId);
|
||||
|
||||
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
|
||||
void ReportSessionLost(string reason);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>Message clair (français) pour une erreur survenue dans un module d'administration.</summary>
|
||||
public static class ApiErrorText
|
||||
{
|
||||
/// <param name="duringLogin">Vrai pendant la connexion : un refus vise la clé, pas la session.</param>
|
||||
public static string Describe(Exception ex, bool duringLogin = false) => ex switch
|
||||
{
|
||||
ApiException { Status: 0, Code: "unreachable" } =>
|
||||
"Serveur d'administration injoignable. Vérifiez votre connexion Internet, puis réessayez.",
|
||||
ApiException { Status: 0, Code: "timeout" } => "Le serveur d'administration ne répond pas. Réessayez dans un instant.",
|
||||
ApiException { Status: 401 } api when duringLogin && api.Code == ApiErrorCodes.Unauthorized =>
|
||||
"Connexion refusée : cette clé n'est pas reconnue par le serveur, est encore en attente d'approbation, ou a été révoquée.",
|
||||
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
|
||||
ApiException api => api.Message,
|
||||
CryptographicException crypto => crypto.Message,
|
||||
FileNotFoundException missing => $"Fichier introuvable : {missing.FileName ?? missing.Message}",
|
||||
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
|
||||
ArgumentException or InvalidOperationException => ex.Message,
|
||||
OperationCanceledException => "Opération annulée.",
|
||||
_ => $"Erreur inattendue : {ex.Message}"
|
||||
};
|
||||
|
||||
/// <summary>La session n'est plus valable (expirée, révoquée) : il faut se reconnecter.</summary>
|
||||
public static bool IsSessionLost(Exception ex) =>
|
||||
ex is ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Socle des modules du launcher (sans WPF) : contrat d'un module, registre, pages du menu
|
||||
d'administration, boîtes de dialogue abstraites, MVVM commun. Chaque module (Administration,
|
||||
puis Actualités, Amis…) est une bibliothèque qui référence ce projet : sa logique et ses
|
||||
ViewModels sont testables sans interface ; le launcher (WPF) n'en fournit que l'affichage.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md, § 5.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,74 @@
|
||||
using GameLauncher.Modules.Admin;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Module du launcher. Il déclare ce qu'il apporte au registre ; il ne connaît ni les fenêtres ni
|
||||
/// les autres modules. Les modules actifs sont listés dans App (launcher WPF).
|
||||
/// </summary>
|
||||
public interface ILauncherModule
|
||||
{
|
||||
/// <summary>Identifiant stable, ex. « admin », « news ».</summary>
|
||||
string Id { get; }
|
||||
|
||||
string DisplayName { get; }
|
||||
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
/// <summary>Ce qu'un module peut ajouter au launcher.</summary>
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
/// <summary>Page du menu d'administration (affichée seulement aux administrateurs ayant la permission requise).</summary>
|
||||
void AddAdminPanel(AdminPanelDescriptor panel);
|
||||
}
|
||||
|
||||
/// <summary>Modules actifs et ce qu'ils ont déclaré.</summary>
|
||||
public sealed class ModuleCatalog : IModuleRegistry
|
||||
{
|
||||
private readonly List<AdminPanelDescriptor> _adminPanels = new();
|
||||
private ILauncherModule? _registering;
|
||||
|
||||
public ModuleCatalog(IEnumerable<ILauncherModule> modules)
|
||||
{
|
||||
var list = new List<ILauncherModule>();
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(module.Id) || list.Any(m => string.Equals(m.Id, module.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Identifiant de module invalide ou en double : « {module.Id} ».");
|
||||
list.Add(module);
|
||||
_registering = module;
|
||||
try
|
||||
{
|
||||
module.Register(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_registering = null;
|
||||
}
|
||||
}
|
||||
Modules = list;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ILauncherModule> Modules { get; }
|
||||
|
||||
/// <summary>Toutes les pages d'administration, dans l'ordre d'affichage.</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanels =>
|
||||
_adminPanels.OrderBy(p => p.Order).ThenBy(p => p.Title, StringComparer.CurrentCulture).ToList();
|
||||
|
||||
/// <summary>Pages visibles pour un administrateur (selon ses permissions ; le serveur reste seul juge).</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanelsFor(IReadOnlyCollection<string> permissions) =>
|
||||
AdminPanels.Where(p => p.RequiredPermission is null || permissions.Contains(p.RequiredPermission)).ToList();
|
||||
|
||||
void IModuleRegistry.AddAdminPanel(AdminPanelDescriptor panel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(panel);
|
||||
if (_registering is null) throw new InvalidOperationException("Les pages se déclarent dans ILauncherModule.Register.");
|
||||
if (string.IsNullOrWhiteSpace(panel.Id) || _adminPanels.Any(p => string.Equals(p.Id, panel.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Page d'administration invalide ou en double : « {panel.Id} » (module {_registering.Id}).");
|
||||
if (panel.RequiredPermission is { } permission &&
|
||||
(permission.Length > 64 || permission.Count(c => c == '.') != 1 || permission.StartsWith('.') || permission.EndsWith('.')))
|
||||
throw new InvalidOperationException($"Permission invalide pour la page « {panel.Id} » : « {permission} » (format module.action).");
|
||||
_adminPanels.Add(panel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
public enum ModuleMessageKind
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>Boîtes de dialogue et actions système offertes aux modules (implémentées par le launcher WPF).</summary>
|
||||
public interface IModuleDialogs
|
||||
{
|
||||
void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info);
|
||||
|
||||
bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false);
|
||||
|
||||
/// <param name="filter">Filtre au format Windows, ex. « Clé d'administrateur (*.key)|*.key ».</param>
|
||||
string? PickOpenFile(string title, string filter, string? initialDirectory);
|
||||
|
||||
string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory);
|
||||
|
||||
void CopyToClipboard(string text);
|
||||
|
||||
void OpenFolder(string path);
|
||||
}
|
||||
|
||||
/// <summary>Exécution sur le fil de l'interface (minuteries, notifications venues d'un autre fil).</summary>
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
|
||||
/// <summary>Exécution immédiate (tests, outils en ligne de commande).</summary>
|
||||
public sealed class ImmediateDispatcher : IUiDispatcher
|
||||
{
|
||||
public static readonly ImmediateDispatcher Instance = new();
|
||||
|
||||
public void Post(Action action) => action();
|
||||
}
|
||||
File renamed without changes.
+5
-2
@@ -43,9 +43,12 @@ public sealed class AsyncRelayCommand : ICommand
|
||||
|
||||
public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
|
||||
|
||||
public async void Execute(object? parameter)
|
||||
public async void Execute(object? parameter) => await ExecuteAsync();
|
||||
|
||||
/// <summary>Exécute la commande et attend la fin (tests, enchaînements).</summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
if (!CanExecute(parameter)) return;
|
||||
if (!CanExecute(null)) return;
|
||||
_isRunning = true;
|
||||
RaiseCanExecuteChanged();
|
||||
try
|
||||
@@ -11,6 +11,8 @@
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
|
||||
<ResourceDictionary Source="Themes/Changelog.xaml" />
|
||||
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
|
||||
<ResourceDictionary Source="Themes/Admin.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -5,10 +5,16 @@ using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.ViewModels;
|
||||
@@ -43,6 +49,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private Views.MainWindow? _mainWindow;
|
||||
private HttpDownloader? _http;
|
||||
private LauncherUpdateService? _updates;
|
||||
private AdminWindowService? _admin;
|
||||
private CatalogService? _catalogService;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
|
||||
@@ -147,6 +156,12 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
var catalogStarted = DateTime.UtcNow;
|
||||
StartCatalog(catalog, args, download: args.CaptureDirectory is null && !args.Uninstall);
|
||||
|
||||
// 3. Mise à jour du launcher lui-même : au démarrage, avant la fenêtre principale
|
||||
var normalStart = args.CaptureDirectory is null && !args.Uninstall && args.UpdatedFromPid is null;
|
||||
if (normalStart)
|
||||
@@ -162,6 +177,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
}
|
||||
|
||||
await ApplyFreshCatalogAsync(catalog, args, catalogStarted);
|
||||
|
||||
// 4. Composition des services (un ensemble de services par jeu)
|
||||
Views.MainWindow window;
|
||||
try
|
||||
@@ -323,12 +340,205 @@ public partial class App : Application, ILauncherRestartHost
|
||||
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
|
||||
launcher.AttachTransition(window);
|
||||
|
||||
// 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);
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
|
||||
ThemeManager.BuiltInThemes.ToList());
|
||||
window.AdminRequested += _admin.Open;
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
window.Closed += (_, _) => Shutdown();
|
||||
window.Show();
|
||||
return window;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue des jeux
|
||||
|
||||
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
|
||||
private static readonly TimeSpan CatalogStartupBudget = TimeSpan.FromSeconds(2.5);
|
||||
private static readonly TimeSpan CatalogImagesBudget = TimeSpan.FromSeconds(1.5);
|
||||
|
||||
/// <summary>Applique le catalogue en cache et lance le téléchargement de la version à jour.</summary>
|
||||
private void StartCatalog(LauncherCatalog catalog, CommandLine args, bool download)
|
||||
{
|
||||
var url = ResolveCatalogUrl(catalog);
|
||||
if (url is null || _http is null) return;
|
||||
try
|
||||
{
|
||||
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url));
|
||||
if (_catalogService.LoadCached() is { } cached) ApplyCatalog(catalog, cached, args);
|
||||
if (download) _catalogFetch = _catalogService.FetchAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Catalogue des jeux", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attend brièvement le catalogue à jour (et ses nouvelles images) puis l'applique. Trop lent :
|
||||
/// le cache reste appliqué, et le téléchargement se termine en arrière-plan pour le démarrage suivant.
|
||||
/// </summary>
|
||||
private async Task ApplyFreshCatalogAsync(LauncherCatalog catalog, CommandLine args, DateTime started)
|
||||
{
|
||||
if (_catalogFetch is not { } fetch || _catalogService is not { } service) return;
|
||||
try
|
||||
{
|
||||
var remaining = CatalogStartupBudget - (DateTime.UtcNow - started);
|
||||
if (remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
|
||||
if (!fetch.IsCompleted)
|
||||
{
|
||||
Log.Info("Catalogue des jeux : serveur lent, catalogue en cache utilisé (mise à jour au prochain démarrage).");
|
||||
_ = FinishCatalogInBackgroundAsync(fetch, service);
|
||||
return;
|
||||
}
|
||||
if (await fetch is not { } fresh) return;
|
||||
|
||||
var missing = fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
|
||||
.Where(u => u is not null && service.CachedImage(u) is null).Distinct().ToList();
|
||||
if (missing.Count > 0)
|
||||
await Task.WhenAny(Task.WhenAll(missing.Select(u => service.GetImageAsync(u))), Task.Delay(CatalogImagesBudget));
|
||||
|
||||
ApplyCatalog(catalog, fresh, args);
|
||||
Log.Info($"Catalogue des jeux : révision {fresh.Revision} appliquée.");
|
||||
_ = FinishCatalogInBackgroundAsync(fetch, service);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Catalogue des jeux", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fin du téléchargement en arrière-plan : images manquantes, nettoyage du cache d'images.</summary>
|
||||
private static async Task FinishCatalogInBackgroundAsync(Task<CatalogDocument?> fetch, CatalogService service)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await fetch is not { } fresh) return;
|
||||
foreach (var url in fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background }).Where(u => u is not null).Distinct())
|
||||
await service.GetImageAsync(url);
|
||||
service.PruneImages(fresh);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Catalogue des jeux (arrière-plan) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCatalog(LauncherCatalog catalog, CatalogDocument document, CommandLine args)
|
||||
{
|
||||
var requested = new[] { args.GameId, args.UninstallGameId };
|
||||
CatalogMerger.Apply(catalog, document, ThemeManager.BuiltInThemes.ToList(),
|
||||
url => _catalogService?.CachedImage(url),
|
||||
game => requested.Any(id => string.Equals(id, game.GameId, StringComparison.OrdinalIgnoreCase)) || IsInstalled(catalog, game));
|
||||
}
|
||||
|
||||
/// <summary>Jeu installé sur ce PC (registre) : jamais masqué par le catalogue.</summary>
|
||||
private bool IsInstalled(LauncherCatalog catalog, LauncherConfig game)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new RegistryService(game, _launcherPath, catalog.DataFolderName, ownsLauncherInstance: false).Read() is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Lecture du registre ({game.GameId}) : {ex.Message}");
|
||||
return true; // dans le doute, le jeu reste affiché
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du catalogue (« catalogUrl »). Comme apiUrl : en Release, seulement celle du
|
||||
/// launcher.json intégré à l'exécutable ; en Debug, celle de la configuration utilisée.
|
||||
/// </summary>
|
||||
private static string? ResolveCatalogUrl(LauncherCatalog catalog)
|
||||
{
|
||||
#if DEBUG
|
||||
if (catalog.CatalogUrlError is not null) Log.Warn(catalog.CatalogUrlError);
|
||||
return catalog.CatalogUrl;
|
||||
#else
|
||||
try
|
||||
{
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
|
||||
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
|
||||
if (catalog.CatalogUrl is not null && !string.Equals(catalog.CatalogUrl, integrated?.CatalogUrl, StringComparison.Ordinal))
|
||||
Log.Warn("« catalogUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
|
||||
if (integrated?.CatalogUrlError is { } error) Log.Warn(error);
|
||||
return integrated?.CatalogUrl;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Adresse du catalogue (launcher.json intégré)", ex);
|
||||
return null;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jeux tels que déclarés dans launcher.json (valeurs par défaut, avant le catalogue), pour le menu
|
||||
/// d'administration. En Release : le launcher.json INTÉGRÉ à l'exe (un fichier posé à côté ne décide
|
||||
/// pas des jeux que l'administration modifie) ; en Debug : la configuration utilisée.
|
||||
/// </summary>
|
||||
private LauncherCatalog LoadDeclaredCatalog(LauncherCatalog active)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if DEBUG
|
||||
return LoadCatalog(_args?.ConfigPath);
|
||||
#else
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName)
|
||||
?? throw new FileNotFoundException($"{ConfigFileName} intégré introuvable.");
|
||||
return LauncherCatalog.Load(embedded);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("launcher.json (jeux déclarés, menu d'administration)", ex);
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Modules du launcher (pages du menu d'administration…). Les futurs modules s'ajoutent ici.</summary>
|
||||
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
|
||||
{
|
||||
new AdminModule(),
|
||||
new CatalogAdminModule(),
|
||||
new ChangelogAdminModule()
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Adresse de l'API d'administration (« apiUrl »). En Release, elle n'est lue QUE dans le
|
||||
/// launcher.json intégré à l'exécutable : un launcher.json posé à côté de l'exe (ou --config) ne
|
||||
/// peut pas rediriger les administrateurs vers un autre serveur. En Debug, la configuration
|
||||
/// utilisée est suivie (serveur local http://127.0.0.1:5080/api/ pour les tests).
|
||||
/// </summary>
|
||||
/// <returns>L'adresse (ou null) et, si elle a été ignorée car invalide, la raison.</returns>
|
||||
private static (string? ApiUrl, string? Error) ResolveAdminApiUrl(LauncherCatalog catalog)
|
||||
{
|
||||
#if DEBUG
|
||||
if (catalog.ApiUrlError is not null) Log.Warn(catalog.ApiUrlError);
|
||||
return (catalog.ApiUrl, catalog.ApiUrlError);
|
||||
#else
|
||||
try
|
||||
{
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
|
||||
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
|
||||
if (catalog.ApiUrl is not null && !string.Equals(catalog.ApiUrl, integrated?.ApiUrl, StringComparison.Ordinal))
|
||||
Log.Warn("« apiUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
|
||||
if (integrated?.ApiUrlError is { } error) Log.Warn(error);
|
||||
return (integrated?.ApiUrl, integrated?.ApiUrlError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Adresse de l'API d'administration (launcher.json intégré)", ex);
|
||||
return (null, "launcher.json intégré illisible.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Option --capture : une capture PNG par jeu (état réel, sans action automatique), puis fermeture.</summary>
|
||||
private async Task CaptureAsync(Views.MainWindow window, string directory)
|
||||
{
|
||||
@@ -381,6 +591,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
|
||||
_admin = null;
|
||||
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
|
||||
_trayIcon = null;
|
||||
_launcher?.Dispose();
|
||||
@@ -432,7 +644,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
/// <returns>Vrai si le jeu fait partie de ce launcher.</returns>
|
||||
private bool OnLaunchRequestedByAnotherInstance(string gameId)
|
||||
{
|
||||
var known = _catalog?.Find(gameId) is not null;
|
||||
// Jeux AFFICHÉS uniquement (un jeu masqué par le catalogue n'est pas proposé) : sinon la demande
|
||||
// serait acceptée sans que le launcher puisse l'afficher.
|
||||
var known = _catalog?.Games.Any(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) == true;
|
||||
Dispatcher.InvokeAsync(() => HandleLaunchRequest(gameId));
|
||||
return known;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<!-- Modules (SDK commun + menu d'administration) : logique et ViewModels, sans WPF ; les vues sont ici (Views\Admin, Themes\Admin.xaml). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.Views;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Boîtes de dialogue offertes aux modules (menu d'administration…) : mêmes fenêtres que le reste
|
||||
/// du launcher (ModernDialog), au-dessus de la fenêtre du module.
|
||||
/// </summary>
|
||||
public sealed class ModuleDialogs : IModuleDialogs
|
||||
{
|
||||
private readonly Func<Window?> _owner;
|
||||
|
||||
/// <param name="owner">Fenêtre propriétaire du moment (null : centrée sur l'écran).</param>
|
||||
public ModuleDialogs(Func<Window?> owner) => _owner = owner;
|
||||
|
||||
private Window? Owner => _owner() is { IsLoaded: true, IsVisible: true } window ? window : null;
|
||||
|
||||
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) =>
|
||||
ModernDialog.ShowMessage(Owner, title, message, kind switch
|
||||
{
|
||||
ModuleMessageKind.Success => DialogKind.Success,
|
||||
ModuleMessageKind.Warning => DialogKind.Warning,
|
||||
ModuleMessageKind.Error => DialogKind.Error,
|
||||
_ => DialogKind.Info
|
||||
});
|
||||
|
||||
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false) =>
|
||||
ModernDialog.ShowConfirm(Owner, title, message, confirmText, cancelText,
|
||||
danger ? DialogKind.Danger : DialogKind.Question, null).Confirmed;
|
||||
|
||||
public string? PickOpenFile(string title, string filter, string? initialDirectory)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
Multiselect = false,
|
||||
CheckFileExists = true,
|
||||
DereferenceLinks = true
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
dialog.InitialDirectory = initialDirectory;
|
||||
var owner = Owner;
|
||||
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
return result == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
FileName = defaultFileName,
|
||||
OverwritePrompt = false, // les services refusent d'écraser un fichier existant (ex. une clé)
|
||||
AddExtension = true
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
dialog.InitialDirectory = initialDirectory;
|
||||
var owner = Owner;
|
||||
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
return result == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public void CopyToClipboard(string text)
|
||||
{
|
||||
// Le presse-papiers peut être momentanément occupé par une autre application.
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(text);
|
||||
return;
|
||||
}
|
||||
catch (COMException ex) when (attempt < 3)
|
||||
{
|
||||
Log.Warn($"Presse-papiers occupé ({ex.ErrorCode:X8}), nouvel essai.");
|
||||
Thread.Sleep(60);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Copie dans le presse-papiers impossible", ex);
|
||||
ShowMessage("Copie impossible", "Le presse-papiers est utilisé par une autre application : réessayez.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dossier LOCAL existant uniquement (jamais une adresse web ni un partage réseau \\serveur\…).</summary>
|
||||
public void OpenFolder(string path)
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal) ||
|
||||
path.StartsWith("//", StringComparison.Ordinal) || !Directory.Exists(path))
|
||||
{
|
||||
Log.Warn($"Ouverture de dossier refusée (chemin local attendu) : {path}");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Ouverture du dossier impossible : {path}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Clés d'administrateur dans la puce de sécurité (TPM 2.0) de ce PC, via le fournisseur Windows
|
||||
/// « Microsoft Platform Crypto Provider » (CNG) :
|
||||
/// • la clé ECDSA P-256 est GÉNÉRÉE par la puce ; ce fournisseur ne permet jamais d'exporter la clé
|
||||
/// privée (Windows n'en garde qu'une copie chiffrée par la puce elle-même, inutilisable ailleurs) ;
|
||||
/// • clé de l'utilisateur Windows courant (un autre compte du PC ne peut pas l'ouvrir) ;
|
||||
/// • chaque usage exige le code PIN (« usage authorization » de la clé), vérifié par la puce, qui se
|
||||
/// bloque temporairement après plusieurs codes faux (protection anti-essais du TPM) ;
|
||||
/// • aucune fenêtre Windows : le code est saisi dans le launcher puis effacé ; la clé est ouverte
|
||||
/// en mode silencieux.
|
||||
/// </summary>
|
||||
public sealed class TpmKeyStore : IAdminTpm
|
||||
{
|
||||
/// <summary>Autorisation d'usage de la clé (NCRYPT_PCP_USAGEAUTH_PROPERTY) : SHA-1 du code PIN en UTF-16.</summary>
|
||||
private const string UsageAuthProperty = "PCP_USAGEAUTH";
|
||||
|
||||
private static CngProvider Provider => CngProvider.MicrosoftPlatformCryptoProvider;
|
||||
|
||||
/// <summary>Préfixe des clés de test de <see cref="Check"/> : distinct de celui des vraies clés.</summary>
|
||||
private const string CheckKeyPrefix = "GameLauncher.AdminCheck.";
|
||||
|
||||
public TpmAvailability Check()
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10))
|
||||
return new TpmAvailability(false, "Clé dans la puce de sécurité : Windows 10 ou plus récent est nécessaire.");
|
||||
|
||||
// Vérification RÉELLE : création d'une clé de test dans la puce, signature, contrôle, suppression.
|
||||
var keyName = CheckKeyPrefix + Guid.NewGuid().ToString("N");
|
||||
var created = false;
|
||||
try
|
||||
{
|
||||
using var key = CreateCore(keyName, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)));
|
||||
created = true;
|
||||
using var signer = new TpmSigner(key);
|
||||
var message = RandomNumberGenerator.GetBytes(32);
|
||||
var signature = signer.SignNow(message);
|
||||
if (!AdminKeys.Verify(signer.PublicKey, message, signature))
|
||||
return new TpmAvailability(false, "La puce de sécurité (TPM) de ce PC a produit une signature invalide.");
|
||||
return new TpmAvailability(true, "Puce de sécurité (TPM 2.0) détectée et vérifiée sur ce PC.");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
Log.Info($"Puce TPM indisponible : {ex.Message} (code 0x{CodeOf(ex):X8})");
|
||||
return new TpmAvailability(false, DescribeUnavailable(ex));
|
||||
}
|
||||
catch (Exception ex) when (ex is PlatformNotSupportedException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
Log.Info($"Puce TPM indisponible : {ex.GetType().Name} {ex.Message}");
|
||||
return new TpmAvailability(false, "Aucune puce de sécurité (TPM 2.0) utilisable sur ce PC.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (created) TryDelete(keyName);
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner CreateKey(string keyName, string pin)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
CngKey key;
|
||||
try
|
||||
{
|
||||
key = CreateCore(keyName, pin);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw Wrap(DescribeUnavailable(ex), ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new TpmSigner(key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
key.Dispose();
|
||||
TryDelete(keyName); // jamais de clé orpheline dans la puce
|
||||
if (ex is CryptographicException crypto) throw Describe(crypto);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
CngKey key;
|
||||
try
|
||||
{
|
||||
key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw Describe(ex);
|
||||
}
|
||||
|
||||
TpmSigner? signer = null;
|
||||
try
|
||||
{
|
||||
// Clé publique comparée AVANT tout essai du code : une référence trompeuse ne consomme
|
||||
// aucun essai de la protection anti-essais de la puce.
|
||||
if (!TpmSigner.ReadPublicKey(key).AsSpan().SequenceEqual(expectedPublicKey))
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
|
||||
SetUsageAuth(key, pin);
|
||||
signer = new TpmSigner(key);
|
||||
// Le code PIN n'est vérifié par la puce qu'à l'usage : signature de test AVANT de contacter le serveur.
|
||||
signer.SignNow(RandomNumberGenerator.GetBytes(32));
|
||||
return signer;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (signer is not null) signer.Dispose();
|
||||
else key.Dispose();
|
||||
if (ex is CryptographicException crypto) throw Describe(crypto);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteKey(string keyName)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
DeleteCore(keyName);
|
||||
}
|
||||
|
||||
/// <summary>Crée la clé (code PIN fixé avant la finalisation) et fournit le code pour la poignée ouverte.</summary>
|
||||
private static CngKey CreateCore(string keyName, string pin)
|
||||
{
|
||||
var parameters = new CngKeyCreationParameters
|
||||
{
|
||||
Provider = Provider,
|
||||
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
|
||||
};
|
||||
var auth = UsageAuth(pin);
|
||||
try
|
||||
{
|
||||
parameters.Parameters.Add(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
}
|
||||
|
||||
var key = CngKey.Create(CngAlgorithm.ECDsaP256, keyName, parameters);
|
||||
try
|
||||
{
|
||||
SetUsageAuth(key, pin); // une fois finalisée, la clé exige son code pour cette poignée aussi
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
TryDelete(keyName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetUsageAuth(CngKey key, string pin)
|
||||
{
|
||||
var auth = UsageAuth(pin);
|
||||
try
|
||||
{
|
||||
key.SetProperty(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteCore(string keyName)
|
||||
{
|
||||
if (!CngKey.Exists(keyName, Provider, CngKeyOpenOptions.Silent)) return;
|
||||
using var key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
|
||||
key.Delete(); // supprime et libère
|
||||
}
|
||||
|
||||
private static void TryDelete(string keyName)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteCore(keyName);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
|
||||
{
|
||||
Log.Warn($"Clé {keyName} non supprimée de la puce ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureKeyName(string keyName)
|
||||
{
|
||||
// Seules les clés créées par le launcher peuvent être ouvertes ou supprimées.
|
||||
if (!AdminTpmKeyFile.IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
|
||||
}
|
||||
|
||||
/// <summary>SHA-1 du code en UTF-16 (sans zéro final) : format attendu par le fournisseur pour l'autorisation d'usage.</summary>
|
||||
private static byte[] UsageAuth(string pin)
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes(pin);
|
||||
try
|
||||
{
|
||||
return SHA1.HashData(bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protection anti-essais
|
||||
|
||||
/// <summary>
|
||||
/// Lit l'état de la protection anti-essais par la commande TPM2_GetCapability (lecture seule,
|
||||
/// sans autorisation), envoyée par le service TPM de Windows (TBS). Null si la lecture est
|
||||
/// impossible (pas de puce, commande réservée aux administrateurs sur ce PC…).
|
||||
/// </summary>
|
||||
public TpmLockoutInfo? GetLockoutInfo()
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
|
||||
var context = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var parameters = new TbsContextParams2 { Version = 2, Flags = 1u << 2 }; // TBS_CONTEXT_VERSION_TWO, includeTpm20
|
||||
if (Tbsi_Context_Create(ref parameters, out context) != 0) return null;
|
||||
|
||||
// TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…TPM_PT_LOCKOUT_RECOVERY)
|
||||
const uint FirstProperty = 0x200, PropertyCount = 0x12;
|
||||
var command = new byte[22];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(command.AsSpan(0), 0x8001); // TPM_ST_NO_SESSIONS
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(2), 22); // taille
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(6), 0x0000017A); // TPM_CC_GetCapability
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(10), 6); // TPM_CAP_TPM_PROPERTIES
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(14), FirstProperty);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(18), PropertyCount);
|
||||
|
||||
var response = new byte[512];
|
||||
var length = (uint)response.Length;
|
||||
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
|
||||
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
|
||||
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
|
||||
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
|
||||
|
||||
uint? permanent = null, counter = null, maxTries = null, interval = null;
|
||||
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
|
||||
{
|
||||
var entry = data.Slice(19 + i * 8, 8);
|
||||
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
|
||||
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
|
||||
{
|
||||
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
|
||||
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
|
||||
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
|
||||
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
|
||||
}
|
||||
}
|
||||
if (counter is null || maxTries is null || interval is null) return null;
|
||||
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
|
||||
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
|
||||
TimeSpan.FromSeconds(interval.Value), inLockout);
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (context != IntPtr.Zero) Tbsip_Context_Close(context);
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct TbsContextParams2
|
||||
{
|
||||
public uint Version;
|
||||
public uint Flags;
|
||||
}
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsi_Context_Create(ref TbsContextParams2 parameters, out IntPtr context);
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsip_Submit_Command(IntPtr context, uint locality, uint priority, byte[] command, uint commandLength,
|
||||
byte[] result, ref uint resultLength);
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsip_Context_Close(IntPtr context);
|
||||
|
||||
// ------------------------------------------------------------------ Messages
|
||||
|
||||
private static class Codes
|
||||
{
|
||||
// Code PIN refusé par la puce (codes TPM normalisés, voir Normalize) ou par le fournisseur.
|
||||
public const uint TpmAuthFail = 0x8028008E, TpmBadAuth = 0x802800A2, Tpm12AuthFail = 0x80280001, Tpm12Auth2Fail = 0x8028001D,
|
||||
PcpAuthenticationFailed = 0x80290408;
|
||||
// Protection anti-essais active.
|
||||
public const uint TpmLockout = 0x80280921, TpmDefendLockRunning = 0x80280803;
|
||||
// Clé absente, ou plus chargeable (puce réinitialisée, profil Windows itinérant…).
|
||||
public const uint BadKeyset = 0x80090016, NoKey = 0x8009000D, NotFound = 0x80090011,
|
||||
PcpWrongParent = 0x8029040E, PcpKeyNotLoaded = 0x8029040F;
|
||||
// Puce absente, désactivée ou pas prête.
|
||||
public const uint TbsTpmNotFound = 0x8028400F, TbsServiceNotRunning = 0x80284008, TbsServiceDisabled = 0x80284010,
|
||||
PcpDeviceNotReady = 0x80290401, DeviceNotReady = 0x80090030, DeviceNotFound = 0x80090035,
|
||||
ProviderNotFound = 0x8009001F, ProviderDllNotFound = 0x8009001D;
|
||||
// Algorithme non géré (TPM 1.2 : pas de courbes elliptiques).
|
||||
public const uint NotSupported = 0x80090029, BadAlgId = 0x80090008, TpmCurve = 0x802800A6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code Windows d'origine (les exceptions décrites ci-dessous enveloppent l'erreur réelle).
|
||||
/// Codes TPM 2.0 « format 1 » (0x8028_0xxx avec le bit 0x080) : les bits qui désignent la session
|
||||
/// ou le paramètre en cause sont retirés (ex. 0x8028098E → 0x8028008E, code PIN refusé).
|
||||
/// </summary>
|
||||
private static uint CodeOf(Exception ex)
|
||||
{
|
||||
for (Exception? current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is not CryptographicException || (uint)current.HResult >> 16 == 0x8013) continue; // code .NET générique
|
||||
var hr = (uint)current.HResult;
|
||||
if ((hr & 0xFFFFF000) == 0x80280000 && (hr & 0x80) != 0) hr = 0x80280000 | 0x80 | (hr & 0x3F);
|
||||
return hr;
|
||||
}
|
||||
return (uint)ex.HResult;
|
||||
}
|
||||
|
||||
/// <summary>Message français, en conservant l'erreur d'origine (et son code) comme cause.</summary>
|
||||
private static CryptographicException Wrap(string message, CryptographicException ex) => new(message, ex);
|
||||
|
||||
private static CryptographicException Describe(CryptographicException ex) => CodeOf(ex) switch
|
||||
{
|
||||
Codes.TpmAuthFail or Codes.TpmBadAuth or Codes.Tpm12AuthFail or Codes.Tpm12Auth2Fail or Codes.PcpAuthenticationFailed =>
|
||||
new TpmPinException("Code PIN incorrect. Après plusieurs erreurs, la puce se bloque temporairement (protection anti-essais).",
|
||||
isLockedOut: false, ex),
|
||||
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
|
||||
new TpmPinException("Trop de codes PIN faux : la puce de sécurité est temporairement bloquée.", isLockedOut: true, ex),
|
||||
Codes.BadKeyset or Codes.NoKey or Codes.NotFound or Codes.PcpWrongParent or Codes.PcpKeyNotLoaded =>
|
||||
Wrap("Clé introuvable ou inutilisable dans la puce de ce PC (autre PC, autre compte Windows, puce réinitialisée ou " +
|
||||
"Windows réinstallé ?). Utilisez une autre clé, ou demandez une nouvelle invitation.", ex),
|
||||
_ when ex.InnerException is null && (uint)ex.HResult >> 16 == 0x8013 => ex, // message déjà rédigé ici (ex. clé différente)
|
||||
_ => Wrap(DescribeUnavailable(ex), ex)
|
||||
};
|
||||
|
||||
private static string DescribeUnavailable(CryptographicException ex) => CodeOf(ex) switch
|
||||
{
|
||||
Codes.TbsTpmNotFound or Codes.DeviceNotFound or Codes.ProviderNotFound or Codes.ProviderDllNotFound =>
|
||||
"Aucune puce de sécurité (TPM) détectée sur ce PC.",
|
||||
Codes.TbsServiceNotRunning or Codes.TbsServiceDisabled or Codes.PcpDeviceNotReady or Codes.DeviceNotReady =>
|
||||
"La puce de sécurité (TPM) de ce PC n'est pas prête (désactivée dans le BIOS/UEFI ou service arrêté).",
|
||||
Codes.NotSupported or Codes.BadAlgId or Codes.TpmCurve =>
|
||||
"La puce de sécurité de ce PC ne gère pas les clés ECDSA P-256 (TPM 2.0 nécessaire).",
|
||||
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
|
||||
"La puce de sécurité est temporairement bloquée (protection anti-essais). Réessayez plus tard.",
|
||||
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Signataire
|
||||
|
||||
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
|
||||
private sealed class TpmSigner : IDisposableAdminSigner
|
||||
{
|
||||
private readonly ECDsaCng _ecdsa;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private int _disposed;
|
||||
|
||||
public TpmSigner(CngKey key)
|
||||
{
|
||||
PublicKey = ReadPublicKey(key);
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
_ecdsa = new ECDsaCng(key);
|
||||
key.Dispose(); // ECDsaCng garde sa propre référence sur la même poignée (code PIN compris)
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
|
||||
/// <summary>Signature immédiate (appelant déjà hors du fil de l'interface).</summary>
|
||||
public byte[] SignNow(byte[] message)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
return _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature dans la puce (lente) : hors du fil de l'interface, une à la fois. L'erreur éventuelle
|
||||
/// est relancée ici, pas dans Task.Run (pas d'arrêt du débogueur sur une erreur gérée).
|
||||
/// </summary>
|
||||
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
var signature = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return SignNow(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex is CryptographicException crypto ? Describe(crypto) : ex);
|
||||
return null;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return signature!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique lue au format brut de Windows (BCRYPT_ECCKEY_BLOB : « ECS1 », 32, X, Y), puis
|
||||
/// encodée en SPKI : ne dépend pas des propriétés facultatives du fournisseur de la puce.
|
||||
/// </summary>
|
||||
internal static byte[] ReadPublicKey(CngKey key)
|
||||
{
|
||||
const uint EcdsaP256PublicMagic = 0x31534345; // « ECS1 »
|
||||
const uint EcdsaGenericPublicMagic = 0x50444345; // « ECDP »
|
||||
var blob = key.Export(CngKeyBlobFormat.EccPublicBlob);
|
||||
var magic = blob.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(blob) : 0;
|
||||
if (blob.Length != 8 + 64 || magic is not (EcdsaP256PublicMagic or EcdsaGenericPublicMagic) ||
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(4)) != 32)
|
||||
throw new CryptographicException("La puce n'a pas renvoyé une clé publique ECDSA P-256.");
|
||||
using var ec = ECDsa.Create(new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
Q = new ECPoint { X = blob[8..40], Y = blob[40..72] }
|
||||
});
|
||||
return AdminKeys.ParsePublicKey(Convert.ToBase64String(ec.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Modules;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>Exécution sur le fil de l'interface WPF.</summary>
|
||||
public sealed class WpfUiDispatcher : IUiDispatcher
|
||||
{
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public WpfUiDispatcher(Dispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
public void Post(Action action) => _dispatcher.InvokeAsync(action);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Views;
|
||||
using GameLauncher.Views.Admin;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ouverture du menu d'administration (accès discret depuis la fenêtre principale).
|
||||
/// Une seule fenêtre à la fois ; la fermer déconnecte la session et efface la clé de la mémoire.
|
||||
/// Sans adresse d'API dans launcher.json (« apiUrl »), le menu n'existe pas.
|
||||
/// </summary>
|
||||
public sealed class AdminWindowService : IDisposable
|
||||
{
|
||||
private readonly Window _owner;
|
||||
private readonly ModuleCatalog _modules;
|
||||
private readonly LauncherCatalog _declared;
|
||||
private readonly IReadOnlyList<string> _themes;
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _launcherVersion;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
/// <param name="apiUrl">Adresse de l'API (null : menu indisponible).</param>
|
||||
/// <param name="apiUrlError">Si l'adresse configurée a été ignorée (invalide), la raison, affichée à l'ouverture.</param>
|
||||
/// <param name="catalog">Configuration active (dossier des données du launcher).</param>
|
||||
/// <param name="declared">Jeux déclarés dans launcher.json, avant le catalogue (valeurs par défaut de la page « Jeux »).</param>
|
||||
/// <param name="themes">Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
_declared = declared;
|
||||
_themes = themes;
|
||||
_settingsPath = Path.Combine(catalog.DataDirectory, "admin.json");
|
||||
_launcherVersion = launcherVersion;
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
|
||||
/// <summary>Ouvre la fenêtre (ou la ramène au premier plan si elle est déjà ouverte).</summary>
|
||||
public void Open()
|
||||
{
|
||||
if (_window is not null)
|
||||
{
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_apiUrl is null)
|
||||
{
|
||||
Log.Info("Administration demandée, mais aucune adresse d'API (apiUrl) dans launcher.json.");
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible",
|
||||
_apiUrlError ?? "Ce launcher n'est relié à aucun serveur d'administration (« apiUrl » absent de launcher.json).",
|
||||
_apiUrlError is null ? DialogKind.Info : DialogKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
|
||||
AdminWindow? window = null;
|
||||
var shell = new AdminShellViewModel(new AdminEnvironment
|
||||
{
|
||||
Client = _client,
|
||||
Modules = _modules,
|
||||
Games = _declared.DeclaredGames,
|
||||
LauncherName = _declared.DeclaredLauncherName,
|
||||
DefaultGameId = _declared.DeclaredDefaultGameId,
|
||||
Themes = _themes,
|
||||
Dialogs = new ModuleDialogs(() => window),
|
||||
Ui = new WpfUiDispatcher(_owner.Dispatcher),
|
||||
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
window.Closed += (_, _) => _window = null;
|
||||
_window = window;
|
||||
Log.Info("Ouverture du menu d'administration.");
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_window = null;
|
||||
Log.Error("Ouverture du menu d'administration", ex);
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible", ex.Message, DialogKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fermeture du launcher : la fenêtre se ferme (déconnexion, clé effacée).</summary>
|
||||
public void Close() => _window?.Close();
|
||||
|
||||
/// <remarks>
|
||||
/// Le client HTTP n'est pas libéré ici : la déconnexion du serveur, lancée par la fermeture de la
|
||||
/// fenêtre, a ainsi une chance de partir avant la fin du processus (la clé, elle, est déjà effacée).
|
||||
/// </remarks>
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
@@ -61,8 +61,16 @@ public static class ThemeManager
|
||||
return dictionary.Contains(key) ? dictionary[key] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plus grande dimension (pixels) d'une image de fond une fois chargée : une image plus grande
|
||||
/// (fond du catalogue, par exemple) est réduite au chargement pour borner la mémoire utilisée.
|
||||
/// </summary>
|
||||
public const int MaxBackgroundSize = 3840;
|
||||
|
||||
/// <summary>Charge une image depuis un chemin (absolu ou relatif au launcher). Null si absente/invalide.</summary>
|
||||
public static ImageSource? LoadImage(string? path, int decodeWidth = 0)
|
||||
/// <param name="decodeWidth">Largeur imposée au chargement (0 : taille de l'image).</param>
|
||||
/// <param name="maxSize">Sans largeur imposée : plus grande dimension autorisée (0 : aucune limite).</param>
|
||||
public static ImageSource? LoadImage(string? path, int decodeWidth = 0, int maxSize = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
try
|
||||
@@ -78,7 +86,22 @@ public static class ThemeManager
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad; // ne garde pas le fichier ouvert
|
||||
image.UriSource = new Uri(full, UriKind.Absolute);
|
||||
if (decodeWidth > 0) image.DecodePixelWidth = decodeWidth;
|
||||
if (decodeWidth > 0)
|
||||
{
|
||||
image.DecodePixelWidth = decodeWidth;
|
||||
}
|
||||
else if (maxSize > 0)
|
||||
{
|
||||
// Dimensions lues dans l'en-tête, sans décoder l'image : réduction proportionnelle si besoin.
|
||||
int width, height;
|
||||
using (var stream = File.OpenRead(full))
|
||||
{
|
||||
var frame = BitmapDecoder.Create(stream, BitmapCreateOptions.DelayCreation, BitmapCacheOption.None).Frames[0];
|
||||
(width, height) = (frame.PixelWidth, frame.PixelHeight);
|
||||
}
|
||||
if (width >= height && width > maxSize) image.DecodePixelWidth = maxSize;
|
||||
else if (height > width && height > maxSize) image.DecodePixelHeight = maxSize;
|
||||
}
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
@@ -108,7 +131,7 @@ public static class ThemeManager
|
||||
if (specific is not null) result.MergedDictionaries.Add(specific);
|
||||
}
|
||||
|
||||
var background = LoadImage(backgroundOverride);
|
||||
var background = LoadImage(backgroundOverride, maxSize: MaxBackgroundSize);
|
||||
if (background is not null) result["BackgroundImage"] = background;
|
||||
|
||||
Cache[cacheKey] = result;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -459,6 +459,170 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Champ texte sur plusieurs lignes (description) : même apparence que ModernTextBoxStyle -->
|
||||
<Style x:Key="ModernMultilineTextBoxStyle" TargetType="TextBox" BasedOn="{StaticResource ModernTextBoxStyle}">
|
||||
<Setter Property="Height" Value="Auto" />
|
||||
<Setter Property="MinHeight" Value="96" />
|
||||
<Setter Property="MaxHeight" Value="220" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="AcceptsReturn" Value="True" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Top" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ Liste déroulante arrondie -->
|
||||
<Style x:Key="ModernComboBoxItemStyle" TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Bd" CornerRadius="9" Padding="10,8" Margin="0,1" Background="Transparent">
|
||||
<ContentPresenter VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ModernComboBoxToggleStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="ClickMode" Value="Press" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="Bd" CornerRadius="12" Background="{DynamicResource InputBrush}"
|
||||
BorderBrush="{DynamicResource BorderStrongBrush}" BorderThickness="1">
|
||||
<Path x:Name="Arrow" Width="10" Height="10" Margin="0,0,14,0" Stretch="Uniform"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource TextSecondaryBrush}" Data="M 0 0 L 5 5 L 10 0 Z" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ModernComboBoxStyle" TargetType="ComboBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource ModernComboBoxItemStyle}" />
|
||||
<Setter Property="MaxDropDownHeight" Value="280" />
|
||||
<Setter Property="ScrollViewer.CanContentScroll" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="Toggle" Style="{StaticResource ModernComboBoxToggleStyle}"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
<ContentPresenter Margin="12,0,34,0" VerticalAlignment="Center" HorizontalAlignment="Left" IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}" />
|
||||
<Popup x:Name="PART_Popup" Placement="Bottom" AllowsTransparency="True" Focusable="False"
|
||||
PopupAnimation="Fade" VerticalOffset="4"
|
||||
IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Border Margin="0,0,8,10" Padding="5" CornerRadius="12" SnapsToDevicePixels="True"
|
||||
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||
Background="{DynamicResource MenuBrush}" BorderBrush="{DynamicResource BorderStrongBrush}" BorderThickness="1">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="18" ShadowDepth="4" Direction="270" Opacity="0.5" Color="Black" />
|
||||
</Border.Effect>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Champ de phrase de passe : même apparence que ModernTextBoxStyle (administration) -->
|
||||
<Style x:Key="ModernPasswordBoxStyle" TargetType="PasswordBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="PasswordBox">
|
||||
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ Menu contextuel arrondi -->
|
||||
<Style TargetType="ContextMenu">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
|
||||
@@ -44,4 +44,46 @@
|
||||
<Geometry x:Key="Icon.ArrowRight">M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z</Geometry>
|
||||
<Geometry x:Key="Icon.LinkOff">M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.43 19.12,14.63 17.79,15L19.25,16.44C20.88,15.61 22,13.95 22,12A5,5 0 0,0 17,7M16,11H13.81L15.81,13H16V11M2,4.27L5.11,7.38C3.29,8.12 2,9.91 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12C3.9,10.41 5.11,9.1 6.66,8.93L8.73,11H8V13H10.73L13,15.27V17H14.73L18.74,21L20,19.74L3.27,3L2,4.27Z</Geometry>
|
||||
|
||||
<!-- Administration (module Admin) -->
|
||||
<Geometry x:Key="Icon.ShieldAccount">M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M17.13,17C15.92,18.85 14.11,20.24 12,20.92C9.89,20.24 8.08,18.85 6.87,17C6.53,16.5 6.24,16 6,15.47C6,13.82 8.71,12.47 12,12.47C15.29,12.47 18,13.79 18,15.47C17.76,16 17.47,16.5 17.13,17Z</Geometry>
|
||||
<Geometry x:Key="Icon.Lock">M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z</Geometry>
|
||||
<Geometry x:Key="Icon.LockOpenVariant">M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.89 2 10V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V10C18 8.9 17.11 8 16 8H15V6C15 4.34 16.34 3 18 3C19.66 3 21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17C8.9 17 8 16.11 8 15C8 13.9 8.9 13 10 13Z</Geometry>
|
||||
<Geometry x:Key="Icon.KeyVariant">M22,18V22H18V19H15V16H12L9.74,13.74C9.19,13.91 8.61,14 8,14A6,6 0 0,1 2,8A6,6 0 0,1 8,2A6,6 0 0,1 14,8C14,8.61 13.91,9.19 13.74,9.74L22,18M7,5A2,2 0 0,0 5,7A2,2 0 0,0 7,9A2,2 0 0,0 9,7A2,2 0 0,0 7,5Z</Geometry>
|
||||
<Geometry x:Key="Icon.KeyPlus">M7.5 3C9.5 3 11.1 4.2 11.7 6H21V9H18V12H15V9H11.7C11.1 10.8 9.4 12 7.5 12C5 12 3 10 3 7.5S5 3 7.5 3M7.5 6C6.7 6 6 6.7 6 7.5S6.7 9 7.5 9 9 8.3 9 7.5 8.3 6 7.5 6M8 17H11V14H13V17H16V19H13V22H11V19H8V17Z</Geometry>
|
||||
<Geometry x:Key="Icon.FileKey">M11 16C11 16.6 10.6 17 10 17S9 16.6 9 16C9 15.4 9.4 15 10 15S11 15.4 11 16M20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14M18 15H12.8C12.2 13.4 10.5 12.6 9 13.2C7.4 13.8 6.6 15.5 7.2 17S9.5 19.4 11 18.8C11.9 18.5 12.5 17.8 12.8 17H14V19H16V17H18M18.5 9L13 3.5V9H18.5Z</Geometry>
|
||||
<Geometry x:Key="Icon.AccountCircle">M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z</Geometry>
|
||||
<Geometry x:Key="Icon.Login">M11 7L9.6 8.4L12.2 11H2V13H12.2L9.6 15.6L11 17L16 12L11 7M20 19H12V21H20C21.1 21 22 20.1 22 19V5C22 3.9 21.1 3 20 3H12V5H20V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.Logout">M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z</Geometry>
|
||||
<Geometry x:Key="Icon.ViewDashboard">M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z</Geometry>
|
||||
<Geometry x:Key="Icon.Server">M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z</Geometry>
|
||||
<Geometry x:Key="Icon.ContentCopy">M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z</Geometry>
|
||||
<Geometry x:Key="Icon.Fingerprint">M17.81,4.47C17.73,4.47 17.65,4.45 17.58,4.41C15.66,3.42 14,3 12,3C10.03,3 8.15,3.47 6.44,4.41C6.2,4.54 5.9,4.45 5.76,4.21C5.63,3.97 5.72,3.66 5.96,3.53C7.82,2.5 9.86,2 12,2C14.14,2 16,2.47 18.04,3.5C18.29,3.65 18.38,3.95 18.25,4.19C18.16,4.37 18,4.47 17.81,4.47M3.5,9.72C3.4,9.72 3.3,9.69 3.21,9.63C3,9.47 2.93,9.16 3.09,8.93C4.08,7.53 5.34,6.43 6.84,5.66C10,4.04 14,4.03 17.15,5.65C18.65,6.42 19.91,7.5 20.9,8.9C21.06,9.12 21,9.44 20.78,9.6C20.55,9.76 20.24,9.71 20.08,9.5C19.18,8.22 18.04,7.23 16.69,6.54C13.82,5.07 10.15,5.07 7.29,6.55C5.93,7.25 4.79,8.25 3.89,9.5C3.81,9.65 3.66,9.72 3.5,9.72M9.75,21.79C9.62,21.79 9.5,21.74 9.4,21.64C8.53,20.77 8.06,20.21 7.39,19C6.7,17.77 6.34,16.27 6.34,14.66C6.34,11.69 8.88,9.27 12,9.27C15.12,9.27 17.66,11.69 17.66,14.66A0.5,0.5 0 0,1 17.16,15.16A0.5,0.5 0 0,1 16.66,14.66C16.66,12.24 14.57,10.27 12,10.27C9.43,10.27 7.34,12.24 7.34,14.66C7.34,16.1 7.66,17.43 8.27,18.5C8.91,19.66 9.35,20.15 10.12,20.93C10.31,21.13 10.31,21.44 10.12,21.64C10,21.74 9.88,21.79 9.75,21.79M16.92,19.94C15.73,19.94 14.68,19.64 13.82,19.05C12.33,18.04 11.44,16.4 11.44,14.66A0.5,0.5 0 0,1 11.94,14.16A0.5,0.5 0 0,1 12.44,14.66C12.44,16.07 13.16,17.4 14.38,18.22C15.09,18.7 15.92,18.93 16.92,18.93C17.16,18.93 17.56,18.9 17.96,18.83C18.23,18.78 18.5,18.96 18.54,19.24C18.59,19.5 18.41,19.77 18.13,19.82C17.56,19.93 17.06,19.94 16.92,19.94M14.91,22C14.87,22 14.82,22 14.78,22C13.19,21.54 12.15,20.95 11.06,19.88C9.66,18.5 8.89,16.64 8.89,14.66C8.89,13.04 10.27,11.72 11.97,11.72C13.67,11.72 15.05,13.04 15.05,14.66C15.05,15.73 16,16.6 17.13,16.6C18.28,16.6 19.21,15.73 19.21,14.66C19.21,10.89 15.96,7.83 11.96,7.83C9.12,7.83 6.5,9.41 5.35,11.86C4.96,12.67 4.76,13.62 4.76,14.66C4.76,15.44 4.83,16.67 5.43,18.27C5.53,18.53 5.4,18.82 5.14,18.91C4.88,19 4.59,18.87 4.5,18.62C4,17.31 3.77,16 3.77,14.66C3.77,13.46 4,12.37 4.45,11.42C5.78,8.63 8.73,6.82 11.96,6.82C16.5,6.82 20.21,10.33 20.21,14.65C20.21,16.27 18.83,17.59 17.13,17.59C15.43,17.59 14.05,16.27 14.05,14.65C14.05,13.58 13.12,12.71 11.97,12.71C10.82,12.71 9.89,13.58 9.89,14.65C9.89,16.36 10.55,17.96 11.76,19.16C12.71,20.1 13.62,20.62 15.03,21C15.3,21.08 15.45,21.36 15.38,21.62C15.33,21.85 15.12,22 14.91,22Z</Geometry>
|
||||
<Geometry x:Key="Icon.TicketAccount">M20,12A2,2 0 0,0 22,14V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V14C3.11,14 4,13.1 4,12A2,2 0 0,0 2,10V6C2,4.89 2.9,4 4,4H20A2,2 0 0,1 22,6V10A2,2 0 0,0 20,12M16.5,16.25C16.5,14.75 13.5,14 12,14C10.5,14 7.5,14.75 7.5,16.25V17H16.5V16.25M12,12.25A2.25,2.25 0 0,0 14.25,10A2.25,2.25 0 0,0 12,7.75A2.25,2.25 0 0,0 9.75,10A2.25,2.25 0 0,0 12,12.25Z</Geometry>
|
||||
<Geometry x:Key="Icon.ClockOutline">M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z</Geometry>
|
||||
<Geometry x:Key="Icon.FolderKey">M4 4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V8C22 6.9 21.1 6 20 6H12L10 4M11 10C12.3 10 13.4 10.8 13.8 12H20V14H18V16H16V14H13.8C13.4 15.2 12.3 16 11 16C9.3 16 8 14.7 8 13S9.3 10 11 10M11 12C10.4 12 10 12.4 10 13C10 13.6 10.4 14 11 14C11.6 14 12 13.6 12 13S11.6 12 11 12Z</Geometry>
|
||||
<Geometry x:Key="Icon.ArrowLeft">M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z</Geometry>
|
||||
<Geometry x:Key="Icon.ChevronRight">M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z</Geometry>
|
||||
<Geometry x:Key="Icon.AccountGroup">M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z</Geometry>
|
||||
<Geometry x:Key="Icon.AccountKey">M11 10V12H9V14H7V12H5.8C5.4 13.2 4.3 14 3 14C1.3 14 0 12.7 0 11S1.3 8 3 8C4.3 8 5.4 8.8 5.8 10H11M3 10C2.4 10 2 10.4 2 11S2.4 12 3 12 4 11.6 4 11 3.6 10 3 10M16 14C18.7 14 24 15.3 24 18V20H8V18C8 15.3 13.3 14 16 14M16 12C13.8 12 12 10.2 12 8S13.8 4 16 4 20 5.8 20 8 18.2 12 16 12Z</Geometry>
|
||||
<Geometry x:Key="Icon.ShieldKey">M12,8A1,1 0 0,1 13,9A1,1 0 0,1 12,10A1,1 0 0,1 11,9A1,1 0 0,1 12,8M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,6A3,3 0 0,0 9,9C9,10.31 9.83,11.42 11,11.83V18H13V16H15V14H13V11.83C14.17,11.42 15,10.31 15,9A3,3 0 0,0 12,6Z</Geometry>
|
||||
<Geometry x:Key="Icon.EmailOutline">M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z</Geometry>
|
||||
<Geometry x:Key="Icon.Chip">M6,4H18V5H21V7H18V9H21V11H18V13H21V15H18V17H21V19H18V20H6V19H3V17H6V15H3V13H6V11H3V9H6V7H3V5H6V4M11,15V18H12V15H11M13,15V18H14V15H13M15,15V18H16V15H15Z</Geometry>
|
||||
<Geometry x:Key="Icon.FileLockOutline">M18 13C16.6 13 15.2 14.1 15.2 15.5V17C14.6 17 14 17.6 14 18.2V21.7C14 22.4 14.6 23 15.2 23H20.7C21.4 23 22 22.4 22 21.8V18.3C22 17.6 21.4 17 20.8 17V15.5C20.8 14.1 19.4 13 18 13M18 14.2C18.8 14.2 19.5 14.7 19.5 15.5V17H16.5V15.5C16.5 14.7 17.2 14.2 18 14.2M6 2C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H12V20H6V4H13V9H18V11C18.7 11 19.4 11.2 20 11.4V8L14 2H6Z</Geometry>
|
||||
<Geometry x:Key="Icon.BadgeAccountHorizontal">M22,4H14V7H10V4H2A2,2 0 0,0 0,6V20A2,2 0 0,0 2,22H22A2,2 0 0,0 24,20V6A2,2 0 0,0 22,4M8,9A2,2 0 0,1 10,11A2,2 0 0,1 8,13A2,2 0 0,1 6,11A2,2 0 0,1 8,9M12,17H4V16C4,14.67 6.67,14 8,14C9.33,14 12,14.67 12,16V17M20,18H14V16H20V18M20,14H14V12H20V14M20,10H14V8H20V10M13,6H11V2H13V6Z</Geometry>
|
||||
|
||||
<!-- Catalogue des jeux (liens, page « Jeux ») : icônes génériques, pas de logos de marques -->
|
||||
<Geometry x:Key="Icon.Forum">M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z</Geometry>
|
||||
<Geometry x:Key="Icon.PlayCircleOutline">M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z</Geometry>
|
||||
<Geometry x:Key="Icon.At">M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z</Geometry>
|
||||
<Geometry x:Key="Icon.Broadcast">M12 10C10.9 10 10 10.9 10 12S10.9 14 12 14 14 13.1 14 12 13.1 10 12 10M18 12C18 8.7 15.3 6 12 6S6 8.7 6 12C6 14.2 7.2 16.1 9 17.2L10 15.5C8.8 14.8 8 13.5 8 12.1C8 9.9 9.8 8.1 12 8.1S16 9.9 16 12.1C16 13.6 15.2 14.9 14 15.5L15 17.2C16.8 16.2 18 14.2 18 12M12 2C6.5 2 2 6.5 2 12C2 15.7 4 18.9 7 20.6L8 18.9C5.6 17.5 4 14.9 4 12C4 7.6 7.6 4 12 4S20 7.6 20 12C20 15 18.4 17.5 16 18.9L17 20.6C20 18.9 22 15.7 22 12C22 6.5 17.5 2 12 2Z</Geometry>
|
||||
<Geometry x:Key="Icon.LinkVariant">M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z</Geometry>
|
||||
<Geometry x:Key="Icon.OpenInNew">M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.ImageOutline">M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z</Geometry>
|
||||
<Geometry x:Key="Icon.EyeOffOutline">M2,5.27L3.28,4L20,20.72L18.73,22L15.65,18.92C14.5,19.3 13.28,19.5 12,19.5C7,19.5 2.73,16.39 1,12C1.69,10.24 2.79,8.69 4.19,7.46L2,5.27M12,9A3,3 0 0,1 15,12C15,12.35 14.94,12.69 14.83,13L11,9.17C11.31,9.06 11.65,9 12,9M12,4.5C17,4.5 21.27,7.61 23,12C22.18,14.08 20.79,15.88 19,17.19L17.58,15.76C18.94,14.82 20.06,13.54 20.82,12C19.17,8.64 15.76,6.5 12,6.5C10.91,6.5 9.84,6.68 8.84,7L7.3,5.47C8.74,4.85 10.33,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C12.69,17.5 13.37,17.43 14,17.29L11.72,15C10.29,14.85 9.15,13.71 9,12.28L5.6,8.87C4.61,9.72 3.78,10.78 3.18,12Z</Geometry>
|
||||
<Geometry x:Key="Icon.ArrowUp">M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z</Geometry>
|
||||
<Geometry x:Key="Icon.ArrowDown">M11,4H13V16L18.5,10.5L19.92,11.92L12,19.84L4.08,11.92L5.5,10.5L11,16V4Z</Geometry>
|
||||
<Geometry x:Key="Icon.TextBoxOutline">M5,3C3.89,3 3,3.89 3,5V19C3,20.11 3.89,21 5,21H19C20.11,21 21,20.11 21,19V5C21,3.89 20.11,3 19,3H5M5,5H19V19H5V5M7,7V9H17V7H7M7,11V13H17V11H7M7,15V17H14V15H7Z</Geometry>
|
||||
<Geometry x:Key="Icon.Restore">M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z</Geometry>
|
||||
<Geometry x:Key="Icon.ContentSaveOutline">M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3M19 19H5V5H16.17L19 7.83V19M12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12M6 6H15V10H6V6Z</Geometry>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -111,7 +112,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
public LauncherConfig Config => _config;
|
||||
public string GameId => _config.GameId;
|
||||
public string GameName => _config.GameName;
|
||||
/// <summary>Nom affiché (catalogue, sinon launcher.json). Le raccourci et le registre gardent celui de launcher.json.</summary>
|
||||
public string GameName => _config.ShownName;
|
||||
public string ThemeName => string.IsNullOrWhiteSpace(_config.Theme) ? "default" : _config.Theme;
|
||||
|
||||
/// <summary>Initiales affichées dans la vignette quand le jeu n'a pas d'icône ("Exovival Zombie Reborn" → "EZR").</summary>
|
||||
@@ -149,6 +151,17 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
public string Publisher => _config.Publisher;
|
||||
public string Tagline => _config.Tagline;
|
||||
|
||||
/// <summary>Présentation du jeu (catalogue).</summary>
|
||||
public string? Description => _config.Description;
|
||||
public bool HasDescription => !string.IsNullOrWhiteSpace(_config.Description);
|
||||
|
||||
/// <summary>Liens du catalogue (Discord, YouTube…), adresses https contrôlées.</summary>
|
||||
public IReadOnlyList<GameLinkItem> Links => _links ??= _config.Links
|
||||
.Select(l => new GameLinkItem(l.Label, l.Url, GameLinkItem.IconFor(l.Kind), new RelayCommand(() => _window.OpenUrl(l.Url))))
|
||||
.ToList();
|
||||
private IReadOnlyList<GameLinkItem>? _links;
|
||||
public bool HasLinks => _config.Links.Count > 0;
|
||||
public string LauncherVersion { get; }
|
||||
public bool HasWebsite => !string.IsNullOrWhiteSpace(_config.WebsiteUrl);
|
||||
public bool IsExitConfirmed { get; private set; }
|
||||
@@ -692,7 +705,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var options = _dialogs.AskInstallOptions(GameName,
|
||||
// Nom de launcher.json (jamais celui du catalogue) : il sert aussi au sous-dossier d'installation.
|
||||
var options = _dialogs.AskInstallOptions(_config.GameName,
|
||||
_lastInstallDirectory ?? _config.ExpandedDefaultInstallDirectory,
|
||||
manifest.Archive.Size, manifest.Archive.Size + manifest.TotalSize);
|
||||
if (options is null) return;
|
||||
@@ -1502,3 +1516,18 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation du jeu (icône intégrée générique, jamais une image distante).</summary>
|
||||
public sealed record GameLinkItem(string Label, string Url, string IconKey, System.Windows.Input.ICommand OpenCommand)
|
||||
{
|
||||
public static string IconFor(string kind) => kind switch
|
||||
{
|
||||
"website" => "Web",
|
||||
"discord" => "Forum",
|
||||
"youtube" => "PlayCircleOutline",
|
||||
"x" => "At",
|
||||
"twitch" => "Broadcast",
|
||||
"steam" => "Gamepad",
|
||||
_ => "LinkVariant"
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<Window x:Class="GameLauncher.Views.Admin.AdminWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
xmlns:local="clr-namespace:GameLauncher.Views.Admin"
|
||||
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
|
||||
Title="{Binding WindowTitle}"
|
||||
Width="1080"
|
||||
Height="720"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
ResizeMode="CanMinimize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/launcher.ico"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal">
|
||||
|
||||
<!--
|
||||
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
|
||||
Un seul écran à la fois, selon AdminShellViewModel.Screen :
|
||||
• LoginViewModel → choix de la clé + phrase de passe ;
|
||||
• EnrollViewModel → « J'ai une invitation » (création de clé, empreinte à communiquer) ;
|
||||
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
|
||||
-->
|
||||
<Window.Resources>
|
||||
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
|
||||
<local:LoginView />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type avm:EnrollViewModel}">
|
||||
<local:EnrollView />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ================================================= Espace d'administration (connecté) -->
|
||||
<DataTemplate DataType="{x:Type avm:AdminWorkspaceViewModel}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="250" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Navigation -->
|
||||
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
|
||||
<DockPanel Margin="16,20,16,18">
|
||||
<!-- Compte connecté -->
|
||||
<Grid DockPanel.Dock="Top" Margin="4,0,0,22">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource AdminAvatarStyle}">
|
||||
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding DisplayName}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding RoleText}" FontSize="11.5" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Session : verrouillage -->
|
||||
<StackPanel DockPanel.Dock="Bottom">
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,0,0,14" />
|
||||
<StackPanel Orientation="Horizontal" Margin="4,0,0,4">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource TextMutedBrush}"
|
||||
Data="{StaticResource Icon.ClockOutline}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding SessionText}" FontSize="11.5" Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="4,0,0,14">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource TextMutedBrush}"
|
||||
Data="{StaticResource Icon.KeyVariant}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding KeyLabel}" FontSize="11.5" Margin="8,0,0,0" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" HorizontalAlignment="Stretch" Content="Verrouiller"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Lock}" Command="{Binding LockCommand}"
|
||||
ToolTip="Ferme la session et efface la clé de la mémoire (Ctrl+L)" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Top" Text="ADMINISTRATION" Style="{StaticResource SectionTitleStyle}" Margin="6,0,0,10" />
|
||||
<ListBox ItemsSource="{Binding PanelItems}" SelectedItem="{Binding SelectedPanel, Mode=TwoWay}"
|
||||
ItemContainerStyle="{StaticResource AdminNavItemStyle}"
|
||||
Background="Transparent" BorderThickness="0" Padding="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Page affichée -->
|
||||
<DockPanel Grid.Column="1" Margin="32,22,18,18">
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
|
||||
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
|
||||
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
|
||||
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<!-- Ombre portée -->
|
||||
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
|
||||
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
|
||||
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="64" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Barre de titre -->
|
||||
<Grid Background="Transparent" Margin="22,0,14,0" MouseLeftButtonDown="OnTitleBarMouseDown">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="36" Height="36" CornerRadius="11" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.ShieldAccount}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="19" Height="19" Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="Administration" FontSize="15" FontWeight="SemiBold" />
|
||||
<TextBlock FontSize="11" Foreground="{DynamicResource TextMutedBrush}">
|
||||
<Run Text="Serveur" />
|
||||
<Run Text="{Binding ServerHost, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border CornerRadius="10" Padding="10,5" Margin="0,0,12,0" Background="{DynamicResource ChipBrush}"
|
||||
Visibility="{Binding IsLoggedIn, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{DynamicResource SuccessBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Connecté" FontSize="11.5" Margin="7,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
|
||||
ToolTip="Réduire" Click="OnMinimize" />
|
||||
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
|
||||
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border Grid.Row="0" Height="1" VerticalAlignment="Bottom" Background="{DynamicResource BorderBrush}" />
|
||||
|
||||
<!-- Écran courant -->
|
||||
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace GameLauncher.Views.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre du menu d'administration. Toute la logique est dans <see cref="AdminShellViewModel"/> ;
|
||||
/// la fenêtre signale l'activité (report du verrouillage automatique), verrouille quand la session
|
||||
/// Windows est verrouillée, et déconnecte à la fermeture (la clé déchiffrée est effacée de la mémoire).
|
||||
/// </summary>
|
||||
public partial class AdminWindow : Window
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private bool _closed;
|
||||
private Point? _lastMousePosition;
|
||||
private bool _windowsSessionLocked;
|
||||
|
||||
public AdminWindow(AdminShellViewModel shell)
|
||||
{
|
||||
InitializeComponent();
|
||||
_shell = shell;
|
||||
DataContext = shell;
|
||||
|
||||
PreviewMouseMove += OnPreviewMouseMove;
|
||||
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
|
||||
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
Loaded += (_, _) => FitToScreen();
|
||||
|
||||
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
|
||||
SystemEvents.SessionSwitch += OnSessionSwitch;
|
||||
// Connexion terminée pendant que Windows était verrouillé : verrouillage immédiat.
|
||||
_shell.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(AdminShellViewModel.IsLoggedIn) && _shell.IsLoggedIn && _windowsSessionLocked)
|
||||
_ = _shell.LockAsync("Verrouillé avec la session Windows.");
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seul un VRAI déplacement compte comme activité : WPF envoie aussi des MouseMove « synthétiques »
|
||||
/// quand la mise en page change sous un curseur immobile (texte du temps restant, animations),
|
||||
/// qui empêcheraient sinon le verrouillage automatique.
|
||||
/// </summary>
|
||||
private void OnPreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var position = PointToScreen(e.GetPosition(this));
|
||||
if (_lastMousePosition is { } last && Math.Abs(last.X - position.X) < 1 && Math.Abs(last.Y - position.Y) < 1) return;
|
||||
_lastMousePosition = position;
|
||||
_shell.NotifyActivity();
|
||||
}
|
||||
|
||||
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
var work = SystemParameters.WorkArea;
|
||||
if (Height > work.Height) Height = work.Height;
|
||||
if (Width > work.Width) Width = work.Width;
|
||||
if (Top < work.Top) Top = work.Top;
|
||||
if (Left < work.Left) Left = work.Left;
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
_shell.NotifyActivity();
|
||||
if (e.Key == Key.L && Keyboard.Modifiers == ModifierKeys.Control && _shell.IsLoggedIn)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = _shell.LockAsync("Session verrouillée.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionSwitch(object sender, SessionSwitchEventArgs e)
|
||||
{
|
||||
var locked = e.Reason is SessionSwitchReason.SessionLock or SessionSwitchReason.ConsoleDisconnect or SessionSwitchReason.RemoteDisconnect;
|
||||
var unlocked = e.Reason is SessionSwitchReason.SessionUnlock or SessionSwitchReason.ConsoleConnect or SessionSwitchReason.RemoteConnect;
|
||||
if (!locked && !unlocked) return;
|
||||
// Événement reçu sur un autre thread.
|
||||
Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_windowsSessionLocked = locked;
|
||||
if (locked && !_closed && _shell.IsLoggedIn) _ = _shell.LockAsync("Verrouillé avec la session Windows.");
|
||||
});
|
||||
}
|
||||
|
||||
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
|
||||
DragMove();
|
||||
}
|
||||
|
||||
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
base.OnClosing(e);
|
||||
if (e.Cancel) return;
|
||||
_closed = true;
|
||||
SystemEvents.SessionSwitch -= OnSessionSwitch; // événement statique : sinon la fenêtre ne serait jamais libérée
|
||||
}
|
||||
|
||||
protected override async void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
try
|
||||
{
|
||||
await _shell.DisposeAsync(); // clé effacée immédiatement, puis déconnexion du serveur
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Administration : fermeture", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<UserControl x:Class="GameLauncher.Views.Admin.EnrollView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls">
|
||||
|
||||
<!--
|
||||
« J'ai une invitation » : la clé est créée sur ce PC (fichier chiffré par la phrase de passe),
|
||||
seule la clé PUBLIQUE est envoyée au serveur avec le code d'invitation. L'empreinte affichée
|
||||
ensuite est à communiquer à la personne qui approuve, par un autre canal (téléphone…).
|
||||
-->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False">
|
||||
<Grid Width="520" Margin="0,24,0,30" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
<!-- ================================================= Formulaire -->
|
||||
<StackPanel Visibility="{Binding IsForm, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" FontSize="12.5" Margin="0,0,0,18"
|
||||
Content="Retour à la connexion" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}"
|
||||
Command="{Binding BackCommand}" />
|
||||
|
||||
<Grid Margin="0,0,0,22">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="52" Height="52" CornerRadius="16" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.KeyPlus}" Fill="{DynamicResource OnAccentBrush}" Width="24" Height="24" Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="Créer ma clé d'administrateur" FontSize="20" FontWeight="SemiBold" />
|
||||
<TextBlock Text="La clé privée reste sur ce PC (fichier chiffré ou puce de sécurité). Seule la clé publique est envoyée."
|
||||
FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Code d'invitation" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,4" CharacterCasing="Upper" MaxLength="64"
|
||||
FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="14"
|
||||
Text="{Binding InvitationCode, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,0,0,16"
|
||||
Text="Reçu de l'administrateur qui vous invite (utilisable une seule fois, valable 24 h par défaut). Laissez vide pour créer seulement la clé (premier propriétaire, déclaré sur le serveur)." />
|
||||
|
||||
<TextBlock Text="Nom de ce PC (visible par les propriétaires)" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,16" MaxLength="64"
|
||||
Text="{Binding KeyLabel, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<!-- Rangement de la clé : la puce n'est proposée qu'une fois RÉELLEMENT vérifiée -->
|
||||
<TextBlock Text="Où ranger ma clé ?" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<RadioButton Style="{StaticResource AdminChoiceCardStyle}" GroupName="StorageFile" Margin="0,0,0,8"
|
||||
IsChecked="{Binding UseFile, Mode=TwoWay}" IsEnabled="{Binding CanChooseFile}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FileLockOutline}" Content="Fichier chiffré"
|
||||
Tag="Protégé par une phrase de passe. Copiable et sauvegardable (clé USB…) : utilisable sur plusieurs PC." />
|
||||
<RadioButton Style="{StaticResource AdminChoiceCardStyle}" GroupName="StorageTpm" Margin="0,0,0,6"
|
||||
IsChecked="{Binding UseTpm, Mode=TwoWay}" IsEnabled="{Binding CanChooseTpm}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Chip}" Content="Puce de sécurité (TPM) de ce PC"
|
||||
Tag="Créée dans la puce, protégée par un code PIN : elle ne peut pas être copiée, même par un virus. Liée à ce PC et à votre compte Windows." />
|
||||
<StackPanel Orientation="Horizontal" Margin="4,0,0,18">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="16" Height="3" IsIndeterminate="True" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" Visibility="{Binding IsCheckingTpm, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Margin="0,1,8,0" VerticalAlignment="Top"
|
||||
Visibility="{Binding IsCheckingTpm, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<Path.Style>
|
||||
<Style TargetType="Path">
|
||||
<Setter Property="Data" Value="{DynamicResource Icon.Info}" />
|
||||
<Setter Property="Fill" Value="{DynamicResource TextMutedBrush}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsTpmAvailable}" Value="True">
|
||||
<Setter Property="Data" Value="{DynamicResource Icon.CheckCircle}" />
|
||||
<Setter Property="Fill" Value="{DynamicResource SuccessBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Path.Style>
|
||||
</Path>
|
||||
<TextBlock Text="{Binding TpmStatus}" Style="{StaticResource AdminHintStyle}" MaxWidth="480" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding KeyFileLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<Border Style="{StaticResource AdminCardStyle}" Padding="14,8" Margin="0,0,0,16">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Changer…"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ChangePathCommand}" />
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{StaticResource Icon.FileKey}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding KeyPath}" Margin="10,0,12,0" VerticalAlignment="Center" FontSize="12.5"
|
||||
TextTrimming="CharacterEllipsis" ToolTip="{Binding KeyPath}" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="{Binding SecretLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<PasswordBox x:Name="PassphraseBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,16" />
|
||||
<TextBlock Text="{Binding ConfirmationLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<PasswordBox x:Name="ConfirmationBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,4"
|
||||
KeyDown="OnConfirmationKeyDown" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,0,0,18" Text="{Binding SecretHint}" />
|
||||
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="450" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" Margin="0,0,0,12" IsIndeterminate="{Binding IsBusy}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Content="Créer ma clé"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.KeyVariant}" Click="OnSubmitClick" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ================================================= Résultat -->
|
||||
<StackPanel Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}">
|
||||
<Border Width="64" Height="64" CornerRadius="20" HorizontalAlignment="Center" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.ShieldKey}" Fill="{DynamicResource OnAccentBrush}" Width="28" Height="28" Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<!-- Avec invitation : en attente d'approbation -->
|
||||
<StackPanel Visibility="{Binding IsEnrolled, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Clé enregistrée, en attente d'approbation" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,0"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Margin="0,6,0,22" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}">
|
||||
<Run Text="Compte :" />
|
||||
<Run Text="{Binding AccountName, Mode=OneWay}" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<LineBreak />
|
||||
<Run Text="Communiquez l'empreinte ci-dessous à la personne qui approuve, de vive voix ou par un autre canal que celui de l'invitation. Elle la saisira pour approuver votre clé." />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Sans invitation : clé seule -->
|
||||
<StackPanel Visibility="{Binding IsKeyOnly, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Clé créée sur ce PC" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,0" HorizontalAlignment="Center" />
|
||||
<TextBlock Margin="0,6,0,22" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Text="Elle n'est encore associée à aucun compte. Premier propriétaire : déclarez la clé publique (.pub) sur le serveur avec la commande add-owner (docs\ADMIN-ARCHITECTURE.md). Clé perdue ou nouveau PC sans invitation : add-key. Sinon, demandez une invitation." />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Invitation envoyée, réponse du serveur perdue : clé conservée -->
|
||||
<StackPanel Visibility="{Binding IsUnconfirmed, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Enregistrement non confirmé" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,14" HorizontalAlignment="Center" />
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding UnconfirmedText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="450" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Empreinte -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Padding="20,16">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Path Width="26" Height="26" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Fingerprint}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="16,0,12,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="EMPREINTE DE LA CLÉ" Style="{StaticResource SectionTitleStyle}" Margin="0,0,0,4" />
|
||||
<TextBlock Text="{Binding Fingerprint}" Style="{StaticResource FingerprintTextStyle}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource SmallButtonStyle}" Content="Copier" VerticalAlignment="Center"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyFingerprintCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Fichiers -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Padding="20,14">
|
||||
<StackPanel>
|
||||
<!-- Clé dans la puce -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10"
|
||||
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Chip}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Clé rangée dans la puce de sécurité (TPM) de ce PC : elle ne peut pas être copiée."
|
||||
Margin="8,0,0,0" FontSize="12.5" FontWeight="SemiBold" TextWrapping="Wrap" MaxWidth="440" />
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}"
|
||||
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}">
|
||||
<Run Text="Fichier de référence (sans secret, inutile sur un autre PC) :" />
|
||||
<LineBreak />
|
||||
<Run Text="{Binding CreatedKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<LineBreak />
|
||||
<Run Text="Clé publique :" />
|
||||
<LineBreak />
|
||||
<Run Text="{Binding PublicKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}"
|
||||
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<Run Text="Clé privée (chiffrée, ne la transmettez jamais) :" />
|
||||
<LineBreak />
|
||||
<Run Text="{Binding CreatedKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<LineBreak />
|
||||
<Run Text="Clé publique :" />
|
||||
<LineBreak />
|
||||
<Run Text="{Binding PublicKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</TextBlock>
|
||||
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="0,10,0,0" FontSize="12.5"
|
||||
Content="Ouvrir le dossier" ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}"
|
||||
Command="{Binding OpenKeyFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Margin="0,6,0,0"
|
||||
Content="Continuer vers la connexion" ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}"
|
||||
Command="{Binding ContinueCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
namespace GameLauncher.Views.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// « J'ai une invitation ». Les phrases de passe restent dans les PasswordBox (jamais liées au
|
||||
/// ViewModel) et sont effacées dès qu'elles ont été transmises.
|
||||
/// </summary>
|
||||
public partial class EnrollView : UserControl
|
||||
{
|
||||
public EnrollView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnSubmitClick(object sender, RoutedEventArgs e) => await SubmitAsync();
|
||||
|
||||
private async void OnConfirmationKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
e.Handled = true;
|
||||
await SubmitAsync();
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
if (DataContext is not EnrollViewModel { IsBusy: false, IsForm: true } vm) return;
|
||||
var passphrase = PassphraseBox.Password;
|
||||
var confirmation = ConfirmationBox.Password;
|
||||
PassphraseBox.Clear();
|
||||
ConfirmationBox.Clear();
|
||||
await vm.SubmitAsync(passphrase, confirmation);
|
||||
if (vm.IsForm && IsLoaded) PassphraseBox.Focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<UserControl x:Class="GameLauncher.Views.Admin.LoginView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls">
|
||||
|
||||
<!--
|
||||
Connexion : la clé d'administrateur (fichier chiffré) signe un défi du serveur.
|
||||
La phrase de passe ne quitte jamais cette vue : elle est passée à LoginAsync puis effacée.
|
||||
-->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False">
|
||||
<StackPanel Width="470" Margin="0,30,0,30" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- En-tête -->
|
||||
<Border Width="64" Height="64" CornerRadius="20" HorizontalAlignment="Center" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.Lock}" Fill="{DynamicResource OnAccentBrush}" Width="28" Height="28" Stretch="Uniform" />
|
||||
</Border>
|
||||
<TextBlock Text="Connexion administrateur" FontSize="22" FontWeight="SemiBold" Margin="0,18,0,0" HorizontalAlignment="Center" />
|
||||
<TextBlock Margin="0,6,0,24" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Text="Votre clé signe un défi envoyé par le serveur : aucun mot de passe ne circule." />
|
||||
|
||||
<!-- Information (verrouillage, clé à faire approuver…) -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource InfoBrush}" Data="{StaticResource Icon.Info}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="400" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Clé -->
|
||||
<DockPanel Margin="0,0,0,10">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" ToolTip="Actualiser la liste"
|
||||
Command="{Binding RefreshCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderKey}" ToolTip="Ouvrir le dossier des clés"
|
||||
Command="{Binding OpenKeysFolderCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="VOTRE CLÉ" Style="{StaticResource SectionTitleStyle}" Margin="0" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
|
||||
<ListBox ItemsSource="{Binding Keys}" SelectedItem="{Binding SelectedKey, Mode=TwoWay}"
|
||||
ItemContainerStyle="{StaticResource AdminKeyItemStyle}" MaxHeight="216"
|
||||
Background="Transparent" BorderThickness="0" Padding="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<Border Style="{StaticResource AdminCardStyle}" Margin="0,0,0,8"
|
||||
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Aucune clé trouvée" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0">
|
||||
<Run Text="Dossier examiné :" />
|
||||
<Run Text="{Binding KeysDirectory, Mode=OneWay}" />
|
||||
<LineBreak />
|
||||
<Run Text="Choisissez votre fichier .key, ou créez une clé avec « J'ai une invitation »." />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<DockPanel Margin="2,2,0,20">
|
||||
<!-- Clés de la puce uniquement (une clé fichier se supprime à la main) -->
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource LinkButtonStyle}" FontSize="12.5"
|
||||
Foreground="{DynamicResource DangerBrush}" Content="Supprimer cette clé de la puce"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding DeleteTpmKeyCommand}"
|
||||
ToolTip="Efface définitivement la clé de la puce de sécurité de ce PC (après confirmation)"
|
||||
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" FontSize="12.5"
|
||||
Content="Choisir un autre fichier de clé…" ui:IconAssist.Icon="{StaticResource Icon.FileKey}"
|
||||
Command="{Binding BrowseKeyCommand}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Puce bloquée par sa protection anti-essais : temps d'attente -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding ShowTpmLockout, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.ClockOutline}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
||||
<TextBlock Text="Puce de sécurité bloquée" FontSize="12.5" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding LockoutText}" FontSize="12.5" Margin="0,3,0,0" TextWrapping="Wrap" />
|
||||
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="0,8,0,0" FontSize="12"
|
||||
Content="Revérifier maintenant" ui:IconAssist.Icon="{StaticResource Icon.Refresh}"
|
||||
Command="{Binding RecheckLockoutCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Phrase de passe -->
|
||||
<TextBlock Text="{Binding SecretLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<PasswordBox x:Name="PassphraseBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,16"
|
||||
IsEnabled="{Binding HasKeys}" KeyDown="OnPassphraseKeyDown" />
|
||||
|
||||
<!-- Erreur -->
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="400" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" Margin="0,0,0,12" IsIndeterminate="{Binding IsBusy}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Content="Se connecter"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Login}" IsEnabled="{Binding CanLogin}" Click="OnLoginClick" />
|
||||
|
||||
<!-- Invitation -->
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,26,0,18" />
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource AdminIconBadgeStyle}" VerticalAlignment="Center">
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.TicketAccount}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="12,0,12,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="Nouvel administrateur ou nouveau PC ?" FontSize="13" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Créez votre clé avec le code d'invitation reçu." Style="{StaticResource AdminHintStyle}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource SmallButtonStyle}" Content="J'ai une invitation"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.KeyPlus}" Command="{Binding ShowEnrollCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
namespace GameLauncher.Views.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Écran de connexion. La phrase de passe reste dans le PasswordBox (jamais liée au ViewModel) :
|
||||
/// elle est lue au moment de la connexion puis effacée, que la connexion réussisse ou non.
|
||||
/// </summary>
|
||||
public partial class LoginView : UserControl
|
||||
{
|
||||
public LoginView()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (PassphraseBox.IsEnabled) PassphraseBox.Focus();
|
||||
};
|
||||
}
|
||||
|
||||
private async void OnLoginClick(object sender, RoutedEventArgs e) => await LoginAsync();
|
||||
|
||||
private async void OnPassphraseKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
e.Handled = true;
|
||||
await LoginAsync();
|
||||
}
|
||||
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
if (DataContext is not LoginViewModel { CanLogin: true } vm) return;
|
||||
var passphrase = PassphraseBox.Password;
|
||||
PassphraseBox.Clear();
|
||||
await vm.LoginAsync(passphrase);
|
||||
if (IsLoaded && PassphraseBox.IsEnabled) PassphraseBox.Focus(); // échec : nouvel essai
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
@@ -224,7 +224,9 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Grid Width="42" Height="42">
|
||||
<!-- Logo : 5 clics rapides ouvrent le menu d'administration (accès discret, voir MainWindow.xaml.cs) -->
|
||||
<Grid x:Name="LogoBadge" Width="42" Height="42" Background="Transparent"
|
||||
PreviewMouseLeftButtonDown="OnLogoMouseDown">
|
||||
<Border CornerRadius="13" Background="{DynamicResource AccentGradientBrush}" Opacity="0.6">
|
||||
<Border.Effect>
|
||||
<BlurEffect Radius="16" />
|
||||
@@ -295,6 +297,29 @@
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding Tagline, Converter={StaticResource StringToVisibility}}" />
|
||||
|
||||
<!-- Présentation et liens (catalogue des jeux, modifiables depuis le menu d'administration) -->
|
||||
<ScrollViewer Margin="0,12,0,0" MaxWidth="540" MaxHeight="96" HorizontalAlignment="Left"
|
||||
VerticalScrollBarVisibility="Auto" Focusable="False"
|
||||
Visibility="{Binding HasDescription, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="{Binding Description}" FontSize="13" LineHeight="20" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Links}" Margin="0,14,0,0" Focusable="False"
|
||||
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel MaxWidth="540" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Margin="0,0,8,8" Content="{Binding Label}"
|
||||
ToolTip="{Binding Url}" Command="{Binding OpenCommand}"
|
||||
ui:IconAssist.Icon="{Binding IconKey, Converter={StaticResource IconKey}}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Informations sur l'installation -->
|
||||
<WrapPanel Margin="0,28,0,0">
|
||||
<Border Style="{StaticResource ChipStyle}"
|
||||
|
||||
@@ -16,9 +16,42 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
/// <summary>Largeur ajoutée à la fenêtre quand la liste des jeux est affichée.</summary>
|
||||
public const double GameRailWidth = 88;
|
||||
|
||||
/// <summary>Clics sur le logo nécessaires pour ouvrir le menu d'administration, et délai pour les faire.</summary>
|
||||
private const int AdminLogoClicks = 5;
|
||||
private static readonly TimeSpan AdminLogoDelay = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly Queue<DateTime> _logoClicks = new();
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accès discret au menu d'administration : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo.
|
||||
/// Discret ne veut pas dire protégé : seule la connexion par clé (vérifiée par le serveur) l'est.
|
||||
/// </summary>
|
||||
public event Action? AdminRequested;
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
|
||||
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
||||
if (key != Key.A || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift | ModifierKeys.Alt)) return;
|
||||
e.Handled = true;
|
||||
AdminRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnLogoMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
while (_logoClicks.Count > 0 && now - _logoClicks.Peek() > AdminLogoDelay) _logoClicks.Dequeue();
|
||||
_logoClicks.Enqueue(now);
|
||||
if (_logoClicks.Count < AdminLogoClicks) return;
|
||||
_logoClicks.Clear();
|
||||
e.Handled = true; // pas de déplacement de la fenêtre pour ce clic
|
||||
AdminRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
// tools\publish-launcher-update.bat. Supprimez la ligne pour désactiver l'auto-mise à jour.
|
||||
"launcherUpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
|
||||
|
||||
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo) :
|
||||
// adresse de l'API du serveur, en https. Par sécurité, la version Release ne la lit QUE dans le
|
||||
// launcher.json intégré à l'exécutable (jamais dans un fichier posé à côté). Supprimez la ligne
|
||||
// pour retirer le menu d'administration.
|
||||
"apiUrl": "https://launcher.lanisys.fr/api/",
|
||||
|
||||
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
|
||||
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
|
||||
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
|
||||
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
|
||||
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
|
||||
|
||||
"websiteUrl": "https://lanisys.fr",
|
||||
|
||||
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
// de produit pour ces mises à jour : ne le changez plus une fois le launcher distribué.
|
||||
"launcherUpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
|
||||
|
||||
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo) :
|
||||
// adresse de l'API du serveur, en https. Par sécurité, la version Release ne la lit QUE dans le
|
||||
// launcher.json intégré à l'exécutable (jamais dans un fichier posé à côté). Propriété de la racine
|
||||
// uniquement (un seul serveur pour tous les jeux). Supprimez la ligne pour retirer le menu d'administration.
|
||||
"apiUrl": "https://launcher.lanisys.fr/api/",
|
||||
|
||||
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
|
||||
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
|
||||
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
|
||||
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
|
||||
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
|
||||
|
||||
// Jeu affiché au premier lancement (ensuite : le dernier jeu sélectionné).
|
||||
"defaultGame": "EZR",
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
public class AdminKeyLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Finds_encrypted_keys_with_their_fingerprint_and_never_the_launcher_signing_key()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using (signer)
|
||||
{
|
||||
AdminTestKit.CreateKeyFile(folder.Path, "launcher-signing").Signer.Dispose(); // clé de signature du launcher
|
||||
File.WriteAllText(folder.File("notes.key"), "pas une clé");
|
||||
using var other = new TempFolder();
|
||||
var (elsewhere, otherSigner) = AdminTestKit.CreateKeyFile(other.Path, "portable");
|
||||
otherSigner.Dispose();
|
||||
File.Delete(Path.ChangeExtension(elsewhere, ".pub")); // .pub absent : empreinte inconnue
|
||||
|
||||
var keys = AdminKeyLocator.Find(folder.Path, new[] { elsewhere, path });
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Equal("admin", keys[0].Name);
|
||||
Assert.Equal(signer.Fingerprint, keys[0].Fingerprint);
|
||||
Assert.Equal(signer.KeyId, keys[0].KeyId);
|
||||
Assert.Equal(Path.GetFullPath(elsewhere), keys[1].Path);
|
||||
Assert.Null(keys[1].Fingerprint);
|
||||
Assert.DoesNotContain(keys, k => k.Name == "launcher-signing" || k.Name == "notes");
|
||||
Assert.True(AdminKeyLocator.IsReserved(folder.File("Launcher-Signing.key")));
|
||||
Assert.True(AdminKeyLocator.IsReserved(folder.File("launcher-signing - Copie.key")));
|
||||
Assert.True(AdminKeyLocator.IsReserved(folder.File("launcher-signing.key.")));
|
||||
Assert.False(AdminKeyLocator.IsReserved(folder.File("admin.key")));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suggests_a_free_file_name()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
Assert.Equal(folder.File("admin.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path));
|
||||
AdminTestKit.CreateKeyFile(folder.Path).Signer.Dispose();
|
||||
Assert.Equal(folder.File("admin-2.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_remembered_paths_are_ignored()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
signer.Dispose();
|
||||
var keys = AdminKeyLocator.Find(folder.Path, new[] { "C:\\bad\0path.key", "", path, path });
|
||||
Assert.Equal(path, Assert.Single(keys).Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_folder_is_not_an_error()
|
||||
{
|
||||
Assert.Empty(AdminKeyLocator.Find(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))));
|
||||
}
|
||||
}
|
||||
|
||||
public class AdminSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Remembers_recent_keys_and_survives_a_corrupted_file()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
var path = folder.File("admin.json");
|
||||
var settings = new AdminSettings();
|
||||
for (var i = 0; i < 12; i++) settings.Remember(folder.File($"k{i}.key"));
|
||||
settings.Remember(folder.File("k3.key"));
|
||||
settings.Save(path);
|
||||
|
||||
var loaded = AdminSettings.Load(path);
|
||||
Assert.Equal(folder.File("k3.key"), loaded.LastKeyPath);
|
||||
Assert.Equal(10, loaded.KeyPaths.Count);
|
||||
Assert.Equal(folder.File("k3.key"), loaded.KeyPaths[0]);
|
||||
Assert.Single(loaded.KeyPaths, p => p == folder.File("k3.key"));
|
||||
|
||||
File.WriteAllText(path, "{ illisible");
|
||||
Assert.Empty(AdminSettings.Load(path).KeyPaths);
|
||||
}
|
||||
}
|
||||
|
||||
public class AdminLoginAndEnrollmentTests
|
||||
{
|
||||
private static void Owner(TestApi api, IAdminSigner key, string name = "Nicolas") =>
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", name, "--public-key", Convert.ToBase64String(key.PublicKey) },
|
||||
TextWriter.Null, api.DataDirectory));
|
||||
|
||||
[Fact]
|
||||
public async Task Login_with_a_key_file_and_logout_on_dispose()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using (signer) Owner(api, signer);
|
||||
|
||||
var connection = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
|
||||
Assert.Equal("Nicolas", connection.Session.Profile.DisplayName);
|
||||
Assert.Equal(signer.Fingerprint, connection.Fingerprint);
|
||||
Assert.True(connection.LocalExpiresAt > DateTimeOffset.UtcNow + TimeSpan.FromHours(7));
|
||||
|
||||
await connection.DisposeAsync();
|
||||
Assert.False(connection.Session.IsOpen);
|
||||
await connection.DisposeAsync(); // deux fois : sans effet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_passphrase_and_launcher_signing_key_are_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
signer.Dispose();
|
||||
var service = new AdminLoginService(api.CreateClient(), "tests");
|
||||
|
||||
var wrong = await Assert.ThrowsAsync<CryptographicException>(() => service.LoginAsync(path, "mauvaise phrase de passe"));
|
||||
Assert.Contains("Phrase de passe incorrecte", ApiErrorText.Describe(wrong));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing.key"), AdminTestKit.Passphrase));
|
||||
File.Copy(path, folder.File("launcher-signing - Copie.key"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing - Copie.key"), AdminTestKit.Passphrase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_key_gets_a_clear_message()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
signer.Dispose();
|
||||
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase));
|
||||
Assert.Contains("en attente d'approbation", ApiErrorText.Describe(refused, duringLogin: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_creates_the_key_file_and_a_pending_enrollment()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var folder = new TempFolder();
|
||||
|
||||
var created = await new AdminEnrollmentService(api.CreateClient())
|
||||
.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, invitation.Code, "PC de Paul");
|
||||
|
||||
Assert.True(File.Exists(created.KeyPath));
|
||||
Assert.True(File.Exists(created.PublicKeyPath));
|
||||
Assert.NotNull(created.Enrollment);
|
||||
Assert.Equal("Paul", created.Enrollment!.DisplayName);
|
||||
Assert.Equal(created.Fingerprint, created.Enrollment.Fingerprint);
|
||||
Assert.Contains("ENCRYPTED PRIVATE KEY", File.ReadAllText(created.KeyPath));
|
||||
|
||||
await owner.ApproveKeyAsync(created.Enrollment.AdminId, created.KeyId, created.Fingerprint);
|
||||
await using var paul = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(created.KeyPath, AdminTestKit.Passphrase);
|
||||
Assert.Equal("Paul", paul.Session.Profile.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refused_invitation_leaves_no_key_file_behind()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var service = new AdminEnrollmentService(api.CreateClient());
|
||||
|
||||
await Assert.ThrowsAsync<ApiException>(() =>
|
||||
service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, InvitationCodes.Generate(), null));
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.key"), "courte", null, null));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.key"), AdminTestKit.Passphrase, "ABCD-0000", null));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.CreateKeyAsync(folder.File("launcher-signing.key"), AdminTestKit.Passphrase, null, null));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.txt"), AdminTestKit.Passphrase, null, null));
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lost_server_answer_keeps_the_key_and_explains_what_to_do()
|
||||
{
|
||||
// Port fermé : la requête d'enregistrement n'obtient aucune réponse.
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
using var folder = new TempFolder();
|
||||
using var client = new LauncherApiClient(new Uri($"http://127.0.0.1:{port}/api/"), "tests");
|
||||
|
||||
var created = await new AdminEnrollmentService(client)
|
||||
.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, InvitationCodes.Generate(), null);
|
||||
|
||||
Assert.Null(created.Enrollment);
|
||||
Assert.Contains("n'a pas confirmé", created.EnrollmentUnconfirmed);
|
||||
Assert.True(File.Exists(created.KeyPath));
|
||||
Assert.True(File.Exists(created.PublicKeyPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_files_are_never_overwritten_and_never_left_half_written()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
File.WriteAllText(folder.File("admin.pub"), "clé publique existante");
|
||||
using var key = AdminKeyFile.Generate();
|
||||
|
||||
Assert.Throws<IOException>(() => AdminKeyFile.Save(key, folder.File("admin.key"), AdminTestKit.Passphrase));
|
||||
Assert.False(File.Exists(folder.File("admin.key")));
|
||||
Assert.Equal("clé publique existante", File.ReadAllText(folder.File("admin.pub")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Key_without_invitation_is_only_created_locally_and_never_overwritten()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var service = new AdminEnrollmentService(api.CreateClient());
|
||||
|
||||
var created = await service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, " ", "PC bureau");
|
||||
Assert.Null(created.Enrollment);
|
||||
Assert.Equal(AdminKeys.ComputeFingerprint(AdminKeys.ParsePublicKey(File.ReadAllText(created.PublicKeyPath))), created.Fingerprint);
|
||||
await Assert.ThrowsAsync<IOException>(() => service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
public class AdminSessionGuardTests
|
||||
{
|
||||
private static async Task<(TestApi Api, AdminConnection Connection, EcdsaAdminSigner Key)> ConnectAsync()
|
||||
{
|
||||
var api = await TestApi.StartAsync();
|
||||
var folder = Path.Combine(Path.GetTempPath(), "launcher-admin-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(folder);
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder);
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", "Nicolas", "--public-key", Convert.ToBase64String(signer.PublicKey) },
|
||||
TextWriter.Null, api.DataDirectory));
|
||||
var connection = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
|
||||
return (api, connection, signer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locks_after_inactivity_and_activity_postpones_it()
|
||||
{
|
||||
var (api, connection, key) = await ConnectAsync();
|
||||
await using var _ = api;
|
||||
await using var __ = connection;
|
||||
using var ___ = key;
|
||||
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
|
||||
string? reason = null;
|
||||
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(8));
|
||||
guard.NotifyActivity();
|
||||
api.Time.Advance(TimeSpan.FromMinutes(8));
|
||||
await guard.TickAsync();
|
||||
Assert.Null(reason);
|
||||
Assert.InRange(guard.Remaining, TimeSpan.FromMinutes(1.9), TimeSpan.FromMinutes(2.1));
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(2));
|
||||
await guard.TickAsync();
|
||||
Assert.Contains("inactivité", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Keeps_the_server_session_alive_while_the_admin_is_active()
|
||||
{
|
||||
var (api, connection, key) = await ConnectAsync();
|
||||
await using var _ = api;
|
||||
await using var __ = connection;
|
||||
using var ___ = key;
|
||||
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
|
||||
string? reason = null;
|
||||
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
|
||||
|
||||
// 40 minutes d'activité sans aucune requête de l'administrateur : le serveur (20 min d'inactivité) garde la session.
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
api.Time.Advance(TimeSpan.FromMinutes(2.5));
|
||||
guard.NotifyActivity();
|
||||
await guard.TickAsync();
|
||||
}
|
||||
Assert.Null(reason);
|
||||
await connection.Session.RefreshAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locks_when_the_server_revokes_the_session_or_the_maximum_duration_is_reached()
|
||||
{
|
||||
var (api, connection, key) = await ConnectAsync();
|
||||
await using var _ = api;
|
||||
await using var __ = connection;
|
||||
using var ___ = key;
|
||||
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
|
||||
string? reason = null;
|
||||
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
|
||||
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "revoke-key", "--key", key.KeyId }, TextWriter.Null, api.DataDirectory));
|
||||
api.Time.Advance(TimeSpan.FromMinutes(5));
|
||||
guard.NotifyActivity();
|
||||
await guard.TickAsync();
|
||||
Assert.Contains("révoquée", reason);
|
||||
|
||||
var (api2, connection2, key2) = await ConnectAsync();
|
||||
await using var ____ = api2;
|
||||
await using var _____ = connection2;
|
||||
using var ______ = key2;
|
||||
using var guard2 = new AdminSessionGuard(connection2, api2.Time, ImmediateDispatcher.Instance, TimeSpan.FromHours(24), startTimer: false);
|
||||
string? reason2 = null;
|
||||
guard2.LockRequested = r => { reason2 = r; return Task.CompletedTask; };
|
||||
api2.Time.Advance(TimeSpan.FromHours(8) + TimeSpan.FromMinutes(1));
|
||||
await guard2.TickAsync();
|
||||
Assert.Contains("durée maximale", reason2);
|
||||
}
|
||||
}
|
||||
|
||||
public class ModuleCatalogTests
|
||||
{
|
||||
private sealed class TestModule : ILauncherModule
|
||||
{
|
||||
private readonly AdminPanelDescriptor[] _panels;
|
||||
public TestModule(string id, params AdminPanelDescriptor[] panels)
|
||||
{
|
||||
Id = id;
|
||||
_panels = panels;
|
||||
}
|
||||
public string Id { get; }
|
||||
public string DisplayName => Id;
|
||||
public void Register(IModuleRegistry registry)
|
||||
{
|
||||
foreach (var panel in _panels) registry.AddAdminPanel(panel);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdminPanelDescriptor Panel(string id, string? permission, int order) =>
|
||||
new(id, id, "", "Info", permission, order, _ => throw new NotSupportedException());
|
||||
|
||||
[Fact]
|
||||
public void Panels_are_ordered_and_filtered_by_permission()
|
||||
{
|
||||
var catalog = new ModuleCatalog(new ILauncherModule[]
|
||||
{
|
||||
new AdminModule(),
|
||||
new TestModule("tests", Panel("tests.admins", AdminPermissions.AdminsManage, 50), Panel("tests.games", AdminPermissions.GamesEdit, 10))
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "admin.dashboard", "tests.games", "tests.admins" }, catalog.AdminPanels.Select(p => p.Id));
|
||||
Assert.Equal(new[] { "admin.dashboard", "tests.games" },
|
||||
catalog.AdminPanelsFor(AdminRoles.PermissionsOf(AdminRoles.Editor).ToList()).Select(p => p.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_declarations_are_refused()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new AdminModule() }));
|
||||
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[]
|
||||
{ new TestModule("a", Panel("x", null, 0)), new TestModule("b", Panel("X", null, 1)) }));
|
||||
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[]
|
||||
{ new TestModule("a", Panel("y", "sans-point", 0)) }));
|
||||
}
|
||||
}
|
||||
|
||||
public class LauncherApiUrlTests
|
||||
{
|
||||
private const string Game = "\"gameId\": \"EZR\", \"gameName\": \"Jeu\", \"manifestUrl\": \"https://launcher.example/ezr/manifest.json\"";
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://launcher.example/api", "https://launcher.example/api/")]
|
||||
[InlineData("https://launcher.example/api/", "https://launcher.example/api/")]
|
||||
[InlineData("http://127.0.0.1:5080/api/", "http://127.0.0.1:5080/api/")]
|
||||
public void Api_url_is_read_and_normalized(string configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": \"{configured}\" }}").ApiUrl);
|
||||
Assert.Equal(expected, LauncherCatalog.Parse($"{{ \"apiUrl\": \"{configured}\", \"games\": [ {{ {Game} }} ] }}").ApiUrl);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://launcher.example/api/")]
|
||||
[InlineData("https://launcher.example/api/?redirect=evil")]
|
||||
[InlineData("https://user:pass@launcher.example/api/")]
|
||||
[InlineData("ftp://launcher.example/api/")]
|
||||
[InlineData("api/")]
|
||||
public void Unsafe_api_urls_are_ignored_without_breaking_the_launcher(string configured)
|
||||
{
|
||||
var catalog = LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": \"{configured}\" }}");
|
||||
Assert.Null(catalog.ApiUrl);
|
||||
Assert.Contains("apiUrl ignorée", catalog.ApiUrlError);
|
||||
Assert.Single(catalog.Games);
|
||||
|
||||
var notText = LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": 42 }}");
|
||||
Assert.Null(notText.ApiUrl);
|
||||
Assert.NotNull(notText.ApiUrlError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_api_url_the_admin_menu_is_disabled()
|
||||
{
|
||||
var catalog = LauncherCatalog.Parse($"{{ {Game} }}");
|
||||
Assert.Null(catalog.ApiUrl);
|
||||
Assert.Null(catalog.ApiUrlError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Boîtes de dialogue simulées : réponses programmées, actions enregistrées.</summary>
|
||||
public sealed class FakeDialogs : IModuleDialogs
|
||||
{
|
||||
public string? NextOpenFile { get; set; }
|
||||
public string? NextSaveFile { get; set; }
|
||||
public List<string> Messages { get; } = new();
|
||||
public string? Clipboard { get; private set; }
|
||||
public List<string> OpenedFolders { get; } = new();
|
||||
|
||||
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) => Messages.Add($"{title} : {message}");
|
||||
/// <summary>Réponse aux confirmations (vrai par défaut).</summary>
|
||||
public bool ConfirmAnswer { get; set; } = true;
|
||||
public List<string> Confirmations { get; } = new();
|
||||
|
||||
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
|
||||
{
|
||||
Confirmations.Add($"{title} : {message}");
|
||||
return ConfirmAnswer;
|
||||
}
|
||||
public string? PickOpenFile(string title, string filter, string? initialDirectory) => NextOpenFile;
|
||||
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => NextSaveFile;
|
||||
public void CopyToClipboard(string text) => Clipboard = text;
|
||||
public void OpenFolder(string path) => OpenedFolders.Add(path);
|
||||
}
|
||||
|
||||
/// <summary>Dossier temporaire (clés, préférences) supprimé à la fin du test.</summary>
|
||||
public sealed class TempFolder : IDisposable
|
||||
{
|
||||
public TempFolder() => Directory.CreateDirectory(Path);
|
||||
|
||||
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "launcher-admin-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public string File(string name) => System.IO.Path.Combine(Path, name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class AdminTestKit
|
||||
{
|
||||
public const string Passphrase = "phrase de passe solide";
|
||||
|
||||
/// <summary>Crée un fichier de clé (comme tools\admin-key.bat) et renvoie un signataire équivalent.</summary>
|
||||
public static (string Path, EcdsaAdminSigner Signer) CreateKeyFile(string directory, string name = "admin")
|
||||
{
|
||||
var path = System.IO.Path.Combine(directory, name + ".key");
|
||||
using (var key = AdminKeyFile.Generate()) AdminKeyFile.Save(key, path, Passphrase);
|
||||
return (path, new EcdsaAdminSigner(AdminKeyFile.Load(path, Passphrase)));
|
||||
}
|
||||
|
||||
/// <summary>Déclare le propriétaire avec la CLI du serveur (comme lors de l'installation).</summary>
|
||||
public static void RegisterOwner(TestApi api, IAdminSigner key, string name = "Nicolas") =>
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", name, "--public-key", Convert.ToBase64String(key.PublicKey) },
|
||||
TextWriter.Null, api.DataDirectory));
|
||||
|
||||
public static LauncherConfig Game(string id = "EZR", string name = "Exovival Zombie Reborn") => new()
|
||||
{
|
||||
GameId = id,
|
||||
GameName = name,
|
||||
Publisher = "LanisysGames",
|
||||
ManifestUrl = $"https://launcher.example/{id.ToLowerInvariant()}/manifest.json"
|
||||
};
|
||||
|
||||
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
|
||||
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
|
||||
IReadOnlyList<string>? themes = null) => new()
|
||||
{
|
||||
Tpm = tpm,
|
||||
Client = api.CreateClient(),
|
||||
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
|
||||
Games = games ?? new[] { Game() },
|
||||
Themes = themes ?? new[] { "default" },
|
||||
Dialogs = dialogs,
|
||||
Ui = ImmediateDispatcher.Instance,
|
||||
SettingsPath = System.IO.Path.Combine(folder, "admin.json"),
|
||||
KeysDirectory = folder,
|
||||
LauncherVersion = "1.4.0",
|
||||
Time = api.Time,
|
||||
StartTimers = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puce TPM simulée : clés en mémoire « non exportables », code PIN vérifié à chaque ouverture,
|
||||
/// blocage après 3 codes faux (comme la protection anti-essais d'une vraie puce).
|
||||
/// </summary>
|
||||
public sealed class FakeTpm : IAdminTpm
|
||||
{
|
||||
private readonly Dictionary<string, (ECDsa Key, string Pin)> _keys = new();
|
||||
private int _failures;
|
||||
|
||||
public TpmAvailability Availability { get; set; } = new(true, "Puce de sécurité TPM 2.0 disponible (simulée).");
|
||||
|
||||
/// <summary>Vrai : les signatures échouent (puce bloquée après la création de la clé…).</summary>
|
||||
public bool FailSigning { get; set; }
|
||||
|
||||
public const int MaxTries = 3;
|
||||
public TimeSpan RecoveryInterval { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>Faux : l'état de la protection anti-essais n'est pas lisible (réservé aux administrateurs…).</summary>
|
||||
public bool LockoutReadable { get; set; } = true;
|
||||
|
||||
public int Failures => _failures;
|
||||
|
||||
/// <summary>Simule le temps qui passe : la puce oublie une erreur.</summary>
|
||||
public void Heal() => _failures = Math.Max(0, _failures - 1);
|
||||
|
||||
public TpmLockoutInfo? GetLockoutInfo() =>
|
||||
LockoutReadable ? new TpmLockoutInfo(_failures, MaxTries, RecoveryInterval, _failures >= MaxTries) : null;
|
||||
public int Checks { get; private set; }
|
||||
public int OpenSigners { get; private set; }
|
||||
public IReadOnlyCollection<string> KeyNames => _keys.Keys;
|
||||
|
||||
public TpmAvailability Check()
|
||||
{
|
||||
Checks++;
|
||||
return Availability;
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner CreateKey(string keyName, string pin)
|
||||
{
|
||||
if (!Availability.IsAvailable) throw new CryptographicException("Puce indisponible.");
|
||||
if (!AdminTpmKeyFile.IsValidKeyName(keyName)) throw new ArgumentException("nom");
|
||||
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
_keys.Add(keyName, (key, pin));
|
||||
return new Signer(this, key);
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin)
|
||||
{
|
||||
if (!_keys.TryGetValue(keyName, out var entry))
|
||||
throw new CryptographicException("Clé introuvable dans la puce de ce PC.");
|
||||
if (!entry.Key.ExportSubjectPublicKeyInfo().AsSpan().SequenceEqual(expectedPublicKey))
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence.");
|
||||
if (_failures >= MaxTries) throw new TpmPinException("Puce temporairement bloquée (trop de codes PIN faux).", isLockedOut: true);
|
||||
if (entry.Pin != pin)
|
||||
{
|
||||
_failures++;
|
||||
throw new TpmPinException("Code PIN incorrect.", isLockedOut: false);
|
||||
}
|
||||
_failures = 0;
|
||||
return new Signer(this, entry.Key);
|
||||
}
|
||||
|
||||
public void DeleteKey(string keyName) => _keys.Remove(keyName);
|
||||
|
||||
/// <summary>Remplace la clé d'un nom par une autre (simule un fichier de référence trompeur).</summary>
|
||||
public void Swap(string keyName)
|
||||
{
|
||||
var (_, pin) = _keys[keyName];
|
||||
_keys[keyName] = (ECDsa.Create(ECCurve.NamedCurves.nistP256), pin);
|
||||
}
|
||||
|
||||
private sealed class Signer : IDisposableAdminSigner
|
||||
{
|
||||
private readonly FakeTpm _tpm;
|
||||
private readonly ECDsa _key;
|
||||
private bool _disposed;
|
||||
|
||||
public Signer(FakeTpm tpm, ECDsa key)
|
||||
{
|
||||
_tpm = tpm;
|
||||
_key = key;
|
||||
PublicKey = key.ExportSubjectPublicKeyInfo();
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
tpm.OpenSigners++;
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_tpm.FailSigning) throw new CryptographicException("Puce temporairement bloquée.");
|
||||
return ValueTask.FromResult(AdminKeys.Sign(_key, message));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_tpm.OpenSigners--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Clés conservées dans la puce de sécurité (TPM), avec une puce simulée.</summary>
|
||||
public class AdminTpmTests
|
||||
{
|
||||
private const string Pin = "482915";
|
||||
|
||||
[Fact]
|
||||
public async Task Tpm_key_is_enrolled_approved_and_used_to_log_in()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
|
||||
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
|
||||
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, "PC de Paul", useTpm: true);
|
||||
|
||||
Assert.True(created.IsTpm);
|
||||
Assert.NotNull(created.Enrollment);
|
||||
Assert.Single(tpm.KeyNames);
|
||||
Assert.Equal(0, tpm.OpenSigners); // poignée de la clé fermée après l'enregistrement
|
||||
var reference = AdminTpmKeyFile.Load(created.KeyPath);
|
||||
Assert.Equal(created.Fingerprint, AdminKeys.ComputeFingerprint(reference.PublicKey));
|
||||
Assert.Equal(created.Fingerprint,
|
||||
AdminKeys.ComputeFingerprint(AdminKeys.ParsePublicKey(File.ReadAllText(created.PublicKeyPath))));
|
||||
Assert.DoesNotContain("PRIVATE", File.ReadAllText(created.KeyPath)); // aucun secret dans la référence
|
||||
|
||||
var entry = Assert.Single(AdminKeyLocator.Find(folder.Path));
|
||||
Assert.True(entry.IsTpm);
|
||||
Assert.Equal("Code PIN de la clé (puce TPM)", entry.SecretLabel);
|
||||
Assert.Equal(created.Fingerprint, entry.Fingerprint);
|
||||
|
||||
await owner.ApproveKeyAsync(created.Enrollment!.AdminId, created.KeyId, created.Fingerprint);
|
||||
var connection = await new AdminLoginService(api.CreateClient(), "tests", tpm).LoginAsync(created.KeyPath, Pin);
|
||||
Assert.True(connection.IsTpm);
|
||||
Assert.Equal("Paul", connection.Session.Profile.DisplayName);
|
||||
Assert.Equal(1, tpm.OpenSigners);
|
||||
|
||||
await connection.DisposeAsync();
|
||||
Assert.Equal(0, tpm.OpenSigners); // verrouillage : poignée de la clé libérée
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_pin_missing_tpm_and_tampered_reference_are_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
|
||||
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true);
|
||||
Assert.Null(created.Enrollment);
|
||||
var login = new AdminLoginService(api.CreateClient(), "tests", tpm);
|
||||
|
||||
var wrong = await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
|
||||
Assert.False(wrong.IsLockedOut);
|
||||
Assert.Contains("Code PIN incorrect", wrong.Message);
|
||||
await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
|
||||
await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
|
||||
var locked = await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, Pin));
|
||||
Assert.True(locked.IsLockedOut);
|
||||
Assert.Contains("bloquée", locked.Message);
|
||||
|
||||
// Sans puce utilisable : message clair, pas de plantage.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new AdminLoginService(api.CreateClient(), "tests").LoginAsync(created.KeyPath, Pin));
|
||||
|
||||
// Référence qui désigne une autre clé que celle annoncée : refusée avant tout contact avec le serveur.
|
||||
var other = new FakeTpm();
|
||||
var reference = AdminTpmKeyFile.Load(created.KeyPath);
|
||||
other.CreateKey(reference.KeyName, Pin).Dispose();
|
||||
other.Swap(reference.KeyName);
|
||||
var mismatch = await Assert.ThrowsAsync<CryptographicException>(() =>
|
||||
new AdminLoginService(api.CreateClient(), "tests", other).LoginAsync(created.KeyPath, Pin));
|
||||
Assert.Contains("ne correspond pas", mismatch.Message);
|
||||
Assert.Equal(0, other.OpenSigners);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refused_invitation_removes_the_key_from_the_chip_and_the_disk()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
var service = new AdminEnrollmentService(api.CreateClient(), tpm);
|
||||
|
||||
await Assert.ThrowsAsync<ApiException>(() =>
|
||||
service.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, InvitationCodes.Generate(), null, useTpm: true));
|
||||
Assert.Empty(tpm.KeyNames);
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
Assert.Equal(0, tpm.OpenSigners);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("admin.tpmkey"), "12345", null, null, useTpm: true));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("admin.key"), Pin, null, null, useTpm: true));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new AdminEnrollmentService(api.CreateClient()).CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true));
|
||||
Assert.Empty(tpm.KeyNames);
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chip_failing_to_sign_the_proof_leaves_nothing_behind()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm { FailSigning = true };
|
||||
|
||||
await Assert.ThrowsAsync<CryptographicException>(() => new AdminEnrollmentService(api.CreateClient(), tpm)
|
||||
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, null, useTpm: true));
|
||||
Assert.Empty(tpm.KeyNames);
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
|
||||
// Rien n'a été envoyé : l'invitation reste utilisable.
|
||||
tpm.FailSigning = false;
|
||||
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
|
||||
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, null, useTpm: true);
|
||||
Assert.NotNull(created.Enrollment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_and_chip_keys_never_share_a_public_key_file()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
AdminTestKit.CreateKeyFile(folder.Path).Signer.Dispose();
|
||||
File.Delete(folder.File("admin.pub"));
|
||||
var tpm = new FakeTpm();
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() => new AdminEnrollmentService(api.CreateClient(), tpm)
|
||||
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true));
|
||||
Assert.Empty(tpm.KeyNames);
|
||||
Assert.False(AdminTpmKeyFile.IsValidKeyName(AdminTpmKeyFile.NewKeyName() + "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reference_files_are_validated_and_names_never_collide()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
var publicKey = ECDsa.Create(ECCurve.NamedCurves.nistP256).ExportSubjectPublicKeyInfo();
|
||||
Assert.Throws<ArgumentException>(() => AdminTpmKeyFile.Save(folder.File("a.tpmkey"), "Microsoft Passport Key", publicKey));
|
||||
|
||||
AdminTpmKeyFile.Save(folder.File("admin.tpmkey"), AdminTpmKeyFile.NewKeyName(), publicKey);
|
||||
Assert.Throws<IOException>(() => AdminTpmKeyFile.Save(folder.File("admin.tpmkey"), AdminTpmKeyFile.NewKeyName(), publicKey));
|
||||
Assert.Equal(folder.File("admin-2.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path)); // admin.pub déjà pris
|
||||
Assert.Equal(folder.File("admin-2.tpmkey"), AdminKeyLocator.SuggestNewKeyPath(folder.Path, tpm: true));
|
||||
|
||||
File.WriteAllText(folder.File("autre.tpmkey"), "{ \"format\": \"gamelauncher-admin-tpm-key\", \"version\": 1, " +
|
||||
"\"keyName\": \"../../clé\", \"publicKey\": \"\" }");
|
||||
Assert.Throws<CryptographicException>(() => AdminTpmKeyFile.Load(folder.File("autre.tpmkey")));
|
||||
Assert.Single(AdminKeyLocator.Find(folder.Path)); // fichier invalide ignoré
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chip_choice_stays_disabled_until_the_chip_is_really_checked()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm { Availability = new TpmAvailability(false, "Aucune puce TPM 2.0 utilisable sur ce PC.") };
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
|
||||
shell.ShowEnroll();
|
||||
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
|
||||
await enroll.TpmCheck;
|
||||
Assert.False(enroll.CanChooseTpm);
|
||||
Assert.Equal("Aucune puce TPM 2.0 utilisable sur ce PC.", enroll.TpmStatus);
|
||||
enroll.UseTpm = true;
|
||||
Assert.False(enroll.UseTpm);
|
||||
Assert.EndsWith(".key", enroll.KeyPath);
|
||||
|
||||
// Sans puce du tout (autre système) : même chose.
|
||||
await using var noTpm = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
|
||||
noTpm.ShowEnroll();
|
||||
var plain = Assert.IsType<EnrollViewModel>(noTpm.Screen);
|
||||
await plain.TpmCheck;
|
||||
Assert.False(plain.CanChooseTpm);
|
||||
Assert.Equal(TpmAvailability.Unsupported.Message, plain.TpmStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_screen_creates_a_chip_key_and_login_asks_for_its_pin()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Admin });
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
|
||||
shell.ShowEnroll();
|
||||
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
|
||||
Assert.True(enroll.IsCheckingTpm || enroll.IsTpmAvailable);
|
||||
await enroll.TpmCheck;
|
||||
Assert.True(enroll.CanChooseTpm);
|
||||
Assert.Equal(1, tpm.Checks);
|
||||
|
||||
enroll.UseTpm = true;
|
||||
Assert.EndsWith(".tpmkey", enroll.KeyPath);
|
||||
Assert.Contains("Code PIN", enroll.SecretLabel);
|
||||
enroll.InvitationCode = invitation.Code;
|
||||
await enroll.SubmitAsync("1234", "1234");
|
||||
Assert.Contains("au moins 6", enroll.ErrorMessage);
|
||||
await enroll.SubmitAsync(Pin, "999999");
|
||||
Assert.Contains("codes PIN sont différents", enroll.ErrorMessage);
|
||||
|
||||
await enroll.SubmitAsync(Pin, Pin);
|
||||
Assert.True(enroll.IsDone, enroll.ErrorMessage);
|
||||
Assert.True(enroll.IsTpmKey);
|
||||
Assert.True(enroll.IsEnrolled);
|
||||
Assert.False(enroll.CanChooseTpm);
|
||||
|
||||
var paul = (await owner.ListAdminsAsync()).Single(a => a.DisplayName == "Paul");
|
||||
await owner.ApproveKeyAsync(paul.Id, paul.Keys.Single().KeyId, enroll.Fingerprint);
|
||||
|
||||
enroll.ContinueCommand.Execute(null);
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
Assert.True(login.IsTpmKey);
|
||||
Assert.Equal("Code PIN de la clé (puce TPM)", login.SecretLabel);
|
||||
await login.LoginAsync("111111");
|
||||
Assert.Contains("Code PIN incorrect", login.ErrorMessage);
|
||||
await login.LoginAsync(Pin);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
Assert.EndsWith("· puce TPM", workspace.KeyLabel);
|
||||
|
||||
await workspace.LockCommand.ExecuteAsync();
|
||||
Assert.Equal(0, tpm.OpenSigners);
|
||||
|
||||
// La vérification de la puce n'est faite qu'une fois par fenêtre.
|
||||
shell.ShowEnroll();
|
||||
await Assert.IsType<EnrollViewModel>(shell.Screen).TpmCheck;
|
||||
Assert.Equal(1, tpm.Checks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Écran de connexion : suppression d'une clé TPM et aide en cas de blocage de la puce.</summary>
|
||||
public class AdminTpmLoginScreenTests
|
||||
{
|
||||
private const string Pin = "482915";
|
||||
|
||||
private static async Task<(AdminKeyCreation Key, string Folder)> CreateTpmKeyAsync(TestApi api, FakeTpm tpm, TempFolder folder) =>
|
||||
(await new AdminEnrollmentService(api.CreateClient(), tpm).CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true),
|
||||
folder.Path);
|
||||
|
||||
[Fact]
|
||||
public async Task Tpm_key_can_be_deleted_from_the_login_screen_after_confirmation()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
var (created, _) = await CreateTpmKeyAsync(api, tpm, folder);
|
||||
var (filePath, fileSigner) = AdminTestKit.CreateKeyFile(folder.Path, "fichier");
|
||||
fileSigner.Dispose();
|
||||
var dialogs = new FakeDialogs { ConfirmAnswer = false };
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, tpm: tpm));
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
login.SelectedKey = login.Keys.Single(k => k.Path == filePath);
|
||||
Assert.False(login.DeleteTpmKeyCommand.CanExecute(null)); // clés fichier : à supprimer à la main
|
||||
|
||||
login.SelectedKey = login.Keys.Single(k => k.IsTpm);
|
||||
Assert.True(login.DeleteTpmKeyCommand.CanExecute(null));
|
||||
await login.DeleteTpmKeyCommand.ExecuteAsync(); // annulé
|
||||
Assert.Single(tpm.KeyNames);
|
||||
Assert.True(File.Exists(created.KeyPath));
|
||||
Assert.Contains(created.KeyId, Assert.Single(dialogs.Confirmations));
|
||||
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await login.DeleteTpmKeyCommand.ExecuteAsync();
|
||||
Assert.Empty(tpm.KeyNames);
|
||||
Assert.False(File.Exists(created.KeyPath));
|
||||
Assert.False(File.Exists(created.PublicKeyPath));
|
||||
Assert.Contains("revoke-key", login.StatusMessage);
|
||||
Assert.Contains(created.KeyId, login.StatusMessage);
|
||||
Assert.Equal(filePath, Assert.Single(login.Keys).Path); // la clé fichier reste
|
||||
Assert.DoesNotContain(created.KeyPath, shell.Settings.KeyPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_pins_show_remaining_tries_then_a_countdown_until_the_chip_accepts_codes_again()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
await CreateTpmKeyAsync(api, tpm, folder);
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
await login.LockoutCheck;
|
||||
Assert.False(login.ShowTpmLockout);
|
||||
|
||||
await login.LoginAsync("000000");
|
||||
Assert.Contains("Encore 2 essai(s)", login.ErrorMessage);
|
||||
await login.LoginAsync("000000");
|
||||
Assert.Contains("Encore 1 essai(s)", login.ErrorMessage);
|
||||
await login.LoginAsync("000000");
|
||||
|
||||
Assert.True(login.ShowTpmLockout);
|
||||
Assert.False(login.HasError);
|
||||
Assert.Contains("dans 10 min au plus", login.LockoutText);
|
||||
Assert.False(login.CanLogin);
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(4));
|
||||
await login.TickLockoutAsync();
|
||||
Assert.Contains("dans 6 min au plus", login.LockoutText);
|
||||
Assert.False(login.CanLogin);
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(6));
|
||||
tpm.Heal(); // la puce a oublié une erreur
|
||||
await login.TickLockoutAsync();
|
||||
Assert.False(login.ShowTpmLockout);
|
||||
Assert.True(login.CanLogin);
|
||||
Assert.Contains("accepte de nouveau", login.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locked_chip_is_shown_as_soon_as_a_tpm_key_is_selected_even_without_a_known_duration()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var tpm = new FakeTpm();
|
||||
var (created, _) = await CreateTpmKeyAsync(api, tpm, folder);
|
||||
var reference = AdminTpmKeyFile.Load(created.KeyPath);
|
||||
for (var i = 0; i < FakeTpm.MaxTries; i++)
|
||||
Assert.Throws<TpmPinException>(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, "000000"));
|
||||
|
||||
await using (var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm)))
|
||||
{
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
await login.LockoutCheck;
|
||||
Assert.True(login.ShowTpmLockout);
|
||||
Assert.Contains("au plus", login.LockoutText);
|
||||
}
|
||||
|
||||
// État illisible sur ce PC : explication générale, et nouvel essai permis.
|
||||
tpm.LockoutReadable = false;
|
||||
await using var other = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
|
||||
var screen = Assert.IsType<LoginViewModel>(other.Screen);
|
||||
await screen.LockoutCheck;
|
||||
Assert.False(screen.ShowTpmLockout); // rien à signaler tant que la puce n'a rien refusé
|
||||
await screen.LoginAsync(Pin);
|
||||
Assert.True(screen.ShowTpmLockout);
|
||||
Assert.Contains("quelques minutes à quelques heures", screen.LockoutText);
|
||||
Assert.True(screen.CanLogin);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(45, "45 s")]
|
||||
[InlineData(600, "10 min")]
|
||||
[InlineData(570, "9 min 30 s")]
|
||||
[InlineData(7200, "2 h")]
|
||||
[InlineData(5430, "1 h 31 min")]
|
||||
public void Waits_are_readable(int seconds, string expected)
|
||||
{
|
||||
Assert.Equal(expected, LoginViewModel.FormatWait(TimeSpan.FromSeconds(seconds)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lockout_estimate_follows_the_chip_counter()
|
||||
{
|
||||
var info = new TpmLockoutInfo(34, 32, TimeSpan.FromMinutes(10), InLockout: true);
|
||||
Assert.True(info.IsLockedOut);
|
||||
Assert.Equal(0, info.RemainingTries);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), info.MaxWait);
|
||||
Assert.Equal(TimeSpan.Zero, new TpmLockoutInfo(5, 32, TimeSpan.FromMinutes(10), false).MaxWait);
|
||||
Assert.False(new TpmLockoutInfo(40, 32, TimeSpan.Zero, true).HealsWithTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
public class AdminViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Login_opens_the_workspace_and_the_dashboard_shows_pending_keys()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
await using (var owner = await api.LoginAsync(signer))
|
||||
{
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var paul = TestApi.NewKey();
|
||||
await api.CreateClient().EnrollAsync(invitation.Code, paul, "PC de Paul");
|
||||
}
|
||||
|
||||
var dialogs = new FakeDialogs();
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
Assert.Equal(path, login.SelectedKey!.Path);
|
||||
Assert.False(shell.IsLoggedIn);
|
||||
|
||||
await login.LoginAsync("mauvaise phrase de passe");
|
||||
Assert.Contains("Phrase de passe incorrecte", login.ErrorMessage);
|
||||
Assert.Same(login, shell.Screen);
|
||||
|
||||
await login.LoginAsync(AdminTestKit.Passphrase);
|
||||
Assert.True(shell.IsLoggedIn);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
Assert.Equal("Nicolas", workspace.DisplayName);
|
||||
Assert.Equal("N", workspace.Initials);
|
||||
Assert.Equal(DashboardPanelViewModel.PanelId, workspace.SelectedPanel!.Descriptor.Id);
|
||||
Assert.StartsWith("Verrouillage dans 10 min", workspace.SessionText);
|
||||
|
||||
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel.ViewModel);
|
||||
Assert.False(dashboard.HasError, dashboard.ErrorMessage);
|
||||
Assert.NotEqual("…", dashboard.ServerVersion);
|
||||
Assert.Equal(8, dashboard.ServerId.Length);
|
||||
Assert.True(dashboard.CanManageAdmins);
|
||||
Assert.Equal(1, dashboard.PendingApprovals);
|
||||
Assert.StartsWith("1 clé attend votre approbation", dashboard.PendingText);
|
||||
Assert.Equal("launcher.example", Assert.Single(dashboard.Games).ManifestHost);
|
||||
Assert.Equal("1.4.0", dashboard.LauncherVersion);
|
||||
|
||||
// La clé utilisée est mémorisée pour la prochaine ouverture.
|
||||
Assert.Equal(path, AdminSettings.Load(Path.Combine(folder.Path, "admin.json")).LastKeyPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locking_closes_the_server_session_and_preselects_the_key()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
AdminTestKit.CreateKeyFile(folder.Path, "autre").Signer.Dispose();
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path, "nicolas");
|
||||
using (signer) AdminTestKit.RegisterOwner(api, signer);
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
login.SelectedKey = login.Keys.Single(k => k.Name == "nicolas");
|
||||
await login.LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
var session = workspace.Session;
|
||||
|
||||
await workspace.LockCommand.ExecuteAsync();
|
||||
|
||||
var relock = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
Assert.Equal("Session verrouillée.", relock.StatusMessage);
|
||||
Assert.Equal(path, relock.SelectedKey!.Path);
|
||||
Assert.False(session.IsOpen);
|
||||
|
||||
// Un rapport tardif de l'ancienne session ne touche pas l'écran courant.
|
||||
workspace.ReportSessionLost("trop tard");
|
||||
Assert.Same(relock, shell.Screen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inactivity_returns_to_the_login_screen()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using (signer) AdminTestKit.RegisterOwner(api, signer);
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(11));
|
||||
await workspace.Guard.TickAsync();
|
||||
|
||||
Assert.Contains("inactivité", Assert.IsType<LoginViewModel>(shell.Screen).StatusMessage);
|
||||
Assert.False(workspace.Session.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editors_only_see_the_panels_they_are_allowed_to_use()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var folder = new TempFolder();
|
||||
var created = await new AdminEnrollmentService(api.CreateClient()).CreateKeyAsync(folder.File("paul.key"), AdminTestKit.Passphrase, invitation.Code, null);
|
||||
await owner.ApproveKeyAsync(created.Enrollment!.AdminId, created.KeyId, created.Fingerprint);
|
||||
|
||||
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new ExtraPanels() });
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
|
||||
Assert.Equal(new[] { DashboardPanelViewModel.PanelId, "extra.notes" }, workspace.PanelItems.Select(p => p.Descriptor.Id));
|
||||
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
Assert.False(dashboard.CanManageAdmins);
|
||||
Assert.Equal(0, dashboard.PendingApprovals);
|
||||
Assert.False(dashboard.HasError, dashboard.ErrorMessage);
|
||||
|
||||
workspace.Navigate("extra.admins"); // non autorisé : ignoré
|
||||
Assert.Equal(DashboardPanelViewModel.PanelId, workspace.SelectedPanel.Descriptor.Id);
|
||||
workspace.Navigate("EXTRA.NOTES");
|
||||
await workspace.Activation;
|
||||
Assert.Equal("extra.notes", workspace.SelectedPanel.Descriptor.Id);
|
||||
Assert.Equal(1, Assert.IsType<CountingPanel>(workspace.SelectedPanel.ViewModel).Activations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_screen_creates_the_key_then_returns_to_login()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Admin });
|
||||
using var folder = new TempFolder();
|
||||
var dialogs = new FakeDialogs();
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
|
||||
Assert.False(Assert.IsType<LoginViewModel>(shell.Screen).HasKeys);
|
||||
shell.ShowEnroll();
|
||||
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
|
||||
Assert.Equal(folder.File("admin.key"), enroll.KeyPath);
|
||||
|
||||
enroll.InvitationCode = invitation.Code.ToLowerInvariant();
|
||||
await enroll.SubmitAsync("trop court", "trop court");
|
||||
Assert.Contains("au moins", enroll.ErrorMessage);
|
||||
await enroll.SubmitAsync(AdminTestKit.Passphrase, AdminTestKit.Passphrase + "!");
|
||||
Assert.Contains("différentes", enroll.ErrorMessage);
|
||||
Assert.False(File.Exists(enroll.KeyPath));
|
||||
|
||||
await enroll.SubmitAsync(AdminTestKit.Passphrase, AdminTestKit.Passphrase);
|
||||
Assert.True(enroll.IsDone, enroll.ErrorMessage);
|
||||
Assert.True(enroll.IsEnrolled);
|
||||
Assert.Equal("Paul", enroll.AccountName);
|
||||
Assert.Matches("^([0-9A-F]{4} ){4}[0-9A-F]{4}$", enroll.Fingerprint);
|
||||
enroll.CopyFingerprintCommand.Execute(null);
|
||||
Assert.Equal(enroll.Fingerprint, dialogs.Clipboard);
|
||||
|
||||
enroll.ContinueCommand.Execute(null);
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
Assert.Equal(enroll.CreatedKeyPath, login.SelectedKey!.Path);
|
||||
Assert.Contains("approuvée", login.StatusMessage);
|
||||
|
||||
// Tant que la clé n'est pas approuvée, la connexion est refusée avec un message clair.
|
||||
await login.LoginAsync(AdminTestKit.Passphrase);
|
||||
Assert.False(shell.IsLoggedIn);
|
||||
Assert.Contains("en attente d'approbation", login.ErrorMessage);
|
||||
|
||||
// Le propriétaire approuve avec l'empreinte affichée (le serveur ne la montre pas pour une clé en attente).
|
||||
var paul = (await owner.ListAdminsAsync()).Single(a => a.DisplayName == "Paul");
|
||||
Assert.Null(paul.Keys.Single().Fingerprint);
|
||||
await owner.ApproveKeyAsync(paul.Id, paul.Keys.Single().KeyId, enroll.Fingerprint);
|
||||
|
||||
await login.LoginAsync(AdminTestKit.Passphrase);
|
||||
Assert.True(shell.IsLoggedIn, login.ErrorMessage);
|
||||
Assert.Equal("Paul", Assert.IsType<AdminWorkspaceViewModel>(shell.Screen).DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browsing_refuses_the_launcher_signing_key_and_plain_files()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
using var elsewhere = new TempFolder();
|
||||
AdminTestKit.CreateKeyFile(elsewhere.Path, "launcher-signing").Signer.Dispose();
|
||||
File.WriteAllText(elsewhere.File("texte.key"), "texte");
|
||||
var (portable, signer) = AdminTestKit.CreateKeyFile(elsewhere.Path, "portable");
|
||||
signer.Dispose();
|
||||
var dialogs = new FakeDialogs();
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
|
||||
var login = Assert.IsType<LoginViewModel>(shell.Screen);
|
||||
|
||||
dialogs.NextOpenFile = elsewhere.File("launcher-signing.key");
|
||||
login.BrowseKeyCommand.Execute(null);
|
||||
Assert.Contains("clé de signature du launcher", login.ErrorMessage);
|
||||
dialogs.NextOpenFile = elsewhere.File("texte.key");
|
||||
login.BrowseKeyCommand.Execute(null);
|
||||
Assert.Contains("n'est pas une clé", login.ErrorMessage);
|
||||
Assert.False(login.HasKeys);
|
||||
|
||||
dialogs.NextOpenFile = portable;
|
||||
login.BrowseKeyCommand.Execute(null);
|
||||
Assert.False(login.HasError);
|
||||
Assert.Equal(portable, login.SelectedKey!.Path);
|
||||
|
||||
// Retrouvée à la prochaine ouverture de la fenêtre.
|
||||
await using var again = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
|
||||
Assert.Equal(portable, Assert.IsType<LoginViewModel>(again.Screen).SelectedKey!.Path);
|
||||
}
|
||||
|
||||
private sealed class CountingPanel : AdminPanelViewModel
|
||||
{
|
||||
public CountingPanel(IAdminContext context) : base(context)
|
||||
{
|
||||
}
|
||||
|
||||
public int Activations { get; private set; }
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime)
|
||||
{
|
||||
Activations++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExtraPanels : ILauncherModule
|
||||
{
|
||||
public string Id => "extra";
|
||||
public string DisplayName => "Pages de test";
|
||||
|
||||
public void Register(IModuleRegistry registry)
|
||||
{
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor("extra.admins", "Administrateurs", "", "AccountGroup",
|
||||
AdminPermissions.AdminsManage, 10, c => new CountingPanel(c)));
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor("extra.notes", "Notes de version", "", "Info",
|
||||
AdminPermissions.ChangelogEdit, 20, c => new CountingPanel(c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Server;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Notes de version » du menu d'administration.</summary>
|
||||
public class ChangelogPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule() });
|
||||
|
||||
private static async Task<(TestApi Api, TempFolder Folder, FakeDialogs Dialogs, AdminShellViewModel Shell, ChangelogPanelViewModel Page)>
|
||||
OpenAsync(string? manifest)
|
||||
{
|
||||
var api = await TestApi.StartAsync(manifest is null
|
||||
? null
|
||||
: new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
signer.Dispose();
|
||||
var dialogs = new FakeDialogs();
|
||||
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(ChangelogPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
return (api, folder, dialogs, shell, Assert.IsType<ChangelogPanelViewModel>(workspace.SelectedPanel!.ViewModel));
|
||||
}
|
||||
|
||||
private static GameManifest ReadManifest(string path) =>
|
||||
JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(path), JsonDefaults.Options)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_are_edited_previewed_checked_and_saved_version_by_version()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
|
||||
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
|
||||
Assert.False(page.HasSeveralGames);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
|
||||
// Version 1.1 : titre, nouvel élément avec étiquette et sous-points ; aperçu à jour.
|
||||
var v11 = page.SelectedVersion!;
|
||||
Assert.Equal("1.1", v11.Version);
|
||||
v11.Title = "Version 1.1 — corrigée";
|
||||
var section = Assert.Single(v11.Sections);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
var item = section.Items[^1];
|
||||
item.Text = "Les portes se ferment correctement.";
|
||||
item.Tag = "CORRIGÉ";
|
||||
item.Details = "salle 2\n\nsalle 3\n";
|
||||
Assert.True(v11.IsDirty);
|
||||
Assert.True(page.HasChanges);
|
||||
Assert.Equal("Version 1.1 — corrigée", v11.Preview.Title);
|
||||
Assert.Equal(new[] { "salle 2", "salle 3" }, v11.Preview.Sections[0].Items[1].Details);
|
||||
Assert.True(section.Items[1].MoveUpCommand.CanExecute(null));
|
||||
Assert.False(section.Items[1].MoveDownCommand.CanExecute(null));
|
||||
|
||||
// Version 1.0 (ancien format : liste à plat) : présentée comme une section sans titre.
|
||||
var v10 = page.Versions[1];
|
||||
var legacy = Assert.Single(v10.Sections);
|
||||
Assert.Equal("", legacy.Title);
|
||||
Assert.Equal("Sortie du niveau 1.", Assert.Single(legacy.Items).Text);
|
||||
legacy.Items[0].Text = "Sortie du niveau 1 « L'école ».";
|
||||
|
||||
// Limite dépassée : signalée, rien n'est envoyé.
|
||||
item.Tag = new string('X', ChangelogLimits.MaxTagLength + 1);
|
||||
Assert.True(v11.HasLimitErrors);
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Notes de la version 1.1 à corriger"));
|
||||
Assert.Equal("Version 1.1", ReadManifest(manifest).Changelog[0].Title);
|
||||
|
||||
item.Tag = "CORRIGÉ";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("1.1, 1.0", page.StatusMessage);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(v11.IsDirty);
|
||||
Assert.False(v10.IsDirty);
|
||||
|
||||
var saved = ReadManifest(manifest);
|
||||
Assert.Equal("Version 1.1 — corrigée", saved.Changelog[0].Title);
|
||||
Assert.Equal("CORRIGÉ", saved.Changelog[0].Sections[0].Items[1].Tag);
|
||||
Assert.Equal("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Sections[0].Items[0].Text);
|
||||
Assert.Contains("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Changes); // anciens launchers
|
||||
Assert.Equal("1.1", saved.Version);
|
||||
saved.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_manifest_changed_on_the_server_is_reported_and_reloaded()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
File.WriteAllText(manifest, File.ReadAllText(manifest).Replace("Première version", "Première version (WinSCP)"));
|
||||
page.SelectedVersion!.Title = "Ma correction";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.True(page.HasConflict);
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Manifest modifié entre-temps"));
|
||||
Assert.DoesNotContain("Ma correction", File.ReadAllText(manifest));
|
||||
|
||||
// Recharger : confirmation (modifications perdues), puis le manifest publié est affiché.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.True(page.HasChanges);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.False(page.HasConflict);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.Equal("1.1", page.SelectedVersion!.Version); // même version resélectionnée
|
||||
Assert.Equal("Première version (WinSCP)", page.Versions[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_section_with_items_asks_first()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
var version = page.SelectedVersion!;
|
||||
version.AddSectionCommand.Execute(null);
|
||||
Assert.Equal(2, version.Sections.Count);
|
||||
version.Sections[1].RemoveCommand.Execute(null); // vide : retirée sans question
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Empty(dialogs.Confirmations);
|
||||
|
||||
dialogs.ConfirmAnswer = false;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Single(dialogs.Confirmations);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Empty(version.Sections);
|
||||
Assert.True(version.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_game_without_configured_manifest_shows_why()
|
||||
{
|
||||
var (api, folder, _, shell, page) = await OpenAsync(manifest: null);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.True(page.HasError);
|
||||
Assert.Contains("non configuré", page.ErrorMessage);
|
||||
Assert.False(page.HasVersions);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Jeux » du menu d'administration (catalogue publié pour les launchers des joueurs).</summary>
|
||||
public class GamesPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new CatalogAdminModule() });
|
||||
|
||||
private static string PublicFile()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "catalog.json");
|
||||
}
|
||||
|
||||
private static async Task<GamesPanelViewModel> OpenGamesPageAsync(AdminShellViewModel shell)
|
||||
{
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(GamesPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
Assert.Equal(GamesPanelViewModel.PanelId, workspace.SelectedPanel!.Descriptor.Id);
|
||||
return Assert.IsType<GamesPanelViewModel>(workspace.SelectedPanel.ViewModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Games_page_edits_checks_and_publishes_the_catalog()
|
||||
{
|
||||
var publicFile = PublicFile();
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs();
|
||||
var games = new[] { AdminTestKit.Game(), AdminTestKit.Game("DEMO", "Démo") };
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules,
|
||||
games: games, themes: new[] { "default", "neon" }));
|
||||
|
||||
var page = await OpenGamesPageAsync(shell);
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.PublishingEnabled);
|
||||
Assert.False(page.ShowPublishingDisabled);
|
||||
Assert.StartsWith("Jamais publié", page.RevisionText);
|
||||
Assert.Equal(new[] { "Launcher", "Exovival Zombie Reborn", "Démo" }, page.Items.Select(i => i.Title));
|
||||
Assert.Same(page.Launcher, page.Selected);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
Assert.False(page.MoveUpCommand.CanExecute(null)); // « Launcher » ne se déplace pas
|
||||
|
||||
// Modifications : accroche, lien, thème, image ; ordre des jeux.
|
||||
var ezr = page.Games.Single(g => g.GameId == "EZR");
|
||||
var demo = page.Games.Single(g => g.GameId == "DEMO");
|
||||
ezr.Tagline = "Survivez à l'école.";
|
||||
ezr.AddLinkCommand.Execute(null);
|
||||
var link = Assert.Single(ezr.Links);
|
||||
link.Kind = link.Kinds.Single(k => k.Value == CatalogLinkKinds.Discord);
|
||||
link.Url = "https://discord.gg/abc";
|
||||
ezr.Theme = ezr.ThemeChoices.Single(c => c.Value == "neon");
|
||||
ezr.Background = $"https://{api.BaseUrl.Host}/catalog/images/fond.jpg";
|
||||
Assert.True(page.HasChanges);
|
||||
Assert.True(ezr.IsDirty);
|
||||
Assert.False(demo.IsDirty);
|
||||
|
||||
page.Selected = demo;
|
||||
Assert.True(page.MoveUpCommand.CanExecute(null));
|
||||
Assert.False(page.MoveDownCommand.CanExecute(null));
|
||||
page.MoveUpCommand.Execute(null);
|
||||
Assert.Equal(new[] { "DEMO", "EZR" }, page.Games.Select(g => g.GameId));
|
||||
Assert.Same(demo, page.Selected);
|
||||
Assert.True(page.Launcher.IsDirty);
|
||||
|
||||
// Valeur refusée : contrôlée AVANT l'envoi (mêmes règles que le serveur), rien n'est publié.
|
||||
ezr.WebsiteUrl = "http://lanisys.fr";
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Valeurs à corriger") && m.Contains("https"));
|
||||
Assert.False(File.Exists(publicFile));
|
||||
|
||||
ezr.WebsiteUrl = "";
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("révision 1", page.StatusMessage);
|
||||
Assert.StartsWith("Révision 1, publiée le", page.RevisionText);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(ezr.IsDirty);
|
||||
Assert.False(page.Launcher.IsDirty);
|
||||
|
||||
var file = JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!;
|
||||
Assert.Equal(1, file.Revision);
|
||||
Assert.Equal(new[] { "DEMO", "EZR" }, file.Launcher!.GameOrder!);
|
||||
Assert.Equal("Survivez à l'école.", file.Games["EZR"].Tagline);
|
||||
Assert.Equal("neon", file.Games["EZR"].Theme);
|
||||
Assert.Equal("https://discord.gg/abc", Assert.Single(file.Games["EZR"].Links!).Url);
|
||||
Assert.False(file.Games.ContainsKey("DEMO")); // rien de modifié : valeurs de launcher.json
|
||||
|
||||
// Champ vidé = retour à la valeur de launcher.json.
|
||||
Assert.Contains("Exovival Zombie Reborn", ezr.NameHint);
|
||||
ezr.GameName = "EZR Reborn";
|
||||
Assert.Equal("EZR Reborn", ezr.Title);
|
||||
ezr.GameName = "";
|
||||
Assert.Equal("Exovival Zombie Reborn", ezr.Title);
|
||||
Assert.False(page.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Games_page_refuses_to_overwrite_a_catalog_published_in_the_meantime()
|
||||
{
|
||||
var publicFile = PublicFile();
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs();
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
var page = await OpenGamesPageAsync(shell);
|
||||
|
||||
// Un autre administrateur publie pendant que la page est ouverte.
|
||||
await using (var other = await api.LoginAsync(signer))
|
||||
await other.PublishCatalogAsync(new CatalogDocument
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Version de Paul" } }
|
||||
}, baseRevision: 0);
|
||||
|
||||
var ezr = Assert.Single(page.Games);
|
||||
ezr.Tagline = "Ma version";
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.True(page.HasConflict);
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Publié entre-temps"));
|
||||
Assert.Equal("Version de Paul",
|
||||
JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!.Games["EZR"].Tagline);
|
||||
|
||||
// Recharger : confirmation (modifications perdues), puis la version publiée est affichée.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.Equal("Ma version", ezr.Tagline);
|
||||
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.Equal(2, dialogs.Confirmations.Count);
|
||||
Assert.Equal("Version de Paul", ezr.Tagline);
|
||||
Assert.False(page.HasConflict);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.StartsWith("Révision 1", page.RevisionText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Theme_unknown_to_this_launcher_is_kept_and_not_reported_as_a_change()
|
||||
{
|
||||
var publicFile = PublicFile();
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
await using (var other = await api.LoginAsync(signer)) // publié par une version plus récente du launcher
|
||||
await other.PublishCatalogAsync(new CatalogDocument
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Theme = "aurora" } }
|
||||
}, baseRevision: 0);
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules));
|
||||
var page = await OpenGamesPageAsync(shell);
|
||||
var ezr = Assert.Single(page.Games);
|
||||
Assert.Equal("aurora", ezr.Theme.Value);
|
||||
Assert.Contains(ezr.Theme, ezr.ThemeChoices);
|
||||
ezr.Theme = null!; // la liste de l'écran se vide lors d'un rechargement : ignoré
|
||||
Assert.Equal("aurora", ezr.Theme.Value);
|
||||
Assert.False(page.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Games_page_cannot_publish_when_the_server_has_no_public_file()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules));
|
||||
var page = await OpenGamesPageAsync(shell);
|
||||
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.ShowPublishingDisabled);
|
||||
Assert.Single(page.Games).Tagline = "Survivez.";
|
||||
Assert.True(page.HasChanges);
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
|
||||
namespace GameLauncher.Tests.Catalog;
|
||||
|
||||
public class CatalogRulesTests
|
||||
{
|
||||
private static CatalogDocument Doc(CatalogGame game, string id = "EZR", CatalogLauncher? launcher = null) => new()
|
||||
{
|
||||
Revision = 3,
|
||||
Launcher = launcher,
|
||||
Games = new Dictionary<string, CatalogGame> { [id] = game }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Valid_values_are_kept_and_cleaned()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var result = CatalogRules.Normalize(Doc(new CatalogGame
|
||||
{
|
||||
GameName = " Exovival Zombie ",
|
||||
Tagline = "Survivez.",
|
||||
Description = "Ligne 1\r\n\r\n - point\r\nLigne 3 ",
|
||||
WebsiteUrl = "https://lanisys.fr",
|
||||
Links = new() { new CatalogLink { Kind = CatalogLinkKinds.Discord, Url = "https://discord.gg/abc" } },
|
||||
Theme = "exovival",
|
||||
Icon = "https://launcher.example/catalog/images/ezr.png",
|
||||
UpdateCheckIntervalMinutes = 0,
|
||||
Hidden = false
|
||||
}, launcher: new CatalogLauncher { LauncherName = "Lanisys", DefaultGame = "EZR", GameOrder = new() { "EZR", "ezr", "B" } }),
|
||||
"launcher.example", errors);
|
||||
|
||||
Assert.Empty(errors);
|
||||
var game = result.Games["ezr"]; // insensible à la casse
|
||||
Assert.Equal("Exovival Zombie", game.GameName);
|
||||
Assert.Equal("Ligne 1\n\n - point\nLigne 3", game.Description);
|
||||
Assert.Equal("https://lanisys.fr/", game.WebsiteUrl);
|
||||
Assert.Null(game.Hidden); // faux = valeur par défaut
|
||||
Assert.Equal(new[] { "EZR", "B" }, result.Launcher!.GameOrder!);
|
||||
Assert.Equal(3, result.Revision);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://lanisys.fr")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("https://user:pass@lanisys.fr")]
|
||||
[InlineData("file:///C:/Windows")]
|
||||
[InlineData("lanisys.fr")]
|
||||
public void Only_https_addresses_are_accepted(string url)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var result = CatalogRules.Normalize(Doc(new CatalogGame { WebsiteUrl = url, Tagline = "ok" }), null, errors);
|
||||
Assert.Single(errors);
|
||||
Assert.Null(result.Games["EZR"].WebsiteUrl);
|
||||
Assert.Equal("ok", result.Games["EZR"].Tagline); // le reste est conservé (le launcher ignore la valeur invalide)
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://autre.example/catalog/x.png")] // autre serveur
|
||||
[InlineData("https://launcher.example/x.gif")] // format
|
||||
[InlineData("https://launcher.example/x.png?v=1")] // paramètres
|
||||
[InlineData("http://launcher.example/x.png")] // http
|
||||
public void Images_must_be_png_or_jpeg_on_the_catalog_server(string url)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var result = CatalogRules.Normalize(Doc(new CatalogGame { Icon = url, Background = url }), "launcher.example", errors);
|
||||
Assert.Equal(2, errors.Count);
|
||||
Assert.False(result.Games.ContainsKey("EZR"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../evil.xaml")]
|
||||
[InlineData("C:\\theme.xaml")]
|
||||
[InlineData("Exovival")]
|
||||
[InlineData("theme.xaml")]
|
||||
public void Theme_is_only_a_built_in_identifier(string theme)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
CatalogRules.Normalize(Doc(new CatalogGame { Theme = theme }), null, errors);
|
||||
Assert.Single(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Limits_are_enforced()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var links = Enumerable.Range(0, 12).Select(i => new CatalogLink { Kind = CatalogLinkKinds.Other, Url = $"https://x.example/{i}" }).ToList();
|
||||
var result = CatalogRules.Normalize(Doc(new CatalogGame
|
||||
{
|
||||
GameName = new string('a', 81),
|
||||
Description = string.Join("\n", Enumerable.Repeat("ligne", 50)),
|
||||
Links = links,
|
||||
UpdateCheckIntervalMinutes = 3
|
||||
}), null, errors);
|
||||
Assert.Equal(4, errors.Count);
|
||||
Assert.Equal(CatalogRules.MaxLinks, result.Games["EZR"].Links!.Count);
|
||||
|
||||
var invisible = new List<string>();
|
||||
CatalogRules.Normalize(Doc(new CatalogGame { Tagline = "abc\u202Edef" }), null, invisible);
|
||||
Assert.Single(invisible);
|
||||
|
||||
var badIds = new List<string>();
|
||||
var ids = CatalogRules.Normalize(new CatalogDocument
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame> { ["../x"] = new() { Tagline = "a" }, ["ok"] = new() { Tagline = "b" } }
|
||||
}, null, badIds);
|
||||
Assert.Single(badIds);
|
||||
Assert.Equal(new[] { "ok" }, ids.Games.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_format_or_version_is_reported()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
CatalogRules.Normalize(new CatalogDocument { Format = "autre", Version = 2 }, null, errors);
|
||||
Assert.Equal(2, errors.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameLauncher.Tests.Catalog;
|
||||
|
||||
/// <summary>Téléchargements du catalogue et des images : redirections suivies sur le même serveur uniquement.</summary>
|
||||
public class HttpDownloaderRedirectTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Small_downloads_follow_same_host_redirects_and_never_contact_another_host()
|
||||
{
|
||||
var port = TestApi.FreePort();
|
||||
var otherPort = TestApi.FreePort();
|
||||
var otherHits = 0;
|
||||
|
||||
await using var other = Server(otherPort, app => app.MapGet("/x", () => { Interlocked.Increment(ref otherHits); return "autre"; }));
|
||||
await using var main = Server(port, app =>
|
||||
{
|
||||
app.MapGet("/ok", () => "contenu");
|
||||
app.MapGet("/moved", () => Results.Redirect("/ok"));
|
||||
app.MapGet("/away", () => Results.Redirect($"http://127.0.0.1:{otherPort}/x"));
|
||||
app.MapGet("/loop", () => Results.Redirect("/loop"));
|
||||
});
|
||||
await main.StartAsync();
|
||||
await other.StartAsync();
|
||||
|
||||
using var http = new HttpDownloader("GameLauncher.Tests");
|
||||
var root = new Uri($"http://127.0.0.1:{port}/");
|
||||
Assert.Equal("contenu", System.Text.Encoding.UTF8.GetString(await http.GetBytesAsync(new Uri(root, "moved"), 1024, default)));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => http.GetBytesAsync(new Uri(root, "away"), 1024, default));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => http.GetBytesAsync(new Uri(root, "loop"), 1024, default));
|
||||
Assert.Equal(0, otherHits); // autre port : aucune requête envoyée
|
||||
}
|
||||
|
||||
private static WebApplication Server(int port, Action<WebApplication> map)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||
builder.Logging.ClearProviders();
|
||||
var app = builder.Build();
|
||||
map(app);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user