Compare commits

...
28 Commits
Author SHA1 Message Date
fred b398e4059a Merge pull request 'Dev/3.4.2' (#150) from dev/3.4.2 into master
Reviewed-on: #150
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-10 20:58:21 +01:00
nicolas 69f943ea53 Notes de version du launcher 3.4.2 2026-11-10 20:52:22 +01:00
nicolas 4c368cb1e0 Documentation : audit des formulaires des demandes 2026-11-10 20:52:13 +01:00
nicolas eca812a718 UiSnapshot : exemples d'audit des formulaires 2026-11-10 20:51:58 +01:00
nicolas e4f2d443e8 Tests : audit détaillé des formulaires 2026-11-10 20:51:46 +01:00
nicolas 6e7b480db5 Menu d'administration : libellés des actions d'audit des types de demande 2026-11-10 20:51:33 +01:00
nicolas 1c7735d25a Launcher API : audit détaillé des formulaires des demandes (par type et par champ) 2026-11-10 20:51:16 +01:00
nicolas 7b153b81ee Anonymisation de xbhost en LanisysGames 2026-11-10 20:14:46 +01:00
nicolas 8fe67053e1 Merge pull request 'Dev/3.4.1' (#144) from dev/3.4.1 into master
Reviewed-on: #144
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:59:13 +01:00
nicolas 927df6c2b2 Version 3.4.1 2026-11-10 16:55:01 +01:00
nicolas 197f5186d0 UiSnapshot : captures en WebP (--format webp, --quality)
- --format png|webp (png par défaut), --quality 1-100 (85 par défaut,
  100 = sans perte) ; transparence conservée, pages .p2.webp comprises.
- Encodage WebP par SkiaSharp 3.119.1 (Microsoft, MIT) : WIC lit le WebP sans
  savoir l'écrire. Outil de développement uniquement.
- index.html et report.md reconnaissent PNG et WebP ; une série complète
  efface aussi les anciens .webp.
- LISEZMOI : captures pour le site vitrine ; README §5 et §9 mis à jour.
2026-11-10 16:50:13 +01:00
nicolas 98cf17c294 Merge pull request 'Web/1.0.0' (#143) from web/1.0.0 into master
Reviewed-on: #143
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:29:33 +01:00
nicolas 4da86bcc6a README : site vitrine (§9)
Ligne GameLauncher.Website dans la structure de la solution, nouvelle section
§9 : pages, thèmes, isolation de la Launcher API, téléchargement vérifié,
sécurité HTTP, service durci, liseuse Markdown, tests, captures à mettre à jour.
2026-11-10 16:17:16 +01:00
nicolas 52761f53b6 Site vitrine : publication et déploiement sur le VPS
- publish-site.bat : exe autonome linux-x64, archive et empreinte SHA-256.
- installer-site.sh :
  - archive vérifiée par son empreinte ;
  - binaires dans /opt/lanisys-site (propriétaire root) ;
  - appsettings.Production.json conservé d'une version à l'autre ;
  - sorties réseau autorisées recalculées (serveur du launcher + DNS) dans
    sortie.conf ;
  - retour à la version précédente si le site ne répond pas.
- lanisys-site.service :
  - compte temporaire (DynamicUser), système en lecture seule, aucun privilège ;
  - dossier personnel /var/lib/lanisys-site (700), nécessaire à .NET pour
    vérifier un certificat HTTPS sous Debian 11 ;
  - connexions sortantes limitées au serveur du launcher ;
  - mémoire limitée.
- nginx-site.conf :
  - redirection http → https et domaine nu → www ;
  - HTTP/2 compatible nginx 1.18 (listen … http2) ;
  - TLS 1.2 et 1.3, renouvellement Let's Encrypt par webroot ;
  - lectures seulement, fichiers cachés jamais servis, gzip, limite de
    requêtes (nginx-site-zones.conf).
- LISEZMOI.md : pourquoi un VPS séparé, domaine, publication, installation,
  nginx et HTTPS, mentions légales, journaux, mise à jour, écriture des
  actualités, ajout d'un jeu, test en local, dépannage, fichiers sur le VPS.
  Debian 11, 12 ou 13.
2026-11-10 16:16:58 +01:00
nicolas cf75a22f19 Tests : site vitrine
Les sources du site (Services, Options) et la clé publique sont compilées
dans le projet de tests, sans référence au projet du site : une référence
copierait l'appsettings.json du site à la place de celui de la Launcher API
dans le dossier des tests.

Tests :
- launcher-update.json : signature valide, contenu modifié, autre clé,
  exécutable hors du serveur (autre hôte, http, autre port, paramètres),
  produit inattendu, clé intégrée ;
- manifest : bornes et nettoyage des notes, autre jeu, fichier illisible ;
- réseau : adresses https uniquement, lecture bornée, message d'erreur
  détaillé (certificat refusé) ;
- Markdown : HTML brut, liens javascript:, data: et //, injection
  d'attribut, images externes, syntaxe prise en charge ;
- actualités : en-tête, noms de fichiers invalides, brouillons.
2026-11-10 16:16:26 +01:00
nicolas 009c84de9f Site vitrine : premières actualités
- Ouverture du site.
- Exovival Zombie Reborn 1.8 : zombies rôdeurs, ouïe des zombies, armes,
  soins.
- Launcher 3.4 : versions prioritaires.
2026-11-10 16:15:28 +01:00
nicolas 9f9b9b9d4d Site vitrine : images, polices et icônes
- Polices auto-hébergées Sora, Barlow et Barlow Condensed (WOFF2), avec leurs
  licences OFL : aucune requête vers un service externe.
- Captures d'Exovival : couloir de l'école, encre UV, images des cassettes VHS
  sans les dates qui révèlent un code, et vues de l'abri en développement. Le
  tout en WebP.
- Captures du launcher (docs/preview*.png et UiSnapshot) en WebP.
- Favicon et icônes reprises du launcher, images de partage (Open Graph) du
  launcher et d'Exovival.
2026-11-10 16:14:55 +01:00
nicolas 12ab8377c9 Site vitrine : pages, thèmes par univers et script
Pages :
- accueil ;
- /launcher : présentation des fonctions joueur, téléchargement vérifié
  (version, taille, empreinte SHA-256), première installation, notes de la
  dernière version, lien vers les publications Gitea, questions fréquentes ;
- /jeux et /jeux/exovival-zombie-reborn : histoire, contenu, cassettes VHS,
  extension « L'abri » en développement, configuration ;
- /jeux/<jeu>/notes-de-version : toutes les versions ;
- page générique des jeux en préparation (projet multijoueur) ;
- actualités, mentions légales, confidentialité, erreurs 404 et 500.

Thèmes par page (data-theme) :
- launcher : violet et cyan, police Sora ;
- exovival : noir et vert, Barlow Condensed ; en-tête « lampe UV » qui révèle
  le message de Léa sous le pointeur ;
- multi : ambre.

Notes de version affichées comme dans le launcher (titre, sous-titre,
introduction, sections, étiquettes), textes toujours encodés.

Bouton « Nous contacter » : simple lien mailto. Aucun formulaire.

Responsive jusqu'au mobile, focus clavier visible, mouvement réduit respecté.
Script sans dépendance (menu mobile, copie de l'empreinte, lampe UV),
compatible avec la CSP (aucun script en ligne).
2026-11-10 16:14:00 +01:00
nicolas 5aa22df409 Site vitrine : nouveau projet GameLauncher.Website (ASP.NET Core Razor Pages)
Projet ajouté à la solution (dossier « website »), sans paquet NuGet tiers.
Références : GameLauncher.Contracts et GameLauncher.Core.

Isolé de la Launcher API : le site ne l'appelle jamais. En arrière-plan, côté
serveur, il lit seulement les fichiers publics launcher-update.json et
manifest.json :
- toutes les 10 minutes, avec requêtes conditionnelles (ETag, Last-Modified) ;
- https uniquement, aucune redirection suivie, taille et délai bornés.
Les pages ne déclenchent aucune requête sortante.

launcher-update.json :
- signature vérifiée avec la clé update-signing.pub, intégrée à l'exe du site,
  par le même code que le launcher (UpdateSignature) ;
- l'exécutable doit être sur le même serveur, en https ;
- produit attendu facultatif (Site:Launcher:Product) ;
- fichier refusé : téléchargement retiré ; serveur injoignable : dernière
  version valide conservée.

Notes de version : bornées et nettoyées comme dans le launcher
(ChangelogLimits.Normalize). Sections réservées à l'équipe masquées pour le
launcher (Site:Launcher:HiddenSections).

Certificat HTTPS du serveur du launcher : même règle que par défaut ; en cas de
refus, le journal détaille la chaîne de certificats (TlsDiagnostics) et la
cause complète de l'erreur réseau.

Actualités en Markdown (Content/News) : liseuse maison SafeMarkdown.
- tout le texte est encodé ;
- liens http(s), mailto et chemins du site seulement ;
- images du site (/img/) seulement.

Sécurité HTTP :
- CSP stricte (aucun script en ligne, aucune ressource externe), HSTS,
  nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP ;
- GET/HEAD seulement (405 sinon) ;
- limite de requêtes par IP ;
- aucun cookie, clés de protection des données en mémoire ;
- en-tête Server retiré.

Autres :
- réglages contrôlés au démarrage (SiteOptionsValidator) ;
- dates en français sans dépendance à ICU (InvariantGlobalization) ;
- robots.txt, sitemap.xml, /healthz réservé à la machine locale.
2026-11-10 16:12:47 +01:00
nicolas 3df009912b Git : fins de ligne LF pour website/deploy/*.conf
Les configurations nginx du site vitrine sont copiées telles quelles sur le VPS :
même règle que server/deploy/*.conf.
2026-11-10 16:10:33 +01:00
nicolas 89570947aa Merge pull request 'Dev/3.4.0' (#142) from dev/3.4.0 into master
Reviewed-on: #142
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 13:49:24 +01:00
nicolas 14b2167218 Launcher 3.4.0 : mises à jour prioritaires
- Version 3.3.1 → 3.4.0
- Notes de version (notes-launcher.txt, RELEASE-3.4.0.md)
- README §7 : section « Version prioritaire », sécurité, publication
2026-11-10 13:35:25 +01:00
nicolas 2e4d2ee76e UiSnapshot : scène launcher-update-priority 2026-11-10 13:34:37 +01:00
nicolas 7d9032fe20 Publication : options --priority et --clear-priority
- LauncherPublisher publish : --priority (confirmation o/N), --clear-priority,
  --yes ; priorité reprise du launcher-update.json précédent s'il est signé
- Ligne « Priorité » dans publish et verify
- publish-launcher-update.bat : paramètres --priority, --clear-priority,
  --help (shift /1 pour garder %~dp0)
2026-11-10 13:34:13 +01:00
nicolas ccd6b1a430 Interface : pastille « Prioritaire » dans la fenêtre de mise à jour du launcher
Sous-titre dédié précisant que le réglage du joueur ne change pas.
2026-11-10 13:33:02 +01:00
nicolas e4a21c6f5d Launcher : version prioritaire installée même si la MAJ auto est désactivée
- Recherche au démarrage même option désactivée ; seule une version
  prioritaire est alors installée, self-update.json jamais modifié
- Erreur de téléchargement d'une version prioritaire : retentée au
  démarrage suivant
- Version prioritaire non installée : avertissement dans le bandeau et
  statut orange dans les Paramètres du launcher
2026-11-10 13:32:42 +01:00
nicolas 1a7f45c38b Tests : versions prioritaires du launcher (8 tests, 12 cas)
Signature, compatibilité sans priorité, launchers forcés ou non,
validation, reprise / retrait par l'outil de publication,
self-update.json sans champ de priorité.
2026-11-10 13:32:05 +01:00
nicolas b48d4222db Core : version prioritaire du launcher dans launcher-update.json (signée)
- LauncherRelease.PriorityVersion : dernière version prioritaire publiée,
  dans le payload signé (ignorée par les launchers < 3.4.0)
- IsPriorityRelease, IsPriorityFor(), TryGetPriorityVersion()
- ResolvePriorityVersion() : marquer, reprendre ou retirer la priorité
  d'une publication à l'autre
- Validate() refuse une version prioritaire illisible ou supérieure à la
  version publiée
- LauncherUpdateCheck.IsPriority
2026-11-10 13:31:36 +01:00
121 changed files with 6275 additions and 165 deletions

No files matched your search

+1
View File
@@ -68,3 +68,4 @@
*.sh text eol=lf
*.service text eol=lf
server/deploy/*.conf text eol=lf
website/deploy/*.conf text eol=lf
+3 -3
View File
@@ -3,9 +3,9 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Xbhost</Company>
<Authors>Xbhost</Authors>
<Copyright>© Xbhost</Copyright>
<Company>LanisysGames</Company>
<Authors>LanisysGames</Authors>
<Copyright>© LanisysGames</Copyright>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
+24
View File
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
tools\publish-site.bat = tools\publish-site.bat
server-example\web.config = server-example\web.config
EndProjectSection
EndProject
@@ -70,6 +71,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
ProjectSection(SolutionItems) = preProject
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
website\deploy\installer-site.sh = website\deploy\installer-site.sh
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -182,6 +196,14 @@ Global
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -201,6 +223,8 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+66 -3
View File
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -367,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -429,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
simplement qu'une nouvelle version existe.
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
### Sécurité
@@ -441,6 +442,7 @@ simplement qu'une nouvelle version existe.
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
### Déroulement
@@ -463,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
### Version prioritaire (correctif critique)
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
| Point | Comportement |
|---|---|
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
### Mise en place (une seule fois)
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
@@ -490,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
Vérification à tout moment :
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
@@ -547,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
@@ -687,3 +709,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
## 9. Site vitrine
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
| Page | Contenu |
|---|---|
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
pas affichées (`Site:Launcher:HiddenSections`).
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
toujours encodés (jamais interprétés comme du HTML).
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
aucune donnée écrite sur le disque.
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
liens `javascript:`/`data:` retirés, images du site uniquement.
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
+1 -1
View File
@@ -827,7 +827,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 416 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 395 KiB

@@ -197,7 +197,7 @@ public sealed class FeedbackModule : IApiModule
/// <summary>
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
/// </summary>
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
@@ -222,8 +222,17 @@ public sealed class FeedbackModule : IApiModule
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
var changes = FeedbackFormsAudit.Changes(previous, published);
foreach (var (action, name, details) in changes
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
.Reverse())
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"« {name} »", details);
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
return Results.Json(FormsState(forms, admins), ApiJson.Options);
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Server.Storage;
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
/// <param name="Details">Détail de chaque changement, en clair.</param>
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
/// <summary>
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
/// numérotées (1/2, 2/2…) plutôt que tronqué.
/// </summary>
public static class FeedbackFormsAudit
{
public const string Published = "feedback.forms";
public const string Created = "feedback.form_create";
public const string Updated = "feedback.form_update";
public const string Deleted = "feedback.form_delete";
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
internal const int MaxDetailsLength = 480;
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
private const int MaxShownValueLength = 80;
private const string Separator = " ; ";
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<FeedbackFormChange>();
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
}
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
changes.Add(new FeedbackFormChange(Deleted, form.Label,
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
return changes;
}
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
{
var parts = new List<string>();
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
}
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
{
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
}
/// <summary>
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
/// </summary>
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
{
// Place réservée au numéro « (12/12) ».
var room = Math.Max(20, maxLength - 8);
var lines = new List<string>();
var current = "";
foreach (var detail in details.SelectMany(d => Split(d, room)))
{
if (current.Length == 0) current = detail;
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
else
{
lines.Add(current);
current = detail;
}
}
if (current.Length > 0) lines.Add(current);
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
}
private static IEnumerable<string> Split(string text, int room)
{
for (var i = 0; i < text.Length; i += room)
yield return text.Substring(i, Math.Min(room, text.Length - i));
}
// ------------------------------------------------------------------ Types de demande
private static List<string> CreatedDetails(FeedbackForm form)
{
var details = new List<string>();
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
details.Add($"{form.Fields.Count} champ(s)");
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
return details;
}
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
{
var details = new List<string>();
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
if (old.TechByDefault != form.TechByDefault)
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
if (old.CustomIcon != form.CustomIcon)
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
if (old.AllowAttachments != form.AllowAttachments)
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
details.Add($"champ supprimé : « {field.Label} »");
foreach (var field in form.Fields)
{
if (old.Field(field.Id) is not { } before)
{
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
continue;
}
var parts = FieldDetails(before, field);
if (parts.Count == 0) continue;
var prefix = $"champ « {field.Label} » modifié : ";
var combined = prefix + string.Join(", ", parts);
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
else details.AddRange(parts.Select(p => prefix + p));
}
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
details.Add("ordre des champs modifié");
return details;
}
// ------------------------------------------------------------------ Champs
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
{
var parts = new List<string>();
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
if ((old.Hint ?? "") != (field.Hint ?? ""))
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
if (old.ShowInList != field.ShowInList)
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
parts.AddRange(OptionDetails(old.Choices, field.Choices));
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
return parts;
}
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
{
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
foreach (var option in now)
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
yield return "ordre des choix modifié";
}
private static string FieldSummary(FeedbackField field)
{
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
if (field.ShowInList) parts.Add("en aperçu dans la liste");
return string.Join(", ", parts);
}
private static string KindText(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "zone de texte",
FeedbackFieldKind.Text => "texte court",
FeedbackFieldKind.Choice => "liste de choix",
FeedbackFieldKind.Checkboxes => "cases à cocher",
FeedbackFieldKind.Rating => "note en étoiles",
FeedbackFieldKind.YesNo => "case oui/non",
_ => kind.ToString()
};
// ------------------------------------------------------------------ Texte
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
private static string ValueChange(string name, bool feminine, string? before, string? after)
{
var e = feminine ? "e" : "";
before = OneLine(before);
after = OneLine(after);
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
if (after.Length == 0) return $"{name} retiré{e}";
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
? $"{name} : {Quote(before)} → {Quote(after)}"
: $"{name} modifié{e}";
}
private static string OneLine(string? value) =>
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static string Quote(string? value) => $"« {OneLine(value)} »";
}
@@ -1,5 +1,4 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
// L'historique est une aide : son échec n'annule pas la publication.
}
}
/// <summary>
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
/// retirés, modifiés ; captures d'écran, icône).
/// </summary>
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<string>();
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) continue;
var parts = new List<string>();
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
old.TechByDefault != form.TechByDefault)
parts.Add("présentation modifiée");
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
if (old.AllowAttachments != form.AllowAttachments)
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var addedFields = newIds.Count(id => !oldIds.Contains(id));
var removedFields = oldIds.Count(id => !newIds.Contains(id));
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
parts.Add("ordre modifié");
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
}
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
}
}
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
public string GameName { get; set; } = "Mon Jeu";
public string Publisher { get; set; } = "Xbhost";
public string Publisher { get; set; } = "LanisysGames";
public string Tagline { get; set; } = "";
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
/// </summary>
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
/// </summary>
public string? PriorityVersion { get; set; }
[JsonIgnore]
public Version ParsedVersion => VersionUtil.Parse(Version);
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
[JsonIgnore]
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
/// <summary>
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
/// </summary>
public bool IsPriorityFor(Version currentVersion) =>
TryGetPriorityVersion(out var priority) && priority > currentVersion;
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
public bool TryGetPriorityVersion(out Version priority)
{
priority = new Version(0, 0, 0);
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
}
/// <summary>
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
/// <list type="bullet">
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
/// </list>
/// </summary>
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
{
if (markPriority && clearPriority)
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
if (markPriority) return VersionUtil.Display(published);
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
return null;
return VersionUtil.Display(carried);
}
public void Validate()
{
if (Format != 1)
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
else if (priority > ParsedVersion)
throw new UpdateSecurityException(
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
Notes ??= new List<string>();
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
Changelog = ChangelogLimits.Normalize(Changelog);
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
public bool IsAvailable => Release is not null && DownloadUri is not null;
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
/// <summary>
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
/// </summary>
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
}
/// <summary>
@@ -505,6 +505,9 @@ public static class AuditTexts
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"feedback.forms" => "Formulaires des demandes publiés",
"feedback.form_create" => "Type de demande ajouté",
"feedback.form_update" => "Type de demande modifié",
"feedback.form_delete" => "Type de demande supprimé",
"maintenance.enable" => "Mode maintenance activé",
"maintenance.disable" => "Mode maintenance terminé",
"maintenance.update" => "Message de la maintenance modifié",
+9 -2
View File
@@ -899,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
/// <summary>
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
/// dossier protégé) : simple information dans le bandeau.
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
/// version prioritaire.
/// </summary>
private async Task NotifyLauncherUpdateAsync()
{
@@ -907,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
{
await Task.Delay(TimeSpan.FromSeconds(4));
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
if (check.IsAvailable)
if (check.IsPriority)
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Warning);
else if (check.IsAvailable)
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Info);
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.3.1</Version>
<Version>3.4.2</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
/// <summary>
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
/// </summary>
public async Task<bool> RunStartupCheckAsync()
{
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
if (_client is null || !IsSingleFileBuild) return false;
LauncherUpdateCheck check;
try
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
}
if (!check.IsAvailable) return false;
if (!AutoUpdate && !check.IsPriority)
{
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
SkippedUpdate = check;
return false;
}
if (check.IsPriority)
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
{
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
SkippedUpdate = check;
return false;
}
return await InstallAsync(check, automatic: true);
if (await InstallAsync(check, automatic: true)) return true;
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
if (check.IsPriority) SkippedUpdate = check;
return false;
}
// ------------------------------------------------------------------ Installation
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
Log.Error("Mise à jour du launcher impossible", ex);
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
if (automatic) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
var retryAtNextStart = automatic && update.IsPriority;
if (automatic && !retryAtNextStart) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
: automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
}
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
if (check.IsAvailable)
{
SetAvailable(check);
LauncherStatusKind = DialogKind.Info;
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
if (check.IsPriority)
{
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
LauncherStatusKind = DialogKind.Warning;
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
"Installez-la dès que possible.";
}
else
{
LauncherStatusKind = DialogKind.Info;
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
}
}
else
{
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
{
Automatic = automatic;
IsPriority = update.IsPriority;
FromVersion = update.CurrentDisplay;
ToVersion = update.AvailableDisplay;
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
Subtitle = automatic
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
Subtitle = (automatic, IsPriority) switch
{
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
};
Progress = new Progress<OperationProgress>(p =>
{
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
public bool Automatic { get; }
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
public bool IsPriority { get; }
public string FromVersion { get; }
public string ToVersion { get; }
public string Subtitle { get; }
@@ -76,6 +76,16 @@
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
@@ -182,7 +182,8 @@ public class FeedbackPanelTests
public void Feedback_audit_actions_have_friendly_labels()
{
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
"feedback.purge", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
"feedback.purge", "feedback.forms", "feedback.form_create", "feedback.form_update",
"feedback.form_delete", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
@@ -7,6 +7,7 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -35,6 +36,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<!--
Site vitrine : ses services sont compilés ici plutôt que référencés. Une référence au projet du site
copierait son appsettings.json dans le dossier des tests, à la place de celui de la Launcher API
(même nom de fichier), et fausserait les tests du serveur.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
<Compile Include="..\..\website\GameLauncher.Website\Services\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<Compile Include="..\..\website\GameLauncher.Website\Options\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Website\Site\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -67,9 +67,22 @@ public class FeedbackFormsModuleTests
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(published),
published.Signature!, key.PublicKey));
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("Problème technique", audit.Details);
Assert.Contains("+3 champ(s)", audit.Details);
// Audit : une ligne de publication, puis une par type modifié, avec son nom et le détail de chaque champ.
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
Assert.Contains("3 types modifiés", audit.Details);
Assert.Contains("« Problème technique »", audit.Details);
string Details(string name) =>
string.Join(" ", entries.Where(e => e.Action == FeedbackFormsAudit.Updated && e.Target == $"« {name} »").Select(e => e.Details));
var bug = Details("Problème technique");
Assert.Contains("nom affiché : « Bug » → « Problème technique »", bug);
Assert.Contains("champ supprimé : « Résultat attendu »", bug);
Assert.Contains("champ ajouté : « Où cela arrive-t-il ? » (cases à cocher, obligatoire, 3 choix)", bug);
Assert.Contains("champ ajouté : « Version affichée en jeu » (texte court, facultatif)", bug);
Assert.Contains("champ ajouté : « Le problème revient après un redémarrage » (case oui/non, facultatif)", bug);
Assert.Contains("champ « Note » modifié : étoiles 5 → 10", Details("Avis"));
Assert.Contains("masqué (plus proposé aux joueurs)", Details("Autre"));
Assert.DoesNotContain(entries, e => e.Target == "« Demande d'évolution »"); // inchangé : pas de ligne
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
using var client = api.CreateClient();
@@ -166,9 +179,17 @@ public class FeedbackFormsModuleTests
Assert.Equal("type_1", state.Document.Forms[0].Kind); // ordre conservé
Assert.Equal(icon, state.Document.Forms[0].CustomIcon);
Assert.True(new FileInfo(Path.Combine(directory, "feedback-forms.json")).Length > 64 * 1024);
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("type(s) ajouté(s)", audit.Details);
Assert.Contains("« Autre »", audit.Details);
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
Assert.Contains("5 types ajoutés", audit.Details);
Assert.Contains("1 type supprimé : « Autre »", audit.Details);
Assert.Equal(5, entries.Count(e => e.Action == FeedbackFormsAudit.Created));
var created = entries.Single(e => e.Action == FeedbackFormsAudit.Created && e.Target == "« Suggestion de niveau »");
Assert.Contains("icône importée", created.Details);
Assert.Contains("captures d'écran non proposées", created.Details);
Assert.Contains("champ « Message » (zone de texte, obligatoire)", created.Details);
Assert.Contains("icône importée", entries.Single(e => e.Action == FeedbackFormsAudit.Updated && e.Target == "« Bug »").Details);
Assert.Equal("« Autre »", entries.Single(e => e.Action == FeedbackFormsAudit.Deleted).Target);
using var client = api.CreateClient();
// Nouveau type : accepté, mais sans capture d'écran.
@@ -193,4 +214,54 @@ public class FeedbackFormsModuleTests
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
Assert.Equal(icon, detail.Form.CustomIcon);
}
[Fact]
public void Audit_names_every_changed_field_and_spreads_long_details_over_numbered_lines()
{
var before = FeedbackFormRules.Defaults();
var many = Enumerable.Range(1, FeedbackFormRules.MaxOptions).Select(i => new FeedbackOption($"o{i}", new string('x', 50) + i)).ToList();
var after = new FeedbackFormsDocument
{
Revision = 1,
Forms = before.Forms.Select(form => form.Kind != FeedbackKinds.Bug ? form : form with
{
TitleHint = "Le souci en une phrase",
TechByDefault = false,
Fields = form.Fields.Select(field => field.Id switch
{
"severity" => field with { Label = "Gravité du problème", Required = false, ShowInList = false, Options = many },
"steps" => field with { Hint = "Autre aide." },
"actual" => field with { MaxLength = 800 },
_ => field
}).Reverse().ToList()
}).ToList()
};
var change = Assert.Single(FeedbackFormsAudit.Changes(before, after));
Assert.Equal(FeedbackFormsAudit.Updated, change.Action);
Assert.Equal("Bug", change.Name);
Assert.Equal("1 type modifié : « Bug »", FeedbackFormsAudit.Summary(before, after, new[] { change }));
// Rien n'est perdu : plusieurs lignes numérotées, chacune sous la limite d'un champ du journal.
var lines = FeedbackFormsAudit.Lines(change.Details);
Assert.True(lines.Count > 1);
Assert.All(lines, line => Assert.True(line.Length <= FeedbackFormsAudit.MaxDetailsLength, line));
Assert.StartsWith("(1/", lines[0]);
Assert.StartsWith($"({lines.Count}/{lines.Count}) ", lines[^1]);
var all = string.Join(" ", lines);
Assert.Contains("exemple de titre : « Le problème en une phrase » → « Le souci en une phrase »", all);
Assert.Contains("« Joindre les informations techniques » décoché par défaut", all);
Assert.Contains("champ « Gravité du problème » modifié : libellé « Gravité » → « Gravité du problème »", all);
Assert.Contains("champ « Gravité du problème » modifié : rendu facultatif", all);
Assert.Contains("champ « Gravité du problème » modifié : retiré de l'aperçu de la liste des demandes", all);
foreach (var option in many) Assert.Contains($"choix ajouté « {option.Label} »", all);
Assert.Contains("choix retiré « Mineur »", all);
Assert.Contains("champ « Étapes pour reproduire » modifié : aide modifiée", all);
Assert.Contains("champ « Résultat obtenu » modifié : longueur maximale 1500 → 800", all);
Assert.Contains("ordre des champs modifié", all);
// Publication sans changement : pas de ligne par type.
Assert.Empty(FeedbackFormsAudit.Changes(before, before with { Revision = 1 }));
Assert.Equal("aucun changement", FeedbackFormsAudit.Summary(before, before, Array.Empty<FeedbackFormChange>()));
}
}
@@ -0,0 +1,139 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Tests.Signing;
/// <summary>
/// Version PRIORITAIRE du launcher (correctif critique) : installée au démarrage même si le joueur a désactivé la mise
/// à jour automatique. Information signée, reprise d'une publication à l'autre, sans effet sur les launchers à jour.
/// </summary>
public class LauncherPriorityTests
{
private static LauncherRelease Release(string version, string? priority = null) => new()
{
Product = "GameLauncher",
Version = version,
File = $"GameLauncher-{version}.exe",
Size = 1234,
Sha256 = new string('a', 64),
PublishedAt = DateTimeOffset.UnixEpoch,
PriorityVersion = priority
};
private static (string Envelope, UpdateSignature.TrustedKey Key) Sign(LauncherRelease release, ECDsa key)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options);
var envelope = UpdateSignature.CreateEnvelope(payload, key);
var spki = key.ExportSubjectPublicKeyInfo();
return (envelope, new UpdateSignature.TrustedKey(UpdateSignature.ComputeKeyId(spki), spki));
}
private static LauncherUpdateCheck Check(string current, LauncherRelease? release) =>
new(VersionUtil.Parse(current), release, release is null ? null : new Uri("https://jeux.exemple.test/launcher/" + release.File));
[Fact]
public void The_priority_version_is_part_of_the_signed_content()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, trusted) = Sign(Release("3.4.0", priority: "3.4.0"), key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Equal("3.4.0", read.PriorityVersion);
Assert.True(read.IsPriorityRelease);
// Serveur compromis : retirer (ou ajouter) la priorité dans le payload casse la signature.
var json = JsonSerializer.Deserialize<SignedEnvelope>(envelope, JsonDefaults.Options)!;
var tampered = Encoding.UTF8.GetString(Convert.FromBase64String(json.Payload)).Replace("\"3.4.0\"", "\"3.3.9\"");
json.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(tampered));
var forged = JsonSerializer.Serialize(json, JsonDefaults.Options);
Assert.Throws<UpdateSecurityException>(() => UpdateSignature.ReadRelease(forged, new[] { trusted }));
}
[Fact]
public void A_release_without_priority_stays_compatible_with_older_launchers()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var release = Release("3.4.0");
var payload = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options));
Assert.DoesNotContain("priorityVersion", payload, StringComparison.OrdinalIgnoreCase); // champ absent : rien ne change
var (envelope, trusted) = Sign(release, key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Null(read.PriorityVersion);
Assert.False(read.IsPriorityRelease);
Assert.False(Check("3.3.1", read).IsPriority);
Assert.True(Check("3.3.1", read).IsAvailable);
}
[Theory]
[InlineData("3.3.1", true)] // plus ancien que le correctif : mise à jour imposée
[InlineData("3.3.9", true)]
[InlineData("3.4.0", false)] // correctif déjà installé : réglage du joueur respecté pour 3.5.0
[InlineData("3.4.2", false)]
public void Only_launchers_older_than_the_priority_version_are_forced(string current, bool forced)
{
var release = Release("3.5.0", priority: "3.4.0"); // 3.4.0 prioritaire, puis 3.5.0 normale
Assert.Equal(forced, release.IsPriorityFor(VersionUtil.Parse(current)));
Assert.Equal(forced, Check(current, release).IsPriority);
Assert.False(release.IsPriorityRelease);
}
[Fact]
public void No_update_available_means_no_priority()
{
Assert.False(Check("3.4.0", null).IsPriority);
Assert.False(new LauncherUpdateCheck(VersionUtil.Parse("3.3.0"), Release("3.4.0", "3.4.0"), null).IsPriority);
}
[Theory]
[InlineData("3.6.0")] // supérieure à la version publiée
[InlineData("pas-une-version")]
public void An_inconsistent_priority_version_is_refused(string priority)
{
var release = Release("3.5.0", priority);
Assert.Throws<UpdateSecurityException>(release.Validate);
}
[Fact]
public void A_blank_priority_version_means_none()
{
var release = Release("3.5.0", " ");
release.Validate();
Assert.Null(release.PriorityVersion);
}
[Fact]
public void The_publisher_marks_carries_over_or_clears_the_priority()
{
var v350 = VersionUtil.Parse("3.5.0");
var previousPriority = Release("3.4.0", priority: "3.4.0");
var previousNormal = Release("3.4.0");
// --priority : la version publiée elle-même
Assert.Equal("3.5.0", LauncherRelease.ResolvePriorityVersion(v350, markPriority: true, clearPriority: false, previousNormal));
// version normale après un correctif critique : priorité reprise (les launchers < 3.4.0 restent forcés)
Assert.Equal("3.4.0", LauncherRelease.ResolvePriorityVersion(v350, false, false, previousPriority));
// --clear-priority : plus aucune version imposée
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, true, previousPriority));
// rien à reprendre
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, previousNormal));
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, null));
// priorité précédente supérieure à la version publiée (--force vers une version inférieure) : non reprise
Assert.Null(LauncherRelease.ResolvePriorityVersion(VersionUtil.Parse("3.3.5"), false, false, previousPriority));
// options contradictoires
Assert.Throws<ArgumentException>(() => LauncherRelease.ResolvePriorityVersion(v350, true, true, null));
}
[Fact]
public void The_player_setting_file_has_no_priority_field()
{
// Effet ponctuel : la priorité vient UNIQUEMENT de la description signée, jamais de self-update.json.
var settings = new SelfUpdateSettings { AutoUpdate = false };
var json = JsonSerializer.Serialize(settings, JsonDefaults.Options);
Assert.DoesNotContain("priority", json, StringComparison.OrdinalIgnoreCase);
Assert.False(JsonSerializer.Deserialize<SelfUpdateSettings>(json, JsonDefaults.Options)!.AutoUpdate);
}
}
@@ -0,0 +1,320 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Website.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace GameLauncher.Tests.Website;
/// <summary>
/// Site vitrine : lecture des fichiers du serveur du launcher (signature, adresses, bornes) et liseuse
/// Markdown des actualités (aucun HTML ni lien dangereux ne doit passer).
/// </summary>
public sealed class WebsiteRemoteContentTests : IDisposable
{
private static readonly Uri UpdateUrl = new("https://launcher.example/launcher/launcher-update.json");
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _trusted;
public WebsiteRemoteContentTests()
{
_trusted = UpdateSignature.ParsePublicKeys(_key.ExportSubjectPublicKeyInfoPem());
}
public void Dispose() => _key.Dispose();
private static LauncherRelease Release(string file = "GameLauncher-3.4.0.exe", string product = "EZRLauncher") => new()
{
Product = product,
Version = "3.4.0",
File = file,
Size = 1234,
Sha256 = new string('A', 64),
PublishedAt = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero),
PriorityVersion = "3.4.0",
Changelog = new()
{
new ChangelogEntry
{
Version = "3.4.0",
Title = "Launcher 3.4.0",
Sections = new()
{
new ChangelogSection { Title = "EN BREF", Items = { new ChangelogItem { Text = "Nouveauté" } } },
new ChangelogSection { Title = "DÉVELOPPEMENT", Items = { new ChangelogItem { Text = "Interne" } } },
new ChangelogSection { Title = "Serveur", Items = { new ChangelogItem { Text = "Rien" } } }
}
},
new ChangelogEntry { Version = "3.3.1", Title = "Ancienne" }
}
};
private byte[] Envelope(LauncherRelease release, ECDsa? key = null) =>
Encoding.UTF8.GetBytes(UpdateSignature.CreateEnvelope(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options), key ?? _key));
private static readonly string[] Hidden = { "Serveur", "Developpement" };
[Fact]
public void Launcher_signature_valide_donne_la_version_l_adresse_et_les_notes_filtrees()
{
var snapshot = RemoteContentReader.ReadLauncher(Envelope(Release()), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow);
Assert.Equal("3.4.0", snapshot.Version);
Assert.Equal("https://launcher.example/launcher/GameLauncher-3.4.0.exe", snapshot.DownloadUrl.ToString());
Assert.Equal("GameLauncher-3.4.0.exe", snapshot.FileName);
Assert.Equal(new string('a', 64), snapshot.Sha256);
Assert.True(snapshot.IsPriority);
Assert.NotNull(snapshot.LatestNotes);
Assert.Equal("Launcher 3.4.0", snapshot.LatestNotes!.Title);
// Sections réservées à l'équipe retirées, sans tenir compte des accents ni de la casse.
Assert.Equal(new[] { "EN BREF" }, snapshot.LatestNotes.Sections.Select(s => s.Title).ToArray());
}
[Fact]
public void Launcher_contenu_modifie_est_refuse()
{
var json = Encoding.UTF8.GetString(Envelope(Release()));
var envelope = JsonSerializer.Deserialize<SignedEnvelope>(json, JsonDefaults.Options)!;
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)).Replace("GameLauncher-3.4.0.exe", "Piege-3.4.0.exe");
envelope.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var tampered = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(tampered, UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_signe_par_une_autre_cle_est_refuse()
{
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(), other), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://ailleurs.example/GameLauncher.exe")]
[InlineData("http://launcher.example/launcher/GameLauncher.exe")]
[InlineData("https://launcher.example:8443/launcher/GameLauncher.exe")]
[InlineData("GameLauncher.exe?x=1")]
[InlineData("notes.txt")]
public void Launcher_executable_hors_du_serveur_ou_inattendu_est_refuse(string file)
{
// Même signé, un fichier ne peut pas envoyer les visiteurs vers un autre serveur.
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(file)), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_produit_inattendu_est_refuse()
{
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(product: "AutreLauncher")), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_cle_publique_integree_au_site()
{
var keys = RemoteContentReader.LoadEmbeddedKeys();
Assert.NotEmpty(keys);
Assert.All(keys, k => Assert.Equal(16, k.KeyId.Length));
}
private static byte[] Manifest(string gameId, int entries, string extraChangelogText = "Texte")
{
var manifest = new
{
formatVersion = 1,
gameId,
version = "1.8",
releaseDate = new DateTimeOffset(2026, 11, 1, 18, 0, 0, TimeSpan.FromHours(1)),
archive = new { url = "jeu.zip", size = 1000L, sha256 = new string('b', 64) },
files = new[] { new { path = "a.bin", size = 600L }, new { path = "b.bin", size = 400L } },
changelog = Enumerable.Range(0, entries).Select(i => new
{
version = $"1.{entries - i}",
title = "Version‮ " + i,
sections = new[] { new { title = "NOUVEAU", items = new[] { new { text = extraChangelogText } } } }
})
};
return JsonSerializer.SerializeToUtf8Bytes(manifest);
}
[Fact]
public void Jeu_manifest_lu_borne_et_nettoye_comme_dans_le_launcher()
{
var snapshot = RemoteContentReader.ReadGame(Manifest("EZR", 50, new string('x', 5000)), "EZR", DateTimeOffset.UtcNow);
Assert.Equal("1.8", snapshot.Version);
Assert.Equal(1000, snapshot.DownloadSize);
Assert.Equal(1000, snapshot.InstalledSize);
Assert.Equal(ChangelogLimits.MaxEntries, snapshot.Changelog.Count);
Assert.DoesNotContain('‮', snapshot.Changelog[0].Title!);
Assert.True(snapshot.Changelog[0].Sections[0].Items[0].Text.Length <= ChangelogLimits.MaxTextLength);
}
[Fact]
public void Jeu_manifest_d_un_autre_jeu_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Manifest("AUTRE", 1), "EZR", DateTimeOffset.UtcNow));
}
[Fact]
public void Jeu_manifest_illisible_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{ pas du json"), "EZR", DateTimeOffset.UtcNow));
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{\"gameId\":\"EZR\",\"version\":\"abc\"}"), "EZR", DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://launcher.example/manifest.json", false, true)]
[InlineData("http://launcher.example/manifest.json", true, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", false, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", true, true)]
[InlineData("https://user:secret@launcher.example/manifest.json", false, false)]
[InlineData("file:///etc/passwd", true, false)]
[InlineData("pas une adresse", false, false)]
public void Adresses_lues_https_seulement(string url, bool allowLoopbackHttp, bool expected)
{
Assert.Equal(expected, RemoteFetcher.IsAllowedUrl(url, allowLoopbackHttp, out _));
}
[Fact]
public void Erreur_reseau_detaillee_certificat_refuse()
{
var ex = new HttpRequestException("The SSL connection could not be established, see inner exception.",
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid: PartialChain"));
var text = RemoteFetcher.Describe(ex);
Assert.StartsWith("certificat HTTPS refusé", text);
Assert.Contains("PartialChain", text);
Assert.StartsWith("serveur injoignable", RemoteFetcher.Describe(new HttpRequestException("Name or service not known")));
}
[Fact]
public async Task Lecture_bornee_au_dela_de_la_limite()
{
using var small = new MemoryStream(new byte[100]);
Assert.NotNull(await RemoteFetcher.ReadBoundedAsync(small, 100, CancellationToken.None));
using var big = new MemoryStream(new byte[101]);
Assert.Null(await RemoteFetcher.ReadBoundedAsync(big, 100, CancellationToken.None));
}
}
public sealed class WebsiteMarkdownTests
{
[Fact]
public void Html_brut_affiche_jamais_interprete()
{
var html = SafeMarkdown.ToHtml("Bonjour <script>alert(1)</script> <img src=x onerror=alert(1)>");
Assert.DoesNotContain("<script", html);
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;script&gt;", html);
}
[Theory]
[InlineData("[clic](javascript:alert(1))")]
[InlineData("[clic](JaVaScRiPt:alert(1))")]
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
[InlineData("[clic](//evil.example/x)")]
[InlineData("[clic](vbscript:msgbox)")]
public void Liens_dangereux_retires(string markdown)
{
var html = SafeMarkdown.ToHtml(markdown);
Assert.DoesNotContain("href", html);
Assert.Contains("clic", html);
}
[Fact]
public void Lien_externe_sans_acces_a_la_page_d_origine()
{
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
}
[Fact]
public void Attribut_impossible_a_injecter_dans_un_lien()
{
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
Assert.DoesNotContain("\"onmouseover", html);
}
[Theory]
[InlineData("![photo](https://evil.example/x.png)")]
[InlineData("![photo](/img/../appsettings.json)")]
[InlineData("![photo](/uploads/x.png)")]
public void Images_hors_du_site_refusees(string markdown)
{
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
}
[Fact]
public void Syntaxe_prise_en_charge()
{
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n![Le gymnase](/img/exovival/vhs-gymnase.webp)");
Assert.Contains("<h2>Titre</h2>", html);
Assert.Contains("<strong>gras</strong>", html);
Assert.Contains("<em>italique</em>", html);
Assert.Contains("<code>du code</code>", html);
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
Assert.Contains("<hr>", html);
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
}
}
public sealed class WebsiteNewsTests
{
[Fact]
public void Article_lu_avec_son_en_tete()
{
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
Assert.NotNull(article);
Assert.Equal("Titre : avec deux-points", article!.Title);
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
Assert.Equal("exovival", article.Theme);
Assert.Equal("/img/exovival/couloir.webp", article.Image);
Assert.Contains("<strong>gras</strong>", article.Html);
}
[Theory]
[InlineData("Mon_Article")]
[InlineData("../secret")]
[InlineData("")]
public void Nom_de_fichier_incorrect_ignore(string slug)
{
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
}
[Fact]
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
{
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
Assert.Equal("launcher", article!.Theme);
Assert.Null(article.Image);
}
[Fact]
public void Brouillons_masques_hors_developpement()
{
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
try
{
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
}
finally
{
Directory.Delete(folder, true);
}
}
}
+68 -11
View File
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
/// [--version 1.2.0] [--notes notes-launcher.txt]
/// [--public-key src\GameLauncher\Security\update-signing.pub]
/// [--priority | --clear-priority] [--yes]
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
///
@@ -49,6 +50,11 @@ public static class Program
default: return Usage();
}
}
catch (OperationCanceledException ex)
{
Warn(ex.Message);
return 1;
}
catch (Exception ex)
{
Error($"Erreur : {ex.Message}");
@@ -136,6 +142,22 @@ public static class Program
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
}
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
var markPriority = o.Has("--priority");
var clearPriority = o.Has("--clear-priority");
if (markPriority && clearPriority)
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
if (markPriority && !o.Has("--yes"))
{
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
if (answer is not ("o" or "oui"))
throw new OperationCanceledException("Abandon : rien n'a été publié.");
}
using var key = LoadPrivateKey(keyPath);
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
@@ -172,10 +194,20 @@ public static class Program
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
}
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
var history = ReadHistory(previousJson, key, keyId, display);
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
var history = ReadHistory(previousRelease, display);
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
"relancez avec --priority si cette version doit rester imposée.");
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
var release = new LauncherRelease
{
Product = product,
@@ -187,7 +219,8 @@ public static class Program
// Format historique, lu par les launchers antérieurs à 1.1.3
Title = entry.Title,
Notes = entry.Changes.ToList(),
Changelog = changelog
Changelog = changelog,
PriorityVersion = priorityVersion
};
release.Validate();
@@ -219,6 +252,7 @@ public static class Program
? " Notes : aucune (--notes non fourni ou fichier vide)"
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
Console.WriteLine($" Priorité : {DescribePriority(release)}");
Console.WriteLine($" Produit : {product}");
Console.WriteLine($" Clé : {keyId}");
Console.WriteLine($" SHA-256 : {sha256}");
@@ -245,6 +279,7 @@ public static class Program
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
Console.WriteLine($" SHA-256 : {release.Sha256}");
Console.WriteLine($" Priorité: {DescribePriority(release)}");
var exe = o.Get("--exe");
if (exe is null) return true;
@@ -295,25 +330,30 @@ public static class Program
}
/// <summary>
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
/// </summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
{
if (previousJson is null) return Array.Empty<ChangelogEntry>();
LauncherRelease previous;
if (previousJson is null) return null;
try
{
previous = UpdateSignature.ReadRelease(previousJson, new[]
return UpdateSignature.ReadRelease(previousJson, new[]
{
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
});
}
catch (UpdateSecurityException ex)
{
Warn($"Historique des notes ignoré ({ex.Message}).");
return Array.Empty<ChangelogEntry>();
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
return null;
}
}
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
{
if (previous is null) return Array.Empty<ChangelogEntry>();
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
var entries = previous.Changelog.Count > 0
@@ -322,6 +362,16 @@ public static class Program
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
}
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
private static string DescribePriority(LauncherRelease release)
{
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
var display = VersionUtil.Display(priority);
return release.IsPriorityRelease
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
}
private static LauncherRelease? TryReadUnverified(string envelopeJson)
{
try
@@ -509,7 +559,14 @@ public static class Program
(--config <launcher.json> | --product <launcherId>)
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
[--public-key <update-signing.pub>] [--force]
[--priority | --clear-priority] [--yes]
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
chez les joueurs qui ont désactivé la mise à jour automatique (leur
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
Reprise par les publications suivantes : un launcher plus ancien que
cette version se met à jour quoi qu'il arrive.
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
Vérifie la signature (et l'exécutable).
+51 -7
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Globalization;
using System.IO;
using System.Text;
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using SkiaSharp;
namespace UiSnapshot;
@@ -54,7 +56,7 @@ internal interface ISceneSet
}
/// <summary>
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
/// </summary>
internal sealed class Capture
@@ -222,7 +224,7 @@ internal sealed class Capture
root.UpdateLayout();
var image = Render(root);
Save(image, record.Id + ".png", record);
Save(image, record.Id + _options.ImageExtension, record);
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
Diagnose(root, record);
@@ -267,16 +269,58 @@ internal sealed class Capture
private void Save(BitmapSource image, string fileName, SceneRecord record)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
encoder.Save(stream);
{
if (_options.Format == SnapshotOptions.FormatWebp)
{
SaveWebp(image, stream, _options.Quality);
}
else
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
}
}
record.Files.Add(fileName);
}
/// <summary>
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
/// </summary>
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
{
if (image.Format != PixelFormats.Pbgra32)
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
var width = image.PixelWidth;
var height = image.PixelHeight;
var stride = width * 4;
var pixels = new byte[stride * height];
image.CopyPixels(pixels, stride, 0);
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
try
{
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
data.SaveTo(stream);
}
finally
{
handle.Free();
}
}
/// <summary>
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
/// (&lt;scène&gt;.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// (&lt;scène&gt;.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
/// </summary>
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
@@ -295,7 +339,7 @@ internal sealed class Capture
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
scroller.ScrollToVerticalOffset(target);
await SettleAsync(160);
Save(Render(root), $"{record.Id}.p{page}.png", record);
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
if (target >= scroller.ScrollableHeight) break;
}
scroller.ScrollToVerticalOffset(original);
+9
View File
@@ -621,6 +621,15 @@ internal sealed class SampleWorld
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
// Publication des formulaires : une ligne de résumé, puis une par type de demande (nom et détail des champs).
Add(12, "feedback.forms", AuditResults.Ok, "Camille", "révision 4",
$"1 type ajouté : « Suggestion de niveau » ; 1 type modifié : « Problème technique » ; signé (clé {OwnerKeyId})");
Add(12, "feedback.form_create", AuditResults.Ok, "Camille", "« Suggestion de niveau »",
"icône importée ; captures d'écran non proposées ; 1 champ(s) ; champ « Message » (zone de texte, obligatoire)");
Add(12, "feedback.form_update", AuditResults.Ok, "Camille", "« Problème technique »",
"nom affiché : « Bug » → « Problème technique » ; champ supprimé : « Résultat attendu » ; champ « Gravité » modifié : " +
"rendu facultatif, choix ajouté « Très grave », choix renommé « Plantage » → « Plantage du jeu » ; " +
"champ ajouté : « Version affichée en jeu » (texte court, facultatif)");
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
+27 -5
View File
@@ -1,6 +1,6 @@
# UiSnapshot — captures de toutes les fenêtres du launcher
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
Outil de développement : il rend **chaque écran du launcher en PNG** (ou en WebP, `--format webp`), avec des données de démonstration,
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
@@ -8,6 +8,7 @@ partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launch
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
tools\ui-snapshot.bat --filter "main-*" --format webp captures pour le site vitrine (WebP)
tools\ui-snapshot.bat --list liste des scènes
tools\ui-snapshot.bat --help toutes les options
```
@@ -19,14 +20,34 @@ arguments éventuels dans Propriétés › Déboguer.
| Fichier | Contenu |
|---|---|
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
| `<scène>.png` (ou `.webp`) | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… (ou `.p2.webp`) | Pages suivantes quand une zone défile (jusqu'à 4) |
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
| `launcher.log` | Journal du launcher pendant les captures |
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels des images).
## Captures pour le site vitrine (WebP)
Le site (`website\GameLauncher.Website\wwwroot\img\launcher\`) utilise des images **WebP** : plus légères que le PNG
à qualité égale. `--format webp` les produit directement :
```
tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp --quality 85
```
* `--quality` : 1 à 100 (défaut 85, très bon compromis pour des captures d'interface) ; **100 = sans perte**.
Ignorée en PNG, toujours sans perte.
* `--backdrop transparent` : la transparence (coins arrondis, ombre) est conservée en WebP.
* `--scale 1` (défaut) donne la taille de la fenêtre (environ 1200 px de large), adaptée au site ; `--scale 2`
double la taille et le poids.
* Copier ensuite les fichiers voulus de `artifacts\ui-snapshots` dans `wwwroot\img\launcher\` sous les noms
utilisés par les pages (`apercu-exovival.webp`, `apercu-menu.webp`, `apercu-telechargement.webp`,
`apercu-multijeux.webp`, `mise-a-jour-verifiee.webp`), puis republier le site.
* Encodage par **SkiaSharp** (paquet NuGet maintenu par Microsoft, licence MIT, restauré automatiquement par
Visual Studio) : Windows sait lire le WebP mais pas l'écrire. Seul cet outil l'utilise.
## Scènes
@@ -36,7 +57,8 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
`launcher.multi-jeux.example.json`).
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
d'installation, mise à jour du launcher (téléchargement, version vérifiée, version prioritaire
`launcher-update-priority`, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
+8 -4
View File
@@ -7,6 +7,10 @@ namespace UiSnapshot;
/// <summary>report.md (problèmes détectés, à lire en premier) et index.html (planche de toutes les captures).</summary>
internal static class Report
{
/// <summary>Image de capture (PNG ou WebP, selon --format), par opposition aux autres fichiers produits.</summary>
private static bool IsImage(string file) =>
file.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
public static void Write(Capture capture, SnapshotOptions options, TimeSpan duration)
{
File.WriteAllText(Path.Combine(capture.OutputDirectory, "report.md"), Markdown(capture, options, duration), new UTF8Encoding(false));
@@ -21,7 +25,7 @@ internal static class Report
sb.AppendLine($"Échelle {options.Scale:0.##} · thème {(options.Theme ?? "du jeu")} · attente {options.WaitMilliseconds} ms · " +
$"{(options.Filters.Count == 0 ? "toutes les scènes" : "filtre " + string.Join(", ", options.Filters))} · " +
$"{duration.TotalSeconds:0} s");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels du PNG). Données de démonstration, aucun serveur réel.");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels des images). Données de démonstration, aucun serveur réel.");
sb.AppendLine();
sb.AppendLine("| Scène | Image | Taille | Problèmes | Remarques |");
@@ -29,7 +33,7 @@ internal static class Report
foreach (var r in capture.Records)
{
var status = r.Error is not null ? "**ÉCHEC**" : r.Skipped is not null ? "ignorée" : (r.Issues.Count + r.Bindings.Count).ToString();
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)))} | {r.Size} | {status} | {r.Notes.Count} |");
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(IsImage))} | {r.Size} | {status} | {r.Notes.Count} |");
}
sb.AppendLine();
@@ -72,12 +76,12 @@ internal static class Report
sb.AppendLine(".pages a{color:var(--muted);margin-right:8px;font-size:12px}");
sb.AppendLine("ul{margin:6px 0 0;padding-left:18px;font-size:12px}li.warn{color:var(--warn)}li.bad{color:var(--bad)}li.note{color:var(--muted)}");
sb.AppendLine("</style></head><body>");
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · " +
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · {options.Format.ToUpperInvariant()} · " +
$"{capture.Records.Count} scène(s) · données de démonstration · détails dans report.md</p>");
sb.AppendLine("<div class=\"grid\">");
foreach (var r in capture.Records)
{
var pngs = r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)).ToList();
var pngs = r.Files.Where(IsImage).ToList();
sb.AppendLine("<div class=\"card\">");
if (pngs.Count > 0) sb.AppendLine($"<a href=\"{E(pngs[0])}\"><img loading=\"lazy\" src=\"{E(pngs[0])}\" alt=\"{E(r.Title)}\"></a>");
sb.AppendLine($"<div class=\"meta\"><div class=\"id\">{E(r.Id)}</div><div class=\"title\">{E(r.Title)} {E(r.Size)}</div>");
+16
View File
@@ -137,6 +137,7 @@ internal sealed class PlayerScenes : ISceneSet
yield return new SceneInfo("install-dialog" + suffix, "Installation : choix du dossier");
yield return new SceneInfo("launcher-update" + suffix, "Mise à jour du launcher · téléchargement");
yield return new SceneInfo("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée");
yield return new SceneInfo("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire");
yield return new SceneInfo("launcher-update-error" + suffix, "Mise à jour du launcher · erreur");
foreach (var (id, title, _) in Dialogs) yield return new SceneInfo(id + suffix, title);
yield return new SceneInfo("tray-menu" + suffix, "Menu de la zone de notification");
@@ -290,6 +291,21 @@ internal sealed class PlayerScenes : ISceneSet
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
// Version prioritaire (correctif critique) : pastille « Prioritaire » et texte dédié
var priorityRelease = SampleData.CreateLauncherRelease(_fixture.CurrentVersion);
priorityRelease.PriorityVersion = priorityRelease.Version;
var priorityCheck = new LauncherUpdateCheck(_fixture.CurrentVersion, priorityRelease,
new Uri("https://jeux.exemple.test/launcher/GameLauncher.exe"));
await capture.ShootAsync("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherUpdateViewModel(priorityCheck, automatic: true);
viewModel.Progress.Report(new OperationProgress("Téléchargement de la nouvelle version",
$"{Format.Bytes(25_800_000)} / {Format.Bytes(priorityRelease.Size)} • {Format.Speed(6_300_000)} • " +
$"{Format.Duration(TimeSpan.FromSeconds(2))} restantes", 67.5));
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
await capture.ShootAsync("launcher-update-error" + suffix, "Mise à jour du launcher · erreur", () =>
{
_fixture.ApplyTheme(theme);
+29 -1
View File
@@ -29,6 +29,18 @@ internal sealed class SnapshotOptions
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
public bool Scroll { get; private set; } = true;
/// <summary>Format des images : « png » (défaut) ou « webp » (format des images du site vitrine).</summary>
public string Format { get; private set; } = FormatPng;
/// <summary>Qualité WebP, 1 à 100 (100 : sans perte). Ignorée en PNG (toujours sans perte).</summary>
public int Quality { get; private set; } = 85;
public const string FormatPng = "png";
public const string FormatWebp = "webp";
/// <summary>Extension des images produites, avec le point (« .png », « .webp »).</summary>
public string ImageExtension => "." + Format;
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
@@ -52,11 +64,14 @@ internal sealed class SnapshotOptions
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
--no-scroll pas de pages supplémentaires pour les zones qui défilent
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
--format <png|webp> format des images : « png » (défaut, sans perte) ou « webp » (format des
images du site vitrine, website\GameLauncher.Website\wwwroot\img)
--quality <1-100> qualité WebP (défaut 85 ; 100 = sans perte) ; ignorée en PNG
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
--open ouvre index.html à la fin
--help cette aide
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
Résultat : une image par scène (PNG ou WebP), index.html (planche de toutes les captures) et report.md
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
""";
@@ -117,6 +132,19 @@ internal sealed class SnapshotOptions
}
}
break;
case "--format":
var format = Next().Trim().TrimStart('.').ToLowerInvariant();
if (format is not (FormatPng or FormatWebp))
throw new ArgumentException($"Format inconnu : « {format} » (png ou webp).");
options.Format = format;
break;
case "--quality":
var qualityText = Next();
if (!int.TryParse(qualityText, NumberStyles.None, CultureInfo.InvariantCulture, out var quality) ||
quality < 1 || quality > 100)
throw new ArgumentException($"Qualité invalide : « {qualityText} » (1 à 100).");
options.Quality = quality;
break;
case "--out":
case "-o":
options.OutputDirectory = Next();
+1 -1
View File
@@ -113,7 +113,7 @@ internal sealed class SnapshotRunner
var marker = Path.Combine(output, MarkerFile);
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
{
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
foreach (var pattern in new[] { "*.png", "*.webp", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
{
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
{
+10
View File
@@ -20,6 +20,16 @@
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<!--
Option « format webp » de l'outil : Windows (WIC) lit le WebP mais ne sait pas l'écrire. SkiaSharp (maintenu par Microsoft,
licence MIT) encode les captures en WebP. Outil de développement uniquement : le launcher, la Launcher API
et le site ne dépendent pas de ce paquet.
-->
<PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="3.119.1" />
</ItemGroup>
<ItemGroup>
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
+41 -59
View File
@@ -1,84 +1,66 @@
# Launcher 3.3.1
# Launcher 3.4.2
LAUNCHER - NOTES DE MISE À JOUR
Correctif du mode maintenance - Mise à jour du 10 novembre 2026
==================================================================
Version de maintenance du launcher - Mise à jour du 10 novembre 2026
=========================================================================
Un launcher resté ouvert pendant la re-signature du trousseau ne voyait pas la maintenance activée ensuite : chaque relecture (toutes les 2 minutes) la refusait jusqu'au redémarrage. Il prend désormais le nouveau trousseau en compte tout seul, et le relit aussi régulièrement quand il reste ouvert longtemps.
Version de maintenance : pour les joueurs, le launcher fonctionne exactement comme en 3.4.1. Le nom de l'éditeur par défaut est désormais LanisysGames, et le journal d'audit du menu d'administration détaille les modifications des formulaires des demandes.
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
Le launcher se met à jour automatiquement. Administrateurs : mettre à jour la Launcher API (voir SERVEUR).
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- CORRIGÉ : mode maintenance activé après la re-signature du
trousseau non vu par un launcher déjà ouvert (redémarrage
nécessaire en 3.3.0).
- AMÉLIORÉ : trousseau retéléchargé quand un contenu est signé par
une clé connue mais pas encore autorisée pour ce contenu.
- SÉCURITÉ : trousseau relu au plus toutes les 30 minutes : un
appareil retiré n'est plus accepté par un launcher laissé ouvert.
- QUALITÉ : 3 nouveaux tests automatisés (370 au total).
- INFO : changement du nom de l'éditeur par défaut. Les propriétés
de GameLauncher.exe (clic droit > Propriétés > Détails)
indiquent désormais LanisysGames.
- INFO : aucun changement dans l'utilisation du launcher :
installation, mises à jour, vérification et réparation des
jeux restent identiques. Vos jeux, vos paramètres et vos
dossiers d'installation sont conservés.
- SÉCURITÉ : comme pour chaque version, la signature numérique
et l'empreinte SHA-256 sont vérifiées avant l'installation.
------------------------------------------------------------------
CORRECTIF
MENU D'ADMINISTRATION
------------------------------------------------------------------
- Cause : le launcher gardait en mémoire le trousseau lu au
démarrage. Il ne le retéléchargeait que pour une clé inconnue,
pas pour une clé connue qui vient de recevoir une autorisation
(« mode maintenance » ajoutée à l'étape 3 decies).
- Effet en 3.3.0 : « la clé … n'est pas autorisée à signer ce
contenu » dans le journal à chaque relecture, maintenance
ignorée jusqu'au redémarrage.
- Désormais : nouveau téléchargement du trousseau, au plus une
fois par minute ; la maintenance s'affiche à la relecture
suivante (2 minutes au plus).
- Concerne aussi les formulaires « Aide », le catalogue et les
versions des jeux signés par un appareil qui vient d'être
autorisé.
------------------------------------------------------------------
VÉRIFICATIONS PÉRIODIQUES DU LAUNCHER
------------------------------------------------------------------
- Mode maintenance : au démarrage (4 s au plus), puis toutes les
2 minutes (1 minute pendant une maintenance), et aussitôt après
une publication depuis le menu de ce PC.
- Versions des jeux (manifest) : au démarrage, puis toutes les
30 minutes (updateCheckIntervalMinutes).
- Trousseau : au démarrage ; après un échec réseau (1 minute) ;
clé inconnue ou non autorisée (1 minute au plus) ; au plus
toutes les 30 minutes (nouveau).
- Catalogue : au démarrage seulement.
- Mise à jour du launcher : au démarrage, et bouton « Rechercher »
des paramètres.
- Formulaires « Aide » : à l'ouverture de la fenêtre « Aide ».
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
- Chaque trousseau retéléchargé passe par les mêmes contrôles :
signature de la clé racine, numéro jamais en baisse, minimum
keyringMinSequence ; un ancien trousseau resservi est refusé.
- Serveur injoignable : dernier trousseau vérifié conservé.
- Journal d'audit : une ligne par type de demande ajouté, modifié
ou supprimé lors d'une publication des formulaires, avec son nom
(« Type de demande ajouté », « modifié », « supprimé »).
- Type modifié : tous ses changements (nom affiché, description,
exemple de titre, masqué, icône, captures d'écran,
informations techniques) et chaque champ ajouté, supprimé ou
modifié, avec ce qui a changé (libellé, type, obligatoire,
aide, longueur, étoiles, choix, aperçu, ordre).
- Ligne de la publication : révision, types ajoutés, modifiés et
supprimés par leur nom, ordre des types, signature.
- Détail trop long : réparti sur plusieurs lignes numérotées
(1/2, 2/2...), rien n'est tronqué.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- Aucun changement.
- Launcher API à mettre à jour (server/deploy/LISEZMOI.md,
étape 8) pour obtenir le détail des formulaires dans le
journal d'audit. Aucun réglage ni bloc nginx à ajouter.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Core : ContentTrustService (KeyChangeRetryDelay, KeyringMaxAge,
KeyMayBeNewer).
- Tests : ContentTrustTests (2), MaintenanceServiceTests (1).
- Documentation : README, docs/ADMIN-ARCHITECTURE.md,
server/deploy/LISEZMOI.md (étapes 3 quinquies et 3 decies).
- Changement du nom de l'éditeur par défaut (LauncherConfig) et
des propriétés communes des projets (Directory.Build.props :
société, auteurs, copyright).
- Documentation : captures docs/preview.png et
docs/preview-update.png mises à jour (changement de
l'éditeur), avec une version WebP de chacune.
- Serveur : FeedbackFormsAudit (détail des publications des
formulaires) remplace FeedbackFormsStore.Describe.
- UiSnapshot : exemples des nouvelles lignes dans la page
« Journal d'audit ».
- Tests : 1 nouveau test, 3 tests complétés.
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
+58
View File
@@ -7,8 +7,41 @@ REM 3. (optionnel) l'envoie sur le VPS avec WinSCP
REM
REM Avant de lancer : augmentez <Version> dans src\GameLauncher\GameLauncher.csproj
REM et ecrivez les nouveautes dans tools\notes-launcher.txt.
REM
REM Parametres (facultatifs, depuis une invite de commandes) :
REM publish-launcher-update.bat version normale (double-clic)
REM publish-launcher-update.bat --priority version PRIORITAIRE (correctif critique) :
REM installee au demarrage MEME chez les joueurs qui ont desactive la mise a jour
REM automatique du launcher. Leur reglage (self-update.json) n'est pas modifie.
REM Reprise par les publications suivantes : un launcher plus ancien que cette
REM version se met a jour quoi qu'il arrive. Confirmation demandee.
REM publish-launcher-update.bat --clear-priority ne reprend pas la version prioritaire
REM deja publiee (priorite publiee par erreur).
REM ---------------------------------------------------------------------------
setlocal
set PRIORITY=
:args
if "%~1"=="" goto :argsdone
if /i "%~1"=="--priority" goto :opt_priority
if /i "%~1"=="--clear-priority" goto :opt_clear
if /i "%~1"=="--help" goto :usage
if "%~1"=="/?" goto :usage
echo Parametre inconnu : %~1
goto :usage
:opt_priority
if defined PRIORITY goto :conflict
set PRIORITY=--priority
REM shift /1 : %0 (donc %~dp0) reste inchange
shift /1
goto :args
:opt_clear
if defined PRIORITY goto :conflict
set PRIORITY=--clear-priority
shift /1
goto :args
:argsdone
set ROOT=%~dp0..
set KEY=%USERPROFILE%\.launcher-keys\launcher-signing.key
set OUTPUT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\Updates
@@ -16,6 +49,18 @@ REM Script WinSCP (laisser vide pour envoyer les fichiers a la main) :
set WINSCP_SCRIPT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\publier-launcher-update.txt
set WINSCP="C:\Program Files (x86)\WinSCP\WinSCP.com"
if /i "%PRIORITY%"=="--priority" (
echo ***************************************************************************
echo Version PRIORITAIRE : installee au demarrage meme chez les joueurs qui ont
echo desactive la mise a jour automatique du launcher. Leur reglage ne change pas.
echo ***************************************************************************
echo.
)
if /i "%PRIORITY%"=="--clear-priority" (
echo Version normale : la version prioritaire deja publiee n'est PAS reprise.
echo.
)
echo === 1/3 Compilation du launcher ===
dotnet publish "%ROOT%\src\GameLauncher\GameLauncher.csproj" -c Release -p:PublishProfile=win-x64
if errorlevel 1 goto :error
@@ -28,6 +73,7 @@ dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -
--config "%ROOT%\src\GameLauncher\launcher.json" ^
--public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub" ^
--notes "%~dp0notes-launcher.txt" ^
%PRIORITY% ^
--output "%OUTPUT%"
if errorlevel 1 goto :error
@@ -52,3 +98,15 @@ echo.
echo La publication a echoue.
pause
exit /b 1
:conflict
echo --priority et --clear-priority sont incompatibles.
exit /b 1
:usage
echo.
echo Utilisation : publish-launcher-update.bat [--priority ^| --clear-priority]
echo --priority version prioritaire (correctif critique) : installee meme si le
echo joueur a desactive la mise a jour automatique (reglage conserve)
echo --clear-priority ne reprend pas la version prioritaire deja publiee
exit /b 1
+50
View File
@@ -0,0 +1,50 @@
@echo off
REM ---------------------------------------------------------------------------
REM Publie le SITE VITRINE pour le VPS (Linux x64, autonome : aucun .NET a
REM installer sur le serveur) et prepare les fichiers a envoyer :
REM Publish\Site\lanisys-site.tar.gz site + service systemd
REM Publish\Site\installer-site.sh script d'installation / mise a jour
REM Guide complet : website\deploy\LISEZMOI.md
REM ---------------------------------------------------------------------------
setlocal
set ROOT=%~dp0..
set OUT=%ROOT%\Publish\Site
set STAGE=%OUT%\lanisys-site
if exist "%STAGE%" rmdir /s /q "%STAGE%"
if exist "%OUT%\lanisys-site.tar.gz" del /q "%OUT%\lanisys-site.tar.gz"
set HASH=
echo === 1/2 Compilation (linux-x64) ===
dotnet publish "%ROOT%\website\GameLauncher.Website\GameLauncher.Website.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
if errorlevel 1 goto :error
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
echo.
echo === 2/2 Archive ===
mkdir "%STAGE%\deploy"
copy /Y "%ROOT%\website\deploy\lanisys-site.service" "%STAGE%\deploy\" >nul
copy /Y "%ROOT%\website\deploy\installer-site.sh" "%OUT%\" >nul
tar -czf "%OUT%\lanisys-site.tar.gz" -C "%STAGE%" .
if errorlevel 1 goto :error
REM Empreinte SHA-256 : verifiee par installer-site.sh (archive alteree = refusee)
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\lanisys-site.tar.gz').Hash.ToLower()"') do set HASH=%%h
if "%HASH%"=="" goto :error
> "%OUT%\lanisys-site.tar.gz.sha256" echo %HASH%
echo.
echo Pret. Envoi sur le VPS du site (compte admin) :
echo scp "%OUT%\lanisys-site.tar.gz" "%OUT%\installer-site.sh" admin@^<VPS du site^>:/tmp/
echo Puis, sur le VPS :
echo sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz %HASH%
echo (empreinte aussi enregistree dans lanisys-site.tar.gz.sha256)
explorer "%OUT%"
pause
exit /b 0
:error
echo.
echo La publication a echoue.
pause
exit /b 1
+2 -1
View File
@@ -2,7 +2,7 @@
rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
@@ -12,6 +12,7 @@ rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
rem tools\ui-snapshot.bat --list
rem ============================================================================
setlocal
@@ -0,0 +1,21 @@
---
title: Bienvenue sur le site de LanisysGames
date: 2026-11-10
summary: Nos jeux, le launcher et les nouvelles du studio ont maintenant leur adresse.
topic: Studio
image: /img/launcher/planete.webp
imageAlt: Planète aux anneaux sur un ciel violet, l'illustration du launcher.
---
Jusqu'ici, il fallait passer par le launcher pour suivre nos jeux. Ce site rassemble désormais tout au même endroit :
- **le launcher**, à télécharger en un clic, avec les nouveautés de sa dernière version ;
- **Exovival Zombie Reborn**, son histoire, ce qui vous attend dans l'école Jean-Moulin, et toutes ses [notes de version](/jeux/exovival-zombie-reborn/notes-de-version) ;
- **les actualités** du studio, comme celle-ci.
## Un site discret
Pas de compte, pas de cookie, pas de mesure d'audience : le site ne vous suit pas. Les polices et les images sont servies par le site lui-même, sans passer par un service externe. Tout est expliqué sur la page [Confidentialité](/confidentialite).
## Une question, une idée ?
Le bouton « Nous contacter » ouvre votre messagerie. Pour un bug dans un jeu, le bouton **Aide** du launcher reste le plus pratique : il joint automatiquement la version du jeu, et vous pouvez ajouter des captures d'écran.
@@ -0,0 +1,30 @@
---
title: "Exovival Zombie Reborn 1.8 : les zombies vous entendent"
date: 2026-11-01
summary: Huit zombies rôdent dès le début de la partie, ils réagissent au bruit de vos pas, et de petits soins à fabriquer soi-même font leur apparition.
topic: Exovival Zombie Reborn
theme: exovival
image: /img/exovival/vhs-gymnase.webp
imageAlt: Le gymnase de l'école, image extraite d'une cassette VHS du jeu.
---
La version 1.8 remplit l'école de zombies, leur donne de l'oreille et rééquilibre les armes. Merci aux bêta-testeurs pour leurs retours !
## L'école n'est plus vide
Huit zombies rôdent dès le début de la partie : autour du car scolaire, dans la cour de service, sur le toit bas, au gymnase et à l'étage. Plus lents et moins forts que ceux qui vous traquent pendant l'histoire, ils se distancent en marchant. Mais attention aux groupes : quand l'un d'eux attaque, ceux d'à côté accourent.
## Faites moins de bruit
- Marcher fait du bruit, courir s'entend de beaucoup plus loin, même dans le dos d'un zombie.
- Un mur ou une porte fermée étouffe vos pas.
- **Accroupi** (touche Ctrl), vous êtes silencieux : de quoi approcher un zombie par derrière.
## Des armes plus lisibles
Chaque arme fait toujours les mêmes dégâts, et viser la tête les double. La hache reste la plus puissante : un seul coup à la tête suffit.
## Se soigner à la Resident Evil
Des herbes et des huiles médicinales sont cachées dans l'école. Mélangez-les dans l'inventaire pour obtenir une potion bien plus efficace. Une note de l'infirmière, quelque part dans les toilettes, vous explique tout.
Le détail complet est dans les [notes de version](/jeux/exovival-zombie-reborn/notes-de-version). La mise à jour s'installe automatiquement avec le launcher.
@@ -0,0 +1,21 @@
---
title: "Launcher 3.4 : les correctifs importants arrivent chez tout le monde"
date: 2026-11-10
summary: Une mise à jour critique du launcher s'installe désormais même si vous avez désactivé la mise à jour automatique. Votre réglage, lui, ne change pas.
topic: Launcher
image: /img/launcher/apercu-exovival.webp
imageAlt: Le launcher ouvert sur Exovival Zombie Reborn.
---
Vous pouvez choisir, dans les paramètres du launcher, de ne pas le mettre à jour automatiquement. C'est votre droit, et ça le reste. Mais si une faille de sécurité devait un jour être corrigée, il faut que le correctif arrive chez tout le monde.
## Ce qui change
- Une version du launcher peut être publiée comme **prioritaire**. Elle s'installe au démarrage, même avec la mise à jour automatique désactivée.
- **Votre réglage n'est pas modifié** : une fois le correctif installé, les versions normales suivantes ne s'installeront toujours pas toutes seules.
- La fenêtre de mise à jour affiche une pastille « Prioritaire ». « Plus tard » reste possible : le launcher réessaiera au prochain démarrage.
## Et la sécurité ?
L'indication « prioritaire » fait partie du contenu signé de la mise à jour : un serveur piraté ne pourrait ni imposer une mise à jour, ni retirer la priorité d'un correctif. Comme avant, seule une version signée et plus récente peut s'installer.
Le launcher se met à jour tout seul. Pour une première installation, il se [télécharge ici](/launcher#telecharger).
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Site vitrine (LanisysGames) : présentation des jeux et du launcher, téléchargement du launcher,
notes de version, actualités. ASP.NET Core Razor Pages, sans base de données, sans formulaire,
sans cookie, sans paquet NuGet tiers.
Isolé de la Launcher API : le site ne l'appelle jamais. Il lit seulement, côté serveur et en
arrière-plan, les fichiers publics launcher-update.json (signature vérifiée avec la même clé
publique que le launcher) et manifest.json de chaque jeu (notes de version).
Voir website\deploy\LISEZMOI.md.
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
tools\publish-site.bat
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Website</RootNamespace>
<AssemblyName>GameLauncher.Website</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
<Version>1.0.0</Version>
<Product>Site vitrine LanisysGames</Product>
<!-- Pas de dépendance à libicu sur le VPS (comme la Launcher API) : les dates sont mises en forme
en français par le site lui-même (Services\FrenchText.cs). -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
<!-- Pas de web.config (IIS) dans la publication : le site tourne derrière nginx. -->
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
</PropertyGroup>
<ItemGroup>
<!-- Notes de version : même modèle, mêmes bornes et même nettoyage que le launcher. -->
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
<!-- launcher-update.json : même vérification de signature que le launcher (UpdateSignature). -->
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Clé publique de mise à jour du launcher, INTÉGRÉE à l'exécutable (comme dans le launcher) :
elle ne peut pas être remplacée par un fichier déposé sur le serveur. -->
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Security\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<!-- Actualités (Markdown) : publiées avec le site, lues au démarrage. -->
<None Update="Content\**\*.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
namespace GameLauncher.Website.Hosting;
/// <summary>
/// En-têtes de sécurité de toutes les réponses, et refus de tout ce qui n'est pas une lecture.
///
/// Politique de sécurité du contenu (CSP) stricte : scripts, styles, images et polices du site
/// uniquement, aucun script en ligne, aucune ressource externe (ni police Google, ni statistiques),
/// aucun formulaire, aucune intégration du site dans un cadre. Le site n'a pas de cookie.
/// </summary>
public static class SecurityHeaders
{
public const string ContentSecurityPolicy =
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; " +
"manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
public static IApplicationBuilder UseSiteSecurityHeaders(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
var request = context.Request;
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
{
// Site en lecture seule : ni formulaire, ni API.
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
context.Response.Headers.Allow = "GET, HEAD";
return;
}
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.ContentSecurityPolicy = ContentSecurityPolicy;
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), " +
"payment=(), usb=(), interest-cohort=(), browsing-topics=()";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
headers["X-Permitted-Cross-Domain-Policies"] = "none";
headers.Remove("Server");
headers.Remove("X-Powered-By");
return Task.CompletedTask;
});
await next();
});
}
@@ -0,0 +1,34 @@
using System.Xml.Linq;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
namespace GameLauncher.Website.Hosting;
/// <summary>Plan du site (sitemap.xml) pour les moteurs de recherche.</summary>
public static class Sitemap
{
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
public static string Build(SiteOptions options, NewsService news)
{
var root = options.PublicUrl.TrimEnd('/');
var paths = new List<(string Path, string? LastModified)>
{
("/", null), ("/launcher", null), ("/jeux", null), ("/actualites", null),
("/mentions-legales", null), ("/confidentialite", null)
};
foreach (var game in options.VisibleGames)
{
paths.Add(($"/jeux/{game.Slug}", null));
if (!string.IsNullOrWhiteSpace(game.ManifestUrl)) paths.Add(($"/jeux/{game.Slug}/notes-de-version", null));
}
paths.AddRange(news.All.Select(a => ($"/actualites/{a.Slug}", (string?)FrenchText.IsoDate(a.Date))));
var document = new XDocument(new XDeclaration("1.0", "utf-8", null),
new XElement(Ns + "urlset",
paths.Select(p => new XElement(Ns + "url",
new XElement(Ns + "loc", root + p.Path),
p.LastModified is null ? null : new XElement(Ns + "lastmod", p.LastModified)))));
return document.Declaration + "\n" + document.Root;
}
}
@@ -0,0 +1,36 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Hosting;
/// <summary>
/// Contrôle du certificat du serveur du launcher : EXACTEMENT la règle par défaut de .NET (aucune erreur
/// acceptée), avec en plus, en cas de refus, le détail dans le journal du site (nom attendu, certificat
/// présenté, maillons de la chaîne et leurs erreurs). Sans ce détail, .NET ne dit que « The SSL connection
/// could not be established ».
/// </summary>
public static class TlsDiagnostics
{
public static RemoteCertificateValidationCallback Create(ILogger logger) => (sender, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None) return true;
var host = (sender as SslStream)?.TargetHostName ?? "?";
var links = chain?.ChainElements.Select((e, i) =>
$"[{i}] {e.Certificate.Subject} (émis par {e.Certificate.Issuer}, jusqu'au {e.Certificate.NotAfter:yyyy-MM-dd})" +
(e.ChainElementStatus.Length == 0 ? "" : " : " + string.Join(", ", e.ChainElementStatus.Select(s => s.Status))))
.ToList() ?? new List<string>();
var chainStatus = chain?.ChainStatus.Select(s => s.Status.ToString()).Distinct().ToList() ?? new List<string>();
logger.LogError(
"Certificat de {Host} REFUSÉ ({Errors}). Chaîne : {Status}. Maillons reçus et reconstruits :{Links}{Hint}",
host, errors, chainStatus.Count == 0 ? "—" : string.Join(", ", chainStatus),
links.Count == 0 ? " aucun" : "\n " + string.Join("\n ", links),
chainStatus.Contains(nameof(X509ChainStatusFlags.PartialChain))
? "\n → Chaîne incomplète : le serveur n'envoie pas le certificat intermédiaire. Sur le VPS du launcher, " +
"nginx doit utiliser fullchain.pem (et non cert.pem) dans ssl_certificate (voir website/deploy/LISEZMOI.md, Dépannage)."
: "");
return false;
};
}
@@ -0,0 +1,137 @@
namespace GameLauncher.Website.Options;
/// <summary>
/// Réglages du site (section « Site » d'appsettings.json, complétée sur le VPS par
/// appsettings.Production.json, conservé d'une mise à jour à l'autre).
/// </summary>
public sealed class SiteOptions
{
public const string Section = "Site";
/// <summary>Adresse publique du site (https), sans « / » final : liens absolus (Open Graph, plan du site).</summary>
public string PublicUrl { get; set; } = "";
/// <summary>Nom du studio, affiché dans l'en-tête et le pied de page.</summary>
public string StudioName { get; set; } = "LanisysGames";
/// <summary>
/// Adresse du bouton « Nous contacter » (lien mailto: : le joueur écrit avec sa propre messagerie,
/// le site ne reçoit ni ne stocke rien). Vide : bouton masqué.
/// </summary>
public string ContactEmail { get; set; } = "";
public LauncherOptions Launcher { get; set; } = new();
public List<GameOptions> Games { get; set; } = new();
public RemoteOptions Remote { get; set; } = new();
public LegalOptions Legal { get; set; } = new();
/// <summary>Jeux affichés (masqués exclus), dans l'ordre du fichier.</summary>
public IEnumerable<GameOptions> VisibleGames => Games.Where(g => g.Visible);
public GameOptions? FindGame(string? slug) =>
string.IsNullOrEmpty(slug) ? null : VisibleGames.FirstOrDefault(g => string.Equals(g.Slug, slug, StringComparison.Ordinal));
}
public sealed class LauncherOptions
{
/// <summary>Nom du launcher affiché sur le site.</summary>
public string Name { get; set; } = "Launcher LanisysGames";
/// <summary>
/// launcher-update.json (https) : version, exécutable à télécharger et notes de version, SIGNÉS.
/// Le site n'affiche le bouton de téléchargement que si la signature est valide.
/// </summary>
public string UpdateUrl { get; set; } = "";
/// <summary>
/// Identifiant de produit attendu dans launcher-update.json (« launcherId », ligne « Produit » de
/// LauncherPublisher verify). Vide : non contrôlé (le produit lu est inscrit au journal du site).
/// </summary>
public string Product { get; set; } = "";
/// <summary>Page « Publications » de Gitea : historique complet des versions du launcher. Vide : lien masqué.</summary>
public string ReleasesUrl { get; set; } = "";
/// <summary>
/// Sections des notes du launcher qui ne concernent pas les joueurs (serveur, développement…),
/// retirées de l'affichage. Comparaison sans tenir compte de la casse ni des accents.
/// </summary>
public List<string> HiddenSections { get; set; } = new();
}
public sealed class GameOptions
{
/// <summary>Adresse de la page : /jeux/&lt;slug&gt; (lettres minuscules, chiffres, tirets).</summary>
public string Slug { get; set; } = "";
public string Name { get; set; } = "";
/// <summary>Thème de la page : « exovival », « multi » ou « launcher ».</summary>
public string Theme { get; set; } = "launcher";
/// <summary>« available » (disponible dans le launcher) ou « upcoming » (en préparation).</summary>
public string Status { get; set; } = "upcoming";
public bool Visible { get; set; } = true;
/// <summary>Identifiant du jeu (« gameId » du manifest), contrôlé à la lecture du manifest.</summary>
public string GameId { get; set; } = "";
/// <summary>manifest.json du jeu (https). Vide : pas de notes de version.</summary>
public string ManifestUrl { get; set; } = "";
/// <summary>Accroche (cartes de l'accueil et de la liste des jeux).</summary>
public string Tagline { get; set; } = "";
/// <summary>Présentation courte d'un jeu en préparation (page générique).</summary>
public string Teaser { get; set; } = "";
/// <summary>Image de la carte (chemin local commençant par /img/).</summary>
public string CardImage { get; set; } = "";
public string CardImageAlt { get; set; } = "";
public bool IsAvailable => string.Equals(Status, "available", StringComparison.OrdinalIgnoreCase);
/// <summary>Page dédiée (Pages/Jeux/&lt;…&gt;.cshtml) plutôt que la page générique.</summary>
public bool HasDedicatedPage { get; set; }
}
public sealed class RemoteOptions
{
/// <summary>Intervalle entre deux lectures des fichiers JSON (minutes).</summary>
public int RefreshMinutes { get; set; } = 10;
/// <summary>Délai maximal d'une lecture (secondes).</summary>
public int TimeoutSeconds { get; set; } = 20;
/// <summary>Taille maximale d'un manifest.json (Mo) : il contient la liste de tous les fichiers du jeu.</summary>
public int MaxManifestMegabytes { get; set; } = 32;
/// <summary>
/// Développement uniquement : autorise http://localhost (fichiers de test servis en local).
/// Ignoré en production (https obligatoire).
/// </summary>
public bool AllowLoopbackHttp { get; set; }
}
public sealed class LegalOptions
{
/// <summary>Éditeur du site (nom ou raison sociale).</summary>
public string Publisher { get; set; } = "";
/// <summary>Statut et coordonnées de l'éditeur (adresse, SIRET… ou mention « éditeur non professionnel »).</summary>
public List<string> PublisherDetails { get; set; } = new();
/// <summary>Directeur de la publication.</summary>
public string Director { get; set; } = "";
/// <summary>Hébergeur du site (nom, adresse, téléphone).</summary>
public List<string> Host { get; set; } = new();
/// <summary>Durée de conservation des journaux du serveur web, en jours (page « Confidentialité »).</summary>
public int LogRetentionDays { get; set; } = 14;
}
@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Options;
/// <summary>Contrôle des réglages au démarrage : un réglage incorrect arrête le site avec un message clair.</summary>
public sealed partial class SiteOptionsValidator : IValidateOptions<SiteOptions>
{
public static readonly string[] Themes = { "launcher", "exovival", "multi" };
public ValidateOptionsResult Validate(string? name, SiteOptions options)
{
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PublicUrl) &&
(!Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var site) || site.Scheme is not ("https" or "http") ||
options.PublicUrl.EndsWith('/')))
errors.Add("Site:PublicUrl doit être une adresse absolue, sans « / » final (ex. https://www.lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.ContactEmail) && !EmailRegex().IsMatch(options.ContactEmail))
errors.Add("Site:ContactEmail n'est pas une adresse e-mail simple (ex. contact@lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.Launcher.ReleasesUrl) &&
(!Uri.TryCreate(options.Launcher.ReleasesUrl, UriKind.Absolute, out var releases) || releases.Scheme != "https"))
errors.Add("Site:Launcher:ReleasesUrl doit être une adresse https.");
var slugs = new HashSet<string>(StringComparer.Ordinal);
foreach (var game in options.Games)
{
game.Slug ??= "";
if (!SlugRegex().IsMatch(game.Slug))
errors.Add($"Site:Games : « {game.Slug} » n'est pas un slug valide (minuscules, chiffres, tirets).");
else if (!slugs.Add(game.Slug))
errors.Add($"Site:Games : slug « {game.Slug} » en double.");
if (string.IsNullOrWhiteSpace(game.Name))
errors.Add($"Site:Games : nom manquant pour « {game.Slug} ».");
if (!Themes.Contains(game.Theme))
errors.Add($"Site:Games : thème inconnu « {game.Theme} » pour « {game.Slug} » ({string.Join(", ", Themes)}).");
if (!string.IsNullOrWhiteSpace(game.CardImage) && !game.CardImage.StartsWith("/img/", StringComparison.Ordinal))
errors.Add($"Site:Games : l'image de « {game.Slug} » doit être un fichier du site (/img/…).");
}
if (options.Remote.RefreshMinutes < 1)
errors.Add("Site:Remote:RefreshMinutes doit valoir au moins 1.");
return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors);
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
[GeneratedRegex(@"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")]
private static partial Regex EmailRegex();
}
@@ -0,0 +1,35 @@
@page "/actualites/{slug:maxlength(80)}"
@model ArticleModel
@{
var article = Model.Article;
ViewData["Nav"] = "actualites";
ViewData["Theme"] = article.Theme;
ViewData["Title"] = article.Title;
ViewData["Description"] = article.Summary;
ViewData["OgType"] = "article";
if (article.Image is not null && article.Image.EndsWith(".jpg", StringComparison.Ordinal)) ViewData["OgImage"] = article.Image;
}
<article>
<header class="article-head">
<div class="wrap wrap-narrow">
<p class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</p>
<h1>@article.Title</h1>
<p class="lead">@article.Summary</p>
</div>
</header>
<div class="wrap wrap-narrow">
@if (article.Image is not null)
{
<figure class="article-cover"><img src="@article.Image" alt="@(article.ImageAlt ?? "")" width="1280" height="720"></figure>
}
<div class="prose">
@* HTML produit par SafeMarkdown : tout le texte de l'article y est encodé, seules ses balises existent. *@
@Html.Raw(article.Html)
</div>
<a class="article-back" href="/actualites">Toutes les actualités</a>
</div>
</article>
@@ -0,0 +1,23 @@
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages.Actualites;
public sealed class ArticleModel : PageModel
{
private readonly NewsService _news;
public ArticleModel(NewsService news) => _news = news;
public NewsArticle Article { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
// Recherche dans les articles chargés au démarrage : l'adresse n'atteint jamais le disque.
var article = _news.Find(slug);
if (article is null) return NotFound();
Article = article;
return Page();
}
}
@@ -0,0 +1,27 @@
@page "/actualites"
@inject NewsService News
@{
ViewData["Nav"] = "actualites";
ViewData["Title"] = "Actualités";
ViewData["Description"] = "Les nouvelles des jeux et du launcher : sorties, mises à jour, coulisses.";
}
<section class="page-hero">
<div class="wrap">
<h1>Actualités</h1>
<p class="lead">Sorties, mises à jour et coulisses de nos jeux et du launcher.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@if (News.All.Count == 0)
{
<p class="notice">Aucune actualité pour le moment. Les nouveautés des jeux sont aussi dans leurs notes de version.</p>
}
else
{
<partial name="_NewsList" model="News.All" />
}
</div>
</section>
@@ -0,0 +1,29 @@
@page "/confidentialite"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Title"] = "Confidentialité";
ViewData["Description"] = "Ce que le site enregistre (presque rien) et pourquoi.";
Uri.TryCreate(site.Launcher.UpdateUrl, UriKind.Absolute, out var launcherServer);
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Confidentialité</h1>
<p class="lead">Ce site ne vous demande rien et ne vous suit pas : pas de compte, pas de formulaire, pas de cookie, pas de mesure d'audience, pas de publicité, aucun service externe (les polices et les images sont servies par le site lui-même).</p>
<h2>Journaux du serveur</h2>
<p>Comme tout serveur web, le nôtre note chaque requête : adresse IP, date, page demandée, navigateur. Ces journaux servent uniquement à la sécurité du site (repérer une attaque, un abus) et au diagnostic des pannes. Ils sont effacés au bout de @site.Legal.LogRetentionDays jours et ne sont ni croisés avec d'autres données, ni transmis à qui que ce soit, sauf obligation légale.</p>
<p>Base légale : notre intérêt légitime à protéger le site (article 6.1.f du RGPD).</p>
<h2>Téléchargement du launcher</h2>
<p>Le bouton « Télécharger le launcher » vous envoie directement vers notre serveur de téléchargement@(launcherServer is null ? "" : $" ({launcherServer.Host})"), qui tient lui aussi un journal technique dans les mêmes conditions.</p>
<h2>Nous contacter</h2>
<p>Le bouton « Nous contacter » ouvre votre propre messagerie. Votre message et votre adresse e-mail ne passent pas par ce site : nous ne les utilisons que pour vous répondre, et les supprimons quand l'échange est terminé.</p>
<h2>Vos droits</h2>
<p>Vous pouvez demander l'accès à vos données, leur rectification ou leur effacement@(string.IsNullOrWhiteSpace(site.ContactEmail) ? "" : " en écrivant à ")@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{<a href="mailto:@site.ContactEmail">@site.ContactEmail</a>}. Vous pouvez aussi adresser une réclamation à la CNIL (<a href="https://www.cnil.fr" rel="noopener noreferrer">cnil.fr</a>).</p>
</div>
</section>
@@ -0,0 +1,22 @@
@page "/erreur/{code:int?}"
@model ErreurModel
@{
ViewData["Title"] = Model.Code == 404 ? "Page introuvable" : "Erreur";
}
<section class="error-page">
<div class="wrap wrap-narrow">
<p class="error-code" aria-hidden="true">@Model.Code</p>
@if (Model.Code == 404)
{
<h1>Cette page n'existe pas.</h1>
<p class="lead">L'adresse est peut-être mal saisie, ou la page a été déplacée.</p>
}
else
{
<h1>Le site a rencontré un problème.</h1>
<p class="lead">Réessayez dans un instant. Si le problème continue, écrivez-nous.</p>
}
<p><a class="btn btn-primary" href="/">Revenir à l'accueil</a></p>
</div>
</section>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages;
/// <summary>Page d'erreur (404, 500…) : aucun détail technique n'est montré au visiteur.</summary>
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public sealed class ErreurModel : PageModel
{
public int Code { get; private set; } = 500;
public void OnGet(int? code)
{
Code = code is >= 400 and <= 599 ? code.Value : 500;
// Accès direct à /erreur/… : garder le code d'erreur dans la réponse.
if (HttpContext.Features.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>() is null)
Response.StatusCode = Code;
}
}
@@ -0,0 +1,77 @@
@page
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject NewsService News
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "accueil";
ViewData["Theme"] = "launcher";
ViewData["Description"] = $"{site.StudioName}, studio indépendant : Exovival Zombie Reborn et le launcher pour installer, mettre à jour et lancer nos jeux sur Windows.";
var available = site.VisibleGames.Where(g => g.IsAvailable).ToList();
var upcoming = site.VisibleGames.Where(g => !g.IsAvailable).ToList();
var latest = News.Latest(3).ToList();
}
<section class="home-hero">
<div class="wrap">
<div>
<h1>Nos jeux s'installent en un clic.</h1>
<p class="lead">Le launcher @site.StudioName installe nos jeux, les garde à jour et vérifie chaque fichier. Téléchargez-le une fois : il s'occupe du reste, et de lui-même.</p>
<partial name="_LauncherDownload" />
</div>
<figure class="hero-shot">
<img src="/img/launcher/apercu-exovival.webp" width="1168" height="708"
alt="Le launcher ouvert sur Exovival Zombie Reborn : bouton Installer, taille du téléchargement et notes de version.">
</figure>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Nos jeux</h2>
<a href="/jeux">Tous les jeux</a>
</div>
@foreach (var game in available)
{
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">Disponible dans le launcher</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in upcoming)
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@if (latest.Count > 0)
{
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Actualités</h2>
<a href="/actualites">Toutes les actualités</a>
</div>
<partial name="_NewsList" model="latest" />
</div>
</section>
}
@@ -0,0 +1,208 @@
@page "/jeux/exovival-zombie-reborn"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
const string Slug = "exovival-zombie-reborn";
var game = SiteOptionsMonitor.CurrentValue.FindGame(Slug);
var snapshot = Store.GetGame(Slug);
var latest = snapshot?.Changelog.FirstOrDefault();
ViewData["Nav"] = "jeux";
ViewData["Theme"] = "exovival";
ViewData["Title"] = "Exovival Zombie Reborn";
ViewData["Description"] = "Exovival Zombie Reborn : survival horror à la première personne. La ville a été évacuée, votre fille Léa manque à l'appel. Retournez seul dans l'école plongée dans le noir.";
}
<section class="exo-hero" data-uv-hero>
<div class="exo-hero-photo">
<img src="/img/exovival/couloir.webp" srcset="/img/exovival/couloir-960.webp 960w, /img/exovival/couloir.webp 1920w" sizes="100vw"
width="1920" height="1080" alt="" fetchpriority="high">
</div>
<div class="exo-uv" role="img" aria-label="Écrit à l'encre invisible sur le mur : Léa ♥ Papa."></div>
<div class="exo-torch"></div>
<div class="wrap">
<p class="exo-mark">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#biohazard" /></svg>
Survival horror à la première personne
</p>
<h1 class="exo-title">Exovival Zombie Reborn</h1>
<p class="lead">La ville a été évacuée en pleine nuit. À l'appel du point de rassemblement, une élève manquait : Léa, dix ans, votre fille. Vous retournez seul à l'école Jean-Moulin. Plus de courant, des portes condamnées, et quelque chose qui gratte dans les couloirs.</p>
<div class="exo-actions">
<a class="btn btn-primary btn-large" href="/launcher#telecharger">@Html.Icon("Download") Jouer avec le launcher</a>
<a class="btn btn-ghost btn-large" href="/jeux/@Slug/notes-de-version">Notes de version</a>
</div>
<p class="uv-hint">@Html.Icon("Flashlight") Promenez votre lampe UV sur le mur : Léa vous a laissé un message.</p>
</div>
<svg width="0" height="0" class="visually-hidden" aria-hidden="true" focusable="false">
<defs>
<mask id="biohazard-crescents" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
<circle cx="12" cy="12.8" r="3.9" fill="#000" />
</mask>
<mask id="biohazard-ring" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="12.8" r="2.2" fill="#000" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
</mask>
<symbol id="biohazard" viewBox="0 0 24 24">
<g fill="currentColor" mask="url(#biohazard-crescents)">
<circle cx="12" cy="7.8" r="5.3" />
<circle cx="16.33" cy="15.3" r="5.3" />
<circle cx="7.67" cy="15.3" r="5.3" />
</g>
<circle cx="12" cy="12.8" r="3.3" fill="currentColor" mask="url(#biohazard-ring)" />
</symbol>
</defs>
</svg>
</section>
<section class="section">
<div class="wrap exo-story">
<div class="prose">
<p>Le soir de l'épidémie, tout le quartier a été évacué. Les cars sont partis, les portes ont été condamnées. Au point de rassemblement, on a fait l'appel classe par classe. En CM1, il manquait une élève : Léa.</p>
<p>Vous revenez seul, de nuit, avec une lampe torche et ce que vous trouverez sur place. L'école Jean-Moulin n'a plus de courant. Des pas résonnent quelque part à l'étage.</p>
<p>Léa et vous avez un jeu secret : des messages à l'encre invisible, écrits avec le stylo espion que vous lui avez offert pour son anniversaire. Elle vous a laissé une piste. Il faut la suivre jusqu'au bout.</p>
</div>
<div>
<h2>Ceux qui étaient là</h2>
<ul class="cast">
<li><b>Léa</b><span>Dix ans, en CM1. Votre fille.</span></li>
<li><b>Mme Roussel</b><span>Sa maîtresse, qui filme la classe au caméscope.</span></li>
<li><b>Mme Garnier</b><span>La directrice. Elle a laissé un mot dans la classe.</span></li>
<li><b>M. Bernard</b><span>Le concierge. Il connaît chaque recoin et en garde les clés.</span></li>
<li><b>L'agent de sécurité</b><span>De garde la nuit de l'évacuation.</span></li>
</ul>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce qui vous attend</h2>
<div class="exo-features">
<div class="exo-feature">
<h3>@Html.Icon("Flashlight") Le noir, et peu de piles</h3>
<p>Lampe torche, briquet, bougie, lanterne à pétrole : chaque source de lumière s'épuise. Pour rallumer l'école, il faudra trouver des fusibles, régler une armoire électrique et remettre en marche un groupe électrogène qui consomme son carburant.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Puzzle") Des énigmes à l'ancienne</h3>
<p>Digicodes, cadenas, coffres à combinaison, crochetage, labyrinthe à bille, circuit électrique à réparer, leviers à actionner dans le bon ordre. Les indices se cachent dans les papiers, sur les cassettes, et sur les murs, à la lampe UV.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Cassette") Cassettes, caméscope et caméras</h3>
<p>Le magnétoscope de la salle des profs relit les cassettes de l'école. Le caméscope de Mme Roussel voit dans le noir. Une fois le courant revenu, les caméras de surveillance montrent des pièces où l'on ne peut pas entrer.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Ear") Les zombies vous entendent</h3>
<p>Marcher fait du bruit, courir s'entend de loin, même dans leur dos ; un mur ou une porte fermée étouffe vos pas. Accroupi, vous êtes silencieux. Cachez-vous dans un casier, observez en vous penchant, contournez. Et attention aux groupes : un zombie qui attaque attire ceux d'à côté.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Axe") Couteau, hache, pistolet</h3>
<p>Chaque arme a ses dégâts, et viser la tête les double. Un coup de hache bien placé suffit ; au pistolet, on vise avec les organes de visée et on compte ses balles.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Medical") Se soigner à l'ancienne</h3>
<p>Herbes et huiles médicinales à ramasser, à mélanger en potion dans l'inventaire. La trousse de secours reste le meilleur remède. Rien n'est gaspillé : un soin ne s'utilise pas quand vous êtes en pleine forme.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("History") Sauvegardes</h3>
<p>Jusqu'à dix sauvegardes, plus des sauvegardes automatiques aux moments clés. Après une mort, « Recommencer » reprend à la dernière. Le jeu est en français et en anglais.</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Cassettes retrouvées</h2>
<p class="lead">Des images enregistrées dans l'école, avant et pendant la nuit de l'évacuation. Elles sont dans le jeu, sur les cassettes à glisser dans le magnétoscope.</p>
<div class="tapes">
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-couloir-nuit.webp" width="640" height="480" loading="lazy" alt="Couloir de l'école filmé en vision nocturne verte, horodatage du 12 octobre 2026 à 21 h 47."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">Le couloir, en vision nocturne.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-escalier.webp" width="640" height="480" loading="lazy" alt="Cage d'escalier filmée en vision nocturne, vue plongeante."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">L'escalier, caméra à la main.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-bibliotheque.webp" width="640" height="480" loading="lazy" alt="La bibliothèque de l'école éclairée, rayonnages et tables, image VHS datée du 19 décembre 2025."></div>
<figcaption><span class="tape-label">Spectacle de Noël</span><span class="tape-text">La bibliothèque, quand tout allait bien.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-gymnase.webp" width="640" height="480" loading="lazy" alt="Le gymnase de l'école en plein jour, parquet et paniers de basket, image VHS datée du 3 mars 2026."></div>
<figcaption><span class="tape-label">EPS</span><span class="tape-text">Le gymnase, un matin de mars.</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="abri">
<div class="abri-head">
<span class="abri-tag">En développement</span>
<h2>Sous l'école, l'abri</h2>
<p>La prochaine extension du niveau ouvre ce que la ville avait muré : un abri de défense passive inauguré en 1962, le parking souterrain de l'ancien commissariat sur trois niveaux, et les égouts de 1890 qui courent sous le quartier. Au programme : de nouvelles énigmes, le stand de tir de la police, des caméras de surveillance et les enregistrements laissés par ceux qui sont passés avant vous.</p>
<p class="small">Images de développement : elles peuvent encore changer avant la sortie.</p>
</div>
<div class="abri-grid">
<figure><img src="/img/exovival/abri-collecteur.webp" width="1280" height="720" loading="lazy" alt="Galerie d'égout voûtée, cerclée de conduites métalliques."><figcaption>Le collecteur</figcaption></figure>
<figure><img src="/img/exovival/abri-camera.webp" width="1280" height="720" loading="lazy" alt="Image d'une caméra de surveillance : CAM 11, égouts, grand collecteur."><figcaption>Caméra 11</figcaption></figure>
<figure><img src="/img/exovival/abri-decantation.webp" width="1280" height="720" loading="lazy" alt="Salle de décantation circulaire avec une fosse noyée au centre."><figcaption>La salle de décantation</figcaption></figure>
<figure><img src="/img/exovival/abri-parking.webp" width="1280" height="720" loading="lazy" alt="Parking souterrain vide, piliers de béton, éclairage de secours."><figcaption>Le parking, niveau -1</figcaption></figure>
<figure><img src="/img/exovival/abri-grand-collecteur.webp" width="1280" height="720" loading="lazy" alt="Grand tunnel d'égout en briques avec une rigole d'eau au centre."><figcaption>Le grand collecteur</figcaption></figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Dernière mise à jour</h2>
<a href="/jeux/@Slug/notes-de-version">Toutes les notes de version</a>
</div>
@if (latest is not null)
{
<div class="changelog">
<partial name="_Release" model="latest" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Pour jouer</h2>
<dl class="specs">
@if (snapshot is not null)
{
<div class="spec"><dt>Version</dt><dd>@snapshot.Version</dd></div>
@if (snapshot.DownloadSize > 0)
{
<div class="spec"><dt>Téléchargement</dt><dd>@FrenchText.Size(snapshot.DownloadSize)</dd></div>
}
@if (snapshot.InstalledSize > 0)
{
<div class="spec"><dt>Espace disque</dt><dd>@FrenchText.Size(snapshot.InstalledSize)</dd></div>
}
}
<div class="spec"><dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd></div>
<div class="spec"><dt>Graphismes</dt><dd>DirectX 11</dd></div>
<div class="spec"><dt>Langues</dt><dd>Français, anglais</dd></div>
</dl>
<div class="section-tight">
<p class="lead">Exovival Zombie Reborn s'installe et se met à jour avec le launcher. Téléchargez-le, choisissez le jeu, cliquez sur Installer.</p>
<partial name="_LauncherDownload" />
</div>
</div>
</section>
@@ -0,0 +1,26 @@
@page "/jeux/{slug:maxlength(80)}"
@model GameModel
@* Page générique d'un jeu sans page dédiée (jeu en préparation, par exemple). *@
@{
var game = Model.Game;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = game.Name;
ViewData["Description"] = string.IsNullOrWhiteSpace(game.Tagline) ? game.Name : game.Tagline;
}
<section class="upcoming">
<div class="wrap">
<span class="status">@(game.IsAvailable ? "Disponible dans le launcher" : "En préparation")</span>
<h1>@game.Name</h1>
<p class="lead">@(string.IsNullOrWhiteSpace(game.Teaser) ? game.Tagline : game.Teaser)</p>
@if (game.IsAvailable)
{
<partial name="_LauncherDownload" />
}
else
{
<p class="muted">En attendant, <a href="/actualites">suivez les actualités</a> ou installez le launcher : le jeu y apparaîtra dès sa sortie.</p>
}
</div>
</section>
@@ -0,0 +1,25 @@
using GameLauncher.Website.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
public sealed class GameModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
public GameModel(IOptionsMonitor<SiteOptions> options) => _options = options;
public GameOptions Game { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null) return NotFound();
// Les jeux qui ont leur propre page (Pages/Jeux/<Jeu>.cshtml) y sont servis directement.
if (game.HasDedicatedPage) return NotFound();
Game = game;
return Page();
}
}
@@ -0,0 +1,49 @@
@page "/jeux"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "jeux";
ViewData["Title"] = "Nos jeux";
ViewData["Description"] = $"Les jeux de {site.StudioName}, à installer avec le launcher.";
}
<section class="page-hero">
<div class="wrap">
<h1>Nos jeux</h1>
<p class="lead">Tous nos jeux s'installent et se mettent à jour avec le launcher. Un seul téléchargement, pour tous.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@foreach (var game in site.VisibleGames.Where(g => g.IsAvailable))
{
var snapshot = Store.GetGame(game.Slug);
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">@(snapshot is null ? "Disponible dans le launcher" : $"Version {snapshot.Version} dans le launcher")</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in site.VisibleGames.Where(g => !g.IsAvailable))
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@@ -0,0 +1,45 @@
@page "/jeux/{slug:maxlength(80)}/notes-de-version"
@model NotesModel
@inject RemoteContentStore Store
@{
var game = Model.Game;
var snapshot = Model.Snapshot;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = $"Notes de version – {game.Name}";
ViewData["Description"] = $"Toutes les nouveautés et corrections de {game.Name}, version par version.";
}
<section class="page-hero">
<div class="wrap wrap-narrow">
<p><a href="/jeux/@game.Slug">@game.Name</a></p>
<h1>Notes de version</h1>
@if (snapshot is not null)
{
<p class="lead">Version actuelle : <strong>@snapshot.Version</strong>@(snapshot.ReleaseDate != default ? $", publiée le {FrenchText.Date(snapshot.ReleaseDate)}" : "").
Le launcher affiche les mêmes notes et installe la mise à jour pour vous.</p>
}
</div>
</section>
<section class="section-tight">
<div class="wrap wrap-narrow">
@if (snapshot is null)
{
<p class="notice">@(Store.FirstRefreshDone ? "Les notes de version sont momentanément indisponibles. Réessayez dans quelques minutes." : "Les notes de version se chargent. Rechargez la page dans quelques secondes.")</p>
}
else if (snapshot.Changelog.Count == 0)
{
<p class="notice">Aucune note de version publiée pour le moment.</p>
}
else
{
<div class="changelog">
@foreach (var entry in snapshot.Changelog)
{
<partial name="_Release" model="entry" view-data='new ViewDataDictionary(ViewData) { ["Anchor"] = "v" + System.Text.RegularExpressions.Regex.Replace(entry.Version, "[^A-Za-z0-9._-]", "") }' />
}
</div>
}
</div>
</section>
@@ -0,0 +1,32 @@
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
/// <summary>Toutes les notes de version d'un jeu (manifest.json lu par le site, jamais par le navigateur).</summary>
public sealed class NotesModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly RemoteContentStore _store;
public NotesModel(IOptionsMonitor<SiteOptions> options, RemoteContentStore store)
{
_options = options;
_store = store;
}
public GameOptions Game { get; private set; } = null!;
public GameSnapshot? Snapshot { get; private set; }
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null || string.IsNullOrWhiteSpace(game.ManifestUrl)) return NotFound();
Game = game;
Snapshot = _store.GetGame(game.Slug);
return Page();
}
}
@@ -0,0 +1,197 @@
@page "/launcher"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
var launcher = Store.Launcher;
ViewData["Nav"] = "launcher";
ViewData["Theme"] = "launcher";
ViewData["Title"] = "Le launcher";
ViewData["Description"] = "Téléchargez le launcher LanisysGames pour Windows : installation en un clic, mises à jour automatiques, vérification et réparation des fichiers, mises à jour signées.";
}
<section class="page-hero launcher-hero" id="telecharger">
<div class="wrap">
<div>
<h1>Installez, jouez, on s'occupe du reste.</h1>
<p class="lead">Le launcher télécharge nos jeux, applique les mises à jour sans tout retélécharger, vérifie chaque fichier et répare ce qui manque. Un seul fichier à télécharger, rien d'autre à installer.</p>
@{ ViewData["DownloadDetails"] = true; }
<partial name="_LauncherDownload" view-data="ViewData" />
</div>
<aside class="download-panel" aria-label="Fiche du launcher">
<h2>En bref</h2>
<dl class="facts">
@if (launcher is not null)
{
<dt>Version</dt><dd>@launcher.Version</dd>
@if (launcher.PublishedAt != default)
{
<dt>Publiée le</dt><dd><time datetime="@FrenchText.IsoDate(launcher.PublishedAt)">@FrenchText.Date(launcher.PublishedAt)</time></dd>
}
<dt>Taille</dt><dd>@FrenchText.Size(launcher.Size)</dd>
}
<dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd>
<dt>À installer</dt><dd>Rien : un seul fichier, .NET inclus</dd>
<dt>Droits</dt><dd>Aucun droit administrateur</dd>
<dt>Prix</dt><dd>Gratuit</dd>
</dl>
</aside>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<div class="shot-strip">
<figure class="shot shot-main">
<img src="/img/launcher/apercu-menu.webp" width="1200" height="740" loading="lazy"
alt="Le launcher avec son menu ouvert : rechercher les mises à jour, vérifier l'intégrité des fichiers, réparer, localiser une installation, ouvrir le dossier du jeu, paramètres, désinstaller.">
<figcaption>Tout se fait depuis un seul menu : vérifier, réparer, localiser, désinstaller.</figcaption>
</figure>
<div class="shot-side">
<figure class="shot">
<img src="/img/launcher/apercu-telechargement.webp" width="1200" height="740" loading="lazy"
alt="Téléchargement en cours : barre de progression, vitesse et temps restant.">
<figcaption>Une mise à jour ne télécharge que les fichiers modifiés.</figcaption>
</figure>
<figure class="shot">
<img src="/img/launcher/apercu-multijeux.webp" width="1256" height="708" loading="lazy"
alt="Le launcher avec la liste des jeux à gauche, chaque jeu avec son propre thème.">
<figcaption>Plusieurs jeux, chacun avec son ambiance.</figcaption>
</figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce que fait le launcher</h2>
<p class="lead">Pensé pour qu'un jeu reste jouable quoi qu'il arrive : coupure pendant un téléchargement, fichier abîmé, dossier déplacé ou supprimé par erreur.</p>
<div class="feature-columns">
<div class="feature-group">
<h3>@Html.Icon("CloudDownload") Installer et mettre à jour</h3>
<ul>
<li><strong>Installation en un clic</strong>, dans le dossier de votre choix ; l'espace disque est vérifié avant de commencer.</li>
<li><strong>Téléchargement repris</strong> là où il s'était arrêté après une coupure ou une annulation.</li>
<li><strong>Mises à jour différentielles</strong> : seuls les fichiers nouveaux ou modifiés sont téléchargés. Vos sauvegardes ne sont jamais touchées.</li>
<li>Automatiques ou manuelles, au choix, jeu par jeu. Recherche au démarrage, régulièrement pendant que le launcher est ouvert, et quand vous quittez le jeu.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Wrench") Vérifier et réparer</h3>
<ul>
<li><strong>Vérification de l'intégrité</strong> : chaque fichier du jeu est comparé à son empreinte SHA-256, même hors ligne.</li>
<li><strong>Réparation</strong> : seuls les fichiers abîmés ou manquants sont retéléchargés. Si le dossier du jeu a été supprimé à la main, le launcher le réinstalle au même endroit.</li>
<li><strong>Localiser une installation</strong> : le jeu est déjà sur votre PC ? Indiquez son dossier, le launcher le reconnaît et le met à jour.</li>
<li>Les raccourcis du Bureau sont remis en état s'ils pointent vers un ancien emplacement.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Gamepad") Au quotidien</h3>
<ul>
<li><strong>Raccourci du Bureau</strong> : il ouvre le launcher, applique les mises à jour puis lance le jeu.</li>
<li><strong>Jouer hors ligne</strong> : sans connexion, un jeu installé se lance quand même.</li>
<li>Notes de version intégrées, à lire en grand avant de jouer.</li>
<li>Pendant la partie, le launcher peut se réduire, se cacher près de l'horloge ou se fermer. Plein écran avec F11.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Delete") Désinstaller proprement</h3>
<ul>
<li>Depuis le launcher ou depuis <strong>Paramètres Windows › Applications installées</strong>, comme n'importe quel logiciel.</li>
<li>Par défaut, vos <strong>sauvegardes et réglages sont conservés</strong>.</li>
<li>Une case permet de tout effacer : sauvegardes, réglages, journaux et fichiers ajoutés dans le dossier du jeu. Les dossiers sensibles de Windows sont protégés.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Forum") Nous écrire depuis le launcher</h3>
<ul>
<li>Le bouton <strong>Aide</strong> envoie un avis, une idée ou un bug sur le jeu sélectionné.</li>
<li><strong>Anonyme</strong> : aucun compte, aucune adresse IP enregistrée. Vous recevez une référence pour en reparler.</li>
<li>Jusqu'à trois captures d'écran, nettoyées de leurs métadonnées avant l'envoi.</li>
</ul>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="trust">
<div>
<h2>Des mises à jour qu'on ne peut pas falsifier</h2>
<p class="muted">Chaque version du launcher est signée numériquement sur notre poste, avec une clé qui ne quitte jamais notre PC. Avant de se mettre à jour, le launcher vérifie cette signature et l'empreinte du fichier téléchargé : même un serveur piraté ne pourrait pas vous faire installer autre chose.</p>
<div class="feature-group"><ul aria-label="Protections">
<li><strong>Jamais de retour en arrière</strong> : seule une version plus récente peut s'installer.</li>
<li><strong>Retour automatique</strong> à la version précédente si la nouvelle ne démarre pas.</li>
<li><strong>Correctifs importants</strong> installés au démarrage, même si vous avez désactivé la mise à jour automatique ; votre réglage n'est pas modifié.</li>
<li>Les versions des jeux et les informations affichées dans le launcher sont signées elles aussi.</li>
</ul></div>
</div>
<img src="/img/launcher/mise-a-jour-verifiee.webp" width="600" height="626" loading="lazy"
alt="Fenêtre de mise à jour du launcher : nouvelle version disponible, signature et empreinte vérifiées, notes de version.">
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Première installation</h2>
<ol class="steps">
<li><strong>Téléchargez le launcher.</strong> Le bouton en haut de cette page donne toujours la dernière version, vérifiée par le site.</li>
<li><strong>Rangez-le dans un dossier à vous, puis double-cliquez dessus.</strong> Un dossier « Jeux » fait très bien l'affaire. Évitez « Program Files » : le launcher ne pourrait pas s'y mettre à jour tout seul.</li>
<li><strong>Windows affiche « Windows a protégé votre ordinateur » ?</strong> Cliquez sur « Informations complémentaires » puis « Exécuter quand même ». Pour être sûr du fichier, comparez d'abord son empreinte avec celle indiquée sous le bouton de téléchargement.</li>
<li><strong>Choisissez un jeu et cliquez sur Installer.</strong> Le launcher propose un dossier et peut créer un raccourci sur le Bureau.</li>
</ol>
</div>
</section>
<section class="section" id="notes">
<div class="wrap">
<div class="section-head">
<h2>Nouveautés de la dernière version</h2>
@if (!string.IsNullOrWhiteSpace(site.Launcher.ReleasesUrl))
{
<a href="@site.Launcher.ReleasesUrl" rel="noopener noreferrer">Toutes les versions sur notre Gitea</a>
}
</div>
@if (launcher?.LatestNotes is { } notes)
{
<div class="changelog">
<partial name="_Release" model="notes" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Questions fréquentes</h2>
<div class="faq">
<details>
<summary>Où sont mes sauvegardes ?</summary>
<p>Dans votre dossier utilisateur, à part du jeu : <code>%APPDATA%\LanisysGames\</code> puis le nom du jeu. Mettre à jour, réparer ou réinstaller un jeu ne les touche pas. Le menu « … » › Paramètres du jeu ouvre ce dossier directement.</p>
</details>
<details>
<summary>Le jeu est déjà sur mon PC, faut-il tout retélécharger ?</summary>
<p>Non. Cliquez sur « Localiser l'installation » sous le bouton Installer et choisissez le dossier du jeu : le launcher le reconnaît, puis ne télécharge que ce qui a changé.</p>
</details>
<details>
<summary>Puis-je déplacer le launcher ?</summary>
<p>Oui. Au démarrage suivant depuis son nouvel emplacement, il met à jour ses raccourcis et l'entrée « Applications installées » de Windows.</p>
</details>
<details>
<summary>Le launcher a-t-il besoin d'Internet ?</summary>
<p>Pour installer et mettre à jour, oui. Un jeu déjà installé se lance aussi hors ligne, et la vérification des fichiers fonctionne sans connexion.</p>
</details>
<details>
<summary>Comment tout désinstaller ?</summary>
<p>Menu « … » › Désinstaller, ou Paramètres Windows › Applications installées. Cochez la case proposée pour supprimer aussi sauvegardes, réglages et journaux. Le launcher lui-même est un simple fichier : supprimez-le quand vous n'avez plus de jeu installé.</p>
</details>
</div>
</div>
</section>
@@ -0,0 +1,46 @@
@page "/mentions-legales"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var legal = site.Legal;
ViewData["Title"] = "Mentions légales";
ViewData["Description"] = "Éditeur, hébergement et propriété intellectuelle du site.";
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Mentions légales</h1>
<h2>Éditeur du site</h2>
<address>
<strong>@(string.IsNullOrWhiteSpace(legal.Publisher) ? site.StudioName : legal.Publisher)</strong><br>
@foreach (var line in legal.PublisherDetails)
{
@line<br>
}
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<text>Contact : </text><a href="mailto:@site.ContactEmail">@site.ContactEmail</a>
}
</address>
@if (!string.IsNullOrWhiteSpace(legal.Director))
{
<p>Directeur de la publication : @legal.Director</p>
}
<h2>Hébergement</h2>
<address>
@foreach (var line in legal.Host)
{
@line<br>
}
</address>
<h2>Propriété intellectuelle</h2>
<p>Les textes, images, captures et vidéos des jeux présentés sur ce site appartiennent à @site.StudioName, sauf mention contraire. Toute reproduction sans autorisation est interdite.</p>
<p>Polices de caractères : Sora et Barlow, sous licence SIL Open Font License 1.1 (<a href="/fonts/OFL-Sora.txt">Sora</a>, <a href="/fonts/OFL-Barlow.txt">Barlow</a>, <a href="/fonts/OFL-Barlow-Condensed.txt">Barlow Condensed</a>). Icônes : Material Design Icons, licence Apache 2.0.</p>
<h2>Données personnelles</h2>
<p>Voir la page <a href="/confidentialite">Confidentialité</a>.</p>
</div>
</section>
@@ -0,0 +1,41 @@
@inject RemoteContentStore Store
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@* Bouton de téléchargement du launcher : affiché seulement si launcher-update.json est signé par la clé
du launcher (vérifiée par le site). L'exécutable est téléchargé directement depuis le serveur du launcher. *@
@{
var launcher = Store.Launcher;
var showDetails = ViewData["DownloadDetails"] as bool? ?? false;
}
@if (launcher is not null)
{
<div class="download">
<a class="btn btn-primary btn-large" href="@launcher.DownloadUrl">
@Html.Icon("Download") Télécharger le launcher
</a>
<p class="download-meta">
Version <strong>@launcher.Version</strong>, @FrenchText.Size(launcher.Size), pour Windows 10 et 11 (64 bits).
@if (launcher.IsPriority)
{
<span class="badge-priority">Correctif important</span>
}
</p>
@if (showDetails)
{
<details class="download-check">
<summary>@Html.Icon("ShieldCheck") Signature vérifiée</summary>
<p>Le site a vérifié la signature numérique de cette version avec la clé publique intégrée au launcher. Après le téléchargement, vous pouvez comparer l'empreinte du fichier :</p>
<code class="hash" id="empreinte-launcher">@launcher.Sha256</code>
<p class="small">Dans PowerShell : <code>Get-FileHash .\@launcher.FileName</code>
<button class="btn btn-ghost small" type="button" data-copy="empreinte-launcher" hidden>Copier l'empreinte</button></p>
</details>
}
</div>
}
else if (!Store.FirstRefreshDone)
{
<p class="notice">Le lien de téléchargement se prépare. Rechargez la page dans quelques secondes.</p>
}
else
{
<p class="notice"><strong>Le téléchargement est momentanément indisponible.</strong> Le site n'a pas pu vérifier la dernière version du launcher ; réessayez dans quelques minutes@(string.IsNullOrWhiteSpace(SiteOptionsMonitor.CurrentValue.ContactEmail) ? "." : " ou écrivez-nous.")</p>
}
@@ -0,0 +1,116 @@
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var theme = ViewData["Theme"] as string ?? "launcher";
var nav = ViewData["Nav"] as string ?? "";
var pageTitle = ViewData["Title"] as string;
var fullTitle = string.IsNullOrEmpty(pageTitle) ? site.StudioName : $"{pageTitle} – {site.StudioName}";
var description = ViewData["Description"] as string ?? $"{site.StudioName} : nos jeux et le launcher pour les installer, les mettre à jour et les lancer en un clic.";
var root = site.PublicUrl.TrimEnd('/');
var path = Context.Request.Path.Value ?? "/";
var ogImage = ViewData["OgImage"] as string ?? (theme == "exovival" ? "/img/og/exovival.jpg" : "/img/og/launcher.jpg");
string? Current(string key) => nav == key ? "page" : null;
}
<!DOCTYPE html>
<html lang="fr" data-theme="@theme">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@fullTitle</title>
<meta name="description" content="@description">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="@(theme == "exovival" ? "#060708" : theme == "multi" ? "#0a0f14" : "#0b0d13")">
@if (!string.IsNullOrEmpty(root))
{
<link rel="canonical" href="@(root + (path == "/" ? "/" : path))">
<meta property="og:type" content="@(ViewData["OgType"] as string ?? "website")">
<meta property="og:site_name" content="@site.StudioName">
<meta property="og:locale" content="fr_FR">
<meta property="og:title" content="@(pageTitle ?? site.StudioName)">
<meta property="og:description" content="@description">
<meta property="og:url" content="@(root + path)">
<meta property="og:image" content="@(root + ogImage)">
<meta name="twitter:card" content="summary_large_image">
}
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<link rel="preload" href="/fonts/@(theme == "exovival" ? "barlow-condensed-800" : "sora-variable").woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
<script src="~/js/site.js" asp-append-version="true" defer></script>
</head>
<body>
<a class="skip-link" href="#contenu">Aller au contenu</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="menu-principal">
@Html.Icon("Menu")
<span class="visually-hidden">Menu</span>
</button>
<nav class="site-nav" id="menu-principal" aria-label="Navigation principale">
<a href="/" aria-current="@Current("accueil")">Accueil</a>
<a href="/launcher" aria-current="@Current("launcher")">Launcher</a>
<a href="/jeux" aria-current="@Current("jeux")">Jeux</a>
<a href="/actualites" aria-current="@Current("actualites")">Actualités</a>
</nav>
<div class="header-actions">
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<a class="btn btn-ghost" href="mailto:@site.ContactEmail">@Html.Icon("EmailOutline") Nous contacter</a>
}
<a class="btn btn-primary" href="/launcher#telecharger">@Html.Icon("Download") Télécharger le launcher</a>
</div>
</div>
</header>
<main id="contenu">
@RenderBody()
</main>
<footer class="site-footer">
<div class="wrap">
<div class="footer-brand">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<p class="small">Studio indépendant. Nos jeux s'installent, se mettent à jour et se lancent depuis notre launcher, pour Windows.</p>
</div>
<nav aria-label="Jeux">
<h2>Jeux</h2>
<ul>
@foreach (var game in site.VisibleGames)
{
<li><a href="/jeux/@game.Slug">@game.Name</a></li>
}
</ul>
</nav>
<nav aria-label="Launcher">
<h2>Launcher</h2>
<ul>
<li><a href="/launcher">Présentation</a></li>
<li><a href="/launcher#telecharger">Télécharger</a></li>
<li><a href="/launcher#notes">Notes de version</a></li>
</ul>
</nav>
<nav aria-label="Informations">
<h2>Informations</h2>
<ul>
<li><a href="/actualites">Actualités</a></li>
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<li><a href="mailto:@site.ContactEmail">Nous contacter</a></li>
}
<li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/confidentialite">Confidentialité</a></li>
</ul>
</nav>
<p class="footer-note">© @DateTime.UtcNow.Year @site.StudioName. Ce site n'utilise ni cookie, ni outil de mesure d'audience, ni service externe.</p>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,22 @@
@model IReadOnlyList<NewsArticle>
<ul class="news-list">
@foreach (var article in Model)
{
<li>
<a class="news-item" href="/actualites/@article.Slug" data-topic-theme="@article.Theme">
@if (article.Image is not null)
{
<img src="@article.Image" alt="@(article.ImageAlt ?? "")" loading="lazy" width="640" height="360">
}
<div class="news-item-body">
<span class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</span>
<h3>@article.Title</h3>
<p>@article.Summary</p>
</div>
</a>
</li>
}
</ul>
@@ -0,0 +1,66 @@
@model ChangelogEntry
@* Une version des notes : même structure que dans le launcher (titre, sous-titre, introduction, sections).
Tous les textes viennent du manifest : ils sont encodés par Razor (jamais interprétés comme du HTML). *@
@{
var anchor = ViewData["Anchor"] as string;
}
<article class="release" id="@anchor">
<header class="release-head">
<span class="version-pill">@Model.Version</span>
@if (Model.Date != default)
{
<time datetime="@FrenchText.IsoDate(Model.Date)">@FrenchText.Date(Model.Date)</time>
}
</header>
@if (!string.IsNullOrWhiteSpace(Model.Title))
{
<h3>@Model.Title</h3>
}
@if (Model.HasSubtitle)
{
<p class="release-subtitle">@Model.Subtitle</p>
}
@foreach (var paragraph in Model.Introduction)
{
<p class="release-intro">@paragraph</p>
}
@foreach (var part in Model.DisplaySections)
{
<section class="release-section">
@if (part.HasTitle)
{
<h4>@part.Title</h4>
}
<ul class="release-items">
@foreach (var item in part.Items)
{
if (item.IsComment)
{
<li class="is-comment">@item.Text</li>
continue;
}
<li>
@if (item.HasTag)
{
<span class="tag">@item.Tag</span>
}
@item.Text
@if (item.HasDetails)
{
<ul class="release-details">
@foreach (var detail in item.Details)
{
<li>@detail</li>
}
</ul>
}
@if (item.HasNote)
{
<p class="release-note">@item.Note</p>
}
</li>
}
</ul>
</section>
}
</article>
@@ -0,0 +1,7 @@
@using GameLauncher.Website
@using GameLauncher.Website.Options
@using GameLauncher.Website.Services
@using GameLauncher.Contracts.Changelog
@using Microsoft.Extensions.Options
@namespace GameLauncher.Website.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+140
View File
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Security;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using System.Threading.RateLimiting;
using GameLauncher.Website.Hosting;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// --- Serveur web (Kestrel, derrière nginx) ---------------------------------------------------------
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.AddServerHeader = false;
kestrel.Limits.MaxRequestBodySize = 1024; // aucun envoi de données attendu
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
kestrel.Limits.MaxRequestLineSize = 4 * 1024;
kestrel.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
});
// --- Réglages ------------------------------------------------------------------------------------
builder.Services.AddOptions<SiteOptions>().Bind(builder.Configuration.GetSection(SiteOptions.Section)).ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<SiteOptions>, SiteOptionsValidator>();
// Textes en UTF-8 lisible (é, à…) dans le HTML : les caractères restent encodés là où il le faut (<, >, ", &).
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.Configure<RouteOptions>(routes =>
{
routes.LowercaseUrls = true;
routes.AppendTrailingSlash = false;
});
// --- Lecture des fichiers publics du launcher ---------------------------------------------------
builder.Services.AddHttpClient(RemoteFetcher.HttpClientName, client =>
{
client.Timeout = Timeout.InfiniteTimeSpan; // délai géré par RemoteFetcher (réglable)
client.DefaultRequestHeaders.UserAgent.ParseAdd("LanisysSite/1.0");
})
.ConfigurePrimaryHttpMessageHandler(services => new SocketsHttpHandler
{
// Même règle que par défaut (certificat valide exigé), avec le détail d'un refus dans le journal.
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = TlsDiagnostics.Create(
services.GetRequiredService<ILoggerFactory>().CreateLogger("GameLauncher.Website.Tls"))
},
AllowAutoRedirect = false,
UseCookies = false,
UseProxy = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli | DecompressionMethods.Deflate,
ConnectTimeout = TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
MaxResponseHeadersLength = 64
});
builder.Services.AddSingleton<RemoteFetcher>();
builder.Services.AddSingleton<RemoteContentStore>();
builder.Services.AddHostedService<RemoteContentRefresher>();
builder.Services.AddSingleton<NewsService>();
// Aucun cookie ni formulaire : les clés de protection des données (utilisées seulement par l'anti-falsification
// d'ASP.NET Core) restent en mémoire, rien n'est écrit sur le disque du VPS.
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
// --- Pages ---------------------------------------------------------------------------------------
builder.Services.AddRazorPages(pages =>
{
pages.Conventions.ConfigureFilter(new Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryTokenAttribute());
});
// --- Limitation du nombre de requêtes par adresse IP (en plus de celle de nginx) ------------------
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "inconnue",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 300, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddHsts(hsts =>
{
hsts.MaxAge = TimeSpan.FromDays(365);
hsts.IncludeSubDomains = false; // les autres sous-domaines (launcher, git…) gèrent leur propre politique
});
var app = builder.Build();
// nginx (même machine) transmet l'adresse du visiteur et le protocole https.
var forwarded = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto };
app.UseForwardedHeaders(forwarded);
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/erreur");
app.UseHsts();
}
app.UseSiteSecurityHeaders();
app.UseRateLimiter();
app.UseStatusCodePagesWithReExecute("/erreur/{0}");
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
// Fichiers avec empreinte (?v=… ajouté par asp-append-version) : cache long.
var versioned = context.Context.Request.Query.ContainsKey("v");
context.Context.Response.Headers.CacheControl = versioned ? "public, max-age=31536000, immutable" : "public, max-age=86400";
}
});
app.UseRouting();
app.MapRazorPages();
// Contrôle de fonctionnement (installer-site.sh) : uniquement depuis la machine elle-même.
app.MapGet("/healthz", (HttpContext context) =>
context.Connection.RemoteIpAddress is { } ip && IPAddress.IsLoopback(ip) ? Results.Text("ok") : Results.NotFound());
app.MapGet("/robots.txt", (IOptionsMonitor<SiteOptions> options) =>
{
var text = new StringBuilder("User-agent: *\nAllow: /\n");
if (!string.IsNullOrWhiteSpace(options.CurrentValue.PublicUrl))
text.Append("Sitemap: ").Append(options.CurrentValue.PublicUrl).Append("/sitemap.xml\n");
return Results.Text(text.ToString(), "text/plain; charset=utf-8");
});
app.MapGet("/sitemap.xml", (IOptionsMonitor<SiteOptions> options, NewsService news) =>
Results.Text(Sitemap.Build(options.CurrentValue, news), "application/xml; charset=utf-8"));
app.Run();
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Development": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "http://127.0.0.1:5090/",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,62 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Mise en forme en français, sans dépendre des données de culture du système (le site est compilé en
/// « InvariantGlobalization », comme la Launcher API : rien à installer sur le VPS).
/// </summary>
public static class FrenchText
{
private static readonly string[] Months =
{
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
};
/// <summary>Fuseau des dates affichées (heure de Paris) ; UTC si le système ne le connaît pas.</summary>
private static readonly TimeZoneInfo Paris = FindParis();
/// <summary>« 1er novembre 2026 », « 10 novembre 2026 ».</summary>
public static string Date(DateOnly date) =>
$"{(date.Day == 1 ? "1er" : date.Day.ToString(System.Globalization.CultureInfo.InvariantCulture))} {Months[date.Month - 1]} {date.Year}";
/// <summary>Date (heure de Paris) d'un instant ; vide si la date est absente.</summary>
public static string Date(DateTimeOffset value) =>
value == default ? "" : Date(DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime));
/// <summary>Format ISO pour l'attribut datetime de &lt;time&gt;.</summary>
public static string IsoDate(DateTimeOffset value) =>
value == default ? "" : DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
public static string IsoDate(DateOnly value) => value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
/// <summary>« 74,2 Mo », « 1,29 Go » (unités binaires, comme le launcher).</summary>
public static string Size(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "octets", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
var format = unit == 0 ? "0" : value >= 100 ? "0" : value >= 10 ? "0.0" : "0.00";
return value.ToString(format, System.Globalization.CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static TimeZoneInfo FindParis()
{
foreach (var id in new[] { "Europe/Paris", "Romance Standard Time" })
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
}
}
return TimeZoneInfo.Utc;
}
}
@@ -0,0 +1,67 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Icônes de l'interface : tracés 24×24 repris de src\GameLauncher\Themes\Icons.xaml (Material Design Icons,
/// licence Apache 2.0), pour la même identité visuelle que le launcher ; les dernières (Menu…Windows) dans le même style.
/// </summary>
public static class IconPaths
{
public static readonly IReadOnlyDictionary<string, string> All = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Download"] = "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z",
["CloudDownload"] = "M17,13L12,18L7,13H10V9H14V13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z",
["Refresh"] = "M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z",
["Wrench"] = "M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z",
["ShieldCheck"] = "M10,17L6,13L7.41,11.58L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z",
["Folder"] = "M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z",
["Locate"] = "M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z",
["Delete"] = "M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z",
["CheckCircle"] = "M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z",
["Help"] = "M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z",
["History"] = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.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",
["Harddisk"] = "M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M12,4A6,6 0 0,0 6,10C6,13.31 8.69,16 12.1,16L11.22,13.77C10.95,13.29 11.11,12.68 11.59,12.4L12.45,11.9C12.93,11.63 13.54,11.79 13.82,12.27L15.74,14.69C17.12,13.59 18,11.9 18,10A6,6 0 0,0 12,4M12,9A1,1 0 0,1 13,10A1,1 0 0,1 12,11A1,1 0 0,1 11,10A1,1 0 0,1 12,9M7,18A1,1 0 0,0 6,19A1,1 0 0,0 7,20A1,1 0 0,0 8,19A1,1 0 0,0 7,18M12.09,13.27L14.58,19.58L17.17,18.08L12.95,12.77L12.09,13.27Z",
["Gamepad"] = "M7,6H17A6,6 0 0,1 23,12A6,6 0 0,1 17,18C15.22,18 13.63,17.23 12.53,16H11.47C10.37,17.23 8.78,18 7,18A6,6 0 0,1 1,12A6,6 0 0,1 7,6M6,9V11H4V13H6V15H8V13H10V11H8V9H6M15.5,12A1.5,1.5 0 0,0 14,13.5A1.5,1.5 0 0,0 15.5,15A1.5,1.5 0 0,0 17,13.5A1.5,1.5 0 0,0 15.5,12M18.5,9A1.5,1.5 0 0,0 17,10.5A1.5,1.5 0 0,0 18.5,12A1.5,1.5 0 0,0 20,10.5A1.5,1.5 0 0,0 18.5,9Z",
["Update"] = "M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z",
["Web"] = "M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z",
["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",
["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",
["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",
["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",
["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",
["Fullscreen"] = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z",
["Lightbulb"] = "M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z",
["Bug"] = "M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z",
["PackageVariant"] = "M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z",
["Laptop"] = "M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z",
["Broom"] = "M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z",
["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",
["ArrowLeft"] = "M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z",
["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",
["Play"] = "M8,5.14V19.14L19,12.14L8,5.14Z",
["Star"] = "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z",
["Tune"] = "M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z",
["FileDocument"] = "M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z",
["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",
["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",
["Menu"] = "M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z",
["Close"] = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z",
["Flashlight"] = "M9,10L6,5H18L15,10H9M18,4H6V2H18V4M9,22V11H15V22H9M12,13A1,1 0 0,0 11,14A1,1 0 0,0 12,15A1,1 0 0,0 13,14A1,1 0 0,0 12,13Z",
["Cassette"] = "M4,5A2,2 0 0,0 2,7V17A2,2 0 0,0 4,19H6L7,17H17L18,19H20A2,2 0 0,0 22,17V7A2,2 0 0,0 20,5H4M7,9.5A2,2 0 0,1 9,11.5A2,2 0 0,1 7,13.5A2,2 0 0,1 5,11.5A2,2 0 0,1 7,9.5M17,9.5A2,2 0 0,1 19,11.5A2,2 0 0,1 17,13.5A2,2 0 0,1 15,11.5A2,2 0 0,1 17,9.5M9,10.5H15V12.5H9V10.5Z",
["Ear"] = "M17,20C16.71,20 16.44,19.94 16.24,19.85C15.53,19.5 15.03,18.97 14.53,17.47C14,15.91 13.06,15.2 12.15,14.5C11.37,13.9 10.56,13.28 9.86,12C9.34,11.05 9,10 9,9C9,6.2 11.2,4 14,4C16.8,4 19,6.2 19,9H21C21,5.07 17.93,2 14,2C10.07,2 7,5.07 7,9C7,10.26 7.38,11.65 8.07,12.9C8.98,14.55 10.04,15.38 10.9,16.04C11.7,16.65 12.27,17.09 12.63,18.1C13.3,20.1 14.16,21.22 15.4,21.73C15.89,21.92 16.42,22 17,22A4,4 0 0,0 21,18H19A2,2 0 0,1 17,20M11.5,9A2.5,2.5 0 0,0 14,11.5A2.5,2.5 0 0,0 16.5,9A2.5,2.5 0 0,0 14,6.5A2.5,2.5 0 0,0 11.5,9Z",
["Medical"] = "M10,3L8,5V7H4A2,2 0 0,0 2,9V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V9A2,2 0 0,0 20,7H16V5L14,3H10M10,5H14V7H10V5M11,10H13V13H16V15H13V18H11V15H8V13H11V10Z",
["Puzzle"] = "M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z",
["Axe"] = "M12.5,6.2L6.2,12.5L4.5,10.8C2.8,9.2 2.8,6.6 4.5,4.9C6.1,3.3 8.7,3.3 10.3,4.9L12.5,6.2M13.9,7.6L21.7,15.4L20.3,16.8L18.2,14.7L16.8,16.1L18.9,18.2L17.5,19.6L9.7,11.8L13.9,7.6Z",
["Cctv"] = "M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.68,20 16.77,20H22V18.04H16.77V16.27C17.95,15.77 18.5,14.4 18,13.2C17.92,13 17.81,12.83 17.69,12.66L19.5,11.64C20.45,11.08 20.78,9.86 20.22,8.91L18.72,6.32C18.35,5.68 17.73,5 17.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L7.5,15.34L6.22,13.17Z",
["Windows"] = "M3,12V6.75L9,5.43V11.91L3,12M20,3V11.75L10,11.9V5.21L20,3M3,13L9,13.09V19.9L3,18.75V13M20,13.25V22L10,20.09V13.1L20,13.25Z",
};
}
/// <summary>Icône SVG en ligne (<c>@Html.Icon("Download")</c>) : tracés constants, aucune donnée de visiteur.</summary>
public static class IconHtmlExtensions
{
public static Microsoft.AspNetCore.Html.IHtmlContent Icon(this Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper _, string name) =>
IconPaths.All.TryGetValue(name, out var path)
? new Microsoft.AspNetCore.Html.HtmlString(
$"<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"{path}\"/></svg>")
: Microsoft.AspNetCore.Html.HtmlString.Empty;
}
@@ -0,0 +1,156 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Services;
/// <summary>Article de la page « Actualités » (fichier Content/News/&lt;slug&gt;.md).</summary>
public sealed class NewsArticle
{
public required string Slug { get; init; }
public required string Title { get; init; }
public DateOnly Date { get; init; }
public required string Summary { get; init; }
/// <summary>Thème de la page : celui du jeu concerné (« exovival »…), sinon « launcher ».</summary>
public string Theme { get; init; } = "launcher";
/// <summary>Jeu ou produit concerné, affiché sur la carte (« Exovival Zombie Reborn », « Launcher »…).</summary>
public string? Topic { get; init; }
/// <summary>Image de couverture (chemin /img/…), facultative.</summary>
public string? Image { get; init; }
public string? ImageAlt { get; init; }
/// <summary>Corps de l'article, déjà converti par <see cref="SafeMarkdown"/>.</summary>
public required string Html { get; init; }
public bool Draft { get; init; }
}
/// <summary>
/// Actualités : un fichier Markdown par article dans Content/News, versionné avec le site.
/// Lus une fois au démarrage (publier une actualité = publier le site) ; aucune entrée de visiteur
/// n'atteint le système de fichiers (le slug d'une adresse est cherché dans un dictionnaire en mémoire).
///
/// En-tête de chaque fichier (entre deux lignes « --- ») :
/// title: Titre de l'article
/// date: 2026-11-10
/// summary: Une ou deux phrases pour la liste des actualités
/// topic: Exovival Zombie Reborn (facultatif)
/// theme: exovival (facultatif : launcher, exovival, multi)
/// image: /img/exovival/vhs-gymnase.webp (facultatif)
/// imageAlt: Description de l'image (facultatif)
/// draft: true (facultatif : visible seulement en développement)
/// </summary>
public sealed partial class NewsService
{
public const string Folder = "Content/News";
private const int MaxArticles = 500;
private static readonly string[] Themes = { "launcher", "exovival", "multi" };
private readonly IReadOnlyList<NewsArticle> _articles;
private readonly Dictionary<string, NewsArticle> _bySlug;
public NewsService(IWebHostEnvironment environment, ILogger<NewsService> logger)
: this(Path.Combine(environment.ContentRootPath, Folder), environment.IsDevelopment(), logger)
{
}
internal NewsService(string folder, bool includeDrafts, ILogger logger)
{
var list = new List<NewsArticle>();
if (Directory.Exists(folder))
{
foreach (var file in Directory.EnumerateFiles(folder, "*.md", SearchOption.TopDirectoryOnly).Take(MaxArticles))
{
try
{
var article = Parse(Path.GetFileNameWithoutExtension(file), File.ReadAllText(file));
if (article is null)
{
logger.LogWarning("Actualité ignorée (nom de fichier ou en-tête incorrect) : {File}", Path.GetFileName(file));
continue;
}
if (article.Draft && !includeDrafts) continue;
list.Add(article);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Actualité illisible : {File}", Path.GetFileName(file));
}
}
}
_articles = list.OrderByDescending(a => a.Date).ThenBy(a => a.Slug, StringComparer.Ordinal).ToList();
_bySlug = _articles.ToDictionary(a => a.Slug, StringComparer.Ordinal);
logger.LogInformation("{Count} actualité(s) chargée(s).", _articles.Count);
}
public IReadOnlyList<NewsArticle> All => _articles;
public IEnumerable<NewsArticle> Latest(int count) => _articles.Take(count);
public NewsArticle? Find(string? slug) => slug is not null && _bySlug.TryGetValue(slug, out var a) ? a : null;
public static bool IsValidSlug(string? slug) => slug is not null && slug.Length <= 80 && SlugRegex().IsMatch(slug);
internal static NewsArticle? Parse(string slug, string text)
{
if (!IsValidSlug(slug)) return null;
text = text.Replace("\r\n", "\n");
if (text.StartsWith('')) text = text[1..];
if (!text.StartsWith("---\n", StringComparison.Ordinal)) return null;
var end = text.IndexOf("\n---", 4, StringComparison.Ordinal);
if (end < 0) return null;
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in text[4..end].Split('\n'))
{
var colon = raw.IndexOf(':');
if (colon <= 0) continue;
var value = raw[(colon + 1)..].Trim();
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') value = value[1..^1];
fields[raw[..colon].Trim()] = value;
}
var bodyStart = text.IndexOf('\n', end + 1);
var body = bodyStart < 0 ? "" : text[(bodyStart + 1)..];
if (!fields.TryGetValue("title", out var title) || string.IsNullOrWhiteSpace(title)) return null;
if (!fields.TryGetValue("date", out var dateText) ||
!DateOnly.TryParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return null;
fields.TryGetValue("summary", out var summary);
fields.TryGetValue("theme", out var theme);
fields.TryGetValue("topic", out var topic);
fields.TryGetValue("image", out var image);
fields.TryGetValue("imageAlt", out var imageAlt);
fields.TryGetValue("draft", out var draft);
return new NewsArticle
{
Slug = slug,
Title = Limit(title, 160),
Date = date,
Summary = string.IsNullOrWhiteSpace(summary) ? SafeMarkdown.ToPlainText(body, 220) : Limit(summary, 300),
Theme = theme is not null && Themes.Contains(theme) ? theme : "launcher",
Topic = string.IsNullOrWhiteSpace(topic) ? null : Limit(topic, 60),
Image = image is not null && SafeMarkdown.IsSafeImage(image) ? image : null,
ImageAlt = string.IsNullOrWhiteSpace(imageAlt) ? null : Limit(imageAlt, 200),
Html = SafeMarkdown.ToHtml(body),
Draft = string.Equals(draft, "true", StringComparison.OrdinalIgnoreCase)
};
}
private static string Limit(string value, int max)
{
value = new string(value.Where(c => !char.IsControl(c)).ToArray()).Trim();
return value.Length <= max ? value : value[..(max - 1)].TrimEnd() + "…";
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
}
@@ -0,0 +1,225 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Website.Services;
/// <summary>Dernière version du launcher, lue dans un launcher-update.json dont la signature est valide.</summary>
public sealed class LauncherSnapshot
{
public required string Version { get; init; }
public required string Product { get; init; }
public DateTimeOffset PublishedAt { get; init; }
public required Uri DownloadUrl { get; init; }
public required string FileName { get; init; }
public long Size { get; init; }
public required string Sha256 { get; init; }
public bool IsPriority { get; init; }
/// <summary>Notes de la dernière version seulement (sections réservées à l'équipe retirées).</summary>
public ChangelogEntry? LatestNotes { get; init; }
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Résumé d'un manifest.json de jeu : version et notes de version (la liste des fichiers n'est pas gardée).</summary>
public sealed class GameSnapshot
{
public required string Version { get; init; }
public DateTimeOffset ReleaseDate { get; init; }
public long DownloadSize { get; init; }
public long InstalledSize { get; init; }
public IReadOnlyList<ChangelogEntry> Changelog { get; init; } = Array.Empty<ChangelogEntry>();
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Contenu lu par erreur ou refusé : jamais affiché.</summary>
public sealed class RemoteContentException : Exception
{
public RemoteContentException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Lecture et contrôle des fichiers JSON du serveur du launcher. Fonctions pures (sans réseau) :
/// testées dans GameLauncher.Tests (WebsiteRemoteContentTests).
/// </summary>
public static class RemoteContentReader
{
/// <summary>
/// Vérifie launcher-update.json avec les clés publiques intégrées (même code que le launcher :
/// <see cref="UpdateSignature.ReadRelease"/>), puis contrôle l'adresse de l'exécutable : https, même
/// serveur que launcher-update.json (un fichier signé ne peut pas envoyer les visiteurs ailleurs).
/// </summary>
public static LauncherSnapshot ReadLauncher(byte[] json, Uri updateUrl, IReadOnlyCollection<UpdateSignature.TrustedKey> keys,
string? expectedProduct, IEnumerable<string> hiddenSections, DateTimeOffset now)
{
LauncherRelease release;
try
{
release = UpdateSignature.ReadRelease(DecodeUtf8(json), keys);
}
catch (UpdateSecurityException ex)
{
throw new RemoteContentException(ex.Message, ex);
}
if (!string.IsNullOrWhiteSpace(expectedProduct) &&
!string.Equals(release.Product, expectedProduct.Trim(), StringComparison.Ordinal))
throw new RemoteContentException($"produit inattendu « {release.Product} » (attendu : « {expectedProduct} »).");
if (!Uri.TryCreate(updateUrl, release.File, out var download) ||
download.Scheme != updateUrl.Scheme ||
!string.Equals(download.IdnHost, updateUrl.IdnHost, StringComparison.OrdinalIgnoreCase) ||
download.Port != updateUrl.Port ||
!string.IsNullOrEmpty(download.Query) || !string.IsNullOrEmpty(download.Fragment) || !string.IsNullOrEmpty(download.UserInfo))
throw new RemoteContentException($"adresse de l'exécutable refusée « {release.File} » (même serveur que launcher-update.json attendu).");
var fileName = Path.GetFileName(download.AbsolutePath);
if (!fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || fileName.Length > 100)
throw new RemoteContentException($"nom d'exécutable inattendu « {fileName} ».");
var version = VersionUtil.Display(release.ParsedVersion);
var notes = release.Changelog.FirstOrDefault(e => VersionUtil.TryParse(e.Version, out var v) && v == release.ParsedVersion)
?? release.Changelog.FirstOrDefault()
?? release.ToLegacyEntry();
return new LauncherSnapshot
{
Version = version,
Product = release.Product,
PublishedAt = release.PublishedAt,
DownloadUrl = download,
FileName = fileName,
Size = release.Size,
Sha256 = release.Sha256.ToLowerInvariant(),
IsPriority = release.IsPriorityRelease,
LatestNotes = notes is null ? null : WithoutSections(notes, hiddenSections),
CheckedAt = now
};
}
/// <summary>
/// Lit le résumé d'un manifest.json : version, dates, tailles et notes de version, bornées et nettoyées
/// comme dans le launcher (<see cref="ChangelogLimits.Normalize"/>). Le jeu doit être celui attendu.
/// </summary>
public static GameSnapshot ReadGame(byte[] json, string expectedGameId, DateTimeOffset now)
{
ManifestSummary? manifest;
try
{
manifest = JsonSerializer.Deserialize<ManifestSummary>(json, ManifestJson);
}
catch (JsonException ex)
{
throw new RemoteContentException("manifest illisible : " + ex.Message, ex);
}
if (manifest is null) throw new RemoteContentException("manifest vide.");
if (!string.IsNullOrWhiteSpace(expectedGameId) &&
!string.Equals(manifest.GameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
throw new RemoteContentException($"manifest d'un autre jeu (« {manifest.GameId} », attendu « {expectedGameId} »).");
if (!VersionUtil.TryParse(manifest.Version, out var parsed))
throw new RemoteContentException($"version illisible « {manifest.Version} ».");
long installed = 0;
foreach (var file in manifest.Files ?? new List<ManifestFileSize>())
if (file is not null && file.Size > 0) installed += file.Size;
return new GameSnapshot
{
Version = ChangelogLimits.Clean(manifest.Version, ChangelogLimits.MaxVersionLength) ?? VersionUtil.Display(parsed),
ReleaseDate = manifest.ReleaseDate,
DownloadSize = Math.Max(0, manifest.Archive?.Size ?? 0),
InstalledSize = installed,
Changelog = ChangelogLimits.Normalize(manifest.Changelog),
CheckedAt = now
};
}
/// <summary>Copie d'une entrée sans les sections dont le titre figure dans <paramref name="hidden"/>.</summary>
public static ChangelogEntry WithoutSections(ChangelogEntry entry, IEnumerable<string> hidden)
{
var keys = hidden.Select(Fold).Where(k => k.Length > 0).ToHashSet(StringComparer.Ordinal);
if (keys.Count == 0) return entry;
return new ChangelogEntry
{
Version = entry.Version,
Date = entry.Date,
Title = entry.Title,
Subtitle = entry.Subtitle,
Introduction = entry.Introduction.ToList(),
Changes = entry.Changes.ToList(),
Sections = entry.Sections.Where(s => s.Title is null || !keys.Contains(Fold(s.Title))).ToList()
};
}
/// <summary>Clé de comparaison : sans accents, sans casse, espaces réduits.</summary>
internal static string Fold(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return "";
var decomposed = text.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(decomposed.Length);
foreach (var c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
sb.Append(char.IsWhiteSpace(c) ? ' ' : char.ToUpperInvariant(c));
}
return string.Join(' ', sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>Clés publiques intégrées à l'exécutable du site (même fichier que le launcher).</summary>
public static IReadOnlyList<UpdateSignature.TrustedKey> LoadEmbeddedKeys()
{
using var stream = typeof(RemoteContentReader).Assembly.GetManifestResourceStream("GameLauncher.Website.update-signing.pub")
?? throw new InvalidOperationException("Clé publique de mise à jour absente de l'exécutable.");
using var reader = new StreamReader(stream, Encoding.UTF8);
try
{
return UpdateSignature.ParsePublicKeys(reader.ReadToEnd());
}
catch (CryptographicException ex)
{
throw new InvalidOperationException("Clé publique de mise à jour illisible.", ex);
}
}
private static string DecodeUtf8(byte[] bytes)
{
// BOM toléré (fichier édité à la main), comme File.ReadAllText.
var span = bytes.AsSpan();
if (span.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) span = span[3..];
return Encoding.UTF8.GetString(span);
}
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
private sealed class ManifestSummary
{
public string? GameId { get; set; }
public string? Version { get; set; }
public DateTimeOffset ReleaseDate { get; set; }
public ArchiveSize? Archive { get; set; }
public List<ManifestFileSize>? Files { get; set; }
public List<ChangelogEntry>? Changelog { get; set; }
}
private sealed class ArchiveSize
{
public long Size { get; set; }
}
private sealed class ManifestFileSize
{
public long Size { get; set; }
}
}
@@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Website.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Services;
/// <summary>
/// Dernières données lues sur le serveur du launcher, servies aux pages. Les pages ne déclenchent
/// jamais de requête sortante : seul <see cref="RemoteContentRefresher"/> lit les fichiers, en arrière-plan.
/// </summary>
public sealed class RemoteContentStore
{
private volatile LauncherSnapshot? _launcher;
private readonly ConcurrentDictionary<string, GameSnapshot> _games = new(StringComparer.Ordinal);
public LauncherSnapshot? Launcher => _launcher;
/// <summary>Faux tant que la première lecture n'est pas terminée (message « chargement » plutôt qu'« indisponible »).</summary>
public bool FirstRefreshDone { get; private set; }
public GameSnapshot? GetGame(string slug) => _games.TryGetValue(slug, out var game) ? game : null;
internal void SetLauncher(LauncherSnapshot? snapshot) => _launcher = snapshot;
internal void SetGame(string slug, GameSnapshot? snapshot)
{
if (snapshot is null) _games.TryRemove(slug, out _);
else _games[slug] = snapshot;
}
internal void MarkFirstRefreshDone() => FirstRefreshDone = true;
}
/// <summary>
/// Relit launcher-update.json et les manifest.json au démarrage puis toutes les N minutes.
///
/// Politique d'erreur :
/// • serveur injoignable, délai dépassé : la dernière version valide reste affichée ;
/// • fichier refusé (signature invalide, produit ou serveur inattendu, manifest d'un autre jeu…) :
/// l'information est retirée du site (rien plutôt qu'un lien douteux) et l'erreur est inscrite au journal.
/// </summary>
public sealed class RemoteContentRefresher : BackgroundService
{
private readonly RemoteFetcher _fetcher;
private readonly RemoteContentStore _store;
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly ILogger<RemoteContentRefresher> _logger;
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _keys;
private readonly Dictionary<string, (string? ETag, string? LastModified)> _validators = new();
/// <summary>launcher-update.json : même limite que le launcher.</summary>
private const long MaxLauncherBytes = UpdateSignature.MaxEnvelopeLength;
public RemoteContentRefresher(RemoteFetcher fetcher, RemoteContentStore store, IOptionsMonitor<SiteOptions> options,
ILogger<RemoteContentRefresher> logger)
{
_fetcher = fetcher;
_store = store;
_options = options;
_logger = logger;
_keys = RemoteContentReader.LoadEmbeddedKeys();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Clés publiques du launcher intégrées : {Keys}.", string.Join(", ", _keys.Select(k => k.KeyId)));
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RefreshAllAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Lecture des fichiers du launcher : erreur inattendue.");
}
finally
{
_store.MarkFirstRefreshDone();
}
var minutes = Math.Clamp(_options.CurrentValue.Remote.RefreshMinutes, 1, 24 * 60);
// Petit décalage aléatoire : pas de rafale à heure fixe sur le serveur du launcher.
var delay = TimeSpan.FromMinutes(minutes) + TimeSpan.FromSeconds(Random.Shared.Next(0, 30));
try
{
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
internal async Task RefreshAllAsync(CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
await RefreshLauncherAsync(options, cancellationToken);
foreach (var game in options.Games.Where(g => !string.IsNullOrWhiteSpace(g.ManifestUrl)))
await RefreshGameAsync(game, options, cancellationToken);
}
private async Task RefreshLauncherAsync(SiteOptions options, CancellationToken cancellationToken)
{
var url = options.Launcher.UpdateUrl;
if (string.IsNullOrWhiteSpace(url))
{
_store.SetLauncher(null);
return;
}
if (!_fetcher.IsAllowedUrl(url, out var uri))
{
_logger.LogError("Site:Launcher:UpdateUrl refusée (https obligatoire) : {Url}", url);
_store.SetLauncher(null);
return;
}
var result = await FetchAsync(url, MaxLauncherBytes, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning("launcher-update.json non lu ({Error}) : {State}.", result.Error,
_store.Launcher is null ? "téléchargement indisponible" : "version précédente conservée");
return;
}
if (result.NotModified) return;
try
{
var snapshot = RemoteContentReader.ReadLauncher(result.Content!, uri, _keys, options.Launcher.Product,
options.Launcher.HiddenSections, DateTimeOffset.UtcNow);
var previous = _store.Launcher;
_store.SetLauncher(snapshot);
if (previous?.Version != snapshot.Version)
_logger.LogInformation("Launcher {Version} (produit « {Product} », signature valide) : {Url}",
snapshot.Version, snapshot.Product, snapshot.DownloadUrl);
}
catch (RemoteContentException ex)
{
ForgetValidators(url);
_store.SetLauncher(null);
_logger.LogError("launcher-update.json REFUSÉ, téléchargement retiré du site : {Reason}", ex.Message);
}
}
private async Task RefreshGameAsync(GameOptions game, SiteOptions options, CancellationToken cancellationToken)
{
var max = (long)Math.Clamp(options.Remote.MaxManifestMegabytes, 1, 256) * 1024 * 1024;
var result = await FetchAsync(game.ManifestUrl, max, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning("Manifest de « {Game} » non lu ({Error}).", game.Name, result.Error);
return;
}
if (result.NotModified) return;
try
{
var snapshot = RemoteContentReader.ReadGame(result.Content!, game.GameId, DateTimeOffset.UtcNow);
var previous = _store.GetGame(game.Slug);
_store.SetGame(game.Slug, snapshot);
if (previous?.Version != snapshot.Version)
_logger.LogInformation("{Game} : version {Version}, {Count} notes de version.", game.Name, snapshot.Version, snapshot.Changelog.Count);
}
catch (RemoteContentException ex)
{
ForgetValidators(game.ManifestUrl);
_store.SetGame(game.Slug, null);
_logger.LogError("Manifest de « {Game} » REFUSÉ : {Reason}", game.Name, ex.Message);
}
}
/// <summary>Lecture conditionnelle : ETag / Last-Modified de la dernière lecture réussie de cette adresse.</summary>
private async Task<FetchResult> FetchAsync(string url, long maxBytes, CancellationToken cancellationToken)
{
_validators.TryGetValue(url, out var validators);
var result = await _fetcher.FetchAsync(url, maxBytes, validators.ETag, validators.LastModified, cancellationToken);
if (result.Ok && !result.NotModified) _validators[url] = (result.ETag, result.LastModified);
return result;
}
private void ForgetValidators(string url) => _validators.Remove(url);
}
@@ -0,0 +1,134 @@
using System.Net.Http.Headers;
using System.Net;
using GameLauncher.Website.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Services;
/// <summary>Résultat d'une lecture : contenu, « inchangé » (304) ou erreur.</summary>
public sealed record FetchResult(byte[]? Content, bool NotModified, string? ETag, string? LastModified, string? Error)
{
public bool Ok => Error is null;
}
/// <summary>
/// Lecture des fichiers JSON publics (launcher-update.json, manifest.json) depuis le serveur du launcher.
///
/// • Uniquement les adresses configurées (pas d'adresse venant d'un visiteur : aucune requête sortante
/// n'est déclenchée par une page), en https (http://localhost seulement en développement).
/// • Aucune redirection suivie, taille bornée AVANT et PENDANT la lecture (y compris après décompression),
/// délai borné.
/// • Requête conditionnelle (ETag / Last-Modified) : un manifest inchangé n'est pas retéléchargé.
/// </summary>
public sealed class RemoteFetcher
{
public const string HttpClientName = "remote-json";
private readonly IHttpClientFactory _factory;
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly IHostEnvironment _environment;
public RemoteFetcher(IHttpClientFactory factory, IOptionsMonitor<SiteOptions> options, IHostEnvironment environment)
{
_factory = factory;
_options = options;
_environment = environment;
}
/// <summary>Adresse acceptée pour une lecture (https, ou http://localhost en développement).</summary>
public bool IsAllowedUrl(string? url, out Uri uri) =>
IsAllowedUrl(url, AllowLoopbackHttp, out uri);
private bool AllowLoopbackHttp => _environment.IsDevelopment() && _options.CurrentValue.Remote.AllowLoopbackHttp;
internal static bool IsAllowedUrl(string? url, bool allowLoopbackHttp, out Uri uri)
{
uri = null!;
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed)) return false;
if (!string.IsNullOrEmpty(parsed.UserInfo) || !string.IsNullOrEmpty(parsed.Fragment)) return false;
var ok = parsed.Scheme == Uri.UriSchemeHttps ||
(allowLoopbackHttp && parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback);
if (!ok) return false;
uri = parsed;
return true;
}
public async Task<FetchResult> FetchAsync(string url, long maxBytes, string? etag, string? lastModified, CancellationToken cancellationToken)
{
if (!IsAllowedUrl(url, out var uri))
return new FetchResult(null, false, null, null, "adresse refusée (https obligatoire)");
var timeout = TimeSpan.FromSeconds(Math.Clamp(_options.CurrentValue.Remote.TimeoutSeconds, 3, 120));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrEmpty(etag) && EntityTagHeaderValue.TryParse(etag, out var tag))
request.Headers.IfNoneMatch.Add(tag);
else if (!string.IsNullOrEmpty(lastModified) && DateTimeOffset.TryParse(lastModified, out var since))
request.Headers.IfModifiedSince = since;
try
{
var client = _factory.CreateClient(HttpClientName);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (response.StatusCode == HttpStatusCode.NotModified)
return new FetchResult(null, true, etag, lastModified, null);
if (!response.IsSuccessStatusCode)
return new FetchResult(null, false, null, null, $"réponse HTTP {(int)response.StatusCode}");
if (response.Content.Headers.ContentLength is { } announced && announced > maxBytes)
return new FetchResult(null, false, null, null, $"fichier trop volumineux ({announced} octets)");
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
var content = await ReadBoundedAsync(stream, maxBytes, cts.Token);
if (content is null)
return new FetchResult(null, false, null, null, $"fichier trop volumineux (plus de {maxBytes} octets)");
return new FetchResult(content, false, response.Headers.ETag?.ToString(),
response.Content.Headers.LastModified?.ToString("R"), null);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new FetchResult(null, false, null, null, $"délai dépassé ({timeout.TotalSeconds:0} s)");
}
catch (HttpRequestException ex)
{
return new FetchResult(null, false, null, null, Describe(ex));
}
}
/// <summary>
/// Cause lisible d'un échec réseau : message de chaque exception imbriquée (certificat refusé, nom
/// inconnu, connexion refusée…), au lieu du seul « The SSL connection could not be established ».
/// </summary>
internal static string Describe(HttpRequestException ex)
{
var parts = new List<string>();
for (Exception? e = ex; e is not null && parts.Count < 5; e = e.InnerException)
if (!string.IsNullOrWhiteSpace(e.Message) && !parts.Contains(e.Message)) parts.Add(e.Message);
var prefix = ex.InnerException is System.Security.Authentication.AuthenticationException
? "certificat HTTPS refusé (détail dans la ligne « Certificat … REFUSÉ » du journal)"
: "serveur injoignable";
return prefix + " : " + string.Join(" → ", parts);
}
/// <summary>Lit au plus <paramref name="maxBytes"/> octets ; null si le flux en contient davantage.</summary>
internal static async Task<byte[]?> ReadBoundedAsync(Stream stream, long maxBytes, CancellationToken cancellationToken)
{
using var buffer = new MemoryStream();
var chunk = new byte[81920];
long total = 0;
int read;
while ((read = await stream.ReadAsync(chunk, cancellationToken)) > 0)
{
total += read;
if (total > maxBytes) return null;
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
}
@@ -0,0 +1,325 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
namespace GameLauncher.Website.Services;
/// <summary>
/// Liseuse Markdown des actualités : sous-ensemble volontairement réduit, sans dépendance tierce.
///
/// Sécurité « par construction » : tout le texte est encodé en HTML ; seules les balises produites par
/// ce fichier existent dans le résultat. Le HTML brut écrit dans un article est affiché tel quel (encodé),
/// jamais interprété. Liens : http(s), mailto et chemins du site uniquement (javascript:, data:… refusés).
/// Images : fichiers du site uniquement (/img/…), comme l'impose aussi la politique de sécurité (CSP).
///
/// Syntaxe prise en charge :
/// ## Titre, ### Sous-titre, #### Petit titre (« # » devient un ## : le titre de l'article est déjà le h1)
/// paragraphes (lignes consécutives), ligne vide = nouveau paragraphe
/// - liste / * liste, 1. liste numérotée (un niveau, + sous-niveau indenté de 2 espaces ou plus)
/// > citation
/// ``` bloc de code ```
/// --- (ligne seule) : séparateur
/// ![texte alternatif](/img/…) seule sur sa ligne : image en pleine largeur (légende = texte alternatif)
/// **gras**, *italique* ou _italique_, `code`, [lien](https://…)
/// </summary>
public static partial class SafeMarkdown
{
private static readonly HtmlEncoder Encoder = HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All);
/// <summary>Taille maximale d'un article (caractères) : protège le démarrage contre un fichier anormal.</summary>
public const int MaxLength = 200_000;
public static string ToHtml(string? markdown)
{
if (string.IsNullOrEmpty(markdown)) return "";
if (markdown.Length > MaxLength) markdown = markdown[..MaxLength];
var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var html = new StringBuilder();
var paragraph = new List<string>();
var i = 0;
void FlushParagraph()
{
if (paragraph.Count == 0) return;
html.Append("<p>").Append(Inline(string.Join(" ", paragraph.Select(p => p.Trim())))).Append("</p>\n");
paragraph.Clear();
}
while (i < lines.Length)
{
var line = StripControl(lines[i]);
var trimmed = line.Trim();
if (trimmed.Length == 0)
{
FlushParagraph();
i++;
continue;
}
// Bloc de code
if (trimmed.StartsWith("```", StringComparison.Ordinal))
{
FlushParagraph();
var code = new List<string>();
i++;
while (i < lines.Length && !lines[i].Trim().StartsWith("```", StringComparison.Ordinal))
code.Add(StripControl(lines[i++]));
i++; // fermeture (ou fin du fichier)
html.Append("<pre><code>").Append(Encoder.Encode(string.Join("\n", code))).Append("</code></pre>\n");
continue;
}
// Titres
var heading = HeadingRegex().Match(trimmed);
if (heading.Success)
{
FlushParagraph();
var level = Math.Clamp(heading.Groups[1].Value.Length + (heading.Groups[1].Value.Length == 1 ? 1 : 0), 2, 4);
html.Append($"<h{level}>").Append(Inline(heading.Groups[2].Value.Trim().TrimEnd('#').TrimEnd())).Append($"</h{level}>\n");
i++;
continue;
}
// Séparateur
if (RuleRegex().IsMatch(trimmed))
{
FlushParagraph();
html.Append("<hr>\n");
i++;
continue;
}
// Image seule sur sa ligne
var image = ImageLineRegex().Match(trimmed);
if (image.Success)
{
FlushParagraph();
var alt = image.Groups[1].Value.Trim();
var src = image.Groups[2].Value.Trim();
if (IsSafeImage(src))
{
html.Append("<figure><img src=\"").Append(Encoder.Encode(src)).Append("\" alt=\"").Append(Encoder.Encode(alt))
.Append("\" loading=\"lazy\" decoding=\"async\">");
if (alt.Length > 0) html.Append("<figcaption>").Append(Encoder.Encode(alt)).Append("</figcaption>");
html.Append("</figure>\n");
}
else
{
html.Append("<p>").Append(Encoder.Encode(alt)).Append("</p>\n");
}
i++;
continue;
}
// Citation
if (trimmed.StartsWith('>'))
{
FlushParagraph();
var quote = new List<string>();
while (i < lines.Length && StripControl(lines[i]).TrimStart().StartsWith('>'))
quote.Add(StripControl(lines[i++]).TrimStart()[1..].Trim());
html.Append("<blockquote>");
foreach (var block in SplitBlocks(quote))
html.Append("<p>").Append(Inline(string.Join(" ", block))).Append("</p>");
html.Append("</blockquote>\n");
continue;
}
// Listes
if (ListItemRegex().IsMatch(line))
{
FlushParagraph();
i = RenderList(lines, i, html);
continue;
}
paragraph.Add(line);
i++;
}
FlushParagraph();
return html.ToString();
}
/// <summary>Texte brut (aperçu, description Open Graph) : marques Markdown retirées.</summary>
public static string ToPlainText(string? markdown, int maxLength)
{
if (string.IsNullOrWhiteSpace(markdown)) return "";
var text = LinkRegex().Replace(markdown, m => m.Groups[2].Value);
text = Regex.Replace(text, @"[*_`#>]+", "");
text = Regex.Replace(StripControl(text), @"\s+", " ").Trim();
return text.Length <= maxLength ? text : text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
}
private static int RenderList(string[] lines, int i, StringBuilder html)
{
var ordered = char.IsDigit(lines[i].TrimStart()[0]);
html.Append(ordered ? "<ol>" : "<ul>");
var open = false;
while (i < lines.Length)
{
var line = StripControl(lines[i]);
if (line.Trim().Length == 0)
{
// Une ligne vide termine la liste, sauf si la ligne suivante est un élément du même type de liste.
var next = i + 1 < lines.Length ? ListItemRegex().Match(StripControl(lines[i + 1])) : Match.Empty;
if (next.Success && char.IsDigit(next.Groups[2].Value[0]) == ordered) { i++; continue; }
break;
}
var match = ListItemRegex().Match(line);
if (!match.Success)
{
if (open && line.StartsWith(" ", StringComparison.Ordinal))
{
html.Append(' ').Append(Inline(line.Trim())); // suite de l'élément
i++;
continue;
}
break;
}
var indent = match.Groups[1].Value.Length;
if (indent < 2 && char.IsDigit(match.Groups[2].Value[0]) != ordered) break; // autre type de liste
if (indent >= 2 && open)
{
// Sous-liste : éléments indentés consécutifs
var subOrdered = char.IsDigit(match.Groups[2].Value[0]);
html.Append(subOrdered ? "<ol>" : "<ul>");
while (i < lines.Length)
{
var sub = ListItemRegex().Match(StripControl(lines[i]));
if (!sub.Success || sub.Groups[1].Value.Length < 2) break;
html.Append("<li>").Append(Inline(sub.Groups[3].Value.Trim())).Append("</li>");
i++;
}
html.Append(subOrdered ? "</ol>" : "</ul>");
continue;
}
if (open) html.Append("</li>");
html.Append("<li>").Append(Inline(match.Groups[3].Value.Trim()));
open = true;
i++;
}
if (open) html.Append("</li>");
html.Append(ordered ? "</ol>\n" : "</ul>\n");
return i;
}
private static IEnumerable<List<string>> SplitBlocks(List<string> lines)
{
var block = new List<string>();
foreach (var line in lines)
{
if (line.Length == 0)
{
if (block.Count > 0) yield return block;
block = new List<string>();
}
else block.Add(line);
}
if (block.Count > 0) yield return block;
}
/// <summary>Mise en forme en ligne. Chaque morceau de texte est encodé ; seules nos balises sont produites.</summary>
internal static string Inline(string text)
{
var sb = new StringBuilder();
var position = 0;
foreach (Match m in InlineRegex().Matches(text))
{
sb.Append(Encoder.Encode(text[position..m.Index]));
position = m.Index + m.Length;
if (m.Groups["code"].Success)
{
sb.Append("<code>").Append(Encoder.Encode(m.Groups["code"].Value)).Append("</code>");
}
else if (m.Groups["ltext"].Success)
{
var url = m.Groups["lurl"].Value.Trim();
var label = Inline(m.Groups["ltext"].Value);
if (TryClassifyLink(url, out var external))
{
sb.Append("<a href=\"").Append(Encoder.Encode(url)).Append('"');
if (external) sb.Append(" rel=\"noopener noreferrer\"");
sb.Append('>').Append(label).Append("</a>");
}
else
{
sb.Append(label); // lien refusé : le texte reste, sans lien
}
}
else if (m.Groups["bold"].Success)
{
sb.Append("<strong>").Append(Inline(m.Groups["bold"].Value)).Append("</strong>");
}
else if (m.Groups["em"].Success)
{
sb.Append("<em>").Append(Inline(m.Groups["em"].Value)).Append("</em>");
}
else if (m.Groups["em2"].Success)
{
sb.Append("<em>").Append(Inline(m.Groups["em2"].Value)).Append("</em>");
}
}
sb.Append(Encoder.Encode(text[position..]));
return sb.ToString();
}
/// <summary>Lien accepté : chemin du site (« /… » sans « // »), ancre, https, http ou mailto.</summary>
internal static bool TryClassifyLink(string url, out bool external)
{
external = false;
if (url.Length == 0 || url.Length > 2000 || url.Any(c => char.IsControl(c) || char.IsWhiteSpace(c))) return false;
if (url.StartsWith('#')) return true;
if (url.StartsWith('/')) return !url.StartsWith("//", StringComparison.Ordinal) && !url.Contains('\\');
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme is not ("https" or "http" or "mailto")) return false;
external = uri.Scheme != "mailto";
return true;
}
/// <summary>Image acceptée : fichier du site, sous /img/, extension d'image.</summary>
internal static bool IsSafeImage(string src) =>
src.StartsWith("/img/", StringComparison.Ordinal) && !src.Contains("..", StringComparison.Ordinal) &&
!src.Contains('\\') && !src.Contains("//", StringComparison.Ordinal) &&
SafeImageRegex().IsMatch(src);
private static string StripControl(string value)
{
if (!value.Any(c => (char.IsControl(c) && c != '\t') || IsBidi(c))) return value.Replace('\t', ' ');
var sb = new StringBuilder(value.Length);
foreach (var c in value)
{
if (c == '\t') sb.Append(' ');
else if (!char.IsControl(c) && !IsBidi(c)) sb.Append(c);
}
return sb.ToString();
}
private static bool IsBidi(char c) => c is '‎' or '‏' or (>= '‪' and <= '‮') or (>= '⁦' and <= '⁩');
[GeneratedRegex(@"^(#{1,4})\s+(.+)$")]
private static partial Regex HeadingRegex();
[GeneratedRegex(@"^(-{3,}|\*{3,}|_{3,})$")]
private static partial Regex RuleRegex();
[GeneratedRegex(@"^!\[([^\]]*)\]\(([^)\s]+)\)$")]
private static partial Regex ImageLineRegex();
[GeneratedRegex(@"^( *)([-*]|\d{1,3}\.)\s+(.*)$")]
private static partial Regex ListItemRegex();
[GeneratedRegex(@"\[([^\]]+)\]\(([^)\s]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"^/img/[A-Za-z0-9/_\-.]+\.(webp|png|jpe?g|svg|avif)$")]
private static partial Regex SafeImageRegex();
[GeneratedRegex(@"`(?<code>[^`]+)`|\[(?<ltext>[^\]]+)\]\((?<lurl>[^)\s]+)\)|\*\*(?<bold>.+?)\*\*|(?<![\w*])\*(?<em>[^\s*](?:.*?[^\s*])?)\*(?![\w*])|(?<!\w)_(?<em2>[^\s_](?:.*?[^\s_])?)_(?!\w)")]
private static partial Regex InlineRegex();
}
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Site": {
"PublicUrl": "http://127.0.0.1:5090",
"Remote": {
"RefreshMinutes": 2,
// Développement uniquement : autorise des fichiers de test servis en http://localhost.
"AllowLoopbackHttp": true
}
}
}
@@ -0,0 +1,84 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning",
// Clés de protection des données volontairement en mémoire (Program.cs) : avertissements sans objet.
"Microsoft.AspNetCore.DataProtection": "Error"
}
},
// Derrière nginx, sur la même machine (website\deploy\nginx-site.conf).
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://127.0.0.1:5090" }
}
},
// Réglages du site. Sur le VPS, les valeurs propres au serveur vont dans appsettings.Production.json
// (conservé par installer-site.sh d'une mise à jour à l'autre) : voir website\deploy\LISEZMOI.md.
"Site": {
"PublicUrl": "https://www.lanisys.fr",
"StudioName": "LanisysGames",
// Bouton « Nous contacter » : simple lien mailto (la messagerie du visiteur s'ouvre). Vide = masqué.
"ContactEmail": "contact@lanisys.fr",
"Launcher": {
"Name": "Launcher LanisysGames",
// Fichier SIGNÉ publié par tools\publish-launcher-update.bat : version, exe, notes de version.
"UpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
// « launcherId » attendu (ligne « Produit » de LauncherPublisher verify). Vide = non contrôlé :
// le produit lu est écrit dans le journal du site au premier démarrage, à recopier ici.
"Product": "",
// Historique complet des versions (page « Publications » de Gitea, dépôt public). Vide = lien masqué.
"ReleasesUrl": "https://git.lanisys.fr/lanisys/GamesLauncher-v2/releases",
// Sections des notes du launcher qui ne concernent pas les joueurs (non affichées sur le site).
"HiddenSections": [ "Serveur", "Développement", "Administration", "Menu d'administration" ]
},
"Games": [
{
"Slug": "exovival-zombie-reborn",
"Name": "Exovival Zombie Reborn",
"Theme": "exovival",
"Status": "available",
"HasDedicatedPage": true,
"GameId": "EZR",
"ManifestUrl": "https://launcher.lanisys.fr/exovivalzombie/manifest.json",
"Tagline": "La ville a été évacuée en pleine nuit. À l'appel, une élève manquait : Léa, votre fille. Vous retournez seul dans l'école.",
"CardImage": "/img/exovival/couloir.webp",
"CardImageAlt": "Couloir de l'école dans le noir, éclairé par le bloc de secours vert d'une sortie."
},
{
"Slug": "projet-multijoueur",
"Name": "Projet multijoueur",
"Theme": "multi",
"Status": "upcoming",
"Tagline": "Notre prochain jeu se jouera à plusieurs. Il est en préparation.",
"Teaser": "Nous travaillons sur un jeu multijoueur. Il n'a pas encore de nom, et nous préférons vous le montrer quand il sera jouable plutôt que de le promettre trop tôt. Les premières nouvelles arriveront dans les actualités du site et dans le launcher."
}
],
"Remote": {
"RefreshMinutes": 10,
"TimeoutSeconds": 20,
"MaxManifestMegabytes": 32
},
// Mentions légales (obligatoires en France, loi pour la confiance dans l'économie numérique).
// À COMPLÉTER dans appsettings.Production.json sur le VPS : voir website\deploy\LISEZMOI.md, étape 6.
"Legal": {
"Publisher": "LanisysGames",
"PublisherDetails": [],
"Director": "",
"Host": [
"IONOS SARL",
"7 place de la Gare, BP 70109, 57201 Sarreguemines Cedex, France",
"Téléphone : 09 70 80 89 11"
],
"LogRetentionDays": 14
}
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ThinItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraLight.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraLightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Light.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-LightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Regular.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Italic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Medium.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-MediumItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-SemiBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-SemiBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Bold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-BoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Black.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-BlackItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ThinItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraLight.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraLightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Light.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-LightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Regular.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Italic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Medium.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-MediumItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-SemiBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-SemiBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Bold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-BoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Black.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-BlackItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2019 The Sora Project Authors (https://github.com/sora-xor/sora-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more