repixelizer/scripts/run_gui.py
2026-04-26 04:23:37 +01:00

201 lines
6.4 KiB
Python

from __future__ import annotations
import argparse
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _ensure_src_on_path() -> None:
src = _repo_root() / "src"
src_text = str(src)
if src_text not in sys.path:
sys.path.insert(0, src_text)
def _socket_family_for_host(host: str) -> socket.AddressFamily:
return socket.AF_INET6 if ":" in host else socket.AF_INET
def _port_is_available(host: str, port: int) -> bool:
with socket.socket(_socket_family_for_host(host), socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind((host, port))
except OSError:
return False
return True
def _powershell(command: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["powershell", "-NoProfile", "-Command", command],
check=False,
capture_output=True,
text=True,
)
def _find_listener_pid(port: int) -> int | None:
if sys.platform == "win32":
result = _powershell(
f"$conn = Get-NetTCPConnection -State Listen -LocalPort {port} -ErrorAction SilentlyContinue | Select-Object -First 1; "
"if ($conn) { Write-Output $conn.OwningProcess }"
)
text = result.stdout.strip()
return int(text) if text.isdigit() else None
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
check=False,
capture_output=True,
text=True,
)
text = result.stdout.strip().splitlines()
return int(text[0]) if text and text[0].isdigit() else None
def _process_commandline(pid: int) -> str:
if sys.platform == "win32":
result = _powershell(
f'$proc = Get-CimInstance Win32_Process -Filter "ProcessId = {pid}" -ErrorAction SilentlyContinue; '
"if ($proc) { Write-Output $proc.CommandLine }"
)
return result.stdout.strip()
result = subprocess.run(
["ps", "-o", "command=", "-p", str(pid)],
check=False,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _normalize_pathish(text: str) -> str:
return text.replace("\\", "/").lower()
def _is_repixelizer_gui_process(commandline: str, repo_root: Path) -> bool:
if not commandline:
return False
normalized = _normalize_pathish(commandline)
repo_text = _normalize_pathish(str(repo_root))
markers = (
"scripts/run_gui.py",
"repixelizer.gui:create_app",
"repixelizer.gui",
)
return repo_text in normalized and any(marker in normalized for marker in markers)
def _terminate_process_tree(pid: int) -> None:
if sys.platform == "win32":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=True, capture_output=True, text=True)
return
os.kill(pid, 15)
def _wait_for_port_release(host: str, port: int, timeout: float = 5.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if _port_is_available(host, port):
return True
time.sleep(0.1)
return _port_is_available(host, port)
def _reclaim_stale_gui_port(host: str, port: int, repo_root: Path) -> str | None:
if _port_is_available(host, port):
return None
pid = _find_listener_pid(port)
if pid is None:
raise RuntimeError(f"Port {port} is already in use, but the owning process could not be identified.")
commandline = _process_commandline(pid)
if not _is_repixelizer_gui_process(commandline, repo_root):
detail = commandline or "<unknown command line>"
raise RuntimeError(
f"Port {port} is already in use by PID {pid}. Refusing to kill an unrelated process: {detail}"
)
_terminate_process_tree(pid)
if not _wait_for_port_release(host, port):
raise RuntimeError(f"Stopped stale Repixelizer GUI process {pid}, but port {port} did not become available.")
return f"Reclaimed stale Repixelizer GUI process {pid} on port {port}."
def _browser_host(host: str) -> str:
if host == "0.0.0.0":
return "127.0.0.1"
if host == "::":
return "::1"
return host
def _host_for_url(host: str) -> str:
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _startup_banner(host: str, port: int, reload: bool) -> str:
bind_url = f"http://{_host_for_url(host)}:{port}"
open_url = f"http://{_host_for_url(_browser_host(host))}:{port}/app/"
health_url = f"http://{_host_for_url(_browser_host(host))}:{port}/api/health"
lines = [
"Repixelizer GUI",
f" PID: {os.getpid()}",
f" Python: {sys.executable}",
f" Bind: {bind_url}",
f" Open: {open_url}",
f" Health: {health_url}",
f" Reload: {'on' if reload else 'off'}",
"",
]
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Launch the Repixelizer GUI from the repo checkout.")
parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
parser.add_argument("--port", type=int, default=8000, help="Port to bind")
parser.add_argument("--reload", action="store_true", help="Enable autoreload while developing the GUI server")
parser.add_argument(
"--queue-ui",
choices=("auto", "show", "hide"),
default="auto",
help="Whether to show the queue panel in the frontend. auto follows hosted-demo mode.",
)
return parser
def main(argv: list[str] | None = None) -> int:
repo_root = _repo_root()
_ensure_src_on_path()
args = build_parser().parse_args(argv)
if args.queue_ui == "show":
os.environ["REPIXELIZER_SHOW_QUEUE_PANEL"] = "1"
elif args.queue_ui == "hide":
os.environ["REPIXELIZER_SHOW_QUEUE_PANEL"] = "0"
else:
os.environ.pop("REPIXELIZER_SHOW_QUEUE_PANEL", None)
from repixelizer.gui import main as gui_main
try:
message = _reclaim_stale_gui_port(args.host, args.port, repo_root)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
if message:
print(message)
print(_startup_banner(args.host, args.port, args.reload), flush=True)
return gui_main(host=args.host, port=args.port, reload=args.reload)
if __name__ == "__main__":
raise SystemExit(main())