Sortir du mode hors-ligne quand la panne venait du serveur, et documenter

Constate en essai : quand c'est le serveur qui tombe, navigator.onLine n'a
jamais change, donc aucun evenement « online » ne viendra jamais. Les lectures
court-circuitaient sur EnLigne et ne retentaient plus rien : l'application
restait sur le cache jusqu'au rechargement de la page.

EtatReseau distingue donc deux notions : EnLigne (ce que l'interface affiche et
ce qui active les boutons) et TenterLeReseau (faut-il tenter un appel, vrai des
que le navigateur a une connexion, meme apres un echec). Verifie : serveur
arrete puis redemarre sans aucun evenement « online », la navigation suivante
retire le bandeau, reactive les actions et fait apparaitre un livre ajoute
cote serveur pendant la coupure.

CLAUDE.md : architecture retenue, ce qui a ete observe reseau coupe, pourquoi
le filtre existe en deux exemplaires, ce que le hors-ligne ne couvre pas, la
chaine de mise a jour du service worker, et la cause etablie de son echec
d'enregistrement dans le navigateur d'automatisation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 14:04:33 +02:00
co-authored by Claude Opus 5
parent ebc5f95d49
commit b89b8ac964
+155 -2
View File
@@ -43,7 +43,7 @@ Application self-hosted de gestion de bibliothèque personnelle, à héberger su
| Hébergement | YunoHost, installation **native** (pas Docker) | YunoHost déconseille Docker pour ses apps (moins fiable, plus lourd) ; installation native = meilleures perfs sur petit matériel | | Hébergement | YunoHost, installation **native** (pas Docker) | YunoHost déconseille Docker pour ses apps (moins fiable, plus lourd) ; installation native = meilleures perfs sur petit matériel |
| Packaging YunoHost | S'inspirer de [`radarr_ynh`](https://github.com/YunoHost-Apps/radarr_ynh) | Radarr est aussi en .NET, packagé sans Docker sur YunoHost. Leur `manifest.toml` montre un déploiement **self-contained** (`dotnet publish -r linux-x64 --self-contained`), donc pas besoin d'installer `dotnet-runtime` via apt côté serveur — le binaire embarque son propre runtime | | Packaging YunoHost | S'inspirer de [`radarr_ynh`](https://github.com/YunoHost-Apps/radarr_ynh) | Radarr est aussi en .NET, packagé sans Docker sur YunoHost. Leur `manifest.toml` montre un déploiement **self-contained** (`dotnet publish -r linux-x64 --self-contained`), donc pas besoin d'installer `dotnet-runtime` via apt côté serveur — le binaire embarque son propre runtime |
| Scan ISBN | **ZXing.Net** (C#, Apache 2.0) exécuté dans le WASM ; le JS ne fournit que les pixels caméra | Décodage en C#, réutilisable hors navigateur si le projet évolue en scanner de bibliothèque. Voir la section dédiée ci-dessous | | Scan ISBN | **ZXing.Net** (C#, Apache 2.0) exécuté dans le WASM ; le JS ne fournit que les pixels caméra | Décodage en C#, réutilisable hors navigateur si le projet évolue en scanner de bibliothèque. Voir la section dédiée ci-dessous |
| Consultation hors-ligne | Cache local des données côté client (mécanisme à trancher) | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » | | Consultation hors-ligne | **Instantanés JSON en IndexedDB**, lecture seule, implémenté le 2026-08-18 | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » |
## Scan du code-barres — ZXing.Net (décision actée) ## Scan du code-barres — ZXing.Net (décision actée)
@@ -152,6 +152,95 @@ public static class IsbnScanner
- Hors-ligne, l'interface doit **désactiver explicitement** les actions d'écriture (ajout, édition, prêt) plutôt que de les laisser échouer silencieusement, et indiquer que les données affichées proviennent du cache. - Hors-ligne, l'interface doit **désactiver explicitement** les actions d'écriture (ajout, édition, prêt) plutôt que de les laisser échouer silencieusement, et indiquer que les données affichées proviennent du cache.
- SQLite compilé en WASM côté client a été **écarté** : ne se justifierait que si l'écriture hors-ligne devenait nécessaire. - SQLite compilé en WASM côté client a été **écarté** : ne se justifierait que si l'écriture hors-ligne devenait nécessaire.
### Ce qui a été implémenté le 2026-08-18
| Pièce | Rôle |
|---|---|
| `wwwroot/js/cache-hors-ligne.js` | IndexedDB (base `mabibli`, magasin `instantanes`) : ranger, relire, et signaler les bascules `online`/`offline`. Aucune logique métier |
| `Services/CacheHorsLigne.cs` | Sérialisation C# des instantanés ; **avale toute panne du stockage** (navigation privée, quota) — ne pas pouvoir cacher n'empêche pas de fonctionner en ligne |
| `Services/EtatReseau.cs` | État réseau visible + date de dernière synchronisation, avec deux événements distincts |
| `Services/FiltreLivresLocal.cs` | Recherche, filtres et tri hors-ligne sur l'instantané |
| `Services/ServiceLivresApi.cs` | Lectures avec repli sur le cache, écritures refusées |
**Quatre instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue
**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`. C'est exactement ce qui
justifie IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne
restituerait que les URL déjà visitées, donc **une recherche jamais tapée en ligne ne rendrait
rien**. Vérifié en exécution — hors-ligne, chercher un auteur jamais affiché auparavant remonte
bien son livre.
L'instantané est réécrit à chaque lecture non filtrée réussie **et après chaque écriture**
(rafraîchissement en tâche de fond) : sans cela, une coupure juste après un ajout montrerait un
catalogue d'avant.
### Le filtre existe en deux exemplaires, et c'est assumé
`FiltreLivres` (serveur, sur `IQueryable<Livre>`, colonnes normalisées déjà calculées) et
`FiltreLivresLocal` (navigateur, sur des `LivreDto` qui n'en portent pas) **ne peuvent pas être
le même code**. La normalisation est refaite à la volée côté client — sans coût perceptible sur
une bibliothèque de foyer — mais par **les mêmes fonctions** (`NormalisationTexte`,
`RapprochementAuteurs.Cle`), et le tri est **ordinal** pour reproduire ce que fait SQLite sur une
colonne sans collation.
⚠️ Le garde-fou est un test qui **confronte les deux implémentations** sur le même jeu de données
et 17 jeux de critères (`FiltreLivresLocalTests`). Une divergence silencieuse serait pire qu'un
cache absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque.
### ⚠️ `navigator.onLine` ne suffit pas — le piège coûte le retour en ligne
`navigator.onLine` **ne vaut que par sa négation** : « faux » est fiable, « vrai » ne prouve rien
(portail captif, serveur arrêté, Wi-Fi sans Internet). D'où deux notions distinctes dans
`EtatReseau`, et il faut tenir les deux :
| Propriété | Sens | Usage |
|---|---|---|
| `EnLigne` | navigateur en ligne **et** dernier appel réussi | ce que l'interface affiche et ce qui active les boutons |
| `TenterLeReseau` | navigateur en ligne, **même si le dernier appel a échoué** | faut-il tenter un appel HTTP |
**Constaté en essai avant correction** : quand la panne vient du *serveur*, `navigator.onLine`
n'a jamais changé, donc aucun événement `online` ne viendra jamais — et une lecture qui
court-circuitait sur `EnLigne` ne retentait plus rien. L'application restait bloquée sur le cache
**jusqu'au rechargement de la page**. Le prix de la correction 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.
Vérifié après correction : serveur arrêté puis redémarré, **sans aucun événement `online`**, la
navigation suivante a fait disparaître le bandeau, réactivé les actions et fait apparaître un
livre ajouté côté serveur pendant la coupure.
### ⚠️ Deux événements, sinon la boucle infinie
`EtatReseau` expose `Change` (bascule en ligne ↔ hors ligne) **et** `SynchroChange` (nouvelle date
de synchronisation). Les écrans se rechargent sur `Change` uniquement. Avec un événement unique,
un rechargement écrirait un instantané → nouvel événement → nouveau rechargement, sans fin.
### Ce que le hors-ligne ne couvre pas, volontairement
- **L'historique des prêts d'un livre** (`GET /api/livres/{id}/prets`) : une requête par livre pour
une information rarement consultée. En revanche l'état *courant* vient de `LivreDto.PreteA`,
donc de l'instantané — c'est lui qui répond à « où est ce livre ? », la seule question qui se
pose devant l'étagère. Le composant **dit** que l'historique est indisponible plutôt que
d'afficher une liste vide, qui se lirait « jamais prêté ».
- **Les rapprochements d'auteurs** : une liste de décisions à prendre, or aucune décision ne peut
être enregistrée hors-ligne.
- **Le lookup ISBN et le scan** : ils interrogent la BnF et OpenLibrary. L'écran le dit
explicitement au lieu de laisser expirer un délai d'attente incompréhensible.
- Une fiche absente de l'instantané affiche « pas dans les données enregistrées sur cet
appareil », **pas** « n'existe plus » : hors-ligne, les deux ne se distinguent pas.
### Ce qui a été observé, réseau coupé
Éprouvé sur un `publish Release` servi par l'API, en coupant réellement l'accès à `/api/*`
(même origine, donc même IndexedDB) puis en **rechargeant** la page :
- démarrage à froid : les 9 livres s'affichent, bandeau « Hors ligne. Données enregistrées
aujourd'hui à 13:59. » — instantanés écrits à 13:59:32, page rechargée à 14:00 ;
- recherche sur tout le fonds : `saint-exupery` (auteur jamais affiché auparavant), `bete` → « La
Bête humaine », `emile` → les Zola, `zola emile` (ordre inversé) → les mêmes ;
- filtres format et statut opérants sur l'instantané ;
- « Ajouter par ISBN », « Saisie manuelle », « Éditer », les quatre boutons de statut, « Prêter »,
« Rendu » et « Ajouter au catalogue » : **désactivés**, chacun portant sa raison ;
- « Prêts en cours » et « Auteurs » servis depuis le cache, le nom d'utilisateur aussi.
## Sources de données ISBN — point d'attention important ## Sources de données ISBN — point d'attention important
**Ne pas dépendre d'une seule source, et éviter Google Books si possible** (préférence explicite de l'utilisateur : pas de dépendance à Google). **Ne pas dépendre d'une seule source, et éviter Google Books si possible** (préférence explicite de l'utilisateur : pas de dépendance à Google).
@@ -290,6 +379,68 @@ Le cache-busting reste assuré par le service worker, qui compare les empreintes
**Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur. **Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur.
### Le service worker est le SEUL cache-busting du projet — conséquences
Les empreintes étant désactivées, `blazor.webassembly.js` et `dotnet.js` portent des noms
stables : rien d'autre que le service worker n'empêche de servir éternellement une version
périmée. Vérifié que la chaîne tient, sans navigateur, en publiant deux fois avec une seule
ligne de différence dans `app.css` :
| | Publication A | Publication B |
|---|---|---|
| `service-worker-assets.js``version` | `Oc+bE5e+` | `jBwHvKfd` |
| Première ligne de `service-worker.js` | `/* Manifest version: Oc+bE5e+ */` | `/* Manifest version: jBwHvKfd */` |
Le point important est la **seconde ligne** : le SDK écrit la version dans le corps même de
`service-worker.js`. Le navigateur compare ce fichier **octet à octet** à chaque vérification de
mise à jour — il n'a donc pas à deviner que `service-worker-assets.js` a changé. Nouveau worker →
nouveau nom de cache (`offline-cache-{version}`) → tous les assets refetchés. Un changement de
code C# suffit aussi (empreintes des `.wasm`), constaté : `Oc+bE5e+``wCvu+Chi`.
**Ce que le mécanisme d'origine ne réglait pas** : un nouveau worker *attend* que **tous** les
onglets de l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
personne comprenne pourquoi. D'où `wwwroot/js/mise-a-jour.js` :
- il **vérifie le support avant d'appeler `navigator.serviceWorker`** — absent en contexte non
sécurisé (http sur une IP locale), où l'appel direct levait une `TypeError` ;
- il **journalise un échec d'enregistrement** au lieu de l'avaler (c'est ce qui a permis
d'élucider le point ci-dessous) ;
- il appelle `registration.update()` à chaque chargement, et affiche un bandeau « Mettre à jour »
quand une version est prête ; le clic envoie `SKIP_WAITING` au worker en attente, qui appelle
`self.skipWaiting()` (ajouté à `service-worker.published.js`), puis `controllerchange` recharge.
⚠️ Le rechargement sur `controllerchange` est **conditionné à un clic** : cet événement survient
aussi à la toute première installation, et recharger à ce moment-là serait un clignotement
inexplicable. Ne pas retirer le drapeau.
### ⚠️ Le service worker ne s'enregistre pas dans le navigateur d'automatisation — c'est l'environnement
Symptôme constaté depuis la phase 3, cause établie le 2026-08-18. **Ne pas repartir en chasse au
bug de configuration PWA** : les quatre observations ci-dessous vont toutes dans le même sens.
1. `navigator.serviceWorker.getRegistrations()` renvoie `[]`, et `register()` échoue en
`TypeError: … An unknown error occurred when fetching the script.`
2. Un `fetch('/service-worker.js')` **depuis la même page** renvoie `200 text/javascript`,
3 335 octets. Le fichier est donc bien servi.
3. **Toutes** les URL échouent identiquement, y compris `/index.html` — or un HTML *récupéré*
échouerait avec une erreur de type MIME, pas avec « fetching the script ». L'échec est donc
**avant** la requête.
4. Décisif : journalisation `Microsoft.AspNetCore` en `Information`, puis un `fetch` et un
`register` sur la même URL portant chacun un repère distinct. Le serveur journalise
`Request starting … ?repere=fetchB` et **rien du tout** pour `?repere=registerB`. La requête
d'enregistrement **ne quitte jamais le navigateur**.
Le navigateur en question n'est pas un Chrome ordinaire : `navigator.userAgent` indique
`Claude/1.30096.1 Chrome/148 Electron/42.7.0` — un hôte Electron, dont la couche d'interception
réseau ne sert pas les requêtes de script de service worker.
**Conséquence à assumer** : le démarrage hors-ligne *complet* (coquille HTML/WASM servie par le
service worker) n'est **pas vérifiable ici**. Ce qui a été vérifié pour de bon, c'est tout le
reste — coquille servie par un serveur statique sur la **même origine**, API réellement
injoignable, et l'application repart de son cache IndexedDB. À confirmer dans un navigateur
ordinaire : charger l'application, vérifier dans les outils de développement (Application →
Service Workers) que le worker est `activated`, cocher « Offline », puis recharger.
### Deux dépôts distincts ### Deux dépôts distincts
| Dépôt | Contenu | Rôle | | Dépôt | Contenu | Rôle |
@@ -544,7 +695,7 @@ Conclusion : aucune des deux solutions existantes ne coche toutes les cases (pr
5. CRUD livres (physique/numérique, statuts de lecture) 5. CRUD livres (physique/numérique, statuts de lecture)
6. Gestion des prêts 6. Gestion des prêts
7. Intégration scan caméra (**ZXing.Net** + interop caméra minimal) 7. Intégration scan caméra (**ZXing.Net** + interop caméra minimal)
8. Cache hors-ligne pour la consultation (voir « Stratégie hors-ligne ») 8. ~~Cache hors-ligne pour la consultation~~ — fait le 2026-08-18 (voir « Stratégie hors-ligne »)
9. Packaging YunoHost (`manifest.toml`, `conf/systemd.service`, `conf/nginx.conf`, `scripts/install`) en s'inspirant de radarr_ynh 9. Packaging YunoHost (`manifest.toml`, `conf/systemd.service`, `conf/nginx.conf`, `scripts/install`) en s'inspirant de radarr_ynh
## Questions ouvertes ## Questions ouvertes
@@ -556,3 +707,5 @@ Points à réévaluer en cours de route, sans blocage :
- **AOT WASM** : mesurer le scan sur un vrai téléphone une fois fonctionnel. Activer `RunAOTCompilation` seulement si la fluidité est insuffisante. - **AOT WASM** : mesurer le scan sur un vrai téléphone une fois fonctionnel. Activer `RunAOTCompilation` seulement si la fluidité est insuffisante.
- **Runner Gitea Actions** : à vérifier le jour où l'utilisateur voudra automatiser les releases. - **Runner Gitea Actions** : à vérifier le jour où l'utilisateur voudra automatiser les releases.
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel. - **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.