Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee | ||
|
|
8fe67053e1 | ||
|
|
927df6c2b2 | ||
|
|
197f5186d0 | ||
|
|
98cf17c294 | ||
|
|
4da86bcc6a | ||
|
|
52761f53b6 | ||
|
|
cf75a22f19 | ||
|
|
009c84de9f | ||
|
|
9f9b9b9d4d | ||
|
|
12ab8377c9 | ||
|
|
5aa22df409 | ||
|
|
3df009912b | ||
|
|
89570947aa | ||
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db |
No files matched your search
@@ -68,3 +68,4 @@
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
website/deploy/*.conf text eol=lf
|
||||
@@ -3,9 +3,9 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Company>Xbhost</Company>
|
||||
<Authors>Xbhost</Authors>
|
||||
<Copyright>© Xbhost</Copyright>
|
||||
<Company>LanisysGames</Company>
|
||||
<Authors>LanisysGames</Authors>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
tools\ui-snapshot.bat = tools\ui-snapshot.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
tools\publish-site.bat = tools\publish-site.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -70,6 +71,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
|
||||
website\deploy\installer-site.sh = website\deploy\installer-site.sh
|
||||
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
|
||||
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
|
||||
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -182,6 +196,14 @@ Global
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -201,6 +223,8 @@ Global
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -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`).
|
||||
@@ -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).
|
||||
|
||||
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
|
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é",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("<script>", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[clic](javascript:alert(1))")]
|
||||
[InlineData("[clic](JaVaScRiPt:alert(1))")]
|
||||
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
|
||||
[InlineData("[clic](//evil.example/x)")]
|
||||
[InlineData("[clic](vbscript:msgbox)")]
|
||||
public void Liens_dangereux_retires(string markdown)
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml(markdown);
|
||||
Assert.DoesNotContain("href", html);
|
||||
Assert.Contains("clic", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lien_externe_sans_acces_a_la_page_d_origine()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
|
||||
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
|
||||
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
|
||||
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attribut_impossible_a_injecter_dans_un_lien()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
|
||||
Assert.DoesNotContain("\"onmouseover", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("")]
|
||||
[InlineData("")]
|
||||
public void Images_hors_du_site_refusees(string markdown)
|
||||
{
|
||||
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Syntaxe_prise_en_charge()
|
||||
{
|
||||
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n");
|
||||
Assert.Contains("<h2>Titre</h2>", html);
|
||||
Assert.Contains("<strong>gras</strong>", html);
|
||||
Assert.Contains("<em>italique</em>", html);
|
||||
Assert.Contains("<code>du code</code>", html);
|
||||
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
|
||||
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
|
||||
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
|
||||
Assert.Contains("<hr>", html);
|
||||
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebsiteNewsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Article_lu_avec_son_en_tete()
|
||||
{
|
||||
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
|
||||
Assert.NotNull(article);
|
||||
Assert.Equal("Titre : avec deux-points", article!.Title);
|
||||
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
|
||||
Assert.Equal("exovival", article.Theme);
|
||||
Assert.Equal("/img/exovival/couloir.webp", article.Image);
|
||||
Assert.Contains("<strong>gras</strong>", article.Html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mon_Article")]
|
||||
[InlineData("../secret")]
|
||||
[InlineData("")]
|
||||
public void Nom_de_fichier_incorrect_ignore(string slug)
|
||||
{
|
||||
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
|
||||
{
|
||||
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
|
||||
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
|
||||
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
|
||||
Assert.Equal("launcher", article!.Theme);
|
||||
Assert.Null(article.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Brouillons_masques_hors_developpement()
|
||||
{
|
||||
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
|
||||
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
|
||||
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
|
||||
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
|
||||
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
|
||||
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
|
||||
/// [--version 1.2.0] [--notes notes-launcher.txt]
|
||||
/// [--public-key src\GameLauncher\Security\update-signing.pub]
|
||||
/// [--priority | --clear-priority] [--yes]
|
||||
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
|
||||
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
|
||||
///
|
||||
@@ -49,6 +50,11 @@ public static class Program
|
||||
default: return Usage();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Warn(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error($"Erreur : {ex.Message}");
|
||||
@@ -136,6 +142,22 @@ public static class Program
|
||||
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
|
||||
}
|
||||
|
||||
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
|
||||
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
|
||||
var markPriority = o.Has("--priority");
|
||||
var clearPriority = o.Has("--clear-priority");
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
|
||||
if (markPriority && !o.Has("--yes"))
|
||||
{
|
||||
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
|
||||
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
|
||||
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
|
||||
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
|
||||
if (answer is not ("o" or "oui"))
|
||||
throw new OperationCanceledException("Abandon : rien n'a été publié.");
|
||||
}
|
||||
|
||||
using var key = LoadPrivateKey(keyPath);
|
||||
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
@@ -172,10 +194,20 @@ public static class Program
|
||||
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
|
||||
}
|
||||
|
||||
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
|
||||
var history = ReadHistory(previousJson, key, keyId, display);
|
||||
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
|
||||
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
|
||||
var history = ReadHistory(previousRelease, display);
|
||||
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
|
||||
|
||||
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
|
||||
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
|
||||
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
|
||||
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
|
||||
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
|
||||
"relancez avec --priority si cette version doit rester imposée.");
|
||||
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
|
||||
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
|
||||
|
||||
var release = new LauncherRelease
|
||||
{
|
||||
Product = product,
|
||||
@@ -187,7 +219,8 @@ public static class Program
|
||||
// Format historique, lu par les launchers antérieurs à 1.1.3
|
||||
Title = entry.Title,
|
||||
Notes = entry.Changes.ToList(),
|
||||
Changelog = changelog
|
||||
Changelog = changelog,
|
||||
PriorityVersion = priorityVersion
|
||||
};
|
||||
release.Validate();
|
||||
|
||||
@@ -219,6 +252,7 @@ public static class Program
|
||||
? " Notes : aucune (--notes non fourni ou fichier vide)"
|
||||
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
|
||||
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
|
||||
Console.WriteLine($" Priorité : {DescribePriority(release)}");
|
||||
Console.WriteLine($" Produit : {product}");
|
||||
Console.WriteLine($" Clé : {keyId}");
|
||||
Console.WriteLine($" SHA-256 : {sha256}");
|
||||
@@ -245,6 +279,7 @@ public static class Program
|
||||
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
|
||||
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
|
||||
Console.WriteLine($" SHA-256 : {release.Sha256}");
|
||||
Console.WriteLine($" Priorité: {DescribePriority(release)}");
|
||||
|
||||
var exe = o.Get("--exe");
|
||||
if (exe is null) return true;
|
||||
@@ -295,25 +330,30 @@ public static class Program
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
|
||||
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
|
||||
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
|
||||
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
|
||||
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
|
||||
{
|
||||
if (previousJson is null) return Array.Empty<ChangelogEntry>();
|
||||
LauncherRelease previous;
|
||||
if (previousJson is null) return null;
|
||||
try
|
||||
{
|
||||
previous = UpdateSignature.ReadRelease(previousJson, new[]
|
||||
return UpdateSignature.ReadRelease(previousJson, new[]
|
||||
{
|
||||
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
|
||||
});
|
||||
}
|
||||
catch (UpdateSecurityException ex)
|
||||
{
|
||||
Warn($"Historique des notes ignoré ({ex.Message}).");
|
||||
return Array.Empty<ChangelogEntry>();
|
||||
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
|
||||
{
|
||||
if (previous is null) return Array.Empty<ChangelogEntry>();
|
||||
|
||||
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
|
||||
var entries = previous.Changelog.Count > 0
|
||||
@@ -322,6 +362,16 @@ public static class Program
|
||||
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
|
||||
private static string DescribePriority(LauncherRelease release)
|
||||
{
|
||||
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
|
||||
var display = VersionUtil.Display(priority);
|
||||
return release.IsPriorityRelease
|
||||
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
|
||||
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
|
||||
}
|
||||
|
||||
private static LauncherRelease? TryReadUnverified(string envelopeJson)
|
||||
{
|
||||
try
|
||||
@@ -509,7 +559,14 @@ public static class Program
|
||||
(--config <launcher.json> | --product <launcherId>)
|
||||
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
|
||||
[--public-key <update-signing.pub>] [--force]
|
||||
[--priority | --clear-priority] [--yes]
|
||||
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
|
||||
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
|
||||
chez les joueurs qui ont désactivé la mise à jour automatique (leur
|
||||
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
|
||||
Reprise par les publications suivantes : un launcher plus ancien que
|
||||
cette version se met à jour quoi qu'il arrive.
|
||||
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
|
||||
|
||||
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
|
||||
Vérifie la signature (et l'exécutable).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace UiSnapshot;
|
||||
|
||||
@@ -54,7 +56,7 @@ internal interface ISceneSet
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
|
||||
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
|
||||
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
|
||||
/// </summary>
|
||||
internal sealed class Capture
|
||||
@@ -222,7 +224,7 @@ internal sealed class Capture
|
||||
root.UpdateLayout();
|
||||
|
||||
var image = Render(root);
|
||||
Save(image, record.Id + ".png", record);
|
||||
Save(image, record.Id + _options.ImageExtension, record);
|
||||
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
|
||||
|
||||
Diagnose(root, record);
|
||||
@@ -267,16 +269,58 @@ internal sealed class Capture
|
||||
|
||||
private void Save(BitmapSource image, string fileName, SceneRecord record)
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
|
||||
encoder.Save(stream);
|
||||
{
|
||||
if (_options.Format == SnapshotOptions.FormatWebp)
|
||||
{
|
||||
SaveWebp(image, stream, _options.Quality);
|
||||
}
|
||||
else
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
encoder.Save(stream);
|
||||
}
|
||||
}
|
||||
record.Files.Add(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
|
||||
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
|
||||
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
|
||||
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
|
||||
/// </summary>
|
||||
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
|
||||
{
|
||||
if (image.Format != PixelFormats.Pbgra32)
|
||||
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
|
||||
|
||||
var width = image.PixelWidth;
|
||||
var height = image.PixelHeight;
|
||||
var stride = width * 4;
|
||||
var pixels = new byte[stride * height];
|
||||
image.CopyPixels(pixels, stride, 0);
|
||||
|
||||
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
||||
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
|
||||
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
|
||||
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
|
||||
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
|
||||
data.SaveTo(stream);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
|
||||
/// (<scène>.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
|
||||
/// (<scène>.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
|
||||
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
|
||||
/// </summary>
|
||||
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
|
||||
@@ -295,7 +339,7 @@ internal sealed class Capture
|
||||
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
|
||||
scroller.ScrollToVerticalOffset(target);
|
||||
await SettleAsync(160);
|
||||
Save(Render(root), $"{record.Id}.p{page}.png", record);
|
||||
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
|
||||
if (target >= scroller.ScrollableHeight) break;
|
||||
}
|
||||
scroller.ScrollToVerticalOffset(original);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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é,
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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,6 +29,18 @@ internal sealed class SnapshotOptions
|
||||
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
|
||||
public bool Scroll { get; private set; } = true;
|
||||
|
||||
/// <summary>Format des images : « png » (défaut) ou « webp » (format des images du site vitrine).</summary>
|
||||
public string Format { get; private set; } = FormatPng;
|
||||
|
||||
/// <summary>Qualité WebP, 1 à 100 (100 : sans perte). Ignorée en PNG (toujours sans perte).</summary>
|
||||
public int Quality { get; private set; } = 85;
|
||||
|
||||
public const string FormatPng = "png";
|
||||
public const string FormatWebp = "webp";
|
||||
|
||||
/// <summary>Extension des images produites, avec le point (« .png », « .webp »).</summary>
|
||||
public string ImageExtension => "." + Format;
|
||||
|
||||
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
|
||||
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
|
||||
|
||||
@@ -52,11 +64,14 @@ internal sealed class SnapshotOptions
|
||||
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
|
||||
--no-scroll pas de pages supplémentaires pour les zones qui défilent
|
||||
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
|
||||
--format <png|webp> format des images : « png » (défaut, sans perte) ou « webp » (format des
|
||||
images du site vitrine, website\GameLauncher.Website\wwwroot\img)
|
||||
--quality <1-100> qualité WebP (défaut 85 ; 100 = sans perte) ; ignorée en PNG
|
||||
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
|
||||
--open ouvre index.html à la fin
|
||||
--help cette aide
|
||||
|
||||
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
|
||||
Résultat : une image par scène (PNG ou WebP), index.html (planche de toutes les captures) et report.md
|
||||
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
|
||||
""";
|
||||
|
||||
@@ -117,6 +132,19 @@ internal sealed class SnapshotOptions
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "--format":
|
||||
var format = Next().Trim().TrimStart('.').ToLowerInvariant();
|
||||
if (format is not (FormatPng or FormatWebp))
|
||||
throw new ArgumentException($"Format inconnu : « {format} » (png ou webp).");
|
||||
options.Format = format;
|
||||
break;
|
||||
case "--quality":
|
||||
var qualityText = Next();
|
||||
if (!int.TryParse(qualityText, NumberStyles.None, CultureInfo.InvariantCulture, out var quality) ||
|
||||
quality < 1 || quality > 100)
|
||||
throw new ArgumentException($"Qualité invalide : « {qualityText} » (1 à 100).");
|
||||
options.Quality = quality;
|
||||
break;
|
||||
case "--out":
|
||||
case "-o":
|
||||
options.OutputDirectory = Next();
|
||||
|
||||
@@ -113,7 +113,7 @@ internal sealed class SnapshotRunner
|
||||
var marker = Path.Combine(output, MarkerFile);
|
||||
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
|
||||
{
|
||||
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
|
||||
foreach (var pattern in new[] { "*.png", "*.webp", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Option « format webp » de l'outil : Windows (WIC) lit le WebP mais ne sait pas l'écrire. SkiaSharp (maintenu par Microsoft,
|
||||
licence MIT) encode les captures en WebP. Outil de développement uniquement : le launcher, la Launcher API
|
||||
et le site ne dépendent pas de ce paquet.
|
||||
-->
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="3.119.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
|
||||
|
||||
@@ -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.
|
||||
@@ -7,8 +7,41 @@ REM 3. (optionnel) l'envoie sur le VPS avec WinSCP
|
||||
REM
|
||||
REM Avant de lancer : augmentez <Version> dans src\GameLauncher\GameLauncher.csproj
|
||||
REM et ecrivez les nouveautes dans tools\notes-launcher.txt.
|
||||
REM
|
||||
REM Parametres (facultatifs, depuis une invite de commandes) :
|
||||
REM publish-launcher-update.bat version normale (double-clic)
|
||||
REM publish-launcher-update.bat --priority version PRIORITAIRE (correctif critique) :
|
||||
REM installee au demarrage MEME chez les joueurs qui ont desactive la mise a jour
|
||||
REM automatique du launcher. Leur reglage (self-update.json) n'est pas modifie.
|
||||
REM Reprise par les publications suivantes : un launcher plus ancien que cette
|
||||
REM version se met a jour quoi qu'il arrive. Confirmation demandee.
|
||||
REM publish-launcher-update.bat --clear-priority ne reprend pas la version prioritaire
|
||||
REM deja publiee (priorite publiee par erreur).
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set PRIORITY=
|
||||
|
||||
:args
|
||||
if "%~1"=="" goto :argsdone
|
||||
if /i "%~1"=="--priority" goto :opt_priority
|
||||
if /i "%~1"=="--clear-priority" goto :opt_clear
|
||||
if /i "%~1"=="--help" goto :usage
|
||||
if "%~1"=="/?" goto :usage
|
||||
echo Parametre inconnu : %~1
|
||||
goto :usage
|
||||
:opt_priority
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--priority
|
||||
REM shift /1 : %0 (donc %~dp0) reste inchange
|
||||
shift /1
|
||||
goto :args
|
||||
:opt_clear
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--clear-priority
|
||||
shift /1
|
||||
goto :args
|
||||
:argsdone
|
||||
|
||||
set ROOT=%~dp0..
|
||||
set KEY=%USERPROFILE%\.launcher-keys\launcher-signing.key
|
||||
set OUTPUT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\Updates
|
||||
@@ -16,6 +49,18 @@ REM Script WinSCP (laisser vide pour envoyer les fichiers a la main) :
|
||||
set WINSCP_SCRIPT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\publier-launcher-update.txt
|
||||
set WINSCP="C:\Program Files (x86)\WinSCP\WinSCP.com"
|
||||
|
||||
if /i "%PRIORITY%"=="--priority" (
|
||||
echo ***************************************************************************
|
||||
echo Version PRIORITAIRE : installee au demarrage meme chez les joueurs qui ont
|
||||
echo desactive la mise a jour automatique du launcher. Leur reglage ne change pas.
|
||||
echo ***************************************************************************
|
||||
echo.
|
||||
)
|
||||
if /i "%PRIORITY%"=="--clear-priority" (
|
||||
echo Version normale : la version prioritaire deja publiee n'est PAS reprise.
|
||||
echo.
|
||||
)
|
||||
|
||||
echo === 1/3 Compilation du launcher ===
|
||||
dotnet publish "%ROOT%\src\GameLauncher\GameLauncher.csproj" -c Release -p:PublishProfile=win-x64
|
||||
if errorlevel 1 goto :error
|
||||
@@ -28,6 +73,7 @@ dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -
|
||||
--config "%ROOT%\src\GameLauncher\launcher.json" ^
|
||||
--public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub" ^
|
||||
--notes "%~dp0notes-launcher.txt" ^
|
||||
%PRIORITY% ^
|
||||
--output "%OUTPUT%"
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
@@ -52,3 +98,15 @@ echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:conflict
|
||||
echo --priority et --clear-priority sont incompatibles.
|
||||
exit /b 1
|
||||
|
||||
:usage
|
||||
echo.
|
||||
echo Utilisation : publish-launcher-update.bat [--priority ^| --clear-priority]
|
||||
echo --priority version prioritaire (correctif critique) : installee meme si le
|
||||
echo joueur a desactive la mise a jour automatique (reglage conserve)
|
||||
echo --clear-priority ne reprend pas la version prioritaire deja publiee
|
||||
exit /b 1
|
||||
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Publie le SITE VITRINE pour le VPS (Linux x64, autonome : aucun .NET a
|
||||
REM installer sur le serveur) et prepare les fichiers a envoyer :
|
||||
REM Publish\Site\lanisys-site.tar.gz site + service systemd
|
||||
REM Publish\Site\installer-site.sh script d'installation / mise a jour
|
||||
REM Guide complet : website\deploy\LISEZMOI.md
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set ROOT=%~dp0..
|
||||
set OUT=%ROOT%\Publish\Site
|
||||
set STAGE=%OUT%\lanisys-site
|
||||
|
||||
if exist "%STAGE%" rmdir /s /q "%STAGE%"
|
||||
if exist "%OUT%\lanisys-site.tar.gz" del /q "%OUT%\lanisys-site.tar.gz"
|
||||
set HASH=
|
||||
|
||||
echo === 1/2 Compilation (linux-x64) ===
|
||||
dotnet publish "%ROOT%\website\GameLauncher.Website\GameLauncher.Website.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
|
||||
if errorlevel 1 goto :error
|
||||
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
|
||||
|
||||
echo.
|
||||
echo === 2/2 Archive ===
|
||||
mkdir "%STAGE%\deploy"
|
||||
copy /Y "%ROOT%\website\deploy\lanisys-site.service" "%STAGE%\deploy\" >nul
|
||||
copy /Y "%ROOT%\website\deploy\installer-site.sh" "%OUT%\" >nul
|
||||
tar -czf "%OUT%\lanisys-site.tar.gz" -C "%STAGE%" .
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
REM Empreinte SHA-256 : verifiee par installer-site.sh (archive alteree = refusee)
|
||||
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\lanisys-site.tar.gz').Hash.ToLower()"') do set HASH=%%h
|
||||
if "%HASH%"=="" goto :error
|
||||
> "%OUT%\lanisys-site.tar.gz.sha256" echo %HASH%
|
||||
|
||||
echo.
|
||||
echo Pret. Envoi sur le VPS du site (compte admin) :
|
||||
echo scp "%OUT%\lanisys-site.tar.gz" "%OUT%\installer-site.sh" admin@^<VPS du site^>:/tmp/
|
||||
echo Puis, sur le VPS :
|
||||
echo sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz %HASH%
|
||||
echo (empreinte aussi enregistree dans lanisys-site.tar.gz.sha256)
|
||||
explorer "%OUT%"
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -2,7 +2,7 @@
|
||||
rem ============================================================================
|
||||
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
|
||||
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
|
||||
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
|
||||
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
|
||||
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
|
||||
rem - index.html : planche de toutes les captures ;
|
||||
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
|
||||
@@ -12,6 +12,7 @@ rem Exemples :
|
||||
rem tools\ui-snapshot.bat
|
||||
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
|
||||
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
|
||||
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
|
||||
rem tools\ui-snapshot.bat --list
|
||||
rem ============================================================================
|
||||
setlocal
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Bienvenue sur le site de LanisysGames
|
||||
date: 2026-11-10
|
||||
summary: Nos jeux, le launcher et les nouvelles du studio ont maintenant leur adresse.
|
||||
topic: Studio
|
||||
image: /img/launcher/planete.webp
|
||||
imageAlt: Planète aux anneaux sur un ciel violet, l'illustration du launcher.
|
||||
---
|
||||
Jusqu'ici, il fallait passer par le launcher pour suivre nos jeux. Ce site rassemble désormais tout au même endroit :
|
||||
|
||||
- **le launcher**, à télécharger en un clic, avec les nouveautés de sa dernière version ;
|
||||
- **Exovival Zombie Reborn**, son histoire, ce qui vous attend dans l'école Jean-Moulin, et toutes ses [notes de version](/jeux/exovival-zombie-reborn/notes-de-version) ;
|
||||
- **les actualités** du studio, comme celle-ci.
|
||||
|
||||
## Un site discret
|
||||
|
||||
Pas de compte, pas de cookie, pas de mesure d'audience : le site ne vous suit pas. Les polices et les images sont servies par le site lui-même, sans passer par un service externe. Tout est expliqué sur la page [Confidentialité](/confidentialite).
|
||||
|
||||
## Une question, une idée ?
|
||||
|
||||
Le bouton « Nous contacter » ouvre votre messagerie. Pour un bug dans un jeu, le bouton **Aide** du launcher reste le plus pratique : il joint automatiquement la version du jeu, et vous pouvez ajouter des captures d'écran.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Exovival Zombie Reborn 1.8 : les zombies vous entendent"
|
||||
date: 2026-11-01
|
||||
summary: Huit zombies rôdent dès le début de la partie, ils réagissent au bruit de vos pas, et de petits soins à fabriquer soi-même font leur apparition.
|
||||
topic: Exovival Zombie Reborn
|
||||
theme: exovival
|
||||
image: /img/exovival/vhs-gymnase.webp
|
||||
imageAlt: Le gymnase de l'école, image extraite d'une cassette VHS du jeu.
|
||||
---
|
||||
La version 1.8 remplit l'école de zombies, leur donne de l'oreille et rééquilibre les armes. Merci aux bêta-testeurs pour leurs retours !
|
||||
|
||||
## L'école n'est plus vide
|
||||
|
||||
Huit zombies rôdent dès le début de la partie : autour du car scolaire, dans la cour de service, sur le toit bas, au gymnase et à l'étage. Plus lents et moins forts que ceux qui vous traquent pendant l'histoire, ils se distancent en marchant. Mais attention aux groupes : quand l'un d'eux attaque, ceux d'à côté accourent.
|
||||
|
||||
## Faites moins de bruit
|
||||
|
||||
- Marcher fait du bruit, courir s'entend de beaucoup plus loin, même dans le dos d'un zombie.
|
||||
- Un mur ou une porte fermée étouffe vos pas.
|
||||
- **Accroupi** (touche Ctrl), vous êtes silencieux : de quoi approcher un zombie par derrière.
|
||||
|
||||
## Des armes plus lisibles
|
||||
|
||||
Chaque arme fait toujours les mêmes dégâts, et viser la tête les double. La hache reste la plus puissante : un seul coup à la tête suffit.
|
||||
|
||||
## Se soigner à la Resident Evil
|
||||
|
||||
Des herbes et des huiles médicinales sont cachées dans l'école. Mélangez-les dans l'inventaire pour obtenir une potion bien plus efficace. Une note de l'infirmière, quelque part dans les toilettes, vous explique tout.
|
||||
|
||||
Le détail complet est dans les [notes de version](/jeux/exovival-zombie-reborn/notes-de-version). La mise à jour s'installe automatiquement avec le launcher.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Launcher 3.4 : les correctifs importants arrivent chez tout le monde"
|
||||
date: 2026-11-10
|
||||
summary: Une mise à jour critique du launcher s'installe désormais même si vous avez désactivé la mise à jour automatique. Votre réglage, lui, ne change pas.
|
||||
topic: Launcher
|
||||
image: /img/launcher/apercu-exovival.webp
|
||||
imageAlt: Le launcher ouvert sur Exovival Zombie Reborn.
|
||||
---
|
||||
Vous pouvez choisir, dans les paramètres du launcher, de ne pas le mettre à jour automatiquement. C'est votre droit, et ça le reste. Mais si une faille de sécurité devait un jour être corrigée, il faut que le correctif arrive chez tout le monde.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
- Une version du launcher peut être publiée comme **prioritaire**. Elle s'installe au démarrage, même avec la mise à jour automatique désactivée.
|
||||
- **Votre réglage n'est pas modifié** : une fois le correctif installé, les versions normales suivantes ne s'installeront toujours pas toutes seules.
|
||||
- La fenêtre de mise à jour affiche une pastille « Prioritaire ». « Plus tard » reste possible : le launcher réessaiera au prochain démarrage.
|
||||
|
||||
## Et la sécurité ?
|
||||
|
||||
L'indication « prioritaire » fait partie du contenu signé de la mise à jour : un serveur piraté ne pourrait ni imposer une mise à jour, ni retirer la priorité d'un correctif. Comme avant, seule une version signée et plus récente peut s'installer.
|
||||
|
||||
Le launcher se met à jour tout seul. Pour une première installation, il se [télécharge ici](/launcher#telecharger).
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Site vitrine (LanisysGames) : présentation des jeux et du launcher, téléchargement du launcher,
|
||||
notes de version, actualités. ASP.NET Core Razor Pages, sans base de données, sans formulaire,
|
||||
sans cookie, sans paquet NuGet tiers.
|
||||
|
||||
Isolé de la Launcher API : le site ne l'appelle jamais. Il lit seulement, côté serveur et en
|
||||
arrière-plan, les fichiers publics launcher-update.json (signature vérifiée avec la même clé
|
||||
publique que le launcher) et manifest.json de chaque jeu (notes de version).
|
||||
Voir website\deploy\LISEZMOI.md.
|
||||
|
||||
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
|
||||
tools\publish-site.bat
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Website</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Website</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>Site vitrine LanisysGames</Product>
|
||||
<!-- Pas de dépendance à libicu sur le VPS (comme la Launcher API) : les dates sont mises en forme
|
||||
en français par le site lui-même (Services\FrenchText.cs). -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<!-- Pas de web.config (IIS) dans la publication : le site tourne derrière nginx. -->
|
||||
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Notes de version : même modèle, mêmes bornes et même nettoyage que le launcher. -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
<!-- launcher-update.json : même vérification de signature que le launcher (UpdateSignature). -->
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Clé publique de mise à jour du launcher, INTÉGRÉE à l'exécutable (comme dans le launcher) :
|
||||
elle ne peut pas être remplacée par un fichier déposé sur le serveur. -->
|
||||
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Security\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Actualités (Markdown) : publiées avec le site, lues au démarrage. -->
|
||||
<None Update="Content\**\*.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// En-têtes de sécurité de toutes les réponses, et refus de tout ce qui n'est pas une lecture.
|
||||
///
|
||||
/// Politique de sécurité du contenu (CSP) stricte : scripts, styles, images et polices du site
|
||||
/// uniquement, aucun script en ligne, aucune ressource externe (ni police Google, ni statistiques),
|
||||
/// aucun formulaire, aucune intégration du site dans un cadre. Le site n'a pas de cookie.
|
||||
/// </summary>
|
||||
public static class SecurityHeaders
|
||||
{
|
||||
public const string ContentSecurityPolicy =
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; " +
|
||||
"manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; " +
|
||||
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||
|
||||
public static IApplicationBuilder UseSiteSecurityHeaders(this IApplicationBuilder app) => app.Use(async (context, next) =>
|
||||
{
|
||||
var request = context.Request;
|
||||
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
|
||||
{
|
||||
// Site en lecture seule : ni formulaire, ni API.
|
||||
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
|
||||
context.Response.Headers.Allow = "GET, HEAD";
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers.ContentSecurityPolicy = ContentSecurityPolicy;
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
headers["Permissions-Policy"] =
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), " +
|
||||
"payment=(), usb=(), interest-cohort=(), browsing-topics=()";
|
||||
headers["Cross-Origin-Opener-Policy"] = "same-origin";
|
||||
headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||
headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
||||
headers.Remove("Server");
|
||||
headers.Remove("X-Powered-By");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Xml.Linq;
|
||||
using GameLauncher.Website.Options;
|
||||
using GameLauncher.Website.Services;
|
||||
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>Plan du site (sitemap.xml) pour les moteurs de recherche.</summary>
|
||||
public static class Sitemap
|
||||
{
|
||||
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
||||
|
||||
public static string Build(SiteOptions options, NewsService news)
|
||||
{
|
||||
var root = options.PublicUrl.TrimEnd('/');
|
||||
var paths = new List<(string Path, string? LastModified)>
|
||||
{
|
||||
("/", null), ("/launcher", null), ("/jeux", null), ("/actualites", null),
|
||||
("/mentions-legales", null), ("/confidentialite", null)
|
||||
};
|
||||
foreach (var game in options.VisibleGames)
|
||||
{
|
||||
paths.Add(($"/jeux/{game.Slug}", null));
|
||||
if (!string.IsNullOrWhiteSpace(game.ManifestUrl)) paths.Add(($"/jeux/{game.Slug}/notes-de-version", null));
|
||||
}
|
||||
paths.AddRange(news.All.Select(a => ($"/actualites/{a.Slug}", (string?)FrenchText.IsoDate(a.Date))));
|
||||
|
||||
var document = new XDocument(new XDeclaration("1.0", "utf-8", null),
|
||||
new XElement(Ns + "urlset",
|
||||
paths.Select(p => new XElement(Ns + "url",
|
||||
new XElement(Ns + "loc", root + p.Path),
|
||||
p.LastModified is null ? null : new XElement(Ns + "lastmod", p.LastModified)))));
|
||||
return document.Declaration + "\n" + document.Root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameLauncher.Website.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle du certificat du serveur du launcher : EXACTEMENT la règle par défaut de .NET (aucune erreur
|
||||
/// acceptée), avec en plus, en cas de refus, le détail dans le journal du site (nom attendu, certificat
|
||||
/// présenté, maillons de la chaîne et leurs erreurs). Sans ce détail, .NET ne dit que « The SSL connection
|
||||
/// could not be established ».
|
||||
/// </summary>
|
||||
public static class TlsDiagnostics
|
||||
{
|
||||
public static RemoteCertificateValidationCallback Create(ILogger logger) => (sender, certificate, chain, errors) =>
|
||||
{
|
||||
if (errors == SslPolicyErrors.None) return true;
|
||||
|
||||
var host = (sender as SslStream)?.TargetHostName ?? "?";
|
||||
var links = chain?.ChainElements.Select((e, i) =>
|
||||
$"[{i}] {e.Certificate.Subject} (émis par {e.Certificate.Issuer}, jusqu'au {e.Certificate.NotAfter:yyyy-MM-dd})" +
|
||||
(e.ChainElementStatus.Length == 0 ? "" : " : " + string.Join(", ", e.ChainElementStatus.Select(s => s.Status))))
|
||||
.ToList() ?? new List<string>();
|
||||
var chainStatus = chain?.ChainStatus.Select(s => s.Status.ToString()).Distinct().ToList() ?? new List<string>();
|
||||
|
||||
logger.LogError(
|
||||
"Certificat de {Host} REFUSÉ ({Errors}). Chaîne : {Status}. Maillons reçus et reconstruits :{Links}{Hint}",
|
||||
host, errors, chainStatus.Count == 0 ? "—" : string.Join(", ", chainStatus),
|
||||
links.Count == 0 ? " aucun" : "\n " + string.Join("\n ", links),
|
||||
chainStatus.Contains(nameof(X509ChainStatusFlags.PartialChain))
|
||||
? "\n → Chaîne incomplète : le serveur n'envoie pas le certificat intermédiaire. Sur le VPS du launcher, " +
|
||||
"nginx doit utiliser fullchain.pem (et non cert.pem) dans ssl_certificate (voir website/deploy/LISEZMOI.md, Dépannage)."
|
||||
: "");
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace GameLauncher.Website.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Réglages du site (section « Site » d'appsettings.json, complétée sur le VPS par
|
||||
/// appsettings.Production.json, conservé d'une mise à jour à l'autre).
|
||||
/// </summary>
|
||||
public sealed class SiteOptions
|
||||
{
|
||||
public const string Section = "Site";
|
||||
|
||||
/// <summary>Adresse publique du site (https), sans « / » final : liens absolus (Open Graph, plan du site).</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Nom du studio, affiché dans l'en-tête et le pied de page.</summary>
|
||||
public string StudioName { get; set; } = "LanisysGames";
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du bouton « Nous contacter » (lien mailto: : le joueur écrit avec sa propre messagerie,
|
||||
/// le site ne reçoit ni ne stocke rien). Vide : bouton masqué.
|
||||
/// </summary>
|
||||
public string ContactEmail { get; set; } = "";
|
||||
|
||||
public LauncherOptions Launcher { get; set; } = new();
|
||||
|
||||
public List<GameOptions> Games { get; set; } = new();
|
||||
|
||||
public RemoteOptions Remote { get; set; } = new();
|
||||
|
||||
public LegalOptions Legal { get; set; } = new();
|
||||
|
||||
/// <summary>Jeux affichés (masqués exclus), dans l'ordre du fichier.</summary>
|
||||
public IEnumerable<GameOptions> VisibleGames => Games.Where(g => g.Visible);
|
||||
|
||||
public GameOptions? FindGame(string? slug) =>
|
||||
string.IsNullOrEmpty(slug) ? null : VisibleGames.FirstOrDefault(g => string.Equals(g.Slug, slug, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public sealed class LauncherOptions
|
||||
{
|
||||
/// <summary>Nom du launcher affiché sur le site.</summary>
|
||||
public string Name { get; set; } = "Launcher LanisysGames";
|
||||
|
||||
/// <summary>
|
||||
/// launcher-update.json (https) : version, exécutable à télécharger et notes de version, SIGNÉS.
|
||||
/// Le site n'affiche le bouton de téléchargement que si la signature est valide.
|
||||
/// </summary>
|
||||
public string UpdateUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de produit attendu dans launcher-update.json (« launcherId », ligne « Produit » de
|
||||
/// LauncherPublisher verify). Vide : non contrôlé (le produit lu est inscrit au journal du site).
|
||||
/// </summary>
|
||||
public string Product { get; set; } = "";
|
||||
|
||||
/// <summary>Page « Publications » de Gitea : historique complet des versions du launcher. Vide : lien masqué.</summary>
|
||||
public string ReleasesUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sections des notes du launcher qui ne concernent pas les joueurs (serveur, développement…),
|
||||
/// retirées de l'affichage. Comparaison sans tenir compte de la casse ni des accents.
|
||||
/// </summary>
|
||||
public List<string> HiddenSections { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GameOptions
|
||||
{
|
||||
/// <summary>Adresse de la page : /jeux/<slug> (lettres minuscules, chiffres, tirets).</summary>
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Thème de la page : « exovival », « multi » ou « launcher ».</summary>
|
||||
public string Theme { get; set; } = "launcher";
|
||||
|
||||
/// <summary>« available » (disponible dans le launcher) ou « upcoming » (en préparation).</summary>
|
||||
public string Status { get; set; } = "upcoming";
|
||||
|
||||
public bool Visible { get; set; } = true;
|
||||
|
||||
/// <summary>Identifiant du jeu (« gameId » du manifest), contrôlé à la lecture du manifest.</summary>
|
||||
public string GameId { get; set; } = "";
|
||||
|
||||
/// <summary>manifest.json du jeu (https). Vide : pas de notes de version.</summary>
|
||||
public string ManifestUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Accroche (cartes de l'accueil et de la liste des jeux).</summary>
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
/// <summary>Présentation courte d'un jeu en préparation (page générique).</summary>
|
||||
public string Teaser { get; set; } = "";
|
||||
|
||||
/// <summary>Image de la carte (chemin local commençant par /img/).</summary>
|
||||
public string CardImage { get; set; } = "";
|
||||
|
||||
public string CardImageAlt { get; set; } = "";
|
||||
|
||||
public bool IsAvailable => string.Equals(Status, "available", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Page dédiée (Pages/Jeux/<…>.cshtml) plutôt que la page générique.</summary>
|
||||
public bool HasDedicatedPage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RemoteOptions
|
||||
{
|
||||
/// <summary>Intervalle entre deux lectures des fichiers JSON (minutes).</summary>
|
||||
public int RefreshMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Délai maximal d'une lecture (secondes).</summary>
|
||||
public int TimeoutSeconds { get; set; } = 20;
|
||||
|
||||
/// <summary>Taille maximale d'un manifest.json (Mo) : il contient la liste de tous les fichiers du jeu.</summary>
|
||||
public int MaxManifestMegabytes { get; set; } = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Développement uniquement : autorise http://localhost (fichiers de test servis en local).
|
||||
/// Ignoré en production (https obligatoire).
|
||||
/// </summary>
|
||||
public bool AllowLoopbackHttp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LegalOptions
|
||||
{
|
||||
/// <summary>Éditeur du site (nom ou raison sociale).</summary>
|
||||
public string Publisher { get; set; } = "";
|
||||
|
||||
/// <summary>Statut et coordonnées de l'éditeur (adresse, SIRET… ou mention « éditeur non professionnel »).</summary>
|
||||
public List<string> PublisherDetails { get; set; } = new();
|
||||
|
||||
/// <summary>Directeur de la publication.</summary>
|
||||
public string Director { get; set; } = "";
|
||||
|
||||
/// <summary>Hébergeur du site (nom, adresse, téléphone).</summary>
|
||||
public List<string> Host { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de conservation des journaux du serveur web, en jours (page « Confidentialité »).</summary>
|
||||
public int LogRetentionDays { get; set; } = 14;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Options;
|
||||
|
||||
/// <summary>Contrôle des réglages au démarrage : un réglage incorrect arrête le site avec un message clair.</summary>
|
||||
public sealed partial class SiteOptionsValidator : IValidateOptions<SiteOptions>
|
||||
{
|
||||
public static readonly string[] Themes = { "launcher", "exovival", "multi" };
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, SiteOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.PublicUrl) &&
|
||||
(!Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var site) || site.Scheme is not ("https" or "http") ||
|
||||
options.PublicUrl.EndsWith('/')))
|
||||
errors.Add("Site:PublicUrl doit être une adresse absolue, sans « / » final (ex. https://www.lanisys.fr).");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ContactEmail) && !EmailRegex().IsMatch(options.ContactEmail))
|
||||
errors.Add("Site:ContactEmail n'est pas une adresse e-mail simple (ex. contact@lanisys.fr).");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Launcher.ReleasesUrl) &&
|
||||
(!Uri.TryCreate(options.Launcher.ReleasesUrl, UriKind.Absolute, out var releases) || releases.Scheme != "https"))
|
||||
errors.Add("Site:Launcher:ReleasesUrl doit être une adresse https.");
|
||||
|
||||
var slugs = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var game in options.Games)
|
||||
{
|
||||
game.Slug ??= "";
|
||||
if (!SlugRegex().IsMatch(game.Slug))
|
||||
errors.Add($"Site:Games : « {game.Slug} » n'est pas un slug valide (minuscules, chiffres, tirets).");
|
||||
else if (!slugs.Add(game.Slug))
|
||||
errors.Add($"Site:Games : slug « {game.Slug} » en double.");
|
||||
if (string.IsNullOrWhiteSpace(game.Name))
|
||||
errors.Add($"Site:Games : nom manquant pour « {game.Slug} ».");
|
||||
if (!Themes.Contains(game.Theme))
|
||||
errors.Add($"Site:Games : thème inconnu « {game.Theme} » pour « {game.Slug} » ({string.Join(", ", Themes)}).");
|
||||
if (!string.IsNullOrWhiteSpace(game.CardImage) && !game.CardImage.StartsWith("/img/", StringComparison.Ordinal))
|
||||
errors.Add($"Site:Games : l'image de « {game.Slug} » doit être un fichier du site (/img/…).");
|
||||
}
|
||||
|
||||
if (options.Remote.RefreshMinutes < 1)
|
||||
errors.Add("Site:Remote:RefreshMinutes doit valoir au moins 1.");
|
||||
|
||||
return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
|
||||
private static partial Regex SlugRegex();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")]
|
||||
private static partial Regex EmailRegex();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@page "/actualites/{slug:maxlength(80)}"
|
||||
@model ArticleModel
|
||||
@{
|
||||
var article = Model.Article;
|
||||
ViewData["Nav"] = "actualites";
|
||||
ViewData["Theme"] = article.Theme;
|
||||
ViewData["Title"] = article.Title;
|
||||
ViewData["Description"] = article.Summary;
|
||||
ViewData["OgType"] = "article";
|
||||
if (article.Image is not null && article.Image.EndsWith(".jpg", StringComparison.Ordinal)) ViewData["OgImage"] = article.Image;
|
||||
}
|
||||
|
||||
<article>
|
||||
<header class="article-head">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p class="news-date">
|
||||
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
|
||||
{<text>, </text><span class="news-topic">@article.Topic</span>}
|
||||
</p>
|
||||
<h1>@article.Title</h1>
|
||||
<p class="lead">@article.Summary</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap wrap-narrow">
|
||||
@if (article.Image is not null)
|
||||
{
|
||||
<figure class="article-cover"><img src="@article.Image" alt="@(article.ImageAlt ?? "")" width="1280" height="720"></figure>
|
||||
}
|
||||
<div class="prose">
|
||||
@* HTML produit par SafeMarkdown : tout le texte de l'article y est encodé, seules ses balises existent. *@
|
||||
@Html.Raw(article.Html)
|
||||
</div>
|
||||
<a class="article-back" href="/actualites">Toutes les actualités</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Website.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Actualites;
|
||||
|
||||
public sealed class ArticleModel : PageModel
|
||||
{
|
||||
private readonly NewsService _news;
|
||||
|
||||
public ArticleModel(NewsService news) => _news = news;
|
||||
|
||||
public NewsArticle Article { get; private set; } = null!;
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
// Recherche dans les articles chargés au démarrage : l'adresse n'atteint jamais le disque.
|
||||
var article = _news.Find(slug);
|
||||
if (article is null) return NotFound();
|
||||
Article = article;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@page "/actualites"
|
||||
@inject NewsService News
|
||||
@{
|
||||
ViewData["Nav"] = "actualites";
|
||||
ViewData["Title"] = "Actualités";
|
||||
ViewData["Description"] = "Les nouvelles des jeux et du launcher : sorties, mises à jour, coulisses.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap">
|
||||
<h1>Actualités</h1>
|
||||
<p class="lead">Sorties, mises à jour et coulisses de nos jeux et du launcher.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap">
|
||||
@if (News.All.Count == 0)
|
||||
{
|
||||
<p class="notice">Aucune actualité pour le moment. Les nouveautés des jeux sont aussi dans leurs notes de version.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<partial name="_NewsList" model="News.All" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
@page "/confidentialite"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Title"] = "Confidentialité";
|
||||
ViewData["Description"] = "Ce que le site enregistre (presque rien) et pourquoi.";
|
||||
Uri.TryCreate(site.Launcher.UpdateUrl, UriKind.Absolute, out var launcherServer);
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap wrap-narrow legal">
|
||||
<h1>Confidentialité</h1>
|
||||
<p class="lead">Ce site ne vous demande rien et ne vous suit pas : pas de compte, pas de formulaire, pas de cookie, pas de mesure d'audience, pas de publicité, aucun service externe (les polices et les images sont servies par le site lui-même).</p>
|
||||
|
||||
<h2>Journaux du serveur</h2>
|
||||
<p>Comme tout serveur web, le nôtre note chaque requête : adresse IP, date, page demandée, navigateur. Ces journaux servent uniquement à la sécurité du site (repérer une attaque, un abus) et au diagnostic des pannes. Ils sont effacés au bout de @site.Legal.LogRetentionDays jours et ne sont ni croisés avec d'autres données, ni transmis à qui que ce soit, sauf obligation légale.</p>
|
||||
<p>Base légale : notre intérêt légitime à protéger le site (article 6.1.f du RGPD).</p>
|
||||
|
||||
<h2>Téléchargement du launcher</h2>
|
||||
<p>Le bouton « Télécharger le launcher » vous envoie directement vers notre serveur de téléchargement@(launcherServer is null ? "" : $" ({launcherServer.Host})"), qui tient lui aussi un journal technique dans les mêmes conditions.</p>
|
||||
|
||||
<h2>Nous contacter</h2>
|
||||
<p>Le bouton « Nous contacter » ouvre votre propre messagerie. Votre message et votre adresse e-mail ne passent pas par ce site : nous ne les utilisons que pour vous répondre, et les supprimons quand l'échange est terminé.</p>
|
||||
|
||||
<h2>Vos droits</h2>
|
||||
<p>Vous pouvez demander l'accès à vos données, leur rectification ou leur effacement@(string.IsNullOrWhiteSpace(site.ContactEmail) ? "" : " en écrivant à ")@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
|
||||
{<a href="mailto:@site.ContactEmail">@site.ContactEmail</a>}. Vous pouvez aussi adresser une réclamation à la CNIL (<a href="https://www.cnil.fr" rel="noopener noreferrer">cnil.fr</a>).</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/erreur/{code:int?}"
|
||||
@model ErreurModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Code == 404 ? "Page introuvable" : "Erreur";
|
||||
}
|
||||
|
||||
<section class="error-page">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p class="error-code" aria-hidden="true">@Model.Code</p>
|
||||
@if (Model.Code == 404)
|
||||
{
|
||||
<h1>Cette page n'existe pas.</h1>
|
||||
<p class="lead">L'adresse est peut-être mal saisie, ou la page a été déplacée.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Le site a rencontré un problème.</h1>
|
||||
<p class="lead">Réessayez dans un instant. Si le problème continue, écrivez-nous.</p>
|
||||
}
|
||||
<p><a class="btn btn-primary" href="/">Revenir à l'accueil</a></p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace GameLauncher.Website.Pages;
|
||||
|
||||
/// <summary>Page d'erreur (404, 500…) : aucun détail technique n'est montré au visiteur.</summary>
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public sealed class ErreurModel : PageModel
|
||||
{
|
||||
public int Code { get; private set; } = 500;
|
||||
|
||||
public void OnGet(int? code)
|
||||
{
|
||||
Code = code is >= 400 and <= 599 ? code.Value : 500;
|
||||
// Accès direct à /erreur/… : garder le code d'erreur dans la réponse.
|
||||
if (HttpContext.Features.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>() is null)
|
||||
Response.StatusCode = Code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@page
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject NewsService News
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Nav"] = "accueil";
|
||||
ViewData["Theme"] = "launcher";
|
||||
ViewData["Description"] = $"{site.StudioName}, studio indépendant : Exovival Zombie Reborn et le launcher pour installer, mettre à jour et lancer nos jeux sur Windows.";
|
||||
var available = site.VisibleGames.Where(g => g.IsAvailable).ToList();
|
||||
var upcoming = site.VisibleGames.Where(g => !g.IsAvailable).ToList();
|
||||
var latest = News.Latest(3).ToList();
|
||||
}
|
||||
|
||||
<section class="home-hero">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<h1>Nos jeux s'installent en un clic.</h1>
|
||||
<p class="lead">Le launcher @site.StudioName installe nos jeux, les garde à jour et vérifie chaque fichier. Téléchargez-le une fois : il s'occupe du reste, et de lui-même.</p>
|
||||
<partial name="_LauncherDownload" />
|
||||
</div>
|
||||
<figure class="hero-shot">
|
||||
<img src="/img/launcher/apercu-exovival.webp" width="1168" height="708"
|
||||
alt="Le launcher ouvert sur Exovival Zombie Reborn : bouton Installer, taille du téléchargement et notes de version.">
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Nos jeux</h2>
|
||||
<a href="/jeux">Tous les jeux</a>
|
||||
</div>
|
||||
|
||||
@foreach (var game in available)
|
||||
{
|
||||
<a class="game-feature" href="/jeux/@game.Slug">
|
||||
<div class="game-feature-media">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CardImage))
|
||||
{
|
||||
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
|
||||
}
|
||||
</div>
|
||||
<div class="game-feature-body">
|
||||
<span class="status">Disponible dans le launcher</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
<span class="game-feature-link">Découvrir le jeu</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@foreach (var game in upcoming)
|
||||
{
|
||||
<a class="game-teaser" href="/jeux/@game.Slug">
|
||||
<div>
|
||||
<span class="status">En préparation</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (latest.Count > 0)
|
||||
{
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Actualités</h2>
|
||||
<a href="/actualites">Toutes les actualités</a>
|
||||
</div>
|
||||
<partial name="_NewsList" model="latest" />
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@page "/jeux/exovival-zombie-reborn"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
const string Slug = "exovival-zombie-reborn";
|
||||
var game = SiteOptionsMonitor.CurrentValue.FindGame(Slug);
|
||||
var snapshot = Store.GetGame(Slug);
|
||||
var latest = snapshot?.Changelog.FirstOrDefault();
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = "exovival";
|
||||
ViewData["Title"] = "Exovival Zombie Reborn";
|
||||
ViewData["Description"] = "Exovival Zombie Reborn : survival horror à la première personne. La ville a été évacuée, votre fille Léa manque à l'appel. Retournez seul dans l'école plongée dans le noir.";
|
||||
}
|
||||
|
||||
<section class="exo-hero" data-uv-hero>
|
||||
<div class="exo-hero-photo">
|
||||
<img src="/img/exovival/couloir.webp" srcset="/img/exovival/couloir-960.webp 960w, /img/exovival/couloir.webp 1920w" sizes="100vw"
|
||||
width="1920" height="1080" alt="" fetchpriority="high">
|
||||
</div>
|
||||
<div class="exo-uv" role="img" aria-label="Écrit à l'encre invisible sur le mur : Léa ♥ Papa."></div>
|
||||
<div class="exo-torch"></div>
|
||||
<div class="wrap">
|
||||
<p class="exo-mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#biohazard" /></svg>
|
||||
Survival horror à la première personne
|
||||
</p>
|
||||
<h1 class="exo-title">Exovival Zombie Reborn</h1>
|
||||
<p class="lead">La ville a été évacuée en pleine nuit. À l'appel du point de rassemblement, une élève manquait : Léa, dix ans, votre fille. Vous retournez seul à l'école Jean-Moulin. Plus de courant, des portes condamnées, et quelque chose qui gratte dans les couloirs.</p>
|
||||
<div class="exo-actions">
|
||||
<a class="btn btn-primary btn-large" href="/launcher#telecharger">@Html.Icon("Download") Jouer avec le launcher</a>
|
||||
<a class="btn btn-ghost btn-large" href="/jeux/@Slug/notes-de-version">Notes de version</a>
|
||||
</div>
|
||||
<p class="uv-hint">@Html.Icon("Flashlight") Promenez votre lampe UV sur le mur : Léa vous a laissé un message.</p>
|
||||
</div>
|
||||
<svg width="0" height="0" class="visually-hidden" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<mask id="biohazard-crescents" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||
<rect width="24" height="24" fill="#fff" />
|
||||
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
|
||||
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="12" cy="12.8" r="3.9" fill="#000" />
|
||||
</mask>
|
||||
<mask id="biohazard-ring" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||
<rect width="24" height="24" fill="#fff" />
|
||||
<circle cx="12" cy="12.8" r="2.2" fill="#000" />
|
||||
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
|
||||
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
|
||||
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
|
||||
</mask>
|
||||
<symbol id="biohazard" viewBox="0 0 24 24">
|
||||
<g fill="currentColor" mask="url(#biohazard-crescents)">
|
||||
<circle cx="12" cy="7.8" r="5.3" />
|
||||
<circle cx="16.33" cy="15.3" r="5.3" />
|
||||
<circle cx="7.67" cy="15.3" r="5.3" />
|
||||
</g>
|
||||
<circle cx="12" cy="12.8" r="3.3" fill="currentColor" mask="url(#biohazard-ring)" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap exo-story">
|
||||
<div class="prose">
|
||||
<p>Le soir de l'épidémie, tout le quartier a été évacué. Les cars sont partis, les portes ont été condamnées. Au point de rassemblement, on a fait l'appel classe par classe. En CM1, il manquait une élève : Léa.</p>
|
||||
<p>Vous revenez seul, de nuit, avec une lampe torche et ce que vous trouverez sur place. L'école Jean-Moulin n'a plus de courant. Des pas résonnent quelque part à l'étage.</p>
|
||||
<p>Léa et vous avez un jeu secret : des messages à l'encre invisible, écrits avec le stylo espion que vous lui avez offert pour son anniversaire. Elle vous a laissé une piste. Il faut la suivre jusqu'au bout.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Ceux qui étaient là</h2>
|
||||
<ul class="cast">
|
||||
<li><b>Léa</b><span>Dix ans, en CM1. Votre fille.</span></li>
|
||||
<li><b>Mme Roussel</b><span>Sa maîtresse, qui filme la classe au caméscope.</span></li>
|
||||
<li><b>Mme Garnier</b><span>La directrice. Elle a laissé un mot dans la classe.</span></li>
|
||||
<li><b>M. Bernard</b><span>Le concierge. Il connaît chaque recoin et en garde les clés.</span></li>
|
||||
<li><b>L'agent de sécurité</b><span>De garde la nuit de l'évacuation.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Ce qui vous attend</h2>
|
||||
<div class="exo-features">
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Flashlight") Le noir, et peu de piles</h3>
|
||||
<p>Lampe torche, briquet, bougie, lanterne à pétrole : chaque source de lumière s'épuise. Pour rallumer l'école, il faudra trouver des fusibles, régler une armoire électrique et remettre en marche un groupe électrogène qui consomme son carburant.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Puzzle") Des énigmes à l'ancienne</h3>
|
||||
<p>Digicodes, cadenas, coffres à combinaison, crochetage, labyrinthe à bille, circuit électrique à réparer, leviers à actionner dans le bon ordre. Les indices se cachent dans les papiers, sur les cassettes, et sur les murs, à la lampe UV.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Cassette") Cassettes, caméscope et caméras</h3>
|
||||
<p>Le magnétoscope de la salle des profs relit les cassettes de l'école. Le caméscope de Mme Roussel voit dans le noir. Une fois le courant revenu, les caméras de surveillance montrent des pièces où l'on ne peut pas entrer.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Ear") Les zombies vous entendent</h3>
|
||||
<p>Marcher fait du bruit, courir s'entend de loin, même dans leur dos ; un mur ou une porte fermée étouffe vos pas. Accroupi, vous êtes silencieux. Cachez-vous dans un casier, observez en vous penchant, contournez. Et attention aux groupes : un zombie qui attaque attire ceux d'à côté.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Axe") Couteau, hache, pistolet</h3>
|
||||
<p>Chaque arme a ses dégâts, et viser la tête les double. Un coup de hache bien placé suffit ; au pistolet, on vise avec les organes de visée et on compte ses balles.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("Medical") Se soigner à l'ancienne</h3>
|
||||
<p>Herbes et huiles médicinales à ramasser, à mélanger en potion dans l'inventaire. La trousse de secours reste le meilleur remède. Rien n'est gaspillé : un soin ne s'utilise pas quand vous êtes en pleine forme.</p>
|
||||
</div>
|
||||
<div class="exo-feature">
|
||||
<h3>@Html.Icon("History") Sauvegardes</h3>
|
||||
<p>Jusqu'à dix sauvegardes, plus des sauvegardes automatiques aux moments clés. Après une mort, « Recommencer » reprend à la dernière. Le jeu est en français et en anglais.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Cassettes retrouvées</h2>
|
||||
<p class="lead">Des images enregistrées dans l'école, avant et pendant la nuit de l'évacuation. Elles sont dans le jeu, sur les cassettes à glisser dans le magnétoscope.</p>
|
||||
<div class="tapes">
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-couloir-nuit.webp" width="640" height="480" loading="lazy" alt="Couloir de l'école filmé en vision nocturne verte, horodatage du 12 octobre 2026 à 21 h 47."></div>
|
||||
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">Le couloir, en vision nocturne.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-escalier.webp" width="640" height="480" loading="lazy" alt="Cage d'escalier filmée en vision nocturne, vue plongeante."></div>
|
||||
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">L'escalier, caméra à la main.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-bibliotheque.webp" width="640" height="480" loading="lazy" alt="La bibliothèque de l'école éclairée, rayonnages et tables, image VHS datée du 19 décembre 2025."></div>
|
||||
<figcaption><span class="tape-label">Spectacle de Noël</span><span class="tape-text">La bibliothèque, quand tout allait bien.</span></figcaption>
|
||||
</figure>
|
||||
<figure class="tape">
|
||||
<div class="tape-screen"><img src="/img/exovival/vhs-gymnase.webp" width="640" height="480" loading="lazy" alt="Le gymnase de l'école en plein jour, parquet et paniers de basket, image VHS datée du 3 mars 2026."></div>
|
||||
<figcaption><span class="tape-label">EPS</span><span class="tape-text">Le gymnase, un matin de mars.</span></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="abri">
|
||||
<div class="abri-head">
|
||||
<span class="abri-tag">En développement</span>
|
||||
<h2>Sous l'école, l'abri</h2>
|
||||
<p>La prochaine extension du niveau ouvre ce que la ville avait muré : un abri de défense passive inauguré en 1962, le parking souterrain de l'ancien commissariat sur trois niveaux, et les égouts de 1890 qui courent sous le quartier. Au programme : de nouvelles énigmes, le stand de tir de la police, des caméras de surveillance et les enregistrements laissés par ceux qui sont passés avant vous.</p>
|
||||
<p class="small">Images de développement : elles peuvent encore changer avant la sortie.</p>
|
||||
</div>
|
||||
<div class="abri-grid">
|
||||
<figure><img src="/img/exovival/abri-collecteur.webp" width="1280" height="720" loading="lazy" alt="Galerie d'égout voûtée, cerclée de conduites métalliques."><figcaption>Le collecteur</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-camera.webp" width="1280" height="720" loading="lazy" alt="Image d'une caméra de surveillance : CAM 11, égouts, grand collecteur."><figcaption>Caméra 11</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-decantation.webp" width="1280" height="720" loading="lazy" alt="Salle de décantation circulaire avec une fosse noyée au centre."><figcaption>La salle de décantation</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-parking.webp" width="1280" height="720" loading="lazy" alt="Parking souterrain vide, piliers de béton, éclairage de secours."><figcaption>Le parking, niveau -1</figcaption></figure>
|
||||
<figure><img src="/img/exovival/abri-grand-collecteur.webp" width="1280" height="720" loading="lazy" alt="Grand tunnel d'égout en briques avec une rigole d'eau au centre."><figcaption>Le grand collecteur</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<h2>Dernière mise à jour</h2>
|
||||
<a href="/jeux/@Slug/notes-de-version">Toutes les notes de version</a>
|
||||
</div>
|
||||
@if (latest is not null)
|
||||
{
|
||||
<div class="changelog">
|
||||
<partial name="_Release" model="latest" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2>Pour jouer</h2>
|
||||
<dl class="specs">
|
||||
@if (snapshot is not null)
|
||||
{
|
||||
<div class="spec"><dt>Version</dt><dd>@snapshot.Version</dd></div>
|
||||
@if (snapshot.DownloadSize > 0)
|
||||
{
|
||||
<div class="spec"><dt>Téléchargement</dt><dd>@FrenchText.Size(snapshot.DownloadSize)</dd></div>
|
||||
}
|
||||
@if (snapshot.InstalledSize > 0)
|
||||
{
|
||||
<div class="spec"><dt>Espace disque</dt><dd>@FrenchText.Size(snapshot.InstalledSize)</dd></div>
|
||||
}
|
||||
}
|
||||
<div class="spec"><dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd></div>
|
||||
<div class="spec"><dt>Graphismes</dt><dd>DirectX 11</dd></div>
|
||||
<div class="spec"><dt>Langues</dt><dd>Français, anglais</dd></div>
|
||||
</dl>
|
||||
<div class="section-tight">
|
||||
<p class="lead">Exovival Zombie Reborn s'installe et se met à jour avec le launcher. Téléchargez-le, choisissez le jeu, cliquez sur Installer.</p>
|
||||
<partial name="_LauncherDownload" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
@page "/jeux/{slug:maxlength(80)}"
|
||||
@model GameModel
|
||||
@* Page générique d'un jeu sans page dédiée (jeu en préparation, par exemple). *@
|
||||
@{
|
||||
var game = Model.Game;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = game.Theme;
|
||||
ViewData["Title"] = game.Name;
|
||||
ViewData["Description"] = string.IsNullOrWhiteSpace(game.Tagline) ? game.Name : game.Tagline;
|
||||
}
|
||||
|
||||
<section class="upcoming">
|
||||
<div class="wrap">
|
||||
<span class="status">@(game.IsAvailable ? "Disponible dans le launcher" : "En préparation")</span>
|
||||
<h1>@game.Name</h1>
|
||||
<p class="lead">@(string.IsNullOrWhiteSpace(game.Teaser) ? game.Tagline : game.Teaser)</p>
|
||||
@if (game.IsAvailable)
|
||||
{
|
||||
<partial name="_LauncherDownload" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">En attendant, <a href="/actualites">suivez les actualités</a> ou installez le launcher : le jeu y apparaîtra dès sa sortie.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Website.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Jeux;
|
||||
|
||||
public sealed class GameModel : PageModel
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteOptions> _options;
|
||||
|
||||
public GameModel(IOptionsMonitor<SiteOptions> options) => _options = options;
|
||||
|
||||
public GameOptions Game { get; private set; } = null!;
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
var game = _options.CurrentValue.FindGame(slug);
|
||||
if (game is null) return NotFound();
|
||||
// Les jeux qui ont leur propre page (Pages/Jeux/<Jeu>.cshtml) y sont servis directement.
|
||||
if (game.HasDedicatedPage) return NotFound();
|
||||
Game = game;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@page "/jeux"
|
||||
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
var site = SiteOptionsMonitor.CurrentValue;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Title"] = "Nos jeux";
|
||||
ViewData["Description"] = $"Les jeux de {site.StudioName}, à installer avec le launcher.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap">
|
||||
<h1>Nos jeux</h1>
|
||||
<p class="lead">Tous nos jeux s'installent et se mettent à jour avec le launcher. Un seul téléchargement, pour tous.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap">
|
||||
@foreach (var game in site.VisibleGames.Where(g => g.IsAvailable))
|
||||
{
|
||||
var snapshot = Store.GetGame(game.Slug);
|
||||
<a class="game-feature" href="/jeux/@game.Slug">
|
||||
<div class="game-feature-media">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CardImage))
|
||||
{
|
||||
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
|
||||
}
|
||||
</div>
|
||||
<div class="game-feature-body">
|
||||
<span class="status">@(snapshot is null ? "Disponible dans le launcher" : $"Version {snapshot.Version} dans le launcher")</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
<span class="game-feature-link">Découvrir le jeu</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@foreach (var game in site.VisibleGames.Where(g => !g.IsAvailable))
|
||||
{
|
||||
<a class="game-teaser" href="/jeux/@game.Slug">
|
||||
<div>
|
||||
<span class="status">En préparation</span>
|
||||
<h3>@game.Name</h3>
|
||||
<p>@game.Tagline</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
@page "/jeux/{slug:maxlength(80)}/notes-de-version"
|
||||
@model NotesModel
|
||||
@inject RemoteContentStore Store
|
||||
@{
|
||||
var game = Model.Game;
|
||||
var snapshot = Model.Snapshot;
|
||||
ViewData["Nav"] = "jeux";
|
||||
ViewData["Theme"] = game.Theme;
|
||||
ViewData["Title"] = $"Notes de version – {game.Name}";
|
||||
ViewData["Description"] = $"Toutes les nouveautés et corrections de {game.Name}, version par version.";
|
||||
}
|
||||
|
||||
<section class="page-hero">
|
||||
<div class="wrap wrap-narrow">
|
||||
<p><a href="/jeux/@game.Slug">@game.Name</a></p>
|
||||
<h1>Notes de version</h1>
|
||||
@if (snapshot is not null)
|
||||
{
|
||||
<p class="lead">Version actuelle : <strong>@snapshot.Version</strong>@(snapshot.ReleaseDate != default ? $", publiée le {FrenchText.Date(snapshot.ReleaseDate)}" : "").
|
||||
Le launcher affiche les mêmes notes et installe la mise à jour pour vous.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="wrap wrap-narrow">
|
||||
@if (snapshot is null)
|
||||
{
|
||||
<p class="notice">@(Store.FirstRefreshDone ? "Les notes de version sont momentanément indisponibles. Réessayez dans quelques minutes." : "Les notes de version se chargent. Rechargez la page dans quelques secondes.")</p>
|
||||
}
|
||||
else if (snapshot.Changelog.Count == 0)
|
||||
{
|
||||
<p class="notice">Aucune note de version publiée pour le moment.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="changelog">
|
||||
@foreach (var entry in snapshot.Changelog)
|
||||
{
|
||||
<partial name="_Release" model="entry" view-data='new ViewDataDictionary(ViewData) { ["Anchor"] = "v" + System.Text.RegularExpressions.Regex.Replace(entry.Version, "[^A-Za-z0-9._-]", "") }' />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
using GameLauncher.Website.Options;
|
||||
using GameLauncher.Website.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Website.Pages.Jeux;
|
||||
|
||||
/// <summary>Toutes les notes de version d'un jeu (manifest.json lu par le site, jamais par le navigateur).</summary>
|
||||
public sealed class NotesModel : PageModel
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteOptions> _options;
|
||||
private readonly RemoteContentStore _store;
|
||||
|
||||
public NotesModel(IOptionsMonitor<SiteOptions> options, RemoteContentStore store)
|
||||
{
|
||||
_options = options;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public GameOptions Game { get; private set; } = null!;
|
||||
public GameSnapshot? Snapshot { get; private set; }
|
||||
|
||||
public IActionResult OnGet(string slug)
|
||||
{
|
||||
var game = _options.CurrentValue.FindGame(slug);
|
||||
if (game is null || string.IsNullOrWhiteSpace(game.ManifestUrl)) return NotFound();
|
||||
Game = game;
|
||||
Snapshot = _store.GetGame(game.Slug);
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 <time>.</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/<slug>.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
|
||||
///  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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 44 KiB |