Files
FocusFix/focusfix.py
T
Andre d91735a2fa Feature: Wrapper, Logging und Doku überarbeitet
- Debug-Logging optional gemacht, den Legacy-Wrapper portabler aufgebaut (relative Pfade, Debug-Weitergabe, Python-Fallback) und die README für Endanwender und Entwickler klarer neu strukturiert.
2026-04-18 17:10:23 +02:00

288 lines
9.5 KiB
Python

"""
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()