feat(web): pages locales aide / statistiques / réglages (serveur HTTP 127.0.0.1)

Nouveau module webserver.nim : mini-serveur HTTP local (port libre a partir de 38943) qui sert des pages HTML generes en Nim - aide (nouvelle entree de menu tray), statistiques et reglages. La page reglages enregistre via POST (config.json) et l'application recharge la configuration au tick suivant. Menu Aide/Statistiques/Reglages ouvrent le navigateur par defaut, avec repli sur les dialogues natifs si le serveur est indisponible. Refactor config/stats (loadConfigFrom/saveConfigTo, loadStatsFrom/saveStatsTo) pour tester sans toucher aux fichiers reels. 8 suites de tests (60 tests), ajout de test_webserver avec integration HTTP reelle.
This commit is contained in:
2026-08-14 21:50:28 -04:00
parent 584c2fd182
commit 28860eaed1
10 changed files with 888 additions and 21 deletions
+28 -4
View File
@@ -43,6 +43,9 @@ prend soin de votre santé autant que de votre productivité, dans une interface
et la configuration est stockée au même endroit (pratique sur clé USB). et la configuration est stockée au même endroit (pratique sur clé USB).
- **Mise à jour automatique** (optionnelle) : vérification en arrière-plan, - **Mise à jour automatique** (optionnelle) : vérification en arrière-plan,
notification si une version plus récente est disponible. notification si une version plus récente est disponible.
- **Pages web locales** : aide, statistiques et réglages présentés sous forme de
pages HTML servies localement par l'application (127.0.0.1) et ouvertes dans
le navigateur par défaut — aucune donnée ne quitte l'ordinateur.
## Prérequis ## Prérequis
@@ -151,7 +154,9 @@ src/
├── i18n.nim # chaînes localisées (français / anglais), détection langue ├── i18n.nim # chaînes localisées (français / anglais), détection langue
├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim) ├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim)
├── floating.nim # fenêtre flottante compacte (toujours au-dessus) ├── floating.nim # fenêtre flottante compacte (toujours au-dessus)
└── updater.nim # comparaison de versions + récupération distante ├── updater.nim # comparaison de versions + récupération distante
└── webserver.nim # serveur HTTP local (127.0.0.1) : pages aide / stats /
# réglages, routage, rendu HTML, formulaire de sauvegarde
``` ```
Séparation des responsabilités : `timer`, `stats` et `updater` sont purs Séparation des responsabilités : `timer`, `stats` et `updater` sont purs
@@ -167,11 +172,29 @@ Windows.
| Menu contextuel | Clic droit | | Menu contextuel | Clic droit |
| Démarrer / mettre en pause | Double-clic | | Démarrer / mettre en pause | Double-clic |
## Pages web locales (aide, statistiques, réglages)
Depuis le menu de l'icône, « Aide », « Statistiques » et « Réglages » ouvrent
des pages HTML **servies localement** par l'application elle-même :
- Le menu **Aide** explique le fonctionnement de l'application (périodes,
pauses, rappels santé, raccourcis, astuces).
- **Statistiques** affiche les pomodoros du jour / semaine, la série de jours
consécutifs, le total et l'historique des 7 derniers jours.
- **Réglages** présente un formulaire complet (durées, objectif, son, langue,
profil actif, rappels santé) : l'enregistrement est appliqué immédiatement
par l'application.
L'application lance un petit serveur sur `127.0.0.1` (premier port libre à
partir de `38943`), uniquement accessible en local. Aucune donnée ne quitte
l'ordinateur. Si le serveur est indisponible, les menus retombent sur les
dialogues natifs.
## Tests ## Tests
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`) sont Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`,
testés unitairement, ainsi que l'intégration Win32 (`tray`). Un exécutable `webserver`) sont testés unitairement, ainsi que l'intégration Win32
unique les lance toutes et affiche un résumé : (`tray`). Un exécutable unique les lance toutes et affiche un résumé :
```bash ```bash
run_tests.cmd run_tests.cmd
@@ -187,6 +210,7 @@ nim c -r tests/test_notifications.nim
nim c -r tests/test_tray_integration.nim nim c -r tests/test_tray_integration.nim
nim c -r tests/test_updater.nim nim c -r tests/test_updater.nim
nim c -r tests/test_floating.nim nim c -r tests/test_floating.nim
nim c -r tests/test_webserver.nim
``` ```
La correspondance exigence → contrôle → résultat est détaillée dans La correspondance exigence → contrôle → résultat est détaillée dans
+12 -1
View File
@@ -34,6 +34,17 @@ concret effectué, et le résultat observé.
| 15 | Mise à jour (comparaison de versions) | `tests/test_updater.nim` (5 tests) | 5 tests **OK** | | 15 | Mise à jour (comparaison de versions) | `tests/test_updater.nim` (5 tests) | 5 tests **OK** |
| 16 | Fenêtre flottante (toujours au-dessus, texte agrandi) | `tests/test_floating.nim` (show/update/hide une vraie fenêtre Win32) + `floatingWindow` round-trip | **OK** (fenêtre créée/mise à jour/détruite sans erreur) | | 16 | Fenêtre flottante (toujours au-dessus, texte agrandi) | `tests/test_floating.nim` (show/update/hide une vraie fenêtre Win32) + `floatingWindow` round-trip | **OK** (fenêtre créée/mise à jour/détruite sans erreur) |
## Fonctionnalité ajoutée (pages web locales)
| # | Exigence | Contrôle concret | Résultat observé |
|---|----------|------------------|------------------|
| 17 | Section Aide expliquant le fonctionnement | menu tray « Aide » (`IdHelpPage`) + page `/help` générée (fr/en) | **OK** (`tests/test_webserver.nim` : page rendue, GET HTTP réel) |
| 18 | Statistiques présentées en page web locale | page `/stats` (jour/semaine/streak/total, 7 jours) servie par le serveur local | **OK** (GET réel via `httpclient` dans le test d'intégration) |
| 19 | Réglages en page web locale avec sauvegarde | page `/settings` (formulaire + rappels santé) ; POST `/settings` écrit `config.json` et pose le drapeau de rechargement | **OK** (POST réel : valeurs relues dans le fichier, page mise à jour) |
| 20 | Serveur local 127.0.0.1, port libre à partir de 38943 | `startWebServer()` dans `tests/test_webserver.nim` | **OK** (port 38943, requêtes GET/POST traitées) |
| 21 | Aucune donnée ne quitte l'ordinateur | serveur lié à `127.0.0.1` uniquement | **OK** (relecture de code) |
| 22 | Repli sur les dialogues natifs si serveur indisponible | branche `gServerPort > 0` dans `nimpulse.nim` | Compile ; logique vérifiée par relecture |
## Contraintes techniques ## Contraintes techniques
| Contrainte | Contrôle concret | Résultat observé | | Contrainte | Contrôle concret | Résultat observé |
@@ -77,4 +88,4 @@ run_tests.cmd
(ou `nim c -d:release --app:gui src/nimpulse.nim` pour le binaire) (ou `nim c -d:release --app:gui src/nimpulse.nim` pour le binaire)
51 tests au total, tous verts (7 suites). 60 tests au total, tous verts (8 suites).
+1 -1
View File
@@ -8,7 +8,7 @@ setlocal enabledelayedexpansion
set PASS=0 set PASS=0
set FAIL=0 set FAIL=0
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating) do ( for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating test_webserver) do (
echo. echo.
echo === %%t === echo === %%t ===
nim c -r --hints:off tests\%%t.nim nim c -r --hints:off tests\%%t.nim
+17 -8
View File
@@ -156,12 +156,11 @@ proc getActiveProfile*(cfg: Config): Profile =
# Chargement / sauvegarde # Chargement / sauvegarde
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc loadConfig*(): Config = proc loadConfigFrom*(path: string): Config =
## Charge la configuration depuis le disque. En cas d'erreur ou de fichier ## Charge la configuration depuis le chemin path. En cas d'erreur ou de
## manquant, on conserve des valeurs par défaut (jamais de crash au boot). ## fichier manquant, on conserve des valeurs par défaut (jamais de crash).
result = defaultConfig() result = defaultConfig()
try: try:
let path = configPath()
if not fileExists(path): if not fileExists(path):
return return
let n = parseFile(path) let n = parseFile(path)
@@ -206,10 +205,16 @@ proc loadConfig*(): Config =
# Fichier corrompu : on garde les valeurs par défaut. # Fichier corrompu : on garde les valeurs par défaut.
discard discard
proc saveConfig*(cfg: Config) = proc loadConfig*(): Config =
## Sauvegarde la configuration au format JSON (pretty-printé). ## Charge la configuration depuis le disque (configPath()).
loadConfigFrom(configPath())
proc saveConfigTo*(cfg: Config, path: string) =
## Sauvegarde la configuration au format JSON (pretty-printé) dans path.
try: try:
ensureConfigDir() let dir = path.parentDir
if dir.len > 0:
createDir(dir)
var n = newJObject() var n = newJObject()
n["workMin"] = %cfg.workMin n["workMin"] = %cfg.workMin
n["shortBreakMin"] = %cfg.shortBreakMin n["shortBreakMin"] = %cfg.shortBreakMin
@@ -245,10 +250,14 @@ proc saveConfig*(cfg: Config) =
rem.add o rem.add o
n["healthReminders"] = rem n["healthReminders"] = rem
writeFile(configPath(), n.pretty) writeFile(path, n.pretty)
except: except:
discard discard
proc saveConfig*(cfg: Config) =
## Sauvegarde la configuration sur le disque (configPath()).
saveConfigTo(cfg, configPath())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Démarrage automatique (registre) # Démarrage automatique (registre)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+6
View File
@@ -44,6 +44,7 @@ type Strings* = object
menuSettings*: string menuSettings*: string
menuStats*: string menuStats*: string
menuExportCsv*: string menuExportCsv*: string
menuHelp*: string
menuQuit*: string menuQuit*: string
# Notifications. # Notifications.
@@ -109,6 +110,7 @@ type Strings* = object
settingsSave*: string settingsSave*: string
settingsCancel*: string settingsCancel*: string
settingsSaved*: string settingsSaved*: string
webServerError*: string
settingsMinutes*: string settingsMinutes*: string
# Libellés génériques. # Libellés génériques.
@@ -154,6 +156,7 @@ const fr* = Strings(
menuSettings: "Paramètres", menuSettings: "Paramètres",
menuStats: "Statistiques", menuStats: "Statistiques",
menuExportCsv: "Exporter les statistiques (CSV)", menuExportCsv: "Exporter les statistiques (CSV)",
menuHelp: "Aide",
menuQuit: "Quitter", menuQuit: "Quitter",
notifWorkDone: "Travail terminé", notifWorkDone: "Travail terminé",
@@ -217,6 +220,7 @@ const fr* = Strings(
settingsSave: "Enregistrer", settingsSave: "Enregistrer",
settingsCancel: "Annuler", settingsCancel: "Annuler",
settingsSaved: "Paramètres enregistrés.", settingsSaved: "Paramètres enregistrés.",
webServerError: "Le serveur web local n'a pas pu démarrer.",
settingsMinutes: "min", settingsMinutes: "min",
btnOk: "OK", btnOk: "OK",
@@ -256,6 +260,7 @@ const en* = Strings(
menuSettings: "Settings", menuSettings: "Settings",
menuStats: "Statistics", menuStats: "Statistics",
menuExportCsv: "Export statistics (CSV)", menuExportCsv: "Export statistics (CSV)",
menuHelp: "Help",
menuQuit: "Quit", menuQuit: "Quit",
notifWorkDone: "Work session complete", notifWorkDone: "Work session complete",
@@ -319,6 +324,7 @@ const en* = Strings(
settingsSave: "Save", settingsSave: "Save",
settingsCancel: "Cancel", settingsCancel: "Cancel",
settingsSaved: "Settings saved.", settingsSaved: "Settings saved.",
webServerError: "The local web server could not be started.",
settingsMinutes: "min", settingsMinutes: "min",
btnOk: "OK", btnOk: "OK",
+31 -2
View File
@@ -23,6 +23,7 @@ import i18n
import dialogs import dialogs
import updater import updater
import floating import floating
import webserver
const const
TimerId = 1'i64 TimerId = 1'i64
@@ -39,6 +40,7 @@ var
gTimer: PomodoroTimer gTimer: PomodoroTimer
gStats: Stats gStats: Stats
gHwnd: HWND gHwnd: HWND
gServerPort = 0 ## Port du serveur web local (0 = non démarré).
gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé
## entre le thread de mise à jour et la boucle ## entre le thread de mise à jour et la boucle
## de messages). ## de messages).
@@ -87,11 +89,20 @@ proc stopTimer() =
proc quitApp() = proc quitApp() =
saveConfig(gCfg) saveConfig(gCfg)
stopWebServer()
hideFloating() hideFloating()
removeTrayIcon(gHwnd) removeTrayIcon(gHwnd)
UnregisterHotKey(gHwnd, HotkeyId) UnregisterHotKey(gHwnd, HotkeyId)
DestroyWindow(gHwnd) DestroyWindow(gHwnd)
proc openWebPage(path: string) =
## Ouvre une page servie localement dans le navigateur par défaut.
if gServerPort == 0:
discard MessageBox(gHwnd, S.webServerError, S.appName, MB_ICONERROR)
return
let url = "http://127.0.0.1:" & $gServerPort & path
discard ShellExecuteW(0, "open", url, nil, nil, SW_SHOWNORMAL)
proc openSettings() = proc openSettings() =
## Ouvre le panneau de réglages graphique. ## Ouvre le panneau de réglages graphique.
if showSettingsDialog(gCfg): if showSettingsDialog(gCfg):
@@ -143,6 +154,12 @@ proc onTick() =
of evWorkCompleted: onWorkCompleted() of evWorkCompleted: onWorkCompleted()
of evBreakCompleted: onBreakCompleted() of evBreakCompleted: onBreakCompleted()
of evNone: discard of evNone: discard
# Réglages sauvegardés depuis la page web : rechargement en douceur.
if webserver.consumeSettingsChanged():
gCfg = loadConfig()
setLang(resolveLang(gCfg))
applyHotkey()
showToast(gHwnd, S.appName, S.settingsSaved)
refreshTray() refreshTray()
proc skipBreak() = proc skipBreak() =
@@ -159,8 +176,17 @@ proc handleCommand(cmd: int) =
of IdStop: stopTimer() of IdStop: stopTimer()
of IdSkipBreak: skipBreak() of IdSkipBreak: skipBreak()
of IdExtendBreak: extendBreak() of IdExtendBreak: extendBreak()
of IdSettings: openSettings() of IdHelpPage: openWebPage("/help")
of IdStats: showStatsDialog() of IdSettings:
if gServerPort > 0:
openWebPage("/settings")
else:
openSettings() # repli sur le dialogue natif si le serveur est indisponible
of IdStats:
if gServerPort > 0:
openWebPage("/stats")
else:
showStatsDialog()
of IdExportCsv: exportStatsCsv() of IdExportCsv: exportStatsCsv()
of IdAutoStart: of IdAutoStart:
gCfg.autoStart = not gCfg.autoStart gCfg.autoStart = not gCfg.autoStart
@@ -275,6 +301,9 @@ proc main() =
# Icône dans le tray. # Icône dans le tray.
discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer)) discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
# Serveur web local (pages aide / statistiques / réglages).
gServerPort = startWebServer()
# Minuteur (1 tick par seconde). # Minuteur (1 tick par seconde).
discard SetTimer(gHwnd, TimerId, 1000, nil) discard SetTimer(gHwnd, TimerId, 1000, nil)
+14 -5
View File
@@ -16,11 +16,10 @@ proc todayStr*(): string =
proc initStats*(): Stats = proc initStats*(): Stats =
Stats(daily: initTable[string, int]()) Stats(daily: initTable[string, int]())
proc loadStats*(): Stats = proc loadStatsFrom*(path: string): Stats =
## Charge les stats depuis le disque (jamais de crash si corrompu). ## Charge les stats depuis le disque (jamais de crash si corrompu).
result = initStats() result = initStats()
try: try:
let path = statsPath()
if not fileExists(path): if not fileExists(path):
return return
let n = parseFile(path) let n = parseFile(path)
@@ -31,18 +30,28 @@ proc loadStats*(): Stats =
except: except:
discard discard
proc saveStats*(st: Stats) = proc loadStats*(): Stats =
## Charge les stats depuis le disque (statsPath()).
loadStatsFrom(statsPath())
proc saveStatsTo*(st: Stats, path: string) =
try: try:
ensureConfigDir() let dir = path.parentDir
if dir.len > 0:
createDir(dir)
var n = newJObject() var n = newJObject()
var daily = newJObject() var daily = newJObject()
for k, v in st.daily: for k, v in st.daily:
daily[k] = %v daily[k] = %v
n["daily"] = daily n["daily"] = daily
writeFile(statsPath(), n.pretty) writeFile(path, n.pretty)
except: except:
discard discard
proc saveStats*(st: Stats) =
## Sauvegarde les stats sur le disque (statsPath()).
saveStatsTo(st, statsPath())
proc recordPomodoro*(st: var Stats) = proc recordPomodoro*(st: var Stats) =
## Incrémente le compteur du jour courant puis sauvegarde. ## Incrémente le compteur du jour courant puis sauvegarde.
let today = todayStr() let today = todayStr()
+3
View File
@@ -21,6 +21,7 @@ const
IdStats* = 1004 IdStats* = 1004
IdQuit* = 1005 IdQuit* = 1005
IdExportCsv* = 1006 IdExportCsv* = 1006
IdHelpPage* = 1007
IdAutoStart* = 1500 IdAutoStart* = 1500
IdDoNotDisturb* = 1501 IdDoNotDisturb* = 1501
IdSkipBreak* = 1502 IdSkipBreak* = 1502
@@ -196,6 +197,8 @@ proc showTrayMenu*(hwnd: HWND, cfg: Config, t: PomodoroTimer) =
AppendMenu(menu, mfStr, int64(IdStats), S.menuStats) AppendMenu(menu, mfStr, int64(IdStats), S.menuStats)
AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv) AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv)
AppendMenu(menu, mfSep, 0, nil) AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdHelpPage), S.menuHelp)
AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit) AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit)
var pt: POINT var pt: POINT
+642
View File
@@ -0,0 +1,642 @@
## webserver.nim
## ==============
## Serveur HTTP local (127.0.0.1) qui sert des pages web générées en Nim :
## aide, statistiques et réglages. Les pages sont ouvertes dans le navigateur
## par défaut de l'utilisateur (ShellExecuteW côté application).
##
## Le serveur tourne dans un thread dédié. Il lit/écrit directement les
## fichiers config.json et stats.json (pas de partage de mémoire GC entre
## threads) et signale une sauvegarde des réglages via le hook
## onSettingsSavedHook (l'application l'utilise pour recharger la config).
##
## Aucune dépendance Win32 : ce module est testable unitairement.
import std/[net, nativesockets, strutils, uri, json, os, tables]
import config
import stats
import i18n
const
DefaultPort* = 38943 ## Port de base du serveur local.
PortAttempts = 6 ## Nombre de ports essayés si le premier est pris.
MaxRequestSize = 32768 ## Taille maximale d'une requête (en-têtes + corps).
const
MaxDataDirLen = 512 ## Longueur max du chemin de surcharge (tests).
var
running = false
gPort = 0
keepSock: Socket ## Référence maintenue vivante (évite la fermeture GC).
serverThread: Thread[SocketHandle]
dataDirBuf: array[MaxDataDirLen, char] ## Surcharge (tests), mémoire non-GC :
## lisible sans risque par le thread serveur.
settingsChangedFlag = false ## Posé par le thread serveur après une
## sauvegarde ; consommé par le thread principal.
proc setDataDirOverride*(dir: string) =
## Surcharge le répertoire de données (utile pour les tests). À appeler
## AVANT startWebServer(). Le thread serveur n'accède qu'à un buffer
## non-GC, ce qui reste sûr avec --threads:on.
zeroMem(addr dataDirBuf[0], MaxDataDirLen)
if dir.len > 0:
let n = min(dir.len, MaxDataDirLen - 1)
copyMem(addr dataDirBuf[0], cstring(dir), n)
proc dataDir(): string {.gcsafe.} =
## Répertoire des données : la surcharge (tests) si définie, sinon configDir().
if dataDirBuf[0] != '\0':
result = newString(0)
for c in dataDirBuf:
if c == '\0': break
result.add c
else:
result = configDir()
proc configFile(): string {.gcsafe.} = dataDir() / "config.json"
proc statsFile(): string {.gcsafe.} = dataDir() / "stats.json"
proc markSettingsChanged() {.gcsafe.} =
## Posé par le thread serveur (booléen simple, sûr sans verrou).
settingsChangedFlag = true
proc consumeSettingsChanged*(): bool =
## Consulté par le thread principal : true si des réglages ont été sauvés
## depuis le dernier appel.
result = settingsChangedFlag
settingsChangedFlag = false
# ---------------------------------------------------------------------------
# Helpers HTTP / HTML
# ---------------------------------------------------------------------------
proc htmlEscape*(s: string): string =
## Échappe les caractères HTML (& < > " ').
result = ""
for c in s:
case c
of '&': result.add "&amp;"
of '<': result.add "&lt;"
of '>': result.add "&gt;"
of '"': result.add "&quot;"
of '\'': result.add "&#39;"
else: result.add c
type
HttpRequest* = object
verb*: string ## GET / POST
path*: string ## chemin décodé (sans la query)
body*: string ## corps (POST)
proc contentLengthFrom(headers: string): int =
## Extrait Content-Length des en-têtes (0 si absent).
for line in headers.split("\r\n"):
let ci = line.find(':')
if ci > 0 and line[0 ..< ci].toLowerAscii().strip() == "content-length":
try: return parseInt(line[ci + 1 .. ^1].strip())
except ValueError: return 0
return 0
proc parseRequest*(data: string): HttpRequest =
## Parse une requête HTTP (ligne de requête + en-têtes + corps déjà lus).
let headerEnd = data.find("\r\n\r\n")
let headerPart = if headerEnd >= 0: data[0 ..< headerEnd] else: data
let lines = headerPart.split("\r\n")
if lines.len > 0:
let parts = lines[0].split(' ')
if parts.len >= 2:
result.verb = parts[0]
let target = parts[1]
let qi = target.find('?')
if qi >= 0:
result.path = decodeUrl(target[0 ..< qi])
else:
result.path = decodeUrl(target)
if headerEnd >= 0:
let bodyLen = contentLengthFrom(headerPart)
if bodyLen > 0 and data.len >= headerEnd + 4 + bodyLen:
result.body = data[headerEnd + 4 ..< headerEnd + 4 + bodyLen]
proc parseForm*(body: string): Table[string, string] =
## Décode un corps application/x-www-form-urlencoded.
for pair in body.split('&'):
if pair.len == 0: continue
let eq = pair.find('=')
if eq < 0:
result[decodeUrl(pair)] = ""
else:
result[decodeUrl(pair[0 ..< eq])] = decodeUrl(pair[eq + 1 .. ^1])
proc httpResponse(body: string, status = "200 OK",
contentType = "text/html; charset=utf-8"): string =
"HTTP/1.1 " & status & "\r\n" &
"Content-Type: " & contentType & "\r\n" &
"Content-Length: " & $body.len & "\r\n" &
"Cache-Control: no-store\r\n" &
"Connection: close\r\n\r\n" & body
proc t(lang: Lang, fr, en: string): string =
## Traduit selon la langue résolue.
if lang == langFr: fr else: en
# ---------------------------------------------------------------------------
# Gabarit de page
# ---------------------------------------------------------------------------
proc pageShell(lang: Lang, title, body: string): string =
let nav = "<nav><a href=\"/help\">" & t(lang, "Aide", "Help") &
"</a> <a href=\"/stats\">" & t(lang, "Statistiques", "Statistics") &
"</a> <a href=\"/settings\">" & t(lang, "Réglages", "Settings") &
"</a></nav>"
result = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NimPulse — """ & title & """</title>
<style>
:root{color-scheme:light dark}
body{font-family:system-ui,'Segoe UI',sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.65;color:#1f2937;background:#fff}
@media (prefers-color-scheme: dark){
body{background:#111827;color:#e5e7eb}
h1{color:#5eead4} h2{color:#99f6e4;border-color:#374151}
code,pre{background:#1f2937} .card{background:#1f2937;border-color:#374151}
th,td{border-color:#374151} .bar{background:#374151} .muted{color:#9ca3af}
input,select,button{color-scheme:dark}
}
h1{color:#0f766e} h2{margin-top:2rem;color:#115e59;border-bottom:1px solid #e5e7eb;padding-bottom:.3rem}
code,pre{background:#f3f4f6;padding:.1rem .35rem;border-radius:4px;font-size:.92em}
table{border-collapse:collapse;width:100%;margin:.6rem 0}
th,td{border:1px solid #e5e7eb;padding:.4rem .6rem;text-align:left}
nav{display:flex;gap:1.2rem;margin-bottom:1.6rem;padding:.6rem .8rem;border-radius:8px;background:#f0fdfa;border:1px solid #99f6e4}
nav a{color:#0f766e;text-decoration:none;font-weight:600}
.card{background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:1rem 1.2rem;margin:1rem 0}
.bar{background:#e5e7eb;border-radius:6px;height:14px;overflow:hidden;min-width:30px;display:inline-block;vertical-align:middle}
.bar>div{background:#14b8a6;height:100%}
.muted{color:#6b7280;font-size:.92em}
.btn{display:inline-block;background:#0f766e;color:#fff;padding:.45rem .95rem;border-radius:6px;text-decoration:none;border:0;font-size:.95rem;cursor:pointer}
.btn:hover{background:#115e59}
label{display:block;margin:.55rem 0 .15rem;font-weight:600}
input[type=text],input[type=number],select{width:100%;max-width:320px;padding:.35rem .5rem;border:1px solid #d1d5db;border-radius:6px}
input[type=checkbox]{width:auto}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 1.5rem}
@media (max-width:640px){.grid{grid-template-columns:1fr}}
.kbd{background:#f3f4f6;border:1px solid #d1d5db;border-bottom-width:2px;border-radius:4px;padding:.05rem .4rem;font-family:ui-monospace,monospace;font-size:.9em}
.ok{color:#047857;font-weight:700}
</style>
</head>
<body>
""" & nav & body & "</body></html>"
# ---------------------------------------------------------------------------
# Page d'aide
# ---------------------------------------------------------------------------
proc renderHelp*(cfg: Config): string =
let lang = resolveLang(cfg)
var b = ""
b.add "<h1>" & t(lang,
"Aide — Comment fonctionne NimPulse",
"Help — How NimPulse works") & "</h1>"
b.add "<div class=\"card\"><h2>" & t(lang, "Le principe", "The concept") & "</h2><p>" &
t(lang,
"NimPulse est un minuteur Pomodoro qui vit dans la zone de notification (tray). " &
"Il alterne des périodes de travail concentré et des pauses, et profite des pauses " &
"pour vous rappeler de prendre soin de votre santé (marcher, boire de l'eau, vous étirer…).",
"NimPulse is a Pomodoro timer living in the system tray. It alternates focused work " &
"sessions with breaks, and uses breaks to remind you to take care of your health " &
"(walk, drink water, stretch…).") & "</p></div>"
b.add "<h2>" & t(lang, "Premiers pas", "Getting started") & "</h2><ul>" &
t(lang,
"<li>Au premier lancement, un assistant vous guide (langue, profil, rappels santé).</li>" &
"<li>L'application démarre minimisée : son icône colorée se trouve dans la zone de notification.</li>" &
"<li>Un clic droit sur l'icône ouvre le menu ; un double-clic démarre ou met en pause.</li>" &
"<li>Le raccourci global <span class=\"kbd\">Ctrl+Alt+P</span> démarre/met en pause sans toucher la souris.</li>",
"<li>On first launch, a wizard guides you (language, profile, health reminders).</li>" &
"<li>The app starts minimized: its colored icon lives in the notification area.</li>" &
"<li>Right-click the icon for the menu; double-click to start or pause.</li>" &
"<li>The global shortcut <span class=\"kbd\">Ctrl+Alt+P</span> starts/pauses without touching the mouse.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Les périodes", "The periods") & "</h2><p>" &
t(lang,
"Un cycle classique : travail (25 min), pause courte (5 min), et après plusieurs cycles, " &
"une pause longue (15 min). Le nombre de cycles avant la pause longue est configurable, " &
"ainsi que les durées via les profils :",
"A classic cycle: work (25 min), short break (5 min), and after several cycles a long " &
"break (15 min). The number of cycles before the long break is configurable, as are the " &
"durations via the profiles:") & "</p><table><tr><th>" &
t(lang, "Profil", "Profile") & "</th><th>" & t(lang, "Travail", "Work") & "</th><th>" &
t(lang, "Pause courte", "Short break") & "</th><th>" & t(lang, "Pause longue", "Long break") & "</th><th>" &
t(lang, "Cycles", "Cycles") & "</th></tr>" &
"<tr><td>" & t(lang, "Classique", "Classic") & "</td><td>25 min</td><td>5 min</td><td>15 min</td><td>4</td></tr>" &
"<tr><td>Deep Work</td><td>50 min</td><td>10 min</td><td>20 min</td><td>4</td></tr>" &
"<tr><td>Sprint</td><td>15 min</td><td>3 min</td><td>10 min</td><td>6</td></tr></table>" &
"<p>" & t(lang,
"Le profil actif se choisit dans le menu de l'icône (« Profils »).",
"The active profile is chosen from the icon menu (“Profiles”).") & "</p>"
b.add "<h2>" & t(lang, "Pendant une pause", "During a break") & "</h2><ul>" &
t(lang,
"<li>« Sauter la pause » passe directement au travail suivant.</li>" &
"<li>« Prolonger la pause » ajoute 5 minutes.</li>" &
"<li>Les rappels santé défilent dans les notifications pendant les pauses, sans répéter deux fois le même.</li>",
"<li>“Skip break” jumps straight to the next work session.</li>" &
"<li>“Extend break” adds 5 minutes.</li>" &
"<li>Health reminders rotate through notifications during breaks, never repeating the same one twice.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2><p>" &
t(lang,
"Les rappels sont entièrement modifiables depuis la page Réglages : ajoutez, modifiez, " &
"supprimez ou désactivez vos propres activités. Seuls les rappels activés sont affichés.",
"Reminders are fully editable from the Settings page: add, edit, remove or disable your " &
"own activities. Only enabled reminders are shown.") & "</p>"
b.add "<h2>" & t(lang, "Statistiques et objectif", "Statistics and goal") & "</h2><p>" &
t(lang,
"La page Statistiques affiche vos pomodoros du jour, de la semaine, votre série de jours " &
"consécutifs et le total. Vous pouvez définir un objectif journalier dans les réglages : " &
"une notification vous prévient quand il est atteint. Le menu de l'icône permet aussi " &
"d'exporter l'historique en CSV.",
"The Statistics page shows your pomodoros for today, this week, your streak of consecutive " &
"days and the total. You can set a daily goal in settings: a notification tells you when " &
"it is reached. The icon menu also lets you export the history as CSV.") & "</p>"
b.add "<h2>" & t(lang, "Réglages", "Settings") & "</h2><ul>" &
t(lang,
"<li><b>Durées</b> : valeurs de base du minuteur (si aucun profil ne correspond).</li>" &
"<li><b>Objectif journalier</b> : nombre de pomodoros par jour (0 = aucun).</li>" &
"<li><b>Son</b> : joue un son en fin de période.</li>" &
"<li><b>Ne pas déranger</b> : suspend les rappels santé (le minuteur continue).</li>" &
"<li><b>Démarrage avec Windows</b> : lance NimPulse à l'ouverture de session.</li>" &
"<li><b>Langue</b> : automatique, français ou anglais.</li>" &
"<li><b>Fenêtre flottante</b> : petit panneau toujours au-dessus affichant la phase et le temps restant.</li>",
"<li><b>Durations</b>: base timer values (when no profile matches).</li>" &
"<li><b>Daily goal</b>: pomodoros per day (0 = none).</li>" &
"<li><b>Sound</b>: plays a sound at the end of a period.</li>" &
"<li><b>Do not disturb</b>: suspends health reminders (the timer keeps running).</li>" &
"<li><b>Start with Windows</b>: launches NimPulse at logon.</li>" &
"<li><b>Language</b>: automatic, French or English.</li>" &
"<li><b>Floating window</b>: small always-on-top panel showing the phase and remaining time.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Astuces", "Tips") & "</h2><ul>" &
t(lang,
"<li>En <b>mode portable</b> (fichier vide <code>portable</code> à côté de l'exécutable), " &
"la configuration et les statistiques sont stockées au même endroit que l'application — pratique sur clé USB.</li>" &
"<li>Lancer NimPulse une seconde fois ne crée pas de doublon (mono-instance).</li>" &
"<li>Ces pages web (aide, statistiques, réglages) sont servies localement par l'application : " &
"aucune donnée ne quitte votre ordinateur.</li>",
"<li>In <b>portable mode</b> (empty file <code>portable</code> next to the executable), " &
"configuration and statistics are stored next to the app — handy on USB sticks.</li>" &
"<li>Launching NimPulse a second time does not create a duplicate (single instance).</li>" &
"<li>These web pages (help, statistics, settings) are served locally by the app: " &
"no data leaves your computer.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Raccourcis", "Shortcuts") & "</h2><table>" &
"<tr><th>" & t(lang, "Action", "Action") & "</th><th>" & t(lang, "Raccourci", "Shortcut") & "</th></tr>" &
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td><span class=\"kbd\">Ctrl+Alt+P</span></td></tr>" &
"<tr><td>" & t(lang, "Menu contextuel", "Context menu") & "</td><td>" & t(lang, "Clic droit sur l'icône", "Right-click the icon") & "</td></tr>" &
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td>" & t(lang, "Double-clic", "Double-click") & "</td></tr>" &
"</table>"
pageShell(lang, t(lang, "Aide", "Help"), b)
# ---------------------------------------------------------------------------
# Page statistiques
# ---------------------------------------------------------------------------
proc renderStats*(cfg: Config): string =
let lang = resolveLang(cfg)
let st = loadStatsFrom(statsFile())
let today = st.completedToday()
let week = st.completedThisWeek()
let streak = st.streak()
let total = st.totalPomodoros()
let days = st.lastNDays(7)
var maxv = 1
for (_, n) in days:
if n > maxv: maxv = n
var b = ""
b.add "<h1>" & t(lang, "Statistiques", "Statistics") & "</h1>"
b.add "<div class=\"card\"><h2>" & t(lang, "Aujourd'hui", "Today") & "</h2>" &
"<p style=\"font-size:2.2rem;margin:.2rem 0;font-weight:700\">" & $today & " <span class=\"muted\">" &
t(lang, "pomodoro(s)", "pomodoro(s)") & "</span></p>" &
(if cfg.dailyGoal > 0:
"<p class=\"muted\">" & t(lang, "Objectif", "Goal") & " : " & $today & " / " & $cfg.dailyGoal & "</p>" &
"<div class=\"bar\" style=\"width:100%\"><div style=\"width:" &
$(if today >= cfg.dailyGoal: 100 else: today * 100 div cfg.dailyGoal) & "%\"></div></div>"
else: "") &
"</div>"
b.add "<div class=\"grid\"><div class=\"card\"><h2>" & t(lang, "Cette semaine", "This week") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $week & "</p></div>" &
"<div class=\"card\"><h2>" & t(lang, "Série (streak)", "Streak") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $streak & " <span class=\"muted\">" &
t(lang, "jour(s)", "day(s)") & "</span></p></div></div>"
b.add "<div class=\"card\"><h2>" & t(lang, "Total", "Total") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $total & " <span class=\"muted\">" &
t(lang, "pomodoros complétés", "completed pomodoros") & "</span></p></div>"
b.add "<h2>" & t(lang, "7 derniers jours", "Last 7 days") & "</h2><table>" &
"<tr><th>" & t(lang, "Date", "Date") & "</th><th>" & t(lang, "Pomodoros", "Pomodoros") & "</th></tr>"
for (date, n) in days:
b.add "<tr><td>" & htmlEscape(date) & "</td><td>" & $n & " <span class=\"bar\" style=\"width:120px\"><div style=\"width:" &
$(n * 100 div maxv) & "%\"></div></span></td></tr>"
b.add "</table>"
b.add "<p class=\"muted\">" & t(lang,
"Un export CSV est disponible depuis le menu de l'icône (Exporter les statistiques).",
"A CSV export is available from the icon menu (Export statistics).") & "</p>"
pageShell(lang, t(lang, "Statistiques", "Statistics"), b)
# ---------------------------------------------------------------------------
# Page réglages
# ---------------------------------------------------------------------------
proc remindersJson(rs: seq[HealthReminder]): string =
## Sérialise les rappels en JSON (compact) pour le champ caché / le script.
var items: seq[string]
for r in rs:
items.add "{\"text\":" & $(%r.text) & ",\"enabled\":" & $(%r.enabled) & "}"
"[" & items.join(",") & "]"
proc renderSettings*(cfg: Config): string =
let lang = resolveLang(cfg)
var profileOpts = ""
for p in cfg.profiles:
let sel = if p.name == cfg.activeProfile: " selected" else: ""
profileOpts.add "<option value=\"" & htmlEscape(p.name) & "\"@@" & sel & ">" &
htmlEscape(p.name) & "</option>"
let langOpts =
"<option value=\"auto\"@@" & (if cfg.lang == "auto": " selected" else: "") & ">" &
t(lang, "Automatique", "Automatic") & "</option>" &
"<option value=\"fr\"@@" & (if cfg.lang == "fr": " selected" else: "") & ">Français</option>" &
"<option value=\"en\"@@" & (if cfg.lang == "en": " selected" else: "") & ">English</option>"
var b = ""
b.add "<h1>" & t(lang, "Réglages", "Settings") & "</h1>"
b.add "<form method=\"post\" action=\"/settings\" onsubmit=\"syncReminders()\">"
b.add "<div class=\"grid\"><div>"
b.add "<label>" & t(lang, "Travail (min)", "Work (min)") & "</label>" &
"<input type=\"number\" name=\"workMin\" min=\"1\" value=\"" & $cfg.workMin & "\">"
b.add "<label>" & t(lang, "Pause courte (min)", "Short break (min)") & "</label>" &
"<input type=\"number\" name=\"shortBreakMin\" min=\"1\" value=\"" & $cfg.shortBreakMin & "\">"
b.add "<label>" & t(lang, "Pause longue (min)", "Long break (min)") & "</label>" &
"<input type=\"number\" name=\"longBreakMin\" min=\"1\" value=\"" & $cfg.longBreakMin & "\">"
b.add "<label>" & t(lang, "Cycles avant pause longue", "Cycles before long break") & "</label>" &
"<input type=\"number\" name=\"cyclesBeforeLongBreak\" min=\"1\" value=\"" & $cfg.cyclesBeforeLongBreak & "\">"
b.add "<label>" & t(lang, "Objectif journalier (0 = aucun)", "Daily goal (0 = none)") & "</label>" &
"<input type=\"number\" name=\"dailyGoal\" min=\"0\" value=\"" & $cfg.dailyGoal & "\">"
b.add "</div><div>"
b.add "<label>" & t(lang, "Langue", "Language") & "</label>" &
"<select name=\"lang\">" & langOpts & "</select>"
b.add "<label>" & t(lang, "Profil actif", "Active profile") & "</label>" &
"<select name=\"activeProfile\">" & profileOpts & "</select>"
b.add "<p style=\"margin-top:.9rem\">"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"soundEnabled\"@@" &
(if cfg.soundEnabled: " checked" else: "") & "> " & t(lang, "Son en fin de période", "Sound at end of period") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"doNotDisturb\"@@" &
(if cfg.doNotDisturb: " checked" else: "") & "> " & t(lang, "Ne pas déranger", "Do not disturb") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"autoStart\"@@" &
(if cfg.autoStart: " checked" else: "") & "> " & t(lang, "Démarrage avec Windows", "Start with Windows") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"floatingWindow\"@@" &
(if cfg.floatingWindow: " checked" else: "") & "> " & t(lang, "Fenêtre flottante", "Floating window") & "</label>"
b.add "</p></div></div>"
# --- Rappels santé (éditeur client) ---
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2>"
b.add "<div id=\"reminders\"></div>"
b.add "<button type=\"button\" class=\"btn\" onclick=\"addReminder()\" style=\"margin-top:.6rem\">" &
t(lang, "Ajouter un rappel", "Add a reminder") & "</button>"
b.add "<input type=\"hidden\" name=\"reminders_json\" id=\"reminders_json\">"
b.add "<p style=\"margin-top:1.4rem\"><button type=\"submit\" class=\"btn\">" &
t(lang, "Enregistrer", "Save") & "</button></p>"
b.add "</form>"
b.add "<script>"
b.add "var reminders = " & remindersJson(cfg.healthReminders) & ";"
b.add """var R = function(fr, en){ return document.documentElement.lang === 'fr' ? fr : en; };
function renderReminders(){
var el = document.getElementById('reminders');
if (reminders.length === 0){ el.innerHTML = '<p class="muted">' + R('Aucun rappel.','No reminders.') + '</p>'; return; }
var html = '<table><tr><th></th><th>' + R('Texte','Text') + '</th><th></th><th></th></tr>';
for (var i = 0; i < reminders.length; i++){
var r = reminders[i];
var safe = r.text.replace(/&/g,'&amp;').replace(/</g,'&lt;');
html += '<tr><td><input type="checkbox" ' + (r.enabled ? 'checked' : '') + ' onchange="toggleReminder(' + i + ')"></td>' +
'<td>' + safe + '</td>' +
'<td><button type="button" onclick="editReminder(' + i + ')">' + R('Modifier','Edit') + '</button></td>' +
'<td><button type="button" onclick="removeReminder(' + i + ')">' + R('Supprimer','Remove') + '</button></td></tr>';
}
html += '</table>';
el.innerHTML = html;
}
function addReminder(){
var t = prompt(R('Nouveau rappel santé :','New health reminder:'));
if (t && t.trim().length > 0){ reminders.push({text: t.trim(), enabled: true}); renderReminders(); }
}
function editReminder(i){
var t = prompt(R('Modifier le rappel :','Edit reminder:'), reminders[i].text);
if (t && t.trim().length > 0){ reminders[i].text = t.trim(); renderReminders(); }
}
function removeReminder(i){ reminders.splice(i, 1); renderReminders(); }
function toggleReminder(i){ reminders[i].enabled = !reminders[i].enabled; }
function syncReminders(){ document.getElementById('reminders_json').value = JSON.stringify(reminders); }
renderReminders();
"""
b.add "</script>"
pageShell(lang, t(lang, "Réglages", "Settings"), b)
proc renderSaved(cfg: Config): string =
let lang = resolveLang(cfg)
var b = "<h1>" & t(lang, "Réglages enregistrés", "Settings saved") & " ✓</h1>" &
"<div class=\"card\"><p>" &
t(lang, "Votre configuration a été enregistrée et appliquée.",
"Your configuration has been saved and applied.") &
"</p><p><a class=\"btn\" href=\"/settings\">" & t(lang, "Retour aux réglages", "Back to settings") & "</a> " &
"<a class=\"btn\" href=\"/stats\">" & t(lang, "Voir les statistiques", "View statistics") & "</a></p></div>"
pageShell(lang, t(lang, "Réglages enregistrés", "Settings saved"), b)
# ---------------------------------------------------------------------------
# Application d'un formulaire de réglages
# ---------------------------------------------------------------------------
proc parseIntSafe(s: string, dflt: int): int =
try: result = parseInt(s)
except ValueError: result = dflt
proc applySettings*(cfg: Config, form: Table[string, string]): Config =
## Applique les valeurs d'un formulaire POST à une configuration.
result = cfg
result.workMin = parseIntSafe(form.getOrDefault("workMin"), cfg.workMin)
result.shortBreakMin = parseIntSafe(form.getOrDefault("shortBreakMin"), cfg.shortBreakMin)
result.longBreakMin = parseIntSafe(form.getOrDefault("longBreakMin"), cfg.longBreakMin)
result.cyclesBeforeLongBreak =
parseIntSafe(form.getOrDefault("cyclesBeforeLongBreak"), cfg.cyclesBeforeLongBreak)
result.dailyGoal = parseIntSafe(form.getOrDefault("dailyGoal"), cfg.dailyGoal)
result.soundEnabled = form.hasKey("soundEnabled")
result.doNotDisturb = form.hasKey("doNotDisturb")
result.autoStart = form.hasKey("autoStart")
result.floatingWindow = form.hasKey("floatingWindow")
let lang = form.getOrDefault("lang")
if lang in ["auto", "fr", "en"]:
result.lang = lang
let prof = form.getOrDefault("activeProfile")
for p in result.profiles:
if p.name == prof:
result.activeProfile = prof
break
let remJson = form.getOrDefault("reminders_json")
if remJson.len > 0:
try:
let n = parseJson(remJson)
if n.kind == JArray:
var newRem: seq[HealthReminder]
for r in n:
let text = r.getOrDefault("text").getStr().strip()
if text.len > 0:
newRem.add HealthReminder(text: text,
enabled: r.getOrDefault("enabled").getBool(true))
if newRem.len > 0:
result.healthReminders = newRem
except:
discard
# ---------------------------------------------------------------------------
# Routage
# ---------------------------------------------------------------------------
proc handleRequest(req: HttpRequest): string {.gcsafe.} =
case req.path
of "/", "/help":
httpResponse(renderHelp(loadConfigFrom(configFile())))
of "/stats":
httpResponse(renderStats(loadConfigFrom(configFile())))
of "/settings":
if req.verb == "POST":
let cfg = applySettings(loadConfigFrom(configFile()), parseForm(req.body))
saveConfigTo(cfg, configFile())
markSettingsChanged()
httpResponse(renderSaved(cfg))
else:
httpResponse(renderSettings(loadConfigFrom(configFile())))
of "/favicon.ico":
"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"
else:
httpResponse("<h1>404</h1><p>" &
t(resolveLang(loadConfigFrom(configFile())), "Page introuvable.", "Page not found.") &
"</p>", status = "404 Not Found")
# ---------------------------------------------------------------------------
# Lecture d'une requête sur la socket
# ---------------------------------------------------------------------------
proc setRecvTimeout(client: Socket) {.gcsafe.} =
## Timeout de lecture de 10 s au niveau socket : un client silencieux ne
## bloque pas le thread serveur indéfiniment (recv bloquant simple).
when defined(windows):
const SoRcvTimeout = 0x1006'i32 ## SO_RCVTIMEO (winsock2)
client.getFd().setSockOptInt(SOL_SOCKET, SoRcvTimeout, 10000)
proc readRequest(client: Socket): HttpRequest =
var data = ""
var buf = newString(4096)
while data.len < MaxRequestSize:
var chunk = ""
try:
chunk = client.recv(4096)
except OSError:
break
if chunk.len == 0:
break
data.add chunk
let headerEnd = data.find("\r\n\r\n")
if headerEnd >= 0:
let bodyLen = contentLengthFrom(data[0 ..< headerEnd])
if data.len >= headerEnd + 4 + bodyLen:
break
parseRequest(data)
# ---------------------------------------------------------------------------
# Boucle du serveur (thread dédié)
# ---------------------------------------------------------------------------
proc serverLoop(fd: SocketHandle) {.thread.} =
var server: owned(Socket)
try:
server = newSocket(fd, buffered = false)
except:
running = false
return
server.getFd().setBlocking(false)
while running:
var client: owned(Socket)
try:
server.accept(client)
except OSError:
sleep(100) # aucune connexion en attente : on réessaie
continue
client.getFd().setBlocking(true)
setRecvTimeout(client)
try:
let req = readRequest(client)
let resp = handleRequest(req)
client.send(resp)
except:
discard
try: client.close()
except: discard
# ---------------------------------------------------------------------------
# Démarrage / arrêt
# ---------------------------------------------------------------------------
proc startWebServer*(): int =
## Démarre le serveur sur 127.0.0.1 (premier port libre à partir de
## DefaultPort). Retourne le port choisi, ou 0 en cas d'échec.
if gPort != 0:
return gPort
running = true
for attempt in 0 ..< PortAttempts:
var srv: Socket
try:
srv = newSocket()
srv.setSockOpt(OptReuseAddr, true)
srv.bindAddr(Port(DefaultPort + attempt), "127.0.0.1")
srv.listen()
except:
if srv != nil:
try: srv.close()
except: discard
continue
keepSock = srv
gPort = DefaultPort + attempt
createThread(serverThread, serverLoop, srv.getFd())
return gPort
running = false
return 0
proc stopWebServer*() =
## Arrête le serveur local (attend la fin du thread).
if gPort != 0:
running = false
joinThread(serverThread)
gPort = 0
keepSock = nil
+134
View File
@@ -0,0 +1,134 @@
## test_webserver.nim
## ==================
## Tests du module webserver : parsing HTTP, décodage du formulaire,
## application des réglages, rendu des pages et intégration réelle
## (serveur local 127.0.0.1 interrogé en HTTP).
import std/[unittest, httpclient, os, uri, tables, strutils]
import ../src/webserver
import ../src/config
proc tmpDir(): string =
getAppFilename().parentDir / ".webdata_test"
suite "http helpers":
test "parseRequest parses GET with query":
let req = parseRequest("GET /help?lang=fr HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
check req.verb == "GET"
check req.path == "/help"
check req.body == ""
test "parseRequest parses POST with body":
let req = parseRequest(
"POST /settings HTTP/1.1\r\nContent-Length: 10\r\n\r\nworkMin=30")
check req.verb == "POST"
check req.path == "/settings"
check req.body == "workMin=30"
test "parseRequest decodes percent-encoding":
let req = parseRequest("GET /settings%3Fx HTTP/1.1\r\n\r\n")
check req.path == "/settings?x"
test "htmlEscape escapes special characters":
check htmlEscape("<a href=\"x\">&'") == "&lt;a href=&quot;x&quot;&gt;&amp;&#39;"
test "parseForm decodes urlencoded pairs":
let f = parseForm("workMin=30&lang=fr&soundEnabled=on&note=Hello%20world")
check f["workMin"] == "30"
check f["lang"] == "fr"
check f["soundEnabled"] == "on"
check f["note"] == "Hello world"
suite "applySettings":
test "applies a full form":
let base = defaultConfig()
let form = parseForm("workMin=40&shortBreakMin=8&longBreakMin=20&" &
"cyclesBeforeLongBreak=5&dailyGoal=6&lang=en&activeProfile=Sprint&" &
"soundEnabled=on&doNotDisturb=on&" &
"reminders_json=" & encodeUrl("""[{"text":"Boire de l'eau","enabled":true}]"""))
let cfg = applySettings(base, form)
check cfg.workMin == 40
check cfg.shortBreakMin == 8
check cfg.longBreakMin == 20
check cfg.cyclesBeforeLongBreak == 5
check cfg.dailyGoal == 6
check cfg.lang == "en"
check cfg.activeProfile == "Sprint"
check cfg.soundEnabled
check cfg.doNotDisturb
check not cfg.autoStart
check cfg.healthReminders.len == 1
check cfg.healthReminders[0].text == "Boire de l'eau"
test "invalid values fall back to current ones":
let base = defaultConfig()
let form = parseForm("workMin=abc&lang=zz&activeProfile=Inexistant")
let cfg = applySettings(base, form)
check cfg.workMin == base.workMin
check cfg.lang == "auto"
check cfg.activeProfile == base.activeProfile
suite "rendering":
test "pages contain expected content":
var cfg = defaultConfig()
cfg.lang = "fr" # langue fixe : le rendu ne dépend pas du système
check "NimPulse" in renderHelp(cfg)
check "Pomodoro" in renderHelp(cfg)
check "Statistiques" in renderStats(cfg)
check "Réglages" in renderSettings(cfg)
check "name=\"workMin\"" in renderSettings(cfg)
check "reminders_json" in renderSettings(cfg)
suite "integration (local HTTP server)":
test "GET routes work and settings POST persists":
let dir = tmpDir()
removeDir(dir)
createDir(dir)
# Configuration initiale en français : le rendu est déterministe.
var initCfg = defaultConfig()
initCfg.lang = "fr"
saveConfigTo(initCfg, dir / "config.json")
setDataDirOverride(dir)
defer: setDataDirOverride("")
let port = startWebServer()
check port > 0
defer: stopWebServer()
let base = "http://127.0.0.1:" & $port
let client = newHttpClient()
let help = client.getContent(base & "/help")
check "NimPulse" in help
let stats = client.getContent(base & "/stats")
check "Statistiques" in stats
let settings = client.getContent(base & "/settings")
check "name=\"workMin\"" in settings
# Sauvegarde via POST (comme le ferait le navigateur).
let body = "workMin=33&shortBreakMin=6&longBreakMin=18&cyclesBeforeLongBreak=4&" &
"dailyGoal=5&lang=fr&activeProfile=Classique&" &
"reminders_json=" & encodeUrl(
"""[{"text":"Étire-toi","enabled":true},{"text":"Bois de l'eau","enabled":false}]""")
let resp = client.postContent(base & "/settings", body)
check "Réglages enregistrés" in resp
# Le fichier config.json a été écrit dans le répertoire de test.
let saved = loadConfigFrom(dir / "config.json")
check saved.workMin == 33
check saved.dailyGoal == 5
check saved.lang == "fr"
check saved.healthReminders.len == 2
check saved.healthReminders[1].enabled == false
# Le drapeau de rechargement est posé puis consommé.
check consumeSettingsChanged()
check not consumeSettingsChanged()
# La page réglages reflète les nouvelles valeurs.
let settings2 = client.getContent(base & "/settings")
check "value=\"33\"" in settings2
removeDir(dir)