mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +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
@@ -225,6 +225,15 @@ Héritées d'AJEAN :
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/itlightning/dateparse v0.2.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -497,6 +497,7 @@ func chatModelName() string {
|
||||
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
|
||||
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
turnStart := time.Now()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
stale := c.epoch != epoch
|
||||
@@ -518,6 +519,17 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// Notification Web Push : ce chemin (generate) ne sert QUE les tours
|
||||
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
|
||||
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
|
||||
// le service de push ne doit pas retenir la fin du tour. Corps générique
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google.
|
||||
//
|
||||
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification,
|
||||
// l'utilisateur est là et a coupé volontairement.
|
||||
if hasPushSubs() && ctx.Err() == nil {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
|
||||
// Snapshot de la vue modèle.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// push.go — notifications Web Push : quand un tour utilisateur se termine
|
||||
// (chat_conversation.go, turn_done), le serveur Loki pousse une notification
|
||||
// vers les navigateurs abonnés (iPhone verrouillé, Android, desktop) via leur
|
||||
// service de push (Apple/Google). C'est le SERVEUR qui pousse, directement vers
|
||||
// le endpoint du navigateur — ça marche donc app fermée, contrairement à une
|
||||
// notification purement client (l'onglet caché relâche son flux SSE).
|
||||
//
|
||||
// Ce que ça ne fait PAS transiter par le tunnel E2E : la charge utile est
|
||||
// chiffrée de bout en bout avec les clés PROPRES de l'abonnement (RFC 8291),
|
||||
// puis remise au service de push public. L'inscription, elle, passe par /api
|
||||
// (donc jfetch/E2E) comme le reste.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
// fmtDurFR : durée → « 42s », « 3 mn 05s », « 1 h 12 mn » pour le corps de la
|
||||
// notification. Miroir serveur de fmtElapsed() côté UI (09-stream.js).
|
||||
func fmtDurFR(d time.Duration) string {
|
||||
secs := int(d.Round(time.Second).Seconds())
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h, m, s := secs/3600, (secs%3600)/60, secs%60
|
||||
switch {
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d h %02d mn", h, m)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d mn %02ds", m, s)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stockées dans bkState (une seule base bbolt, voir store.go).
|
||||
const (
|
||||
stPushVAPIDPriv = "push_vapid_priv" // clé privée VAPID (base64url)
|
||||
stPushVAPIDPub = "push_vapid_pub" // clé publique VAPID (base64url), servie à l'UI
|
||||
stPushSubs = "push_subs" // liste JSON des abonnements
|
||||
)
|
||||
|
||||
// pushSub : un abonnement PushSubscription du navigateur. Même forme que l'objet
|
||||
// JS, on le reçoit tel quel depuis l'UI et on le rejoue tel quel à l'envoi.
|
||||
type pushSub struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys webpush.Keys `json:"keys"`
|
||||
}
|
||||
|
||||
// vapidMu sérialise la génération paresseuse des clés : deux requêtes /api/push/key
|
||||
// concurrentes sur une base neuve ne doivent pas produire deux paires différentes
|
||||
// (la seconde écraserait la première, invalidant les abonnements de la première).
|
||||
var vapidMu sync.Mutex
|
||||
|
||||
// vapidKeys renvoie la paire VAPID, la générant et la persistant au premier appel.
|
||||
// La paire est STABLE pour la vie de l'installation : la changer invaliderait tous
|
||||
// les abonnements déjà pris (le navigateur signe l'abonnement avec la clé publique).
|
||||
func vapidKeys() (priv, pub string, err error) {
|
||||
vapidMu.Lock()
|
||||
defer vapidMu.Unlock()
|
||||
priv = getStr(bkState, stPushVAPIDPriv)
|
||||
pub = getStr(bkState, stPushVAPIDPub)
|
||||
if priv != "" && pub != "" {
|
||||
return priv, pub, nil
|
||||
}
|
||||
priv, pub, err = webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPriv, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPub, pub); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
// subsMu sérialise les mutations de la liste d'abonnements (lecture-modif-écriture) :
|
||||
// une inscription et une purge concurrentes ne doivent pas s'écraser l'une l'autre.
|
||||
var subsMu sync.Mutex
|
||||
|
||||
func loadSubs() []pushSub {
|
||||
var subs []pushSub
|
||||
getJSON(bkState, stPushSubs, &subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
func saveSubs(subs []pushSub) error { return putJSON(bkState, stPushSubs, subs) }
|
||||
|
||||
// addSub enregistre un abonnement (idempotent : ré-inscrire le même endpoint met
|
||||
// simplement à jour ses clés au lieu de le dupliquer — un navigateur ré-abonné
|
||||
// garde le même endpoint mais peut renouveler ses clés).
|
||||
func addSub(s pushSub) error {
|
||||
if s.Endpoint == "" {
|
||||
return nil
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
for i, x := range subs {
|
||||
if x.Endpoint == s.Endpoint {
|
||||
subs[i] = s
|
||||
return saveSubs(subs)
|
||||
}
|
||||
}
|
||||
subs = append(subs, s)
|
||||
return saveSubs(subs)
|
||||
}
|
||||
|
||||
// removeSub retire un abonnement par son endpoint (désinscription explicite, ou
|
||||
// purge après un 404/410 « gone » renvoyé par le service de push).
|
||||
func removeSub(endpoint string) {
|
||||
if endpoint == "" {
|
||||
return
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
out := subs[:0]
|
||||
for _, x := range subs {
|
||||
if x.Endpoint != endpoint {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
_ = saveSubs(out)
|
||||
}
|
||||
|
||||
// pushPayload : ce que le service worker reçoit dans l'événement `push`.
|
||||
type pushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// hasPushSubs : y a-t-il au moins un abonnement ? Évite de sérialiser une charge
|
||||
// utile pour rien à chaque fin de tour quand personne n'a activé les notifs.
|
||||
func hasPushSubs() bool { return len(loadSubs()) > 0 }
|
||||
|
||||
// sendPushToAll pousse une notification à tous les abonnés. Best-effort : les
|
||||
// échecs réseau sont ignorés (le service de push réessaiera selon le TTL), mais un
|
||||
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
|
||||
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
|
||||
func sendPushToAll(title, body string) {
|
||||
subs := loadSubs()
|
||||
if len(subs) == 0 {
|
||||
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
|
||||
return
|
||||
}
|
||||
priv, pub, err := vapidKeys()
|
||||
if err != nil {
|
||||
fmt.Printf("[push] clés VAPID indisponibles : %v\n", err)
|
||||
return
|
||||
}
|
||||
msg, _ := json.Marshal(pushPayload{Title: title, Body: body, Tag: "loki-turn"})
|
||||
opts := &webpush.Options{
|
||||
// ⚠️ SANS préfixe « mailto: » : webpush-go l'ajoute lui-même (sauf si la
|
||||
// chaîne commence par « https: »). Passer « mailto:… » ici donnait
|
||||
// « mailto:mailto:… », un sujet VAPID malformé → Apple répond 403
|
||||
// BadJwtToken et rien n'arrive. On donne donc l'email nu.
|
||||
Subscriber: "mail@nathaninline.com",
|
||||
VAPIDPublicKey: pub,
|
||||
VAPIDPrivateKey: priv,
|
||||
TTL: 120, // périmé après 2 min : une notif « réponse prête » n'a pas de sens tardive
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
}
|
||||
fmt.Printf("[push] envoi à %d abonné(s)…\n", len(subs))
|
||||
for _, s := range subs {
|
||||
sub := &webpush.Subscription{Endpoint: s.Endpoint, Keys: s.Keys}
|
||||
resp, err := webpush.SendNotification(msg, sub, opts)
|
||||
if err != nil {
|
||||
fmt.Printf("[push] échec envoi (%s) : %v\n", endpointHost(s.Endpoint), err)
|
||||
continue
|
||||
}
|
||||
// 201 Created = accepté par le service de push. 4xx = problème (VAPID,
|
||||
// chiffrement, abonnement mort) — on TRACE le corps de la réponse, qui porte
|
||||
// le motif exact d'Apple/Google (ex. « BadJwtToken »).
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
fmt.Printf("[push] refus %d (%s) : %s\n", resp.StatusCode, endpointHost(s.Endpoint), strings.TrimSpace(string(body)))
|
||||
} else {
|
||||
fmt.Printf("[push] accepté %d (%s)\n", resp.StatusCode, endpointHost(s.Endpoint))
|
||||
}
|
||||
// 404/410 = abonnement expiré côté service de push : on le retire.
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
removeSub(s.Endpoint)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// endpointHost : hôte de l'endpoint pour les logs (miroir de pushEndpointHost,
|
||||
// gardé dans ce fichier pour éviter un couplage de compilation entre les deux).
|
||||
func endpointHost(endpoint string) string { return pushEndpointHost(endpoint) }
|
||||
@@ -285,4 +285,15 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
cur.LastReport = report
|
||||
}
|
||||
_ = saveTask(cur)
|
||||
// Notification Web Push : une tâche planifiée tourne SANS personne devant
|
||||
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
|
||||
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
|
||||
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
|
||||
if hasPushSubs() && !errors.Is(err, context.Canceled) {
|
||||
title, body := "Loki · tâche terminée", cur.Name
|
||||
if err != nil {
|
||||
title = "Loki · tâche en échec"
|
||||
}
|
||||
go sendPushToAll(title, body)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -2561,6 +2565,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<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). -->
|
||||
@@ -10650,4 +10660,128 @@ async function deleteTracker(){
|
||||
if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return;
|
||||
closeTracker();
|
||||
}
|
||||
// 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(); });
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Loki",
|
||||
"short_name": "Loki",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#5E7F5A",
|
||||
"icons": [
|
||||
{
|
||||
"src": "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>",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(); });
|
||||
@@ -0,0 +1,41 @@
|
||||
// Loki service worker — UNIQUEMENT les notifications Web Push.
|
||||
//
|
||||
// ⚠️ VOLONTAIREMENT sans cache ni handler `fetch` : l'interface est un seul
|
||||
// index.html régénéré à chaque build (assemble-ui) et servi depuis le binaire.
|
||||
// Un service worker qui interposerait une réponse en cache servirait une UI
|
||||
// périmée après une mise à jour de l'image, sans moyen simple de s'en rendre
|
||||
// compte. Ce worker ne fait donc QUE recevoir les push du serveur et afficher
|
||||
// la notification.
|
||||
//
|
||||
// Le SERVEUR (push.go) pousse à la fin d'un tour utilisateur et à la fin d'une
|
||||
// tâche planifiée, même app fermée ou téléphone verrouillé — c'est tout
|
||||
// l'intérêt par rapport à une notification côté page, que l'onglet caché ne
|
||||
// peut plus produire (il relâche son flux SSE).
|
||||
|
||||
self.addEventListener('install', function(){ self.skipWaiting(); });
|
||||
self.addEventListener('activate', function(e){ e.waitUntil(self.clients.claim()); });
|
||||
|
||||
self.addEventListener('push', function(e){
|
||||
var data = { title: 'Loki', body: 'Réponse prête' };
|
||||
try { if (e.data) data = Object.assign(data, e.data.json()); } catch (_){}
|
||||
e.waitUntil(self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
// tag + renotify : une nouvelle réponse REMPLACE l'ancienne notif (pas
|
||||
// d'empilement), mais re-sonne/vibre pour signaler qu'elle est fraîche.
|
||||
tag: data.tag || 'loki-turn',
|
||||
renotify: true,
|
||||
// Icône = le carré « loki », en data-URI : aucun fichier externe à servir.
|
||||
icon: "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>"
|
||||
}));
|
||||
});
|
||||
|
||||
// Clic sur la notif : ramène l'onglet Loki au premier plan s'il est déjà ouvert,
|
||||
// sinon en ouvre un. `includeUncontrolled` : les onglets ouverts AVANT que ce
|
||||
// worker prenne le contrôle comptent aussi.
|
||||
self.addEventListener('notificationclick', function(e){
|
||||
e.notification.close();
|
||||
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(cl){
|
||||
for (var i = 0; i < cl.length; i++){ if ('focus' in cl[i]) return cl[i].focus(); }
|
||||
if (self.clients.openWindow) return self.clients.openWindow('/');
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// web_push.go — endpoints d'inscription aux notifications Web Push (voir push.go).
|
||||
// Tous passent par /api/* (donc par jfetch, et par le canal E2E à distance)
|
||||
// en simple req/resp : le service worker et le manifest, eux, sont servis en clair
|
||||
// à la racine (web_server.go), car un service worker doit venir de l'origine même.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// pushEndpointHost extrait l'hôte d'un endpoint de push pour les logs (ex.
|
||||
// web.push.apple.com) sans divulguer le jeton complet.
|
||||
func pushEndpointHost(endpoint string) string {
|
||||
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
|
||||
return u.Host
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// handlePushKey (GET) : remet la clé publique VAPID dont l'UI a besoin pour
|
||||
// s'abonner (PushManager.subscribe applicationServerKey). Générée à la volée au
|
||||
// premier appel, puis stable.
|
||||
func handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
_, pub, err := vapidKeys()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "key": pub})
|
||||
}
|
||||
|
||||
// handlePushSubscribe (POST) : enregistre l'abonnement PushSubscription du
|
||||
// navigateur. Corps = l'objet renvoyé par subscription.toJSON() côté JS.
|
||||
func handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
var s pushSub
|
||||
if err := json.NewDecoder(r.Body).Decode(&s); err != nil || s.Endpoint == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "abonnement invalide"})
|
||||
return
|
||||
}
|
||||
if err := addSub(s); err != nil {
|
||||
fmt.Printf("[push] abonnement REFUSÉ (stockage) : %v\n", err)
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
fmt.Printf("[push] abonnement enregistré (%s) — %d au total\n", pushEndpointHost(s.Endpoint), len(loadSubs()))
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handlePushUnsubscribe (POST {endpoint}) : retire un abonnement (l'utilisateur a
|
||||
// coupé les notifs dans les réglages).
|
||||
func handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
removeSub(body.Endpoint)
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
//go:generate go run ../../tools/assemble-ui ui
|
||||
//go:embed ui/index.html ui/marked.min.js ui/fonts/*.woff2
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
||||
var uiFS embed.FS
|
||||
|
||||
// cmdWeb starts the HTTP server on the given port (default 8090).
|
||||
@@ -141,6 +141,23 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(b)
|
||||
})
|
||||
// Service worker + manifeste des notifications Web Push (voir push.go / sw.js).
|
||||
// PUBLICS (aucun secret) et servis en clair à la RACINE : un service worker
|
||||
// doit venir de l'origine même, et son scope est celui de son URL. no-store
|
||||
// sur le worker pour qu'une mise à jour de l'image soit toujours reprise.
|
||||
mux.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := uiFS.ReadFile("ui/sw.js")
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := uiFS.ReadFile("ui/manifest.webmanifest")
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(b)
|
||||
})
|
||||
// Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
|
||||
// par Loki et non par Google Fonts : une instance locale ou derrière un
|
||||
// réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
|
||||
@@ -264,6 +281,9 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/tasks/pause", handleTasksPause)
|
||||
api("/api/tasks/run", handleTaskRun)
|
||||
api("/api/tasks/stop", handleTaskStop) // arrête la tâche en cours (script : via son registre)
|
||||
api("/api/push/key", handlePushKey) // clé publique VAPID (pour s'abonner)
|
||||
api("/api/push/subscribe", handlePushSubscribe) // enregistre un abonnement du navigateur
|
||||
api("/api/push/unsubscribe", handlePushUnsubscribe) // retire un abonnement
|
||||
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
|
||||
Reference in new issue
Block a user