Files
mabibli/MaBibli.Client/Services/EtatReseau.cs
T
mathieuandClaude Opus 5 ebc5f95d49 Consulter la bibliotheque hors-ligne depuis un cache IndexedDB
Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le
navigateur alors que SQLite vit sur le serveur, et le service worker ne met en
cache que les assets. Sans travail explicite, l'application demarre hors-ligne
et affiche une bibliotheque vide.

- js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des
  bascules online/offline. Aucune logique metier.
- CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau
  combinant navigator.onLine (fiable seulement par sa negation) avec le sort
  reel des appels HTTP.
- ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont
  refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est
  ce qui rend la recherche hors-ligne possible sur tout le fonds.
- FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui
  confronte les deux implementations sur les memes donnees.
- Interface : pastille et bandeau d'etat avec la date de synchronisation, et
  actions d'ecriture desactivees avec leur raison plutot que boutons morts.
- js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker
  est le seul cache-busting du projet. L'enregistrement journalise desormais ses
  echecs, et un bandeau propose la nouvelle version sans attendre la fermeture
  de tous les onglets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:19 +02:00

123 lines
5.1 KiB
C#

using Microsoft.JSInterop;
namespace MaBibli.Client.Services;
/// <summary>
/// État du réseau, tel que l'interface doit le montrer, et date du dernier rafraîchissement
/// des données mises en cache.
/// </summary>
/// <remarks>
/// <b>Deux sources, pas une.</b> <c>navigator.onLine</c> ne vaut que par sa négation : « faux »
/// est fiable, « vrai » ne prouve rien (portail captif, serveur arrêté, Wi-Fi sans Internet).
/// L'état affiché combine donc l'événement du navigateur et le sort réel des appels HTTP —
/// un appel qui échoue bascule l'application en « hors ligne » sans attendre l'accord du
/// navigateur, et le premier appel qui repasse la rebascule en ligne.
/// <para>
/// Ce service est le seul point d'observation de l'interface : la mise en page s'y abonne pour
/// afficher le bandeau et la date de synchronisation, les écrans pour désactiver les actions
/// d'écriture.
/// </para>
/// </remarks>
public sealed class EtatReseau(CacheHorsLigne cache) : IAsyncDisposable
{
private DotNetObjectReference<EtatReseau>? _reference;
private bool _navigateurEnLigne = true;
private bool _dernierAppelEchoue;
/// <summary>Faux dès qu'un appel a échoué ou que le navigateur signale la perte du réseau.</summary>
public bool EnLigne => _navigateurEnLigne && !_dernierAppelEchoue;
/// <summary>
/// Faut-il tenter un appel réseau ? <b>Oui dès que le navigateur a une connexion</b>, même
/// si le dernier appel a échoué.
/// </summary>
/// <remarks>
/// C'est ce qui permet de sortir tout seul du mode hors-ligne quand la panne venait du
/// <b>serveur</b> et non de l'appareil : <c>navigator.onLine</c> n'a alors jamais changé, donc
/// aucun événement <c>online</c> ne viendra jamais. Sans cette distinction, l'application
/// resterait bloquée sur le cache jusqu'au rechargement de la page — constaté en essai.
/// Le prix est d'une requête qui échoue par lecture tant que le serveur est absent : elle
/// échoue vite, et l'affichage retombe sur le cache comme avant.
/// </remarks>
public bool TenterLeReseau => _navigateurEnLigne;
/// <summary>
/// Instant du dernier rafraîchissement réussi des données en cache, <c>null</c> si l'on n'a
/// jamais rien pu enregistrer sur cet appareil.
/// </summary>
public DateTimeOffset? DerniereSynchro { get; private set; }
/// <summary>Vrai quand ce qui est affiché vient du cache et non du réseau.</summary>
public bool DonneesDuCache => !EnLigne && DerniereSynchro is not null;
/// <summary>Raison affichable du blocage des actions d'écriture.</summary>
public const string MotifHorsLigne =
"Hors ligne : la bibliothèque reste consultable, mais rien ne peut être modifié.";
/// <summary>
/// Bascule en ligne ↔ hors ligne, et <b>elle seule</b>.
/// </summary>
/// <remarks>
/// La date de synchronisation a son propre événement (<see cref="SynchroChange"/>) : sans
/// cette séparation, un écran qui rechargerait ses données sur <c>Change</c> déclencherait
/// l'écriture d'un instantané, donc un nouvel événement, donc un rechargement — une boucle
/// sans fin.
/// </remarks>
public event Action? Change;
/// <summary>Nouvelle date de synchronisation : de quoi rafraîchir un affichage, rien de plus.</summary>
public event Action? SynchroChange;
/// <summary>À appeler une fois au démarrage : lit l'état courant et s'abonne aux bascules.</summary>
public async Task DemarrerAsync()
{
_reference ??= DotNetObjectReference.Create(this);
var enLigne = await cache.SurveillerAsync(_reference);
Definir(enLigne, false);
}
[JSInvokable]
public void SurChangementReseau(bool enLigne) => Definir(enLigne, _dernierAppelEchoue && !enLigne);
/// <summary>Un appel HTTP a échoué : on est hors ligne, quoi qu'en dise le navigateur.</summary>
public void SignalerEchecReseau() => Definir(_navigateurEnLigne, true);
/// <summary>Un appel HTTP a abouti : la preuve la plus solide qu'on soit en ligne.</summary>
public void SignalerSuccesReseau() => Definir(true, false);
/// <summary>Enregistre qu'un instantané vient d'être rangé, avec sa date.</summary>
public void SignalerSynchro(DateTimeOffset date)
{
if (DerniereSynchro is { } precedente && precedente >= date)
{
return;
}
DerniereSynchro = date;
SynchroChange?.Invoke();
}
private void Definir(bool navigateurEnLigne, bool dernierAppelEchoue)
{
var avant = EnLigne;
_navigateurEnLigne = navigateurEnLigne;
_dernierAppelEchoue = dernierAppelEchoue;
// L'événement ne parle que de l'état VISIBLE : les écrans s'y abonnent pour se recharger,
// et le déclencher pour un changement interne les ferait boucler.
if (avant != EnLigne)
{
Change?.Invoke();
}
}
public ValueTask DisposeAsync()
{
_reference?.Dispose();
_reference = null;
return ValueTask.CompletedTask;
}
}