""" focusfix.py Bringt ein gestartetes Spiel automatisch in den Vordergrund. Verwendung: pythonw focusfix.py [--timeout 120] --process GameName pythonw focusfix.py [--timeout 120] "C:\\Path\\To\\Game.exe" [args...] Abhängigkeiten (im .venv): pip install psutil pywin32 """ import argparse import ctypes import logging import subprocess import sys import time import psutil import win32api import win32con import win32gui import win32process from pathlib import Path _LOG_PATH = Path(__file__).resolve().parent / "focusfix.log" _LOGGER = logging.getLogger("focusfix") _LOGGER.addHandler(logging.NullHandler()) _LOGGER.propagate = False # Minimale Fensterbreite in Pixeln, um den Splashscreen auszuschließen. MIN_WINDOW_WIDTH = 800 # Anzahl der Fokus-Versuche und Pause dazwischen FOCUS_RETRIES = 15 FOCUS_RETRY_DELAY = 2.0 def derive_process_name(command: list[str]) -> str: if not command: raise ValueError("No game command provided") return Path(command[0]).stem def configure_logging(enabled: bool, log_path: Path = _LOG_PATH) -> None: _LOGGER.handlers.clear() if not enabled: _LOGGER.addHandler(logging.NullHandler()) _LOGGER.setLevel(logging.CRITICAL + 1) return file_handler = logging.FileHandler(log_path, mode="w", encoding="utf-8") file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) _LOGGER.addHandler(file_handler) _LOGGER.setLevel(logging.DEBUG) def launch_game(command: list[str]) -> subprocess.Popen: _LOGGER.info("Launching game command: %r", command) return subprocess.Popen(command) def find_process_pids(name: str) -> set[int]: pids = set() for proc in psutil.process_iter(["pid", "name"]): try: if name.lower() in proc.info["name"].lower(): pids.add(proc.info["pid"]) except (psutil.NoSuchProcess, psutil.AccessDenied): pass return pids def find_game_window(pids: set[int]) -> int | None: found = [] def callback(hwnd, _): if not win32gui.IsWindowVisible(hwnd): return True try: _, pid = win32process.GetWindowThreadProcessId(hwnd) except Exception: return True if pid not in pids: return True try: rect = win32gui.GetWindowRect(hwnd) except Exception: return True width = rect[2] - rect[0] height = rect[3] - rect[1] if width >= MIN_WINDOW_WIDTH: title = win32gui.GetWindowText(hwnd) _LOGGER.debug("Candidate window: hwnd=%s title=%r size=%dx%d pid=%d", hwnd, title, width, height, pid) found.append((hwnd, title, width, height)) return True win32gui.EnumWindows(callback, None) if not found: return None # Fenster mit Titel bevorzugen, dann nach Größe sortieren found.sort(key=lambda x: (bool(x[1]), x[2] * x[3]), reverse=True) return found[0][0] def wait_for_stable_size(hwnd: int, polls: int = 3, interval: float = 0.5, timeout: float = 5.0) -> tuple[int, int]: """Wartet bis die Fenstergröße sich stabilisiert hat (kein Resize mehr). Gibt die finale (width, height) zurück.""" last_size = (0, 0) stable_count = 0 deadline = time.time() + timeout while time.time() < deadline: try: rect = win32gui.GetWindowRect(hwnd) size = (rect[2] - rect[0], rect[3] - rect[1]) except Exception: break if size == last_size: stable_count += 1 if stable_count >= polls: break else: stable_count = 0 last_size = size time.sleep(interval) _LOGGER.debug("Stable size for hwnd=%s: %dx%d (after %d stable polls)", hwnd, last_size[0], last_size[1], stable_count) return last_size def is_foreground(hwnd: int) -> bool: """Prüft ob das angegebene Fenster aktuell das Vordergrundfenster ist.""" return win32gui.GetForegroundWindow() == hwnd def bring_to_front(hwnd: int) -> bool: """ Bringt das Fenster in den Vordergrund. Gibt True zurück wenn erfolgreich. Verwendet Alt-Key + AttachThreadInput als Workaround. """ try: # Alt-Taste simulieren — hebt die SetForegroundWindow-Sperre auf win32api.keybd_event(win32con.VK_MENU, 0, 0, 0) time.sleep(0.05) fg_hwnd = win32gui.GetForegroundWindow() fg_tid, _ = win32process.GetWindowThreadProcessId(fg_hwnd) tgt_tid, _ = win32process.GetWindowThreadProcessId(hwnd) attached = False if fg_tid != tgt_tid: ctypes.windll.user32.AttachThreadInput(tgt_tid, fg_tid, True) attached = True win32gui.ShowWindow(hwnd, win32con.SW_SHOW) win32gui.BringWindowToTop(hwnd) win32gui.SetForegroundWindow(hwnd) ctypes.windll.user32.SetFocus(hwnd) ctypes.windll.user32.SetActiveWindow(hwnd) if attached: ctypes.windll.user32.AttachThreadInput(tgt_tid, fg_tid, False) result = is_foreground(hwnd) _LOGGER.debug("bring_to_front result: %s (foreground hwnd=%s, target hwnd=%s)", result, win32gui.GetForegroundWindow(), hwnd) return result except Exception as e: _LOGGER.exception("bring_to_front failed: %s", e) return False finally: win32api.keybd_event(win32con.VK_MENU, 0, win32con.KEYEVENTF_KEYUP, 0) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("-d", "--debug", "-debug", action="store_true", help="Debug-Logging nach focusfix.log aktivieren") parser.add_argument("--timeout", type=int, default=120) parser.add_argument("--process", type=str, help="Prozessname des Spiels (ohne .exe)") parser.add_argument("command", nargs="*", help="Steam-Spielbefehl, z.B. %%command%%") args, extra_command_args = parser.parse_known_args() configure_logging(args.debug) process_name = args.process command = args.command + extra_command_args if command: if process_name: parser.error("Use either --process or a game command, not both.") try: process_name = derive_process_name(command) except ValueError as exc: parser.error(str(exc)) try: launch_game(command) except OSError as exc: _LOGGER.exception("Failed to launch game command: %s", exc) sys.exit(1) elif not process_name: parser.error("Either --process or a game command is required.") deadline = time.time() + args.timeout _LOGGER.info("Monitoring process name: %s", process_name) # --- Phase 1: Warten auf den Prozess --- pids: set[int] = set() while time.time() < deadline: pids = find_process_pids(process_name) if pids: break time.sleep(0.5) else: sys.exit(1) # --- Phase 2: Warten auf das Hauptfenster --- hwnd: int | None = None while time.time() < deadline: pids = find_process_pids(process_name) hwnd = find_game_window(pids) if hwnd: _LOGGER.info("Initial window found: hwnd=%s title=%r", hwnd, win32gui.GetWindowText(hwnd)) # Warten bis das Spiel sein Fenster vollständig aufgebaut hat time.sleep(3) break time.sleep(0.5) else: _LOGGER.error("Timeout waiting for game window") sys.exit(1) # --- Phase 3: Spielfenster fokussieren und auf Fensterwechsel reagieren --- # Beobachtet über die gesamte Retry-Dauer, ob sich das Fenster ändert # (Splash -> Hauptfenster), und fokussiert jedes neue Fenster erneut. focused_hwnd: int | None = None for attempt in range(FOCUS_RETRIES): pids = find_process_pids(process_name) if not pids: _LOGGER.warning("Game process gone, stopping") break current_hwnd = find_game_window(pids) if current_hwnd and current_hwnd != hwnd: new_title = win32gui.GetWindowText(current_hwnd) old_title = win32gui.GetWindowText(hwnd) if hwnd else "" # Nicht von einem Fenster mit Titel auf ein titelloses wechseln if not new_title and old_title: _LOGGER.debug("Ignoring titleless window hwnd=%s (keeping %s title=%r)", current_hwnd, hwnd, old_title) else: _LOGGER.info("Window changed: old=%s new=%s title=%r", hwnd, current_hwnd, new_title) hwnd = current_hwnd focused_hwnd = None # neues Fenster muss neu fokussiert werden time.sleep(1) if not hwnd: time.sleep(FOCUS_RETRY_DELAY) continue # Bereits erfolgreich fokussiert — weiter beobachten ob Fenster wechselt if focused_hwnd == hwnd and is_foreground(hwnd): time.sleep(FOCUS_RETRY_DELAY) continue # Warten bis Fenstergröße stabil ist (Resize abgeschlossen) wait_for_stable_size(hwnd) _LOGGER.info("Focus attempt %d/%d for hwnd=%s title=%r", attempt + 1, FOCUS_RETRIES, hwnd, win32gui.GetWindowText(hwnd)) success = bring_to_front(hwnd) if success: _LOGGER.info("Focus succeeded on attempt %d", attempt + 1) focused_hwnd = hwnd time.sleep(FOCUS_RETRY_DELAY) _LOGGER.info("Done. Final focused hwnd=%s", focused_hwnd) if __name__ == "__main__": main()