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 @@ Revues Prêts Envies + + @* + « À 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 ». + *@ +

info@limonier.be

+ + @* + Le site, lui, est une vraie navigation vers l'extérieur : hors-ligne elle finirait sur la + page d'erreur du navigateur, hors de l'application. Un ne se désactivant pas, il + bascule en bouton inerte portant sa raison — même règle que les liens d'export des envies. + *@ + @if (Reseau.EnLigne) + { +

+ www.limonier.be +

+ } + else + { +

+ +

+ } +
+ +
+

Version

+ @if (_version is null) + { +

Chargement…

+ } + else if (_version.Publiee) + { +

+ @_version.Numero + — compilée @Quand(_version.DateBuild!.Value) +

+ + @if (!Reseau.EnLigne) + { +

+ 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 : + @Depot +

+ } + else + { +

+ 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.