Fusionner le cache hors-ligne IndexedDB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # MaBibli.Client/wwwroot/css/app.css
This commit is contained in:
@@ -833,4 +833,37 @@ body {
|
||||
background: #e6e6f5;
|
||||
color: #3b3b7a;
|
||||
font-weight: 600;
|
||||
/* --- Mise à jour de l'application --- */
|
||||
|
||||
/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher
|
||||
même si l'application WebAssembly n'a pas démarré. D'où une règle globale et non scopée.
|
||||
Il existe parce que les empreintes des assets WASM sont désactivées (CLAUDE.md) : le service
|
||||
worker est le seul cache-busting du projet, et sa mise à jour attendrait sinon la fermeture
|
||||
de tous les onglets. */
|
||||
#mb-maj {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.7rem 1rem;
|
||||
background: #1b3a5c;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#mb-maj button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #f5d76e;
|
||||
color: #4a3800;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
<script>navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' });</script>
|
||||
<!-- Enregistrement du service worker déporté : il vérifie le support avant d'appeler
|
||||
navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de
|
||||
l'avaler, et propose la mise à jour quand une nouvelle version est prête. -->
|
||||
<script src="js/mise-a-jour.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Cache de consultation hors-ligne (CLAUDE.md — « Stratégie hors-ligne »).
|
||||
//
|
||||
// Rôle unique de ce fichier : ranger et relire des instantanés JSON dans IndexedDB, et dire à
|
||||
// C# quand le navigateur bascule en ligne / hors ligne. AUCUNE logique métier ici.
|
||||
//
|
||||
// Pourquoi IndexedDB et pas le cache du service worker : c'est la seule option qui permette de
|
||||
// rechercher et trier hors-ligne sur TOUTE la bibliothèque. Un cache de réponses HTTP ne
|
||||
// restituerait que les URL déjà visitées — une recherche jamais tapée ne rendrait rien.
|
||||
//
|
||||
// Les valeurs sont stockées telles quelles, en chaîne JSON : c'est System.Text.Json côté C# qui
|
||||
// sérialise et désérialise, donc une seule forme fait autorité et le clone structuré d'IndexedDB
|
||||
// n'a rien à interpréter.
|
||||
|
||||
const NOM_BASE = 'mabibli';
|
||||
const MAGASIN = 'instantanes';
|
||||
const VERSION = 1;
|
||||
|
||||
let promesseBase = null;
|
||||
|
||||
function ouvrir() {
|
||||
if (promesseBase) return promesseBase;
|
||||
|
||||
promesseBase = new Promise((resoudre, rejeter) => {
|
||||
// IndexedDB peut être absent ou refusé (navigation privée stricte, stockage bloqué).
|
||||
// On rejette proprement : l'appelant C# retombe alors sur « pas de cache ».
|
||||
if (!self.indexedDB) { rejeter(new Error('IndexedDB indisponible')); return; }
|
||||
|
||||
const requete = indexedDB.open(NOM_BASE, VERSION);
|
||||
requete.onupgradeneeded = () => {
|
||||
const base = requete.result;
|
||||
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
|
||||
};
|
||||
requete.onsuccess = () => resoudre(requete.result);
|
||||
requete.onerror = () => rejeter(requete.error);
|
||||
requete.onblocked = () => rejeter(new Error('IndexedDB bloqué'));
|
||||
}).catch(e => { promesseBase = null; throw e; });
|
||||
|
||||
return promesseBase;
|
||||
}
|
||||
|
||||
function attendre(requete, transaction) {
|
||||
return new Promise((resoudre, rejeter) => {
|
||||
requete.onsuccess = () => resoudre(requete.result);
|
||||
requete.onerror = () => rejeter(requete.error);
|
||||
if (transaction) transaction.onabort = () => rejeter(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Écrit un instantané. `dateIso` est l'instant de la synchronisation, pas celui de l'écriture :
|
||||
// c'est cette date que l'interface affiche pour dire de quand datent les données montrées.
|
||||
export async function ecrire(cle, json, dateIso) {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN, 'readwrite');
|
||||
const requete = tx.objectStore(MAGASIN).put({ json, date: dateIso }, cle);
|
||||
await attendre(requete, tx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Renvoie { json, date } ou null si rien n'a jamais été rangé sous cette clé.
|
||||
export async function lire(cle) {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN, 'readonly');
|
||||
const valeur = await attendre(tx.objectStore(MAGASIN).get(cle), tx);
|
||||
return valeur ?? null;
|
||||
}
|
||||
|
||||
export async function vider() {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN, 'readwrite');
|
||||
await attendre(tx.objectStore(MAGASIN).clear(), tx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// navigator.onLine ne vaut que par sa négation : « false » est fiable (aucune interface réseau),
|
||||
// « true » ne prouve rien (portail captif, serveur éteint). C# complète donc cet état avec le
|
||||
// résultat réel de ses appels HTTP — voir EtatReseau.SignalerEchecReseau.
|
||||
export function enLigne() {
|
||||
return navigator.onLine !== false;
|
||||
}
|
||||
|
||||
export function surveiller(reference) {
|
||||
const prevenir = () => reference.invokeMethodAsync('SurChangementReseau', navigator.onLine !== false);
|
||||
self.addEventListener('online', prevenir);
|
||||
self.addEventListener('offline', prevenir);
|
||||
return navigator.onLine !== false;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Enregistrement du service worker et bandeau de mise à jour.
|
||||
//
|
||||
// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) :
|
||||
//
|
||||
// 1. Les empreintes des assets WASM sont DÉSACTIVÉES (voir CLAUDE.md). `blazor.webassembly.js`
|
||||
// et `dotnet.js` portent donc des noms stables, et c'est le service worker — lui seul — qui
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
// Cas normal en http sur une IP de réseau local : rien à signaler à l'utilisateur,
|
||||
// l'application fonctionne, elle n'est simplement pas installable ni hors-ligne.
|
||||
console.info('Service worker indisponible (contexte non sécurisé ou navigateur sans support).');
|
||||
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;
|
||||
|
||||
function proposerLaMiseAJour(enAttente) {
|
||||
if (document.getElementById('mb-maj')) return;
|
||||
|
||||
const 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');
|
||||
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' });
|
||||
});
|
||||
|
||||
barre.appendChild(bouton);
|
||||
document.body.appendChild(barre);
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register('service-worker.js', { 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);
|
||||
}
|
||||
|
||||
enregistrement.addEventListener('updatefound', function () {
|
||||
const 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vérification explicite à chaque chargement : ne pas dépendre du seul rythme
|
||||
// interne du navigateur pour découvrir une version plus récente.
|
||||
enregistrement.update().catch(function () { /* hors-ligne : sans objet */ });
|
||||
})
|
||||
.catch(function (erreur) {
|
||||
// Ne jamais laisser cet échec passer inaperçu : sans service worker, l'application
|
||||
// ne démarre pas hors-ligne, et le cache IndexedDB ne sert alors à rien.
|
||||
console.error("Échec de l'enregistrement du service worker :", erreur);
|
||||
});
|
||||
|
||||
let recharge = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||
if (!demandee || recharge) return;
|
||||
recharge = true;
|
||||
window.location.reload();
|
||||
});
|
||||
})();
|
||||
@@ -6,6 +6,16 @@ self.addEventListener('install', event => event.waitUntil(onInstall(event)));
|
||||
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
|
||||
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
|
||||
|
||||
// Mise à jour à la demande. Sans cela, un nouveau worker attend que TOUS les onglets de
|
||||
// l'application soient fermés — un onglet oublié fige indéfiniment l'utilisateur sur l'ancienne
|
||||
// version. C'est d'autant plus important ici que les empreintes des assets WASM sont désactivées
|
||||
// (voir CLAUDE.md) : ce worker est le SEUL mécanisme de cache-busting du projet.
|
||||
// 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.
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
const cacheNamePrefix = 'offline-cache-';
|
||||
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
||||
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ];
|
||||
|
||||
Reference in New Issue
Block a user