mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 22:18:13 +02:00
Notifications Web Push (+ manifeste PWA)
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs abonnés, directement via leur service de push — donc app fermée et téléphone verrouillé, là où une notification côté page ne peut rien (un onglet caché relâche son flux SSE). Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki — la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte le plus : une tâche tourne justement quand personne ne regarde. Clés VAPID générées à la première demande et rangées dans la base ; abonnements persistés et purgés quand le service de push répond 404/410. Corps de notification générique, sans extrait de réponse : elle transite par Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine (un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir les push, sans cache — mettre l'UI en cache servirait une interface périmée après une mise à jour de l'image. Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil. Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
This commit is contained in:
13 files changed
+712
-2
No files matched your search
@@ -14,6 +14,10 @@
|
||||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
|
||||
<!-- Manifeste PWA : installabilité Android/desktop et, surtout, Web Push. iOS
|
||||
s'appuie sur les métas apple-* ci-dessus ; le manifeste ne le gêne pas.
|
||||
Servi à la racine par web_server.go, comme /sw.js. -->
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
|
||||
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
|
||||
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
|
||||
@@ -360,6 +364,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<section class="set-pane" data-pane="agent">
|
||||
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Notifications Web Push. Indépendantes du mode agent (une réponse de chat
|
||||
prête est utile à savoir sans aucun outil), donc HORS du groupe grisé.
|
||||
À régler sur CHAQUE appareil : l'abonnement appartient au navigateur. -->
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="push-toggle" onchange="togglePush()"><span class="slider"></span></span> me prévenir quand c'est prêt<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le serveur envoie une <b>notification</b> à la fin d'une réponse et à la fin d'une <b>tâche planifiée</b>, même l'app fermée ou le téléphone verrouillé. Demande <b>HTTPS</b> (ou localhost). Sur iPhone : <b>ajoute d'abord Loki à l'écran d'accueil</b>, puis active depuis l'app installée. À régler sur chaque appareil.</span></span></label>
|
||||
<div class="rowbody"><div id="push-status" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 27-push.js — notifications Web Push côté client (voir push.go / sw.js).
|
||||
//
|
||||
// Le SERVEUR pousse une notif à la fin d'un tour et à la fin d'une tâche
|
||||
// planifiée, même app fermée ou téléphone verrouillé. Ici on ne gère que
|
||||
// l'INSCRIPTION : enregistrer le service worker, demander la permission (sur
|
||||
// clic — un geste utilisateur est obligatoire), s'abonner avec la clé publique
|
||||
// VAPID du serveur, et lui transmettre l'abonnement.
|
||||
//
|
||||
// ⚠️ Le service worker est un fichier de l'ORIGINE (/sw.js), pas un appel
|
||||
// /api : il doit venir de la racine pour couvrir toute l'app. La clé VAPID et
|
||||
// l'enregistrement de l'abonnement, eux, passent par jfetch (donc par la clé
|
||||
// de pilotage, et par le canal E2E à distance).
|
||||
|
||||
// pushSupported : le navigateur sait-il faire du Web Push ? (Safari iOS hors
|
||||
// PWA installée, vieux navigateurs → non.)
|
||||
function pushSupported(){
|
||||
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
|
||||
}
|
||||
|
||||
// La clé VAPID est renvoyée en base64url ; PushManager.subscribe veut un Uint8Array.
|
||||
function urlB64ToUint8Array(base64){
|
||||
const pad = '='.repeat((4 - base64.length % 4) % 4);
|
||||
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for(let i=0;i<raw.length;i++) out[i]=raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Enregistre (une seule fois) le service worker et renvoie sa registration.
|
||||
// Scope racine : le worker doit couvrir toute l'app pour recevoir les push.
|
||||
let _swReg = null;
|
||||
async function pushRegisterSW(){
|
||||
if(_swReg) return _swReg;
|
||||
_swReg = await navigator.serviceWorker.register('/sw.js', {scope:'/'});
|
||||
return _swReg;
|
||||
}
|
||||
|
||||
function pushSetStatus(msg){
|
||||
const el = document.getElementById('push-status');
|
||||
if(el) el.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Reflète l'état RÉEL (abonné ou non, permission refusée, non supporté) dans
|
||||
// l'interrupteur et la ligne d'état. Appelé au chargement et après bascule.
|
||||
async function pushRefresh(){
|
||||
const cb = document.getElementById('push-toggle');
|
||||
if(!cb) return;
|
||||
if(!pushSupported()){
|
||||
cb.checked = false; cb.disabled = true;
|
||||
// iOS ne sait faire du Web Push QUE depuis une PWA ajoutée à l'écran d'accueil.
|
||||
if(document.documentElement.getAttribute('data-pwa')!=='1' && /iphone|ipad|ipod/i.test(navigator.userAgent))
|
||||
pushSetStatus("sur iPhone : ajoute d'abord Loki à l'écran d'accueil, puis active depuis l'app installée.");
|
||||
else
|
||||
pushSetStatus('ce navigateur ne sait pas recevoir de notifications push.');
|
||||
return;
|
||||
}
|
||||
// HTTPS (ou localhost) obligatoire : sans contexte sûr, ni service worker ni
|
||||
// push. Le dire ici évite un « échec » incompréhensible au clic.
|
||||
if(!window.isSecureContext){
|
||||
cb.checked = false; cb.disabled = true;
|
||||
pushSetStatus('HTTPS requis (ou localhost) : ouvre Loki en https pour activer les notifications.');
|
||||
return;
|
||||
}
|
||||
if(Notification.permission === 'denied'){
|
||||
cb.checked = false; cb.disabled = false;
|
||||
pushSetStatus('notifications bloquées dans le navigateur — à débloquer dans ses réglages de site.');
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const reg = await pushRegisterSW();
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
cb.checked = !!sub; cb.disabled = false;
|
||||
pushSetStatus(sub ? 'activées sur cet appareil.' : '');
|
||||
}catch(e){ cb.checked=false; pushSetStatus(''); }
|
||||
}
|
||||
|
||||
// togglePush : abonne ou désabonne selon l'état de l'interrupteur.
|
||||
async function togglePush(){
|
||||
const cb = document.getElementById('push-toggle');
|
||||
const want = cb.checked;
|
||||
if(!pushSupported()){ cb.checked=false; await pushRefresh(); return; }
|
||||
try{
|
||||
if(want){
|
||||
// Permission (geste utilisateur = ce clic). Refus → on éteint et on explique.
|
||||
const perm = await Notification.requestPermission();
|
||||
if(perm !== 'granted'){ cb.checked=false; pushSetStatus('permission refusée.'); return; }
|
||||
const reg = await pushRegisterSW();
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if(!sub){
|
||||
const r = await jget('/api/push/key');
|
||||
if(!r || !r.key){ cb.checked=false; pushSetStatus('clé du serveur indisponible.'); return; }
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true, // exigé par Chrome : pas de push silencieux
|
||||
applicationServerKey: urlB64ToUint8Array(r.key)
|
||||
});
|
||||
}
|
||||
const res = await jpost('/api/push/subscribe', sub.toJSON());
|
||||
if(!res || !res.ok){ pushSetStatus("le serveur n'a pas enregistré l'abonnement."); }
|
||||
else pushSetStatus('activées sur cet appareil.');
|
||||
toast('notifications activées');
|
||||
} else {
|
||||
const reg = await pushRegisterSW();
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if(sub){
|
||||
const ep = sub.endpoint;
|
||||
await sub.unsubscribe().catch(()=>{});
|
||||
await jpost('/api/push/unsubscribe', {endpoint:ep}).catch(()=>{});
|
||||
}
|
||||
pushSetStatus('');
|
||||
toast('notifications désactivées');
|
||||
}
|
||||
}catch(e){
|
||||
cb.checked = !want;
|
||||
pushSetStatus('erreur : ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
// Au chargement : enregistre le SW en avance (pour recevoir les push sans même
|
||||
// ouvrir les réglages) et cale l'interrupteur. Silencieux si non supporté.
|
||||
if(pushSupported() && window.isSecureContext){
|
||||
navigator.serviceWorker.register('/sw.js', {scope:'/'}).then(r=>{ _swReg=r; }).catch(()=>{});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', ()=>{ pushRefresh(); });
|
||||
Reference in new issue
Block a user