foxygit / RPA-Remisser Log in
commit 97c6c0e274f04ac1d0d58dfdae5870b60aa50c86
Author:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Tue Sep 8 12:57:32 2026 +0200
Commit:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Tue Sep 8 12:57:32 2026 +0200

    run_webui: startkontroll - vagra om porten redan anvands

    python run_webui.py vagrar nu starta om 127.0.0.1:8765 redan anvands (visar
    PID). --force dodar den korande instansen och tar over, --port N byter port.
---
 README.md               |  1 +
 TODO.md                 |  3 ++
 run_webui.py            | 99 +++++++++++++++++++++++++++++++++++++++++++++++--
 tests/test_run_webui.py | 24 ++++++++++++
 4 files changed, 123 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index c4c7668..ef62e8a 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ powershell -ExecutionPolicy Bypass -File .\install_win.ps1
 ```powershell
 .venv\Scripts\Activate.ps1
 python run_webui.py     # webui:t på http://127.0.0.1:8765
+python run_webui.py --force   # om en gammal instans redan kör: ta över porten
 python main.py          # fristående 1177-exempel
 ```

diff --git a/TODO.md b/TODO.md
index 5d15cb9..9a1d9bb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -43,6 +43,9 @@
   `get_block`); `rpa/desktop_inspect.py` refaktorerad till `list_windows()`/
   `inspect_window()` (+ `/api/desktop/windows|inspect`); dra steg till canvasen
   eller in i loop/if-kropp (`stepops.appendToContainer`).
+- **~~Startkontroll i run_webui.py~~ – klar.** Vägrar starta om porten redan
+  används (visar PID), `--force` dödar den gamla instansen och tar över,
+  `--port N`.
 - **Äkta grafmotor / fri kabeldragning i UI:t** – sömmen finns i
   `webui/frontend/src/lib/graph.ts` (se kommentaren där).
 - **Fler desktop-steg vid behov** – t.ex. `desktop_drag`, `desktop_menu_select`,
diff --git a/run_webui.py b/run_webui.py
index d08720c..f88120f 100644
--- a/run_webui.py
+++ b/run_webui.py
@@ -1,6 +1,97 @@
-import uvicorn
+"""Startar flödesbyggarens webui på http://127.0.0.1:8765.
+
+    python run_webui.py            # startar; vägrar om porten redan används
+    python run_webui.py --force    # dödar en redan körande instans och tar över
+    python run_webui.py --port 9000
+
+Endast lokal åtkomst (127.0.0.1) - webui:t styr en riktig webbläsare mot din
+inloggade session och ska inte exponeras på nätverket.
+"""
+
+import argparse
+import socket
+import subprocess
+import sys
+import time
+
+HOST = "127.0.0.1"
+
+
+def _port_in_use(port: int) -> bool:
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
+        try:
+            s.bind((HOST, port))
+            return False
+        except OSError:
+            return True
+
+
+def _pids_on_port(port: int) -> list[int]:
+    """PID:erna som lyssnar på porten. Bäst effort, plattformsspecifikt."""
+    pids: set[int] = set()
+    try:
+        if sys.platform == "win32":
+            out = subprocess.run(
+                ["netstat", "-ano", "-p", "tcp"], capture_output=True, text=True, timeout=5
+            ).stdout
+            for line in out.splitlines():
+                parts = line.split()
+                if len(parts) >= 5 and parts[0].upper() == "TCP" and parts[1].endswith(f":{port}") \
+                        and parts[3].upper() == "LISTENING":
+                    pids.add(int(parts[4]))
+        else:
+            out = subprocess.run(
+                ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"], capture_output=True, text=True, timeout=5
+            ).stdout
+            pids.update(int(x) for x in out.split())
+    except Exception:  # noqa: BLE001
+        pass
+    return sorted(pids)
+
+
+def _kill(pids: list[int]) -> None:
+    for pid in pids:
+        try:
+            if sys.platform == "win32":
+                subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5)
+            else:
+                subprocess.run(["kill", "-9", str(pid)], capture_output=True, timeout=5)
+            print(f"Stängde process {pid}.")
+        except Exception as exc:  # noqa: BLE001
+            print(f"Kunde inte stänga {pid}: {exc}", file=sys.stderr)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    parser.add_argument("--port", type=int, default=8765)
+    parser.add_argument("--force", action="store_true",
+                        help="Döda en redan körande instans på porten och ta över")
+    args = parser.parse_args()
+
+    if _port_in_use(args.port):
+        pids = _pids_on_port(args.port)
+        who = f" (PID {', '.join(map(str, pids))})" if pids else ""
+        if not args.force:
+            print(
+                f"Port {args.port} används redan{who}.\n"
+                f"  - webui:t kör troligen redan: öppna http://{HOST}:{args.port}\n"
+                f"  - annars: kör 'python run_webui.py --force' för att ta över, "
+                f"eller '--port <annan port>'.",
+                file=sys.stderr,
+            )
+            sys.exit(1)
+        if pids:
+            _kill(pids)
+            time.sleep(1)
+        if _port_in_use(args.port):
+            print(f"Port {args.port} är fortfarande upptagen. Avbryter.", file=sys.stderr)
+            sys.exit(1)
+
+    import uvicorn
+
+    uvicorn.run("webui.app:app", host=HOST, port=args.port, reload=False)
+

 if __name__ == "__main__":
-    # Endast lokal åtkomst (127.0.0.1) - detta styr en riktig webbläsare mot
-    # din inloggade 1177-session och ska inte exponeras på nätverket.
-    uvicorn.run("webui.app:app", host="127.0.0.1", port=8765, reload=False)
+    main()
diff --git a/tests/test_run_webui.py b/tests/test_run_webui.py
new file mode 100644
index 0000000..ca1fddd
--- /dev/null
+++ b/tests/test_run_webui.py
@@ -0,0 +1,24 @@
+"""Startkontrollen i run_webui.py (vägrar om porten redan används)."""
+
+import socket
+
+import run_webui
+
+
+def test_port_in_use_detection():
+    # en ledig hög port ska rapporteras fri
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+        probe.bind(("127.0.0.1", 0))
+        free_port = probe.getsockname()[1]
+    assert run_webui._port_in_use(free_port) is False
+
+    # medan vi håller en lyssnande socket ska porten rapporteras upptagen
+    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    srv.bind(("127.0.0.1", 0))
+    srv.listen(1)
+    port = srv.getsockname()[1]
+    try:
+        assert run_webui._port_in_use(port) is True
+        assert isinstance(run_webui._pids_on_port(port), list)
+    finally:
+        srv.close()