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:
@@ -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 |
|
||||
| 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 |
|
||||
| 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)
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
**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.
|
||||
|
||||
### 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
|
||||
|
||||
| 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)
|
||||
6. Gestion des prêts
|
||||
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
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user