diff --git a/.gitignore b/.gitignore index c0d17af..3833f58 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ config.json _tmp/ *.7z.* release/ +rechnungstool.pid diff --git a/app.py b/app.py index 905f65f..c60d9d9 100644 --- a/app.py +++ b/app.py @@ -48,6 +48,94 @@ def safe_filename(s): return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" +def einzelinstanz_pruefen(): + """Sperrt das Programm gegen ein zweites Fenster. + + Beide Fenster lesen beim Start denselben Zaehler aus der config und + schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt + vergeben. Darum: nur eine Instanz je Windows-Sitzung. + + Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder + False, wenn schon eine Instanz laeuft. + """ + if not sys.platform.startswith("win"): + return None + import ctypes + from ctypes import wintypes + ERROR_ALREADY_EXISTS = 183 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] + k32.CreateMutexW.restype = wintypes.HANDLE + handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") + if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: + return False + return handle + + + +# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. +PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") + + +def eigene_pid_merken(): + """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" + try: + with open(PID_DATEI, "w", encoding="ascii") as f: + f.write(str(os.getpid())) + except OSError: + pass + + +def laufende_pid_lesen(): + """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" + try: + with open(PID_DATEI, "r", encoding="ascii") as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None + + +def fenster_nach_vorn(pid): + """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. + + Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel + haengt am Firmennamen aus der config und aendert sich mit ihm. + """ + if not pid or not sys.platform.startswith("win"): + return False + import ctypes + from ctypes import wintypes + SW_RESTORE = 9 + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.IsWindowVisible.argtypes = [wintypes.HWND] + u32.IsIconic.argtypes = [wintypes.HWND] + u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + u32.SetForegroundWindow.argtypes = [wintypes.HWND] + u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + + treffer = [] + + def besuch(hwnd, _lparam): + fremd = wintypes.DWORD() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) + # nur das echte Hauptfenster: sichtbar und mit Titel + if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): + treffer.append(hwnd) + return False + return True + + CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + u32.EnumWindows(CB(besuch), 0) + if not treffer: + return False + hwnd = treffer[0] + if u32.IsIconic(hwnd): + u32.ShowWindow(hwnd, SW_RESTORE) + u32.SetForegroundWindow(hwnd) + return True + + def windows_dark_mode(): """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" if not sys.platform.startswith("win"): @@ -931,5 +1019,22 @@ class RechnungsApp(tk.Tk): if __name__ == "__main__": + # Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen. + _sperre = einzelinstanz_pruefen() + if _sperre is False: + # Laeuft schon: dessen Fenster nach vorn holen und still beenden. + if not fenster_nach_vorn(laufende_pid_lesen()): + _r = tk.Tk() + _r.withdraw() + messagebox.showerror( + "Rechnungstool läuft bereits", + """Das Rechnungstool ist schon geöffnet, das Fenster ließ sich +aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln. + +Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""") + _r.destroy() + sys.exit(0) + + eigene_pid_merken() app = RechnungsApp() app.mainloop()