diff --git a/Pseudonymisation_Gui_V6.py b/Pseudonymisation_Gui_V6.py index 7ed8c5c..27e299c 100644 --- a/Pseudonymisation_Gui_V6.py +++ b/Pseudonymisation_Gui_V6.py @@ -62,9 +62,24 @@ def main(argv=None) -> int: setup_file_logging() from gui_v6.app import AnonymisationApp + from gui_v6.single_instance import AlreadyRunningError, SingleInstance - application = AnonymisationApp() - application.mainloop() + 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 diff --git a/gui_v6/single_instance.py b/gui_v6/single_instance.py new file mode 100644 index 0000000..b3b4c53 --- /dev/null +++ b/gui_v6/single_instance.py @@ -0,0 +1,70 @@ +"""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 diff --git a/tests/unit/test_gui_v6_single_instance.py b/tests/unit/test_gui_v6_single_instance.py new file mode 100644 index 0000000..6497750 --- /dev/null +++ b/tests/unit/test_gui_v6_single_instance.py @@ -0,0 +1,33 @@ +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()