From 55e8839613f615ceb9caefc892099714dc0a06f6 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Thu, 25 Jun 2026 16:49:01 +0200 Subject: [PATCH] =?UTF-8?q?docs(beta):=20plan=20d'impl=C3=A9mentation=201a?= =?UTF-8?q?=20=E2=80=94=20socle=20s=C3=BBret=C3=A9=20&=20cha=C3=AEne=20pro?= =?UTF-8?q?d=20GUI=20V6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan bite-sized TDD pour les chantiers A/B/E1 : fail-close PII (P0-1), URL portail (P0-2), binding licence souple (P0-6), log fichier V6 (E1), flag frozen ONNX (P0-5), instance unique + mutex installeur (P0-7). 6 tâches, code complet, tests unitaires. Plans 1b (gating cœur), 1c (UI), 2 (diagnostics), 3 (build/release) à suivre. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-25-gui-v6-beta-plan-1a-socle.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md diff --git a/docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md b/docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md new file mode 100644 index 0000000..446b11b --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md @@ -0,0 +1,649 @@ +# GUI V6 bêta — Plan 1a : socle sûreté & chaîne prod + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre la GUI V6 sûre (fail-close PII) et réellement connectée à la chaîne de prod (portail, binding licence, stabilité frozen, log fichier) — sans toucher au cœur de détection ni à l'UI. + +**Architecture:** 6 corrections ciblées, toutes dans `gui_v6/` + l'entrée frozen `Pseudonymisation_Gui_V6.py`. Chaque fix est testable en `pytest` sur Linux (sauf confirmation frozen de P0-5/P0-7 au smoke EXE, hors de ce plan). On suit le contrat existant : factories injectables, sessions HTTP injectables, aucun appel réseau en test. + +**Tech Stack:** Python 3.10-3.12, pytest, `logging` stdlib + loguru (best-effort), `ctypes`/`msvcrt` (Windows), `fcntl` (POSIX). + +**Référence spec :** `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md` (chantiers A, B, E1). + +--- + +### Task 1 : URL portail réelle (P0-2) + +**Files:** +- Modify: `gui_v6/app.py:12-24` (imports), `:41` (défaut client), `:199` (fallback télémétrie) +- Test: `tests/unit/test_gui_v6_portal_url.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_portal_url.py +import pytest + + +def test_default_portal_url_is_prod(monkeypatch): + monkeypatch.delenv("ANON_PORTAL_URL", raising=False) + from gui_v6.app import DEFAULT_PORTAL_URL, resolve_portal_url + assert DEFAULT_PORTAL_URL == "https://app.aivanov.eu" + assert resolve_portal_url() == "https://app.aivanov.eu" + + +def test_portal_url_env_override(monkeypatch): + monkeypatch.setenv("ANON_PORTAL_URL", "http://localhost:8088") + from gui_v6.app import resolve_portal_url + assert resolve_portal_url() == "http://localhost:8088" +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v` +Expected: FAIL — `ImportError: cannot import name 'DEFAULT_PORTAL_URL'`. + +- [ ] **Step 3 : Implémenter** + +Dans `gui_v6/app.py`, ajouter `import os` en tête (sous `from __future__ import annotations`), puis après les imports (avant `_TABS`) : + +```python +DEFAULT_PORTAL_URL = "https://app.aivanov.eu" + + +def resolve_portal_url() -> str: + """URL du portail : env ``ANON_PORTAL_URL`` sinon défaut prod.""" + return os.environ.get("ANON_PORTAL_URL", DEFAULT_PORTAL_URL) +``` + +Remplacer la ligne 41 : +```python + self._license_client = license_client or LicenseClient(resolve_portal_url()) +``` + +Remplacer le fallback de la ligne 199 : +```python + base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url() +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5 : Commit** + +```bash +git add gui_v6/app.py tests/unit/test_gui_v6_portal_url.py +git commit -m "fix(gui): connecter la GUI V6 au portail prod (P0-2, plus localhost)" +``` + +--- + +### Task 2 : Fail-close si le NER obligatoire est indisponible (P0-1) + +**Files:** +- Modify: `gui_v6/engine_bridge.py:36-41` (nouvelle exception), `:222-231` (`process_fn`) +- Test: `tests/unit/test_gui_v6_engine_failclose.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_engine_failclose.py +from pathlib import Path + +import pytest + +from gui_v6.engine_bridge import ( + EngineSettings, + EngineUnavailableError, + NerManagers, + make_process_fn, +) + + +def _managers_with_broken_camembert(settings): + def boom(): + raise RuntimeError("model.onnx absent") + + return NerManagers( + settings, + factories={"camembert": boom, "eds": boom, "gliner": boom}, + caps_provider=lambda: {}, + ) + + +def test_process_fn_raises_when_mandatory_ner_unavailable(): + settings = EngineSettings(use_local_ner=True) + managers = _managers_with_broken_camembert(settings) + called = {"engine": False} + + def fake_engine(*a, **k): + called["engine"] = True + return {"pdf": "x"} + + fn = make_process_fn(settings, managers=managers, engine=fake_engine) + with pytest.raises(EngineUnavailableError): + fn(Path("doc.pdf"), Path("/tmp/out")) + # Le moteur ne doit JAMAIS être appelé → aucune sortie possible. + assert called["engine"] is False + + +def test_process_fn_runs_when_ner_ok(): + settings = EngineSettings(use_local_ner=True) + managers = NerManagers( + settings, + factories={"camembert": lambda: object(), "eds": lambda: None, "gliner": lambda: None}, + caps_provider=lambda: {}, + ) + fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"}) + assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"} +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v` +Expected: FAIL — `ImportError: cannot import name 'EngineUnavailableError'`. + +- [ ] **Step 3 : Implémenter** + +Dans `gui_v6/engine_bridge.py`, après la classe `ManagerState` (l.41) : + +```python +class EngineUnavailableError(RuntimeError): + """Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé. + + Garantit le fail-close : on refuse de produire une sortie plutôt que de + livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI). + """ +``` + +Remplacer le corps de `process_fn` (l.222-231) : + +```python + def process_fn(doc_path: Path, out_dir: Path) -> dict: + if settings.use_local_ner: + state = managers.ensure_loaded() + if state == ManagerState.UNAVAILABLE: + raise EngineUnavailableError( + "Modèle de détection obligatoire (CamemBERT-bio) indisponible — " + "traitement refusé pour éviter une anonymisation incomplète." + ) + kwargs = build_engine_kwargs(settings, managers) + run = engine + if run is None: + from anonymizer_core_refactored_onnx import process_document + + run = process_document + return run(doc_path, out_dir, **kwargs) +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v` +Expected: PASS (2 tests). Le runner (`processing_runner._run_impl:221`) attrape déjà toute `Exception` → le doc est compté **échec** et non livré. + +- [ ] **Step 5 : Vérifier la non-régression du runner** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_processing_runner.py -v` +Expected: PASS (inchangé). + +- [ ] **Step 6 : Commit** + +```bash +git add gui_v6/engine_bridge.py tests/unit/test_gui_v6_engine_failclose.py +git commit -m "fix(gui): fail-close si CamemBERT-bio indisponible (P0-1, anti-fuite PII)" +``` + +--- + +### Task 3 : Binding licence ↔ poste (souple, affichage) (P0-6) + +**Files:** +- Modify: `gui_v6/app.py` (imports + `_safe_local_status`), nouvelle fonction `bound_local_status` +- Test: `tests/unit/test_gui_v6_license_binding.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_license_binding.py +from gui_v6.app import bound_local_status +from gui_v6.license_client import LicenseStatus + + +def test_binding_flags_other_machine(): + st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111") + out = bound_local_status(st, "BBBB2222") + assert out.valid is False + assert out.status == "autre_poste" + assert "autre poste" in out.message.lower() + + +def test_binding_ok_same_machine(): + st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111") + out = bound_local_status(st, "AAAA1111") + assert out.valid is True + assert out.status == "active" + + +def test_binding_noop_without_machine_id(): + # licence locale sans machine_id (ancien payload) → inchangée, pas de blocage. + st = LicenseStatus(valid=True, status="active", machine_id=None) + assert bound_local_status(st, "AAAA1111").valid is True +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v` +Expected: FAIL — `ImportError: cannot import name 'bound_local_status'`. + +- [ ] **Step 3 : Implémenter** + +Dans `gui_v6/app.py`, ajouter en tête l'import `from gui_v6.machine_id import default_machine_id` (sous l'import de `license_client`). Ajouter au niveau module (après `resolve_portal_url`) : + +```python +def bound_local_status(status: LicenseStatus, local_machine_id: str) -> LicenseStatus: + """Annoter le statut licence selon le binding poste. + + Souple (décision D1) : on N'EMPÊCHE PAS le traitement. Si la licence locale + est valide mais liée à un autre ``machine_id`` que le poste courant (ex. + ``license.json`` copié), on le **signale** par un statut non valide d'affichage. + """ + if status.valid and status.machine_id and status.machine_id != local_machine_id: + return LicenseStatus( + valid=False, + status="autre_poste", + message="Licence liée à un autre poste", + expires_at=status.expires_at, + grace_days=status.grace_days, + machine_id=status.machine_id, + license_ref=status.license_ref, + ) + return status +``` + +Remplacer `_safe_local_status` (l.81-85) : + +```python + def _safe_local_status(self) -> LicenseStatus: + try: + status = self._license_client.local_status() + return bound_local_status(status, default_machine_id()) + except Exception: + return LicenseStatus.unavailable() +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5 : Commit** + +```bash +git add gui_v6/app.py tests/unit/test_gui_v6_license_binding.py +git commit -m "feat(gui): binding licence-poste souple (P0-6/D-20.4, affichage sans blocage)" +``` + +--- + +### Task 4 : Log fichier V6 à chemin connu (E1) + +**Files:** +- Create: `gui_v6/logging_setup.py` +- Test: `tests/unit/test_gui_v6_logging_setup.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_logging_setup.py +import logging + + +def test_setup_file_logging_writes_to_known_path(tmp_path, monkeypatch): + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + from gui_v6.logging_setup import setup_file_logging + + log_path = setup_file_logging() + assert log_path.parent.exists() + logging.getLogger("test.e1").warning("ligne-temoin-42") + for h in logging.getLogger().handlers: + h.flush() + assert "ligne-temoin-42" in log_path.read_text(encoding="utf-8") +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.logging_setup'`. + +- [ ] **Step 3 : Implémenter** + +```python +# gui_v6/logging_setup.py +"""Configuration du log fichier de la GUI V6 (E1). + +Sans ceci, la GUI frozen fenêtrée (sans console) perd ses logs de diagnostic. +Le log est posé dans le même répertoire applicatif que la licence +(``%LOCALAPPDATA%/Aivanov/Anonymisation``) pour faciliter sa récupération (E2/E3). +""" + +from __future__ import annotations + +import logging +import os +from logging.handlers import RotatingFileHandler +from pathlib import Path + +_CONFIGURED = False + + +def _app_data_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") + if base: + root = Path(base) + else: # Linux/dev + root = Path.home() / ".local" / "share" + return root / "Aivanov" / "Anonymisation" + + +def log_file_path() -> Path: + return _app_data_dir() / "logs" / "anonymisation.log" + + +def setup_file_logging() -> Path: + """Configure un handler fichier rotatif sur le logger racine. Idempotent.""" + global _CONFIGURED + path = log_file_path() + if _CONFIGURED: + return path + path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + str(path), maxBytes=2_000_000, backupCount=3, encoding="utf-8" + ) + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + ) + root = logging.getLogger() + root.setLevel(logging.INFO) + root.addHandler(handler) + # Best-effort : si le cœur utilise loguru, on ajoute aussi un sink fichier. + try: + from loguru import logger as _loguru + + _loguru.add(str(path), rotation="2 MB", retention=3, encoding="utf-8") + except Exception: + pass + _CONFIGURED = True + return path +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v` +Expected: PASS. + +- [ ] **Step 5 : Commit** + +```bash +git add gui_v6/logging_setup.py tests/unit/test_gui_v6_logging_setup.py +git commit -m "feat(gui): log fichier rotatif V6 à chemin connu (E1)" +``` + +--- + +### Task 5 : Stabilité frozen — flag legacy ONNX au plus tôt (P0-5) + +**Files:** +- Modify: `Pseudonymisation_Gui_V6.py:12-15` (en-tête) + `main` (appel logging) +- Test: `tests/unit/test_gui_v6_entry_frozen_flag.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_entry_frozen_flag.py +import os +import importlib + + +def test_entry_sets_legacy_onnx_flag_on_import(monkeypatch): + monkeypatch.delenv("ANON_SKIP_LEGACY_ONNX_MANAGER", raising=False) + import Pseudonymisation_Gui_V6 as entry + importlib.reload(entry) + assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "1" + + +def test_entry_does_not_override_explicit_flag(monkeypatch): + monkeypatch.setenv("ANON_SKIP_LEGACY_ONNX_MANAGER", "0") + import Pseudonymisation_Gui_V6 as entry + importlib.reload(entry) + assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "0" +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v` +Expected: FAIL — le flag n'est pas posé. + +- [ ] **Step 3 : Implémenter** + +Dans `Pseudonymisation_Gui_V6.py`, juste après `import sys` (l.14), **avant toute autre logique** : + +```python +import os + +# Frozen Windows : désactiver le manager ONNX legacy AVANT tout import du cœur, +# pour éviter « cannot load module more than once per process » (hotfix CLI 6c6f653). +os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER", "1") +``` + +Dans `main()` (avant `from gui_v6.app import AnonymisationApp`, l.55), initialiser le log fichier : + +```python + from gui_v6.logging_setup import setup_file_logging + + setup_file_logging() +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5 : Vérifier le self-test** + +Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test` +Expected: `GUI V6 self-test OK`, exit 0. + +- [ ] **Step 6 : Commit** + +```bash +git add Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_entry_frozen_flag.py +git commit -m "fix(gui): flag legacy ONNX + log fichier dès l'entrée frozen (P0-5/E1)" +``` + +--- + +### Task 6 : Instance unique + mutex partagé installeur (P0-7) + +**Files:** +- Create: `gui_v6/single_instance.py` +- Modify: `Pseudonymisation_Gui_V6.py:main` +- Test: `tests/unit/test_gui_v6_single_instance.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/unit/test_gui_v6_single_instance.py +import pytest + +from gui_v6.single_instance import ( + APP_MUTEX_NAME, + AlreadyRunningError, + SingleInstance, +) + + +def test_mutex_name_is_stable(): + # Nom partagé avec l'installeur (Inno AppMutex). Ne pas changer sans MAJ .iss. + assert APP_MUTEX_NAME == "AivanonymAnonymisationV6" + + +def test_second_instance_is_rejected(tmp_path, monkeypatch): + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + first = SingleInstance() + first.acquire() + try: + with pytest.raises(AlreadyRunningError): + SingleInstance().acquire() + finally: + first.release() + + +def test_release_allows_reacquire(tmp_path, monkeypatch): + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + a = SingleInstance() + a.acquire() + a.release() + b = SingleInstance() + b.acquire() # ne lève pas + b.release() +``` + +- [ ] **Step 2 : Lancer le test pour le voir échouer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.single_instance'`. + +- [ ] **Step 3 : Implémenter** + +```python +# gui_v6/single_instance.py +"""Protection multi-instance de la GUI V6 (P0-7). + +- Windows (frozen) : mutex nommé kernel via ctypes — c'est CE nom que l'installeur + Inno détecte (``AppMutex``) pour fermer l'app avant une mise à jour (D8). +- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Nom partagé avec installer/Anonymisation.iss (AppMutex). NE PAS modifier seul. +APP_MUTEX_NAME = "AivanonymAnonymisationV6" + + +class AlreadyRunningError(RuntimeError): + """Une autre instance de l'application est déjà en cours d'exécution.""" + + +def _lock_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") + root = Path(base) if base else Path.home() / ".local" / "share" + d = root / "Aivanov" / "Anonymisation" + d.mkdir(parents=True, exist_ok=True) + return d + + +class SingleInstance: + def __init__(self) -> None: + self._handle = None # mutex Windows + self._fh = None # file handle POSIX + + def acquire(self) -> None: + if sys.platform.startswith("win"): + self._acquire_windows() + else: + self._acquire_posix() + + def _acquire_windows(self) -> None: # pragma: no cover (exécuté sur Windows) + import ctypes + + ERROR_ALREADY_EXISTS = 183 + handle = ctypes.windll.kernel32.CreateMutexW(None, False, APP_MUTEX_NAME) + if not handle or ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS: + raise AlreadyRunningError("L'application est déjà ouverte.") + self._handle = handle + + def _acquire_posix(self) -> None: + import fcntl + + path = _lock_dir() / "instance.lock" + fh = open(path, "w") + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + fh.close() + raise AlreadyRunningError("L'application est déjà ouverte.") + self._fh = fh + + def release(self) -> None: + if self._handle is not None: # pragma: no cover + import ctypes + + ctypes.windll.kernel32.CloseHandle(self._handle) + self._handle = None + if self._fh is not None: + self._fh.close() + self._fh = None +``` + +- [ ] **Step 4 : Lancer le test pour le voir passer** + +Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5 : Câbler dans l'entrée** + +Dans `Pseudonymisation_Gui_V6.py:main`, après `setup_file_logging()` et avant `AnonymisationApp()` : + +```python + from gui_v6.single_instance import AlreadyRunningError, SingleInstance + + guard = SingleInstance() + try: + guard.acquire() + except AlreadyRunningError: + try: + import tkinter.messagebox as mb + + mb.showinfo("Anonymisation", "L'application est déjà ouverte.") + except Exception: + print("L'application est déjà ouverte.") + return 0 + try: + application = AnonymisationApp() + application.mainloop() + finally: + guard.release() + return 0 +``` + +(Remplace les lignes `application = AnonymisationApp()` / `application.mainloop()` / `return 0` existantes.) + +- [ ] **Step 6 : Vérifier self-test + suite GUI V6** + +Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test && .venv/bin/pytest tests/unit/ -k gui_v6 -q` +Expected: `GUI V6 self-test OK` + suite verte. + +- [ ] **Step 7 : Commit** + +```bash +git add gui_v6/single_instance.py Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_single_instance.py +git commit -m "feat(gui): instance unique + mutex partagé installeur (P0-7)" +``` + +--- + +## Self-review (couverture spec chantiers A/B/E1) +- P0-1 fail-close → Task 2 ✓ · P0-2 URL → Task 1 ✓ · P0-5 frozen flag → Task 5 ✓ · + P0-6 binding → Task 3 ✓ · P0-7 lock+mutex → Task 6 ✓ · E1 log fichier → Task 4 (+ câblage Task 5) ✓. +- Hors de ce plan (par décision de découpe) : P1-2 (Plan 1b), P1-1/3/4/5 + P2 (Plan 1c), diagnostics + E2-E4 (Plan 2), build/release C+F (Plan 3). +- Cohérence types : `EngineUnavailableError`, `ManagerState.UNAVAILABLE`, `LicenseStatus(machine_id=…)`, + `default_machine_id()`, `setup_file_logging()`, `SingleInstance/APP_MUTEX_NAME` — tous définis ici et + réutilisés de façon cohérente. +- À confirmer au smoke EXE (Plan 3) : P0-5 (crash frozen réel) et P0-7 (mutex Windows).