diff --git a/dataprovider.cpp b/dataprovider.cpp index f0fa058..91f2922 100644 --- a/dataprovider.cpp +++ b/dataprovider.cpp @@ -2,13 +2,57 @@ #include #include #include +#include #include #include +#ifdef Q_OS_WASM +#include +#endif + DataProvider::DataProvider(QObject *parent) : QObject(parent) { } +// ---------------------------------------------------------------------------- +// Adresse de l'API (synthèse §49) : "/api" sur l'ORIGINE de la page qui a chargé le wasm (protocole + hôte + port), +// lue dans window.location.origin : +// - à distance : https://b.ship2net.fr/api -> Traefik (session Live vérifiée à chaque requête) -> nginx du CM5 +// -> API (port 8080). Même origine que la page : le cookie de session est joint automatiquement par le +// navigateur, et il n'y a plus de requête entre origines (CORS). +// - en local : https:///api -> nginx du CM5 -> API. +// Hors navigateur (exécution native pour la mise au point) : variable d'environnement SHIPHEART_API, ou l'API locale. +// ---------------------------------------------------------------------------- +QString DataProvider::adresseApi() +{ +#ifdef Q_OS_WASM + const std::string origine = emscripten::val::global("location")["origin"].as(); + return QString::fromStdString(origine) + QStringLiteral("/api"); +#else + return qEnvironmentVariable("SHIPHEART_API", QStringLiteral("http://127.0.0.1:8080")); +#endif +} + +// ---------------------------------------------------------------------------- +// Traitement commun d'une réponse en erreur. Une réponse 401 vient du vérificateur du Live (session expirée, ou accès +// suspendu/révoqué depuis le portail) : elle est signalée à part (sessionExpiree), car elle ne se résoudra pas seule - +// contrairement à une coupure réseau, qui relève du simple "hors ligne". +// Retour : true si la réponse est en erreur (déjà signalée : l'appelant n'a plus qu'à sortir). +// ---------------------------------------------------------------------------- +bool DataProvider::reponseEnErreur(QNetworkReply *reply) +{ + if (reply->error() == QNetworkReply::NoError) return false; + + const int statut = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statut == 401 && !m_sessionExpiree) { + m_sessionExpiree = true; + emit sessionExpireeChange(); + } + signalerEchec(); + emit erreur(reply->errorString()); + return true; +} + void DataProvider::signalerSucces() { m_connecte = true; @@ -18,6 +62,8 @@ void DataProvider::signalerSucces() void DataProvider::signalerEchec() { + // Ne touche JAMAIS m_derniereConnexionReussie ici : on veut garder la + // trace du dernier succès réel, pas la remplacer par l'instant de l'échec. if (m_connecte) { m_connecte = false; emit etatConnexionChange(); @@ -26,28 +72,15 @@ void DataProvider::signalerEchec() void DataProvider::get_familles(const QString &type) { - if (m_replyFamillesEnCours) m_replyFamillesEnCours->abort(); - QUrl url(m_baseUrl + "/elements/familles"); QUrlQuery query; query.addQueryItem("type", type); url.setQuery(query); - QNetworkRequest request(url); - request.setTransferTimeout(5000); - - auto reply = m_manager.get(request); - m_replyFamillesEnCours = reply; - + auto reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply, type]() { reply->deleteLater(); - if (m_replyFamillesEnCours == reply) m_replyFamillesEnCours = nullptr; - if (reply->error() == QNetworkReply::OperationCanceledError) return; - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } + if (reponseEnErreur(reply)) return; const auto doc = QJsonDocument::fromJson(reply->readAll()); const auto obj = doc.object(); if (obj["status"].toString() != "ok") { @@ -62,29 +95,16 @@ void DataProvider::get_familles(const QString &type) void DataProvider::get_elements(const QString &type, int famille) { - if (m_replyElementsEnCours) m_replyElementsEnCours->abort(); - QUrl url(m_baseUrl + "/elements"); QUrlQuery query; query.addQueryItem("type", type); query.addQueryItem("famille", QString::number(famille)); url.setQuery(query); - QNetworkRequest request(url); - request.setTransferTimeout(5000); - - auto reply = m_manager.get(request); - m_replyElementsEnCours = reply; - + auto reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply, type, famille]() { reply->deleteLater(); - if (m_replyElementsEnCours == reply) m_replyElementsEnCours = nullptr; - if (reply->error() == QNetworkReply::OperationCanceledError) return; - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } + if (reponseEnErreur(reply)) return; const auto doc = QJsonDocument::fromJson(reply->readAll()); const auto obj = doc.object(); if (obj["status"].toString() != "ok") { @@ -102,7 +122,6 @@ void DataProvider::set_circuit(int id, int consigne) QUrl url(m_baseUrl + "/elements/circuit/" + QString::number(id)); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setTransferTimeout(5000); QJsonObject body; body["consigne"] = consigne; @@ -111,89 +130,8 @@ void DataProvider::set_circuit(int id, int consigne) auto reply = m_manager.sendCustomRequest(request, "PATCH", data); connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { reply->deleteLater(); - const bool succes = (reply->error() == QNetworkReply::NoError); + const bool succes = !reponseEnErreur(reply); if (succes) signalerSucces(); - else { - signalerEchec(); - emit erreur(reply->errorString()); - } emit circuitModifie(id, succes); }); } - -void DataProvider::get_mesures(const QString &type, int id, int periode, const QString &variable) -{ - if (m_replyMesuresEnCours) m_replyMesuresEnCours->abort(); - - QUrl url(m_baseUrl + "/mesures"); - QUrlQuery query; - query.addQueryItem("type", type); - query.addQueryItem("id", QString::number(id)); - query.addQueryItem("periode", QString::number(periode)); - if (!variable.isEmpty()) query.addQueryItem("variable", variable); - url.setQuery(query); - - QNetworkRequest request(url); - request.setTransferTimeout(8000); // Requête potentiellement plus lourde (jusqu'à 200 points) qu'un simple /elements - délai un peu plus généreux. - - auto reply = m_manager.get(request); - m_replyMesuresEnCours = reply; - - connect(reply, &QNetworkReply::finished, this, [this, reply, type, id, periode, variable]() { - reply->deleteLater(); - if (m_replyMesuresEnCours == reply) m_replyMesuresEnCours = nullptr; - if (reply->error() == QNetworkReply::OperationCanceledError) return; - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } - const auto doc = QJsonDocument::fromJson(reply->readAll()); - const auto obj = doc.object(); - if (obj["status"].toString() != "ok") { - signalerEchec(); - emit erreur(obj["message"].toString()); - return; - } - signalerSucces(); - emit mesuresRecues(type, id, periode, variable, obj); - }); -} - -void DataProvider::get_energie(const QString &type, int id, int periode) -{ - if (m_replyEnergieEnCours) m_replyEnergieEnCours->abort(); - - QUrl url(m_baseUrl + "/energie"); - QUrlQuery query; - query.addQueryItem("type", type); - query.addQueryItem("id", QString::number(id)); - query.addQueryItem("periode", QString::number(periode)); - url.setQuery(query); - - QNetworkRequest request(url); - request.setTransferTimeout(8000); - - auto reply = m_manager.get(request); - m_replyEnergieEnCours = reply; - - connect(reply, &QNetworkReply::finished, this, [this, reply, type, id, periode]() { - reply->deleteLater(); - if (m_replyEnergieEnCours == reply) m_replyEnergieEnCours = nullptr; - if (reply->error() == QNetworkReply::OperationCanceledError) return; - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } - const auto doc = QJsonDocument::fromJson(reply->readAll()); - const auto obj = doc.object(); - if (obj["status"].toString() != "ok") { - signalerEchec(); - emit erreur(obj["message"].toString()); - return; - } - signalerSucces(); - emit energieRecue(type, id, periode, obj); - }); -} diff --git a/dataprovider.h b/dataprovider.h index c7225ef..7cc5414 100644 --- a/dataprovider.h +++ b/dataprovider.h @@ -2,11 +2,8 @@ #include #include #include -#include #include -class QNetworkReply; - // ============================================================================ // DataProvider - couche d'accès aux données du client WASM (cas 2/3). // ---------------------------------------------------------------------------- @@ -15,14 +12,13 @@ class QNetworkReply; // HTTP/JSON est encapsulée ici - le QML ne voit que des signaux avec des // tableaux déjà prêts à l'emploi. // -// Portée actuelle : les 4 domaines (circuits, batteries, capteurs, reservoirs) -// en lecture, écriture (consigne/mode auto) sur circuits uniquement, et -// désormais l'historique/courbe (capteurs uniquement pour l'instant, cf -// GET /mesures côté API). +// Portée actuelle : les 4 domaines (circuit, batterie, capteur, reservoir) +// en lecture, écriture (consigne/mode auto) sur circuit uniquement. // // Suit également l'état de connexion (succès/échec de chaque requête) pour // permettre à l'UI d'afficher un état "hors ligne" explicite plutôt que de -// laisser les listes silencieusement vides ou figées. +// laisser les listes silencieusement vides ou figées (cf synthèse, gestion +// du hors-ligne). // ============================================================================ class DataProvider : public QObject { @@ -31,54 +27,48 @@ class DataProvider : public QObject Q_PROPERTY(bool connecte READ connecte NOTIFY etatConnexionChange) Q_PROPERTY(bool aDejaEteConnecte READ aDejaEteConnecte NOTIFY etatConnexionChange) Q_PROPERTY(QDateTime derniereConnexionReussie READ derniereConnexionReussie NOTIFY etatConnexionChange) + // Live distant (synthèse §49) : vrai dès qu'une réponse 401 indique que la session Live a expiré ou que l'accès a + // été suspendu/révoqué depuis le portail - l'UI peut alors inviter à rouvrir le Live depuis le portail, plutôt + // que d'afficher un simple "hors ligne" (rien ne reviendra tout seul). + Q_PROPERTY(bool sessionExpiree READ sessionExpiree NOTIFY sessionExpireeChange) public: explicit DataProvider(QObject *parent = nullptr); - // TODO : rendre l'adresse de base configurable (IP locale du CM5 en cas 2, - // domaine VPS en cas 3 - cf QR code d'accès, §"Architecture cible"/Cas 2-3 - // de la synthèse) - en dur sur le relais VPS pour cette étape. + // Adresse de l'API : "/api" sur l'origine même de la page (synthèse §49) - https://b.ship2net.fr/api à + // distance (relais Traefik, session vérifiée), https:///api en local (nginx du CM5). Une seule + // version du wasm pour tous les cas, et plus d'adresse en dur. Q_INVOKABLE void get_familles(const QString &type); Q_INVOKABLE void get_elements(const QString &type, int famille); // Écriture rapide sur un circuit (bouton d'accueil) - PATCH /elements/circuit/. Q_INVOKABLE void set_circuit(int id, int consigne); - // Historique/courbe pour la page de détail - GET /mesures. - // periode : 0=1h, 1=24h, 2=7j, 3=30j (même convention que le natif). - // variable : requis pour circuits/batteries (sélecteur de variable côté - // client), ignoré pour capteurs/reservoirs (variable fixe côté serveur). - Q_INVOKABLE void get_mesures(const QString &type, int id, int periode, const QString &variable = QString()); - - // Consommation d'énergie (Phase 4, batteries uniquement pour l'instant) - - // GET /energie. Endpoint séparé de get_mesures() : réponse de forme très - // différente (barres + comparatif par circuit, pas une simple courbe). - Q_INVOKABLE void get_energie(const QString &type, int id, int periode); - bool connecte() const { return m_connecte; } bool aDejaEteConnecte() const { return m_derniereConnexionReussie.isValid(); } QDateTime derniereConnexionReussie() const { return m_derniereConnexionReussie; } + bool sessionExpiree() const { return m_sessionExpiree; } signals: + // Le type (et, pour les éléments, la famille) sont désormais inclus dans + // chaque signal - indispensable avec plusieurs domaines actifs en + // parallèle, pour que le QML sache à quelle réponse il a affaire. void famillesRecues(const QString &type, const QJsonArray &familles); void elementsRecus(const QString &type, int famille, const QJsonArray &elements); void circuitModifie(int id, bool succes); - void mesuresRecues(const QString &type, int id, int periode, const QString &variable, const QJsonObject &donnees); - void energieRecue(const QString &type, int id, int periode, const QJsonObject &donnees); void erreur(const QString &message); void etatConnexionChange(); + void sessionExpireeChange(); private: void signalerSucces(); void signalerEchec(); + bool reponseEnErreur(QNetworkReply *reply); // true si erreur (signalée), en distinguant la session expirée (401) + static QString adresseApi(); QNetworkAccessManager m_manager; - QString m_baseUrl = QStringLiteral("https://api.ship2net.fr"); + QString m_baseUrl = adresseApi(); bool m_connecte = false; - QDateTime m_derniereConnexionReussie; - - QNetworkReply *m_replyFamillesEnCours = nullptr; - QNetworkReply *m_replyElementsEnCours = nullptr; - QNetworkReply *m_replyMesuresEnCours = nullptr; - QNetworkReply *m_replyEnergieEnCours = nullptr; + bool m_sessionExpiree = false; + QDateTime m_derniereConnexionReussie; // invalide (isValid()==false) tant qu'aucun succès n'a jamais eu lieu }; diff --git a/deploy/sw.js b/deploy/sw.js index d366c8e..563a325 100644 --- a/deploy/sw.js +++ b/deploy/sw.js @@ -1,12 +1,22 @@ -// Service worker du client ShipHeart. -// Stratégie "cache d'abord" pour la coquille statique de l'app (mêmes fichiers -// que le domaine de la page elle-même), MAIS jamais pour les appels vers -// l'API (données live, cross-origine) - ceux-ci passent toujours par le -// réseau, sans jamais être mis en cache, sous peine de figer les données -// affichées (rencontré concrètement : rafraîchissement périodique et même -// rechargement de page ne montraient plus jamais de valeurs à jour). +// Service worker du client ShipHeart (synthèse §49). +// +// Coquille statique de l'app (index.html, wasm, JS, icônes) : "cache d'abord" - le wasm pèse ~25 Mo, il ne doit +// pas être retéléchargé à chaque ouverture (connexion mobile en mer). Mais avec une MISE À JOUR FIABLE : +// 1. CACHE_NAME porte l'horodatage du déploiement : toute nouvelle version modifie sw.js, que le navigateur +// revérifie à chaque ouverture -> installation d'une nouvelle version du service worker ; +// 2. à l'installation, les fichiers sont téléchargés en CONTOURNANT le cache HTTP du navigateur +// (cache: "reload") - sinon, la nouvelle version pouvait se remplir avec les anciens fichiers gardés par ce +// cache (cas rencontré : ancien wasm toujours servi après un déploiement) ; +// 3. à l'activation, les anciens caches sont supprimés et, s'il y en avait (= c'est une MISE À JOUR, pas une +// première installation), les pages ouvertes sont rechargées une fois : elles exécutent aussitôt la nouvelle +// version, sans action de l'utilisateur. +// +// Jamais de cache (le navigateur traite la requête normalement, sans passer par ce service worker) : +// - l'API (/api/..., même origine depuis la synthèse §49) : données live - un cache figerait les valeurs ; +// - l'ouverture de session Live (/_auth) ; +// - toute requête autre que GET, et toute requête vers une autre origine. -const CACHE_NAME = "shipheart-wasm-v1"; +const CACHE_NAME = "shipheart-wasm-20261005_193211"; // Horodatage posé par le déploiement - ne pas modifier à la main const APP_SHELL = [ "./index.html", "./qtloader.js", @@ -23,7 +33,8 @@ self.addEventListener("install", (event) => { caches.open(CACHE_NAME).then((cache) => Promise.allSettled( APP_SHELL.map((url) => - cache.add(url).catch((err) => console.warn("Échec de mise en cache:", url, err)) + cache.add(new Request(url, { cache: "reload" })) // jamais le cache HTTP du navigateur (point 2) + .catch((err) => console.warn("Échec de mise en cache:", url, err)) ) ) ) @@ -32,34 +43,41 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) - ) - ) - ); - self.clients.claim(); + event.waitUntil((async () => { + const anciens = (await caches.keys()).filter((key) => key !== CACHE_NAME); + await Promise.all(anciens.map((key) => caches.delete(key))); + await self.clients.claim(); + + // Mise à jour (et non première installation) : recharger les pages ouvertes, qui exécutent encore l'ancienne + // version (point 3). NB : rechargement lancé SANS l'attendre - le navigateur ne transmet les requêtes de la page + // rechargée à ce service worker qu'une fois l'activation terminée ; l'attendre ici bloquerait les deux + // indéfiniment (constaté en test). + if (anciens.length > 0) { + const fenetres = await self.clients.matchAll({ type: "window" }); + for (const fenetre of fenetres) { + fenetre.navigate(fenetre.url).catch((err) => console.warn("Rechargement impossible:", fenetre.url, err)); + } + } + })()); }); self.addEventListener("fetch", (event) => { - const url = new URL(event.request.url); + const requete = event.request; + const url = new URL(requete.url); - // Appels vers l'API (données live, autre origine) : jamais de cache, - // toujours le réseau. - if (url.origin !== self.location.origin) { - event.respondWith(fetch(event.request)); - return; - } + // Hors coquille statique : pas de respondWith -> le navigateur traite la requête normalement (réseau). + if (requete.method !== "GET") return; + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/") || url.pathname === "/_auth") return; - // Coquille statique de l'app (même origine) : cache d'abord, comme avant. + // Coquille statique : cache d'abord, réseau sinon (et mise en cache de la réponse). event.respondWith( - caches.match(event.request).then((cached) => { + caches.match(requete).then((cached) => { if (cached) return cached; - return fetch(event.request).then((response) => { + return fetch(requete).then((response) => { if (response && response.ok) { const responseClone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone)); + caches.open(CACHE_NAME).then((cache) => cache.put(requete, responseClone)); } return response; });