diff --git a/CLAUDE.md b/CLAUDE.md
index 261c684..fa0d2c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2056,3 +2056,90 @@ accents et à la casse, cohérente avec le reste de l'application sans dupliquer
champ n'apparaît que s'il y a plus d'un prêt en cours — avec un seul prêt, filtrer n'apprendrait
rien, même raison que les lignes de filtre qui se cachent déjà ailleurs dans le catalogue quand
elles seraient inutiles.
+
+## La mise à jour s'applique seule au démarrage — corrigé le 2026-08-20
+
+Défaut remonté en usage sur l'**application installée sur téléphone** : bandeau « Une nouvelle
+version est disponible », clic sans effet, chargement figé à **100 %**, puis un menu principal
+d'une version antérieure au lot d'interface du 2026-08-18 (pas d'onglets en bas, filtres
+dépliés, « Saisie manuelle » au catalogue).
+
+### Ce que la capture d'écran prouve, et qu'aucune supposition ne remplaçait
+
+Le bandeau s'affichait **sans son CSS** : texte brut en haut de l'écran, bouton par défaut, alors
+que `#mb-maj` le pose en barre fixe **en bas**, fond bleu, bouton jaune. Or `js/mise-a-jour.js`
+et la règle `#mb-maj` de `css/app.css` sont arrivés dans le **même commit** (`ebc5f95`). Le JS
+était donc là, son CSS non — pendant que le cercle de chargement, issu du même `app.css`, était
+bien stylé.
+
+⚠️ **Le symptôme n'était donc pas « la mise à jour ne s'applique pas » mais « une mise à jour
+s'est appliquée à moitié ».** Un blocage à 100 % est la signature exacte d'un assemblage
+dépareillé : tout est téléchargé, le manifeste de démarrage et les `.wasm` ne concordent pas,
+l'application ne démarre jamais. Chercher un défaut dans le bouton aurait manqué la cause.
+
+### Le fallback n'hérite pas des `StaticFileOptions` — mesuré
+
+Le lot A1 avait posé `Cache-Control: no-cache, must-revalidate` sur `UseStaticFiles`. Mesuré sur
+un publish du code de l'époque :
+
+| Requête | Cache-Control |
+|---|---|
+| `/index.html` | `no-cache, must-revalidate` ✅ |
+| `/_framework/*` | `no-cache` (posé par `UseBlazorFrameworkFiles`) ✅ |
+| **`/`** | **aucun** ❌ |
+
+`MapFallbackToFile` a son **propre pipeline** et ne passe pas par les options du middleware
+statique. Or `/` est le **`start_url` de la PWA**, la seule URL qu'ouvre l'application installée :
+elle repartait en cache heuristique du navigateur (une fraction de l'âge du fichier), donc
+potentiellement périmée pendant des jours. A1 avait couvert tous les fichiers statiques **sauf le
+seul qui compte pour une PWA installée**. La coquille HTML décide de tout le reste : la servir
+périmée suffit à mélanger deux versions sur l'appareil.
+
+Correction : `app.MapFallbackToFile("index.html", optionsFichiersStatiques)`. Vérifié après
+correction — `/`, `/livres/3` et `/souhaits` portent tous l'en-tête.
+
+### L'enregistrement se résolvait contre l'URL du document, pas contre ``
+
+`register('service-worker.js')` se résout contre l'**URL du document**, et **non** contre
+``. Ouvrir l'application sur une route profonde visait donc
+`/souhaits/service-worker.js` — vérifié : **404**, l'extension empêchant le fallback SPA de
+répondre. L'enregistrement échouait alors silencieusement, **et avec lui tout le hors-ligne**.
+Le chemin est désormais absolu, avec `scope: '/'` explicite.
+
+### Trois maillons sans filet
+
+`SKIP_WAITING` → `skipWaiting()` → `controllerchange` → `reload()`. Si un maillon manquait — un
+worker en attente antérieur au gestionnaire de message, une reprise en main qui n'a pas lieu — le
+bouton se grisait et **rien ne se passait, sans que rien ne le dise**. Deux ajouts :
+
+- `self.clients.claim()` dans `onActivate` : `skipWaiting()` est censé reprendre les pages seul,
+ mais c'est cette reprise qui déclenche `controllerchange`, donc le rechargement ;
+- un rechargement **forcé** 5 s après `SKIP_WAITING` si le contrôleur n'a pas changé.
+
+⚠️ **Le rechargement forcé est borné à une fois par session** (`sessionStorage`), et **renonce**
+si le stockage est refusé (navigation privée). Sans ce garde-fou, une version qui n'arrive pas à
+s'activer transformerait une mise à jour ratée en **boucle de rechargement**, c'est-à-dire en
+application inutilisable — bien pire que le défaut d'origine.
+
+### ⚠️ Le clic n'est plus obligatoire — décision inversée, et pourquoi
+
+CLAUDE.md actait « le message ne vient que de `js/mise-a-jour.js`, après un clic explicite :
+jamais tout seul, pour ne pas mélanger deux versions au milieu d'une session ». Le raisonnement
+reste juste — recharger sous une saisie en cours fait perdre le formulaire — **mais il ne vaut
+que pour une session déjà entamée**.
+
+D'où `FENETRE_DEMARRAGE` (10 s) : une version prête dans les premières secondes après l'ouverture
+s'applique **toute seule, sans bandeau** ; au-delà, on repasse par le bandeau. Ouvrir
+l'application depuis l'écran d'accueil du téléphone tombe toujours dans cette fenêtre — c'est
+exactement le cas qui ne se mettait plus à jour, et c'est celui où l'interruption ne coûte rien.
+
+### Ce qui n'est pas vérifiable ici, et qu'il faut confirmer sur le téléphone
+
+Le navigateur d'automatisation **n'enregistre aucun service worker** (voir la section dédiée) :
+la chaîne complète n'a donc pas pu être éprouvée en exécution. Ce qui **est** vérifié : les
+en-têtes sur toutes les routes, le 404 du chemin relatif, le démarrage de l'application avec le
+nouveau script, et les 441 tests.
+
+⚠️ **Un appareil déjà dans l'état dépareillé ne se répare pas tout seul** : son cache mélangé
+précède le correctif. Il faut vider les données du site (ou désinstaller puis réinstaller la
+PWA) **une fois**. Le correctif empêche d'y retomber, il ne défait pas ce qui est déjà en place.
diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs
index 12220c7..b6f9c3e 100644
--- a/MaBibli.Api/Program.cs
+++ b/MaBibli.Api/Program.cs
@@ -99,6 +99,12 @@ app.MapRevuesEndpoints();
app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints();
-app.MapFallbackToFile("index.html");
+// ⚠️ 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,
+// la seule URL qu'ouvre l'application installée — repartait sans aucun Cache-Control, donc en
+// cache heuristique du navigateur. Mesuré : /index.html portait bien l'en-tête, « / » non.
+// C'est la coquille HTML qui décide de tout le reste : la servir périmée suffit à mélanger deux
+// versions sur l'appareil.
+app.MapFallbackToFile("index.html", optionsFichiersStatiques);
app.Run();
diff --git a/MaBibli.Client/wwwroot/js/mise-a-jour.js b/MaBibli.Client/wwwroot/js/mise-a-jour.js
index 0c445e8..cd4cce8 100644
--- a/MaBibli.Client/wwwroot/js/mise-a-jour.js
+++ b/MaBibli.Client/wwwroot/js/mise-a-jour.js
@@ -1,4 +1,4 @@
-// Enregistrement du service worker et bandeau de mise à jour.
+// Enregistrement du service worker, application automatique au démarrage, bandeau en secours.
//
// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) :
//
@@ -7,11 +7,16 @@
// empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne,
// mais il est silencieux et différé : le 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ù un bandeau explicite, avec un bouton qui l'applique.
+// personne comprenne pourquoi.
//
// 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale).
// L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un
// défaut de la PWA alors que c'est le contexte qui n'est pas éligible.
+//
+// ⚠️ Le clic n'est plus obligatoire (décidé le 2026-08-20, après un blocage constaté sur
+// l'application installée sur téléphone). CLAUDE.md actait « jamais tout seul » pour ne pas
+// recharger sous une saisie en cours ; la raison reste valable, mais elle ne vaut que pour une
+// session déjà entamée. Voir FENETRE_DEMARRAGE ci-dessous.
(function () {
'use strict';
@@ -23,49 +28,103 @@
return;
}
- // Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur
- // survient aussi à la toute première installation, et recharger la page à ce moment-là
- // serait un clignotement inexplicable.
- let demandee = false;
+ // Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander.
+ // Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un
+ // prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son
+ // travail. Ouvrir l'application depuis l'écran d'accueil du téléphone tombe toujours dans
+ // cette fenêtre — c'est exactement le cas qui ne se mettait plus à jour.
+ var FENETRE_DEMARRAGE = 10000;
+
+ // Si la reprise en main n'a pas lieu, on recharge quand même : le pire scénario est de
+ // laisser l'utilisateur devant un bouton grisé qui ne fait rien.
+ var DELAI_REPRISE = 5000;
+
+ // Une seule reprise forcée par session : si la nouvelle version n'arrive pas à s'activer,
+ // recharger en boucle transformerait une mise à jour ratée en application inutilisable.
+ var CLE_FORCE = 'mb-maj-forcee';
+
+ var demandee = false;
+ var recharge = false;
+
+ function recharger() {
+ if (recharge) return;
+ recharge = true;
+ window.location.reload();
+ }
+
+ function appliquer(enAttente) {
+ demandee = true;
+ // Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
+ enAttente.postMessage({ type: 'SKIP_WAITING' });
+
+ // Filet. `controllerchange` devrait suivre ; s'il ne vient pas (worker en attente issu
+ // d'une version antérieure au gestionnaire SKIP_WAITING, reprise en main qui échoue),
+ // rien ne se passerait du tout et le bouton resterait grisé pour toujours.
+ window.setTimeout(function () {
+ if (recharge) return;
+ var dejaForcee = false;
+ try {
+ dejaForcee = window.sessionStorage.getItem(CLE_FORCE) === '1';
+ window.sessionStorage.setItem(CLE_FORCE, '1');
+ } catch (e) {
+ // Navigation privée ou stockage refusé : on préfère ne pas forcer plutôt que
+ // risquer une boucle de rechargement qu'on ne saurait plus arrêter.
+ dejaForcee = true;
+ }
+ if (!dejaForcee) recharger();
+ }, DELAI_REPRISE);
+ }
function proposerLaMiseAJour(enAttente) {
if (document.getElementById('mb-maj')) return;
- const barre = document.createElement('div');
+ var barre = document.createElement('div');
barre.id = 'mb-maj';
barre.setAttribute('role', 'status');
barre.textContent = 'Une nouvelle version de MaBibli est disponible. ';
- const bouton = document.createElement('button');
+ var bouton = document.createElement('button');
bouton.type = 'button';
bouton.textContent = 'Mettre à jour';
bouton.addEventListener('click', function () {
bouton.disabled = true;
- demandee = true;
- // Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
- enAttente.postMessage({ type: 'SKIP_WAITING' });
+ bouton.textContent = 'Mise à jour…';
+ appliquer(enAttente);
});
barre.appendChild(bouton);
document.body.appendChild(barre);
}
- navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' })
+ // Au démarrage : on applique sans demander. Plus tard : on propose.
+ function traiter(enAttente) {
+ if (performance.now() < FENETRE_DEMARRAGE) {
+ appliquer(enAttente);
+ } else {
+ proposerLaMiseAJour(enAttente);
+ }
+ }
+
+ // ⚠️ Chemin ABSOLU, et scope explicite. `register('service-worker.js')` se résout contre
+ // l'URL du DOCUMENT et non contre : ouvrir l'application sur une route
+ // profonde (/souhaits, /livres/3) visait /souhaits/service-worker.js, que le serveur ne sert
+ // pas — l'enregistrement échouait alors silencieusement, et avec lui tout le hors-ligne.
+ navigator.serviceWorker.register('/service-worker.js', { scope: '/', updateViaCache: 'none' })
.then(function (enregistrement) {
// Un worker déjà installé attendait peut-être depuis la visite précédente.
if (enregistrement.waiting && navigator.serviceWorker.controller) {
- proposerLaMiseAJour(enregistrement.waiting);
+ traiter(enregistrement.waiting);
}
enregistrement.addEventListener('updatefound', function () {
- const nouveau = enregistrement.installing;
+ var nouveau = enregistrement.installing;
if (!nouveau) return;
nouveau.addEventListener('statechange', function () {
// `controller` non nul = ce n'est pas la première installation, donc il y a
// bien une version précédente à remplacer.
if (nouveau.state === 'installed' && navigator.serviceWorker.controller) {
- proposerLaMiseAJour(nouveau);
+ traiter(nouveau);
}
});
});
@@ -80,10 +139,10 @@
console.error("Échec de l'enregistrement du service worker :", erreur);
});
- let recharge = false;
+ // Le rechargement n'a lieu que si la mise à jour a été demandée — automatiquement ou par un
+ // clic. Un changement de contrôleur survient aussi à la toute première installation, et
+ // recharger la page à ce moment-là serait un clignotement inexplicable.
navigator.serviceWorker.addEventListener('controllerchange', function () {
- if (!demandee || recharge) return;
- recharge = true;
- window.location.reload();
+ if (demandee) recharger();
});
})();
diff --git a/MaBibli.Client/wwwroot/service-worker.published.js b/MaBibli.Client/wwwroot/service-worker.published.js
index ae8705a..1d16a12 100644
--- a/MaBibli.Client/wwwroot/service-worker.published.js
+++ b/MaBibli.Client/wwwroot/service-worker.published.js
@@ -45,6 +45,12 @@ async function onActivate(event) {
await Promise.all(cacheKeys
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key)));
+
+ // Prendre la main sur les pages déjà ouvertes. skipWaiting() est censé le faire seul, mais
+ // c'est ce qui déclenche `controllerchange`, donc le rechargement côté page : sans reprise
+ // effective, le bouton « Mettre à jour » se grise et il ne se passe plus rien — symptôme
+ // observé sur téléphone. Cet appel est sans effet quand la reprise a déjà eu lieu.
+ await self.clients.claim();
}
async function onFetch(event) {