diff --git a/README.md b/README.md index e688a00..a06ce7b 100644 --- a/README.md +++ b/README.md @@ -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). - **Mise à jour automatique** (optionnelle) : vérification en arrière-plan, 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 @@ -151,7 +154,9 @@ src/ ├── i18n.nim # chaînes localisées (français / anglais), détection langue ├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim) ├── 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 @@ -167,11 +172,29 @@ Windows. | Menu contextuel | Clic droit | | 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 -Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`) sont -testés unitairement, ainsi que l'intégration Win32 (`tray`). Un exécutable -unique les lance toutes et affiche un résumé : +Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`, +`webserver`) sont testés unitairement, ainsi que l'intégration Win32 +(`tray`). Un exécutable unique les lance toutes et affiche un résumé : ```bash 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_updater.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 diff --git a/VERIFICATION.md b/VERIFICATION.md index ed79270..9d8b4fa 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -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** | | 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 | 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) -51 tests au total, tous verts (7 suites). +60 tests au total, tous verts (8 suites). diff --git a/run_tests.cmd b/run_tests.cmd index 938aaae..fddcdbd 100644 --- a/run_tests.cmd +++ b/run_tests.cmd @@ -8,7 +8,7 @@ setlocal enabledelayedexpansion set PASS=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 === %%t === nim c -r --hints:off tests\%%t.nim diff --git a/src/config.nim b/src/config.nim index 8d37af2..1d622a7 100644 --- a/src/config.nim +++ b/src/config.nim @@ -156,12 +156,11 @@ proc getActiveProfile*(cfg: Config): Profile = # Chargement / sauvegarde # --------------------------------------------------------------------------- -proc loadConfig*(): Config = - ## Charge la configuration depuis le disque. En cas d'erreur ou de fichier - ## manquant, on conserve des valeurs par défaut (jamais de crash au boot). +proc loadConfigFrom*(path: string): Config = + ## Charge la configuration depuis le chemin path. En cas d'erreur ou de + ## fichier manquant, on conserve des valeurs par défaut (jamais de crash). result = defaultConfig() try: - let path = configPath() if not fileExists(path): return let n = parseFile(path) @@ -206,10 +205,16 @@ proc loadConfig*(): Config = # Fichier corrompu : on garde les valeurs par défaut. discard -proc saveConfig*(cfg: Config) = - ## Sauvegarde la configuration au format JSON (pretty-printé). +proc loadConfig*(): Config = + ## 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: - ensureConfigDir() + let dir = path.parentDir + if dir.len > 0: + createDir(dir) var n = newJObject() n["workMin"] = %cfg.workMin n["shortBreakMin"] = %cfg.shortBreakMin @@ -245,10 +250,14 @@ proc saveConfig*(cfg: Config) = rem.add o n["healthReminders"] = rem - writeFile(configPath(), n.pretty) + writeFile(path, n.pretty) except: discard +proc saveConfig*(cfg: Config) = + ## Sauvegarde la configuration sur le disque (configPath()). + saveConfigTo(cfg, configPath()) + # --------------------------------------------------------------------------- # Démarrage automatique (registre) # --------------------------------------------------------------------------- diff --git a/src/i18n.nim b/src/i18n.nim index 1c032c8..e6abf52 100644 --- a/src/i18n.nim +++ b/src/i18n.nim @@ -44,6 +44,7 @@ type Strings* = object menuSettings*: string menuStats*: string menuExportCsv*: string + menuHelp*: string menuQuit*: string # Notifications. @@ -109,6 +110,7 @@ type Strings* = object settingsSave*: string settingsCancel*: string settingsSaved*: string + webServerError*: string settingsMinutes*: string # Libellés génériques. @@ -154,6 +156,7 @@ const fr* = Strings( menuSettings: "Paramètres", menuStats: "Statistiques", menuExportCsv: "Exporter les statistiques (CSV)", + menuHelp: "Aide", menuQuit: "Quitter", notifWorkDone: "Travail terminé", @@ -217,6 +220,7 @@ const fr* = Strings( settingsSave: "Enregistrer", settingsCancel: "Annuler", settingsSaved: "Paramètres enregistrés.", + webServerError: "Le serveur web local n'a pas pu démarrer.", settingsMinutes: "min", btnOk: "OK", @@ -256,6 +260,7 @@ const en* = Strings( menuSettings: "Settings", menuStats: "Statistics", menuExportCsv: "Export statistics (CSV)", + menuHelp: "Help", menuQuit: "Quit", notifWorkDone: "Work session complete", @@ -319,6 +324,7 @@ const en* = Strings( settingsSave: "Save", settingsCancel: "Cancel", settingsSaved: "Settings saved.", + webServerError: "The local web server could not be started.", settingsMinutes: "min", btnOk: "OK", diff --git a/src/nimpulse.nim b/src/nimpulse.nim index aa8cf0a..4908678 100644 --- a/src/nimpulse.nim +++ b/src/nimpulse.nim @@ -23,6 +23,7 @@ import i18n import dialogs import updater import floating +import webserver const TimerId = 1'i64 @@ -39,6 +40,7 @@ var gTimer: PomodoroTimer gStats: Stats gHwnd: HWND + gServerPort = 0 ## Port du serveur web local (0 = non démarré). gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé ## entre le thread de mise à jour et la boucle ## de messages). @@ -87,11 +89,20 @@ proc stopTimer() = proc quitApp() = saveConfig(gCfg) + stopWebServer() hideFloating() removeTrayIcon(gHwnd) UnregisterHotKey(gHwnd, HotkeyId) 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() = ## Ouvre le panneau de réglages graphique. if showSettingsDialog(gCfg): @@ -143,6 +154,12 @@ proc onTick() = of evWorkCompleted: onWorkCompleted() of evBreakCompleted: onBreakCompleted() 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() proc skipBreak() = @@ -159,8 +176,17 @@ proc handleCommand(cmd: int) = of IdStop: stopTimer() of IdSkipBreak: skipBreak() of IdExtendBreak: extendBreak() - of IdSettings: openSettings() - of IdStats: showStatsDialog() + of IdHelpPage: openWebPage("/help") + 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 IdAutoStart: gCfg.autoStart = not gCfg.autoStart @@ -275,6 +301,9 @@ proc main() = # Icône dans le tray. discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer)) + # Serveur web local (pages aide / statistiques / réglages). + gServerPort = startWebServer() + # Minuteur (1 tick par seconde). discard SetTimer(gHwnd, TimerId, 1000, nil) diff --git a/src/stats.nim b/src/stats.nim index 8ba0225..879422f 100644 --- a/src/stats.nim +++ b/src/stats.nim @@ -16,11 +16,10 @@ proc todayStr*(): string = proc initStats*(): Stats = Stats(daily: initTable[string, int]()) -proc loadStats*(): Stats = +proc loadStatsFrom*(path: string): Stats = ## Charge les stats depuis le disque (jamais de crash si corrompu). result = initStats() try: - let path = statsPath() if not fileExists(path): return let n = parseFile(path) @@ -31,18 +30,28 @@ proc loadStats*(): Stats = except: discard -proc saveStats*(st: Stats) = +proc loadStats*(): Stats = + ## Charge les stats depuis le disque (statsPath()). + loadStatsFrom(statsPath()) + +proc saveStatsTo*(st: Stats, path: string) = try: - ensureConfigDir() + let dir = path.parentDir + if dir.len > 0: + createDir(dir) var n = newJObject() var daily = newJObject() for k, v in st.daily: daily[k] = %v n["daily"] = daily - writeFile(statsPath(), n.pretty) + writeFile(path, n.pretty) except: discard +proc saveStats*(st: Stats) = + ## Sauvegarde les stats sur le disque (statsPath()). + saveStatsTo(st, statsPath()) + proc recordPomodoro*(st: var Stats) = ## Incrémente le compteur du jour courant puis sauvegarde. let today = todayStr() diff --git a/src/tray.nim b/src/tray.nim index 9ef60ea..0199008 100644 --- a/src/tray.nim +++ b/src/tray.nim @@ -21,6 +21,7 @@ const IdStats* = 1004 IdQuit* = 1005 IdExportCsv* = 1006 + IdHelpPage* = 1007 IdAutoStart* = 1500 IdDoNotDisturb* = 1501 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(IdExportCsv), S.menuExportCsv) AppendMenu(menu, mfSep, 0, nil) + AppendMenu(menu, mfStr, int64(IdHelpPage), S.menuHelp) + AppendMenu(menu, mfSep, 0, nil) AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit) var pt: POINT diff --git a/src/webserver.nim b/src/webserver.nim new file mode 100644 index 0000000..69aa2cb --- /dev/null +++ b/src/webserver.nim @@ -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 "&" + of '<': result.add "<" + of '>': result.add ">" + of '"': result.add """ + of '\'': result.add "'" + 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 = "" + result = """ + +
+ + +" & + 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…).") & "
" & + 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:") & "
| " & + t(lang, "Profil", "Profile") & " | " & t(lang, "Travail", "Work") & " | " & + t(lang, "Pause courte", "Short break") & " | " & t(lang, "Pause longue", "Long break") & " | " & + t(lang, "Cycles", "Cycles") & " |
|---|---|---|---|---|
| " & t(lang, "Classique", "Classic") & " | 25 min | 5 min | 15 min | 4 |
| Deep Work | 50 min | 10 min | 20 min | 4 |
| Sprint | 15 min | 3 min | 10 min | 6 |
" & 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”).") & "
" + + b.add "" & + 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.") & "
" + + b.add "" & + 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.") & "
" + + b.add "portable à 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.portable next to the executable), " &
+ "configuration and statistics are stored next to the app — handy on USB sticks.| " & t(lang, "Action", "Action") & " | " & t(lang, "Raccourci", "Shortcut") & " |
|---|---|
| " & t(lang, "Démarrer / mettre en pause", "Start / pause") & " | Ctrl+Alt+P |
| " & t(lang, "Menu contextuel", "Context menu") & " | " & t(lang, "Clic droit sur l'icône", "Right-click the icon") & " |
| " & t(lang, "Démarrer / mettre en pause", "Start / pause") & " | " & t(lang, "Double-clic", "Double-click") & " |
" & $today & " " & + t(lang, "pomodoro(s)", "pomodoro(s)") & "
" & + (if cfg.dailyGoal > 0: + "" & t(lang, "Objectif", "Goal") & " : " & $today & " / " & $cfg.dailyGoal & "
" & + "" + else: "") & + "" & $week & "
" & $streak & " " & + t(lang, "jour(s)", "day(s)") & "
" & $total & " " & + t(lang, "pomodoros complétés", "completed pomodoros") & "
| " & t(lang, "Date", "Date") & " | " & t(lang, "Pomodoros", "Pomodoros") & " |
|---|---|
| " & htmlEscape(date) & " | " & $n & " |
" & 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).") & "
" + + 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 "" + + let langOpts = + "" & + "" & + "" + + var b = "" + b.add "" & + t(lang, "Votre configuration a été enregistrée et appliquée.", + "Your configuration has been saved and applied.") & + "
" & t(lang, "Retour aux réglages", "Back to settings") & " " & + "" & t(lang, "Voir les statistiques", "View statistics") & "
" & + t(resolveLang(loadConfigFrom(configFile())), "Page introuvable.", "Page not found.") & + "
", 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 diff --git a/tests/test_webserver.nim b/tests/test_webserver.nim new file mode 100644 index 0000000..0f65451 --- /dev/null +++ b/tests/test_webserver.nim @@ -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">&'" + + test "parseForm decodes urlencoded pairs": + let f = parseForm("workMin=30&lang=fr&soundEnabled=on¬e=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)