diff --git a/MaBibli.Api/Endpoints/VersionEndpoints.cs b/MaBibli.Api/Endpoints/VersionEndpoints.cs
new file mode 100644
index 0000000..9296b5c
--- /dev/null
+++ b/MaBibli.Api/Endpoints/VersionEndpoints.cs
@@ -0,0 +1,38 @@
+using System.Reflection;
+using MaBibli.Shared.Dtos;
+
+namespace MaBibli.Api.Endpoints;
+
+///
+/// Ce que l'application sait d'elle-même : sa version publiée et sa date de build.
+///
+///
+/// C'est le serveur qui répond, et non le client Blazor, bien que les deux soient
+/// compilés du même coup : lire ses propres attributs depuis le WebAssembly dépendrait de ce que
+/// le trimmer veut bien conserver, alors qu'ici la lecture est certaine. La page « À propos »
+/// affiche donc la version du binaire réellement déployé — ce qu'on veut savoir en diagnostiquant
+/// un appareil.
+///
+public static class VersionEndpoints
+{
+ // Lue une fois : les attributs d'un assembly ne changent pas en cours d'exécution.
+ private static readonly VersionApplication Version = Lire(typeof(VersionEndpoints).Assembly);
+
+ public static IEndpointRouteBuilder MapVersionEndpoints(this IEndpointRouteBuilder routes)
+ {
+ routes.MapGet("/api/version", () => Results.Ok(Version))
+ .WithName("VersionApplication")
+ .WithTags("Version")
+ .WithSummary("Version publiée et date de build, ou « rien » pour un binaire non publié.")
+ .Produces();
+
+ return routes;
+ }
+
+ /// Extrait les deux valeurs de l'assembly, sans rien interpréter lui-même.
+ public static VersionApplication Lire(Assembly assembly) =>
+ VersionApplication.Depuis(
+ assembly.GetCustomAttribute()?.InformationalVersion,
+ assembly.GetCustomAttributes()
+ .FirstOrDefault(a => a.Key == "MaBibliDateBuild")?.Value);
+}
diff --git a/MaBibli.Api/MaBibli.Api.csproj b/MaBibli.Api/MaBibli.Api.csproj
index 4273be2..ff76f7f 100644
--- a/MaBibli.Api/MaBibli.Api.csproj
+++ b/MaBibli.Api/MaBibli.Api.csproj
@@ -16,6 +16,20 @@
+
+
+
+
+
diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs
index d0acd93..6e39083 100644
--- a/MaBibli.Api/Program.cs
+++ b/MaBibli.Api/Program.cs
@@ -126,6 +126,7 @@ app.MapRevuesEndpoints();
app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints();
app.MapCouverturesEndpoints();
+app.MapVersionEndpoints();
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées
// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor
index a3ce45b..f7fc280 100644
--- a/MaBibli.Client/Layout/MainLayout.razor
+++ b/MaBibli.Client/Layout/MainLayout.razor
@@ -99,6 +99,19 @@
RevuesPrêtsEnvies
+
+ @*
+ « À propos » n'est PAS une septième destination : c'est une annexe, et elle est
+ visuellement détachée pour cela. Les six entrées ci-dessus sont la bibliothèque ; celle-ci
+ porte le manuel, le contact, la version et la licence — on y va une fois, pas tous les
+ jours. Mise sur le même rang, elle diluerait une navigation qu'on venait justement de
+ reprendre. Elle reste dans le menu plutôt qu'en pied de page : sur téléphone, un pied de
+ page vit sous une liste de trois cents livres.
+
+ ⚠️ Elle reste active hors-ligne, comme les six autres : la version qu'on vient y lire est
+ justement ce qu'on cherche quand quelque chose ne va pas.
+ *@
+ À propos
diff --git a/MaBibli.Client/Pages/APropos.razor b/MaBibli.Client/Pages/APropos.razor
new file mode 100644
index 0000000..1bd3b88
--- /dev/null
+++ b/MaBibli.Client/Pages/APropos.razor
@@ -0,0 +1,150 @@
+@page "/a-propos"
+@inject ServiceLivresApi Api
+@inject EtatReseau Reseau
+@implements IDisposable
+
+@*
+ Ce que l'application dit d'elle-même : à qui écrire, où trouver de l'aide, quelle version
+ tourne réellement, et sous quelle licence elle est distribuée.
+
+ ⚠️ Ce n'est pas une page d'ornement. Elle porte la version, c'est-à-dire la première chose
+ qu'on demande à quelqu'un dont l'application se comporte bizarrement — le projet a déjà connu
+ un appareil au cache dépareillé, où seule une version affichée aurait tranché. Et elle porte
+ le lien vers la source, que l'AGPL v3 attend qu'un service en réseau offre à ses utilisateurs.
+*@
+
+MaBibli — à propos
+
+
À propos
+
+
+
Manuel
+
+ Un manuel d'utilisation est à venir. En attendant, chaque écran porte ses
+ explications à l'endroit où elles servent.
+
+
+
+
+
Contact
+ @*
+ ⚠️ Le « mailto: » reste un lien même hors-ligne, et c'est délibéré : il ne charge aucune
+ page, il passe la main au client de messagerie de l'appareil, lequel sait mettre un
+ message en attente d'envoi. Le désactiver empêcherait précisément d'écrire « ça ne marche
+ pas, je n'ai plus de réseau ».
+ *@
+
+ Hors ligne : c'est la dernière version annoncée par le serveur, pas forcément
+ celle qui y tourne en ce moment.
+
+ }
+ }
+ else
+ {
+ @*
+ ⚠️ Ne JAMAIS afficher ici le « 1.0.0 » que le SDK .NET pose par défaut : il se lirait
+ comme une vraie version. Mieux vaut ne rien annoncer que d'annoncer un faux numéro,
+ surtout sur la valeur qui sert à diagnostiquer.
+ *@
+
+ Version de développement : ce binaire n'a pas été produit par la chaîne de
+ publication, il n'a donc pas de numéro de version à annoncer.
+
+ }
+
+
+
+
Licence
+
+ MaBibli est distribuée sous licence GNU AGPL v3.
+
+ @*
+ Le lien vers le dépôt n'est pas un ornement : l'AGPL demande que les utilisateurs d'un
+ service accessible par le réseau puissent en obtenir le code source.
+ *@
+ @if (Reseau.EnLigne)
+ {
+
+ Son code source est disponible à l'adresse @Depot
+
+
+ }
+
+
+@code {
+ /// Site de l'auteur, ouvert dans une autre fenêtre.
+ private const string Site = "https://www.limonier.be";
+
+ /// Dépôt du code — ce que l'AGPL attend qu'on rende accessible.
+ private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
+
+ private VersionApplication? _version;
+
+ protected override async Task OnInitializedAsync()
+ {
+ Reseau.Change += SurChangementReseau;
+ _version = await Api.ObtenirVersionAsync();
+ }
+
+ ///
+ /// Au retour du réseau, la version se redemande : hors-ligne on affichait la dernière connue.
+ ///
+ private void SurChangementReseau() => _ = InvokeAsync(async () =>
+ {
+ _version = await Api.ObtenirVersionAsync();
+ StateHasChanged();
+ });
+
+ private static string Quand(DateTimeOffset instant)
+ {
+ var local = instant.ToLocalTime();
+ return $"le {local:dd/MM/yyyy} à {local:HH:mm}";
+ }
+
+ public void Dispose() => Reseau.Change -= SurChangementReseau;
+}
diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs
index d67d670..c8f3b96 100644
--- a/MaBibli.Client/Services/CacheHorsLigne.cs
+++ b/MaBibli.Client/Services/CacheHorsLigne.cs
@@ -19,6 +19,17 @@ public static class ClesCache
public const string Utilisateur = "utilisateur";
+ ///
+ /// La version publiée et sa date de build, telles que le serveur les a annoncées.
+ ///
+ ///
+ /// ⚠️ Elle vaut la peine d'être rangée précisément parce qu'on la lit quand quelque chose ne
+ /// va pas — et un appareil dont on soupçonne le cache est aussi bien celui qui n'a plus de
+ /// réseau. Ce qui est affiché hors-ligne est la dernière version vue du serveur, et
+ /// l'écran le dit : c'est un fait daté, pas une supposition.
+ ///
+ public const string Version = "version";
+
///
/// La liste d'envies de l'utilisateur courant.
///
diff --git a/MaBibli.Client/Services/RemonteeRoutes.cs b/MaBibli.Client/Services/RemonteeRoutes.cs
index d983a71..4ebb81c 100644
--- a/MaBibli.Client/Services/RemonteeRoutes.cs
+++ b/MaBibli.Client/Services/RemonteeRoutes.cs
@@ -76,6 +76,10 @@ public static class RemonteeRoutes
("/auteurs", Racine),
("/prets", Racine),
+
+ // « À propos » est une annexe du menu : elle remonte au catalogue, comme toute racine de
+ // branche. Elle n'a pas d'enfants, et n'en aura pas.
+ ("/a-propos", Racine),
("/not-found", Racine),
];
diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs
index d89d138..cb43f0e 100644
--- a/MaBibli.Client/Services/ServiceLivresApi.cs
+++ b/MaBibli.Client/Services/ServiceLivresApi.cs
@@ -53,6 +53,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerSeriesAsync(ct);
await ListerRevuesAsync(ct);
await ObtenirUtilisateurAsync(ct);
+ await ObtenirVersionAsync(ct);
return reseau.EnLigne;
}
catch (OperationCanceledException)
@@ -459,6 +460,30 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
}
+ ///
+ /// Version publiée du serveur, avec repli sur la dernière connue de cet appareil.
+ ///
+ ///
+ /// Même forme que . Hors-ligne on rend l'instantané
+ /// plutôt que rien : c'est un fait daté (« voilà ce que le serveur annonçait »), et c'est
+ /// justement quand l'application se comporte mal qu'on veut ce numéro. L'écran distingue les
+ /// deux cas ; ici on ne fabrique aucune valeur.
+ ///
+ public async Task ObtenirVersionAsync(CancellationToken ct = default)
+ {
+ var (ok, version) = await EssayerAsync(
+ () => http.GetFromJsonAsync("api/version", Json, ct));
+
+ if (ok && version is not null)
+ {
+ await MemoriserAsync(ClesCache.Version, version);
+ return version;
+ }
+
+ var instantane = await LireCacheAsync(ClesCache.Version);
+ return instantane?.Donnees ?? VersionApplication.Developpement;
+ }
+
///
/// Exécute un appel de lecture. Renvoie false — sans exception — quand le réseau
/// manque, ce qui est la bascule vers le cache. Une annulation demandée par l'appelant,
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 5cfcfe7..26c5c11 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -1486,6 +1486,19 @@ body {
border-left-color: #f5d76e;
}
+/*
+ « À propos » est une annexe, pas une septième destination : un filet la sépare des six
+ entrées de la bibliothèque, et elle se porte en retrait. Elle reste un lien de plein droit —
+ même cible tactile, même état actif — parce qu'on la touche au pouce comme les autres.
+*/
+.menu-lien-annexe {
+ margin-top: 0.35rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
+ color: rgba(232, 237, 243, 0.8);
+ font-weight: 400;
+ font-size: 0.9rem;
+}
+
/*
À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus
de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css`
@@ -1526,6 +1539,16 @@ body {
background: rgba(255, 255, 255, 0.12);
border-bottom-color: #f5d76e;
}
+
+ /* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge
+ supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */
+ .menu-lien-annexe {
+ margin-top: 0;
+ margin-left: 0.4rem;
+ padding-left: 0.9rem;
+ border-top: 0;
+ border-left: 1px solid rgba(255, 255, 255, 0.25);
+ }
}
/* --- Attente (lot L1) -------------------------------------------------
diff --git a/MaBibli.Shared/Dtos/VersionApplication.cs b/MaBibli.Shared/Dtos/VersionApplication.cs
new file mode 100644
index 0000000..11b1e34
--- /dev/null
+++ b/MaBibli.Shared/Dtos/VersionApplication.cs
@@ -0,0 +1,62 @@
+namespace MaBibli.Shared.Dtos;
+
+///
+/// Version publiée de l'application, et date à laquelle son binaire a été produit.
+///
+///
+///
+/// ⚠️ Mieux vaut ne rien afficher qu'un numéro faux. Sans injection par la chaîne de
+/// publication, le SDK .NET pose 1.0.0 dans tout assembly : ce numéro se lirait comme une
+/// vraie version alors qu'il ne désigne rien. Or c'est précisément la valeur qu'on ira lire pour
+/// diagnostiquer un appareil dont le cache est dépareillé — s'y tromper coûterait le diagnostic.
+///
+///
+/// D'où le témoin retenu : l'horodatage de build. Il n'existe que si
+/// build/publier-release.sh (dépôt mabibli_ynh) l'a posé, et le script pose les deux
+/// valeurs ensemble. Sans lui, la version lue est celle du SDK et n'est pas rendue : l'application
+/// se déclare « version de développement », ce qui est vrai.
+///
+///
+public record VersionApplication
+{
+ /// Numéro publié (« 0.4.0 »), ou null pour un binaire non publié.
+ public string? Numero { get; init; }
+
+ /// Instant de production du binaire, en UTC. null hors publication.
+ public DateTimeOffset? DateBuild { get; init; }
+
+ /// Vrai quand le binaire vient bien de la chaîne de publication.
+ public bool Publiee => Numero is { Length: > 0 } && DateBuild is not null;
+
+ /// Un binaire compilé à la main : il n'a pas de version à annoncer.
+ public static readonly VersionApplication Developpement = new();
+
+ ///
+ /// Interprète les deux valeurs lues dans l'assembly. Fonction pure, pour être
+ /// éprouvable sans fabriquer d'assembly.
+ ///
+ ///
+ /// AssemblyInformationalVersion. ⚠️ Le SDK y ajoute parfois +empreinte (source
+ /// link, SourceRevisionId) : la partie utile est ce qui précède le +.
+ ///
+ /// Métadonnée MaBibliDateBuild, au format ISO 8601 en UTC.
+ public static VersionApplication Depuis(string? versionInformative, string? horodatage)
+ {
+ // Le témoin d'abord : sans horodatage, rien n'a été injecté, et la version lue est celle
+ // que le SDK invente. On ne la rend donc pas, même si elle est présente.
+ if (!DateTimeOffset.TryParse(
+ horodatage,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
+ out var date))
+ {
+ return Developpement;
+ }
+
+ var numero = versionInformative?.Split('+')[0].Trim();
+
+ return numero is { Length: > 0 }
+ ? new VersionApplication { Numero = numero, DateBuild = date }
+ : Developpement;
+ }
+}
diff --git a/MaBibli.Tests/RemonteeRoutesTests.cs b/MaBibli.Tests/RemonteeRoutesTests.cs
index a2536fc..015cc52 100644
--- a/MaBibli.Tests/RemonteeRoutesTests.cs
+++ b/MaBibli.Tests/RemonteeRoutesTests.cs
@@ -37,6 +37,8 @@ public class RemonteeRoutesTests
[InlineData("/auteurs", "/")]
// Le reste du menu
[InlineData("/prets", "/")]
+ // L'annexe du menu
+ [InlineData("/a-propos", "/")]
[InlineData("/", "/")]
public void Remonte_dun_cran(string chemin, string attendu) =>
Assert.Equal(attendu, RemonteeRoutes.Parent(chemin));
diff --git a/MaBibli.Tests/VersionApplicationTests.cs b/MaBibli.Tests/VersionApplicationTests.cs
new file mode 100644
index 0000000..52cdff0
--- /dev/null
+++ b/MaBibli.Tests/VersionApplicationTests.cs
@@ -0,0 +1,95 @@
+using System.Reflection;
+using MaBibli.Api.Endpoints;
+using MaBibli.Shared.Dtos;
+
+namespace MaBibli.Tests;
+
+///
+/// La version affichée par « À propos » est ce qu'on demandera à quelqu'un dont l'application se
+/// comporte mal : elle doit être exacte ou absente, jamais approximative.
+///
+public class VersionApplicationTests
+{
+ [Fact]
+ public void Version_et_horodatage_injectes_donnent_une_version_publiee()
+ {
+ var version = VersionApplication.Depuis("0.4.0", "2026-08-21T10:30:00Z");
+
+ Assert.True(version.Publiee);
+ Assert.Equal("0.4.0", version.Numero);
+ Assert.Equal(
+ new DateTimeOffset(2026, 8, 21, 10, 30, 0, TimeSpan.Zero),
+ version.DateBuild!.Value.ToUniversalTime());
+ }
+
+ ///
+ /// ⚠️ Le cas qui justifie tout le mécanisme : sans injection, le SDK .NET pose « 1.0.0 »
+ /// dans l'assembly. Ce numéro se lirait comme une vraie version alors qu'il ne désigne rien.
+ ///
+ [Fact]
+ public void Sans_horodatage_le_numero_du_SDK_nest_PAS_annonce()
+ {
+ var version = VersionApplication.Depuis("1.0.0", null);
+
+ Assert.False(version.Publiee);
+ Assert.Null(version.Numero);
+ Assert.Null(version.DateBuild);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("pas une date")]
+ public void Un_horodatage_illisible_vaut_absence(string horodatage) =>
+ Assert.False(VersionApplication.Depuis("0.4.0", horodatage).Publiee);
+
+ /// Un horodatage sans version n'est pas une version : on n'invente pas le numéro.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Sans_numero_rien_nest_annonce(string? numero) =>
+ Assert.False(VersionApplication.Depuis(numero, "2026-08-21T10:30:00Z").Publiee);
+
+ ///
+ /// Le SDK ajoute parfois « +empreinte » à l'InformationalVersion (SourceRevisionId) : seule
+ /// la partie qui précède le « + » est un numéro de version lisible par un humain.
+ ///
+ [Fact]
+ public void L_empreinte_de_commit_est_retiree() =>
+ Assert.Equal(
+ "0.4.0",
+ VersionApplication.Depuis("0.4.0+9a3f21c", "2026-08-21T10:30:00Z").Numero);
+
+ /// Une date locale injectée est ramenée en UTC, comme toutes les dates du projet.
+ [Fact]
+ public void L_horodatage_est_ramene_en_UTC() =>
+ Assert.Equal(
+ new DateTimeOffset(2026, 8, 21, 8, 30, 0, TimeSpan.Zero),
+ VersionApplication.Depuis("0.4.0", "2026-08-21T10:30:00+02:00")!.DateBuild!.Value.ToUniversalTime());
+
+ ///
+ /// La lecture des attributs est branchée sur le bon assembly. Les tests ne sont pas compilés
+ /// par la chaîne de publication : elle doit donc rendre « version de développement », ce qui
+ /// est exactement le comportement attendu d'un binaire compilé à la main.
+ ///
+ [Fact]
+ public void La_lecture_de_lassembly_ne_leve_rien_et_ne_publie_rien()
+ {
+ var version = VersionEndpoints.Lire(typeof(VersionEndpoints).Assembly);
+
+ Assert.False(version.Publiee);
+ }
+
+ /// Une métadonnée bien nommée est bien lue, quel que soit l'assembly qui la porte.
+ [Fact]
+ public void La_metadonnee_MaBibliDateBuild_est_celle_qui_est_lue()
+ {
+ var assembly = typeof(VersionApplicationTests).Assembly;
+
+ // Aucun assembly de test ne la porte : la lecture doit rendre null, pas la première
+ // métadonnée venue (elles sont nombreuses, posées par le SDK).
+ Assert.Null(assembly.GetCustomAttributes()
+ .FirstOrDefault(a => a.Key == "MaBibliDateBuild"));
+ }
+}
diff --git a/README.md b/README.md
index 2b676cd..6642b4a 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,17 @@ archive `.tar.gz` reproductible, calcule son `sha256`, et met à jour `version`,
`amd64.url` et `amd64.sha256` dans `mabibli_ynh/manifest.toml` — sauf avec
`--no-manifest-update`.
+Il injecte au passage, dans l'assembly, la **version** (`-p:Version`) et un
+**horodatage de build** (`-p:MaBibliDateBuild`), que la page « À propos » de
+l'application affiche via `GET /api/version`.
+
+⚠️ **L'horodatage est le témoin de l'injection** : sans lui, l'application se déclare
+« version de développement » plutôt que d'afficher le `1.0.0` que le SDK .NET pose par
+défaut. Ce numéro-là se lirait comme une vraie version alors qu'il ne désigne rien — or
+c'est précisément la valeur qu'on va chercher pour diagnostiquer un appareil dont le
+cache est dépareillé. Un `dotnet build` local n'annonce donc aucune version, et c'est
+voulu.
+
⚠️ YunoHost lit le manifeste **depuis Gitea**, jamais une copie locale : un manifeste
corrigé mais non poussé n'existe pas pour le serveur. C'est précisément ce que
`publier.sh` empêche d'oublier.