diff --git a/README.md b/README.md index 84fe152..45419bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 86ba813..5eb3fcc 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b8ded5f..ad6fddc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 33041a4..bccff02 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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. diff --git a/internal/loki/push.go b/internal/loki/push.go new file mode 100644 index 0000000..5f5a69a --- /dev/null +++ b/internal/loki/push.go @@ -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) } diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 8b55f1c..999e6cc 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -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) + } } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 0f2f43f..66d06b7 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -14,6 +14,10 @@ (iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. --> + + +