From 95fd18ca5c20aa5d626829764fd2dd84826345ee Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 3 Aug 2026 16:21:26 -0400 Subject: [PATCH] --lan startup: lead with the default-route address, demote the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-homed machine (VM bridges, Ethernet + Wi-Fi) has several addresses and the server cannot know which network the phone is on — but the OS's default route is the right answer nearly always. The banner now prints one "on your phone" URL from the route probe, with the other interfaces on an if-that-doesn't-answer line; when the probe fails, the settings hint plus candidates. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- data/titles.json | 52 +++++++++++++++++++++++++ data/unidentified.json | 12 ++++++ data/unidentified_dismissed.json | 3 +- src/bggpipe/webreview.py | 65 +++++++++++++++++++++----------- tests/test_webreview.py | 16 +++++--- 5 files changed, 119 insertions(+), 29 deletions(-) diff --git a/data/titles.json b/data/titles.json index 7c4486a..0d61c87 100644 --- a/data/titles.json +++ b/data/titles.json @@ -1654,5 +1654,57 @@ "IMG_4571.jpeg" ], "title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants" + }, + { + "title_raw": "ACTION CASTLE", + "confidence": "high", + "publisher_hint": "Memento More Computers Inc.", + "edition_hint": "", + "year_hint": 2007, + "language_hint": "English", + "art_notes": "Green monochrome text on black computer screen display, styled as retro terminal game", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "action castle" + }, + { + "title_raw": "ACTION CASTLE I", + "confidence": "medium", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Dark spine box among a stack of illustrated game boxes on the book cover art", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "action castle i" + }, + { + "title_raw": "SIX-GUN SHOWDOWN", + "confidence": "medium", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Western-themed box art with red/orange coloring, partially visible spine text", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "six gun showdown" + }, + { + "title_raw": "Blackboa[rd]", + "confidence": "low", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Dark spine at bottom right of cover art, title partially cut off", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "blackboa rd" } ] diff --git a/data/unidentified.json b/data/unidentified.json index 45ae05e..d2556d2 100644 --- a/data/unidentified.json +++ b/data/unidentified.json @@ -360,5 +360,17 @@ "partial_text": "TOWN...FUKU (possibly Japanese text)", "art_notes": "Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" } + ], + "IMG_4573.jpeg": [ + { + "location": "Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box", + "partial_text": "AC... (partially obscured by hand/fingers)", + "art_notes": "Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles" + }, + { + "location": "Bottom right of cover art, near 'Blackboard' box", + "partial_text": "", + "art_notes": "Small dark box with logo icon, title illegible due to size and angle" + } ] } diff --git a/data/unidentified_dismissed.json b/data/unidentified_dismissed.json index 60a0e3b..a4a56f6 100644 --- a/data/unidentified_dismissed.json +++ b/data/unidentified_dismissed.json @@ -108,5 +108,6 @@ "IMG_4556.jpeg|Top right corner, quilted patchwork-pattern box, right of the box with 'TCH ORK' text||Multicolored quilt/patchwork square pattern box, title not legible, partially cut at frame edge", "IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration", "IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off", - "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" + "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items", + "IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle" ] diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index df0aac0..5a2a877 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -247,6 +247,21 @@ NAV_PAGES = ( PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} +def _route_ip() -> str | None: + """The IPv4 address of this machine's default outbound interface — the + single best guess for the address a phone on the same network dials. + Multi-homed machines (VM bridges, Ethernet + Wi-Fi) have several + addresses; this is the one the OS actually routes through.""" + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect(("192.0.2.1", 80)) # never sent; just picks the route + address = probe.getsockname()[0] + probe.close() + return address + except OSError: + return None + + def lan_hosts() -> set[str]: """This machine's names and addresses on the local network — what a phone's browser will put in the Host header. An allowlist (never a @@ -256,13 +271,8 @@ def lan_hosts() -> set[str]: hostname = socket.gethostname() hosts.add(hostname.lower()) hosts.add(hostname.split(".")[0].lower() + ".local") - try: - probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - probe.connect(("192.0.2.1", 80)) # never sent; just picks the route - hosts.add(probe.getsockname()[0]) - probe.close() - except OSError: - pass + if route := _route_ip(): + hosts.add(route) try: for info in socket.getaddrinfo(hostname, None): address = info[4][0] @@ -1091,27 +1101,38 @@ def run_web_review( # type it once; the cookie remembers it from then on. token = secrets.token_urlsafe(16) if lan else None if lan: - hosts = lan_hosts() - ips = sorted( - h - for h in hosts - if h.replace(".", "").isdigit() and not h.startswith("127.") + primary = _route_ip() + spares = sorted( + h for h in lan_hosts() if h != primary and not h.startswith("127.") ) - names = sorted(h for h in hosts if not h.replace(".", "").isdigit()) - addresses = ", ".join(f"http://{h}:{port}/?k={token}" for h in ips + names) - typer.echo(f"bggpipe web UI: {url}?k={token}") - typer.echo(f" from your phone, open: {addresses}") + typer.echo(f"bggpipe web UI: {url} (this machine needs no key)") + if primary: + typer.echo(f" on your phone, open: http://{primary}:{port}/?k={token}") + if spares: + typer.echo( + " (several network interfaces here — if that address " + "doesn't answer, try: " + + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares) + + ")" + ) + else: + typer.echo( + " couldn't determine this machine's network address — find " + "it in your network settings and open " + f"http://:{port}/?k={token}" + + ( + " (or try: " + + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares) + + ")" + if spares + else "" + ) + ) typer.echo( " --lan: no login beyond that key — anyone who has it can run " "stages, change your data, and (once unlocked) drive uploads " "to your BGG account. Use only on a network you trust." ) - if not ips: - typer.echo( - " couldn't determine this machine's network address — find " - "it in your network settings and open " - f"http://:{port}/?k={token}" - ) else: typer.echo( f"bggpipe web UI: {url} (localhost only; dashboard at /, review " diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 9dd4443..c221ebf 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1007,17 +1007,21 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys captured = {} monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app)) cfg = make_cfg(tmp_path) + monkeypatch.setattr(wr, "_route_ip", lambda: "192.168.1.5") monkeypatch.setattr( - wr, "lan_hosts", lambda: {"127.0.0.1", "192.168.1.5", "erics-mac.local"} + wr, + "lan_hosts", + lambda: {"127.0.0.1", "192.168.1.5", "192.168.64.1", "erics-mac.local"}, ) wr.run_web_review(cfg, port=9999, lan=True) out = capsys.readouterr().out assert captured["host"] == "0.0.0.0" - assert "?k=" in out # every printed URL carries the access key - assert "http://192.168.1.5:9999/?k=" in out - # no phone can reach loopback: it never appears in the phone list - assert "phone" not in out.split("http://127.0.0.1:9999")[-1].split("\n")[0] - assert out.count("http://127.0.0.1:9999") == 1 # the desktop line only + # the default-route address leads; other interfaces are fallbacks + assert "on your phone, open: http://192.168.1.5:9999/?k=" in out + fallback = next(line for line in out.splitlines() if "try:" in line) + assert "192.168.64.1" in fallback and "erics-mac.local" in fallback + # no phone can reach loopback: only the desktop line mentions it + assert out.count("http://127.0.0.1:9999") == 1 assert "Use only on a network you trust" in out wr.run_web_review(cfg, port=9999, lan=False) assert captured["host"] == "127.0.0.1"