--lan startup: lead with the default-route address, demote the rest

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 16:21:26 -04:00
co-authored by Claude Fable 5
parent eb2e841d27
commit 95fd18ca5c
5 changed files with 119 additions and 29 deletions
+52
View File
@@ -1654,5 +1654,57 @@
"IMG_4571.jpeg" "IMG_4571.jpeg"
], ],
"title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants" "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"
} }
] ]
+12
View File
@@ -360,5 +360,17 @@
"partial_text": "TOWN...FUKU (possibly Japanese text)", "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" "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"
}
] ]
} }
+2 -1
View File
@@ -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 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, 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_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"
] ]
+41 -20
View File
@@ -247,6 +247,21 @@ NAV_PAGES = (
PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} 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]: def lan_hosts() -> set[str]:
"""This machine's names and addresses on the local network — what a """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 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() hostname = socket.gethostname()
hosts.add(hostname.lower()) hosts.add(hostname.lower())
hosts.add(hostname.split(".")[0].lower() + ".local") hosts.add(hostname.split(".")[0].lower() + ".local")
try: if route := _route_ip():
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) hosts.add(route)
probe.connect(("192.0.2.1", 80)) # never sent; just picks the route
hosts.add(probe.getsockname()[0])
probe.close()
except OSError:
pass
try: try:
for info in socket.getaddrinfo(hostname, None): for info in socket.getaddrinfo(hostname, None):
address = info[4][0] address = info[4][0]
@@ -1091,26 +1101,37 @@ def run_web_review(
# type it once; the cookie remembers it from then on. # type it once; the cookie remembers it from then on.
token = secrets.token_urlsafe(16) if lan else None token = secrets.token_urlsafe(16) if lan else None
if lan: if lan:
hosts = lan_hosts() primary = _route_ip()
ips = sorted( spares = sorted(
h h for h in lan_hosts() if h != primary and not h.startswith("127.")
for h in hosts
if h.replace(".", "").isdigit() and not h.startswith("127.")
) )
names = sorted(h for h in hosts if not h.replace(".", "").isdigit()) typer.echo(f"bggpipe web UI: {url} (this machine needs no key)")
addresses = ", ".join(f"http://{h}:{port}/?k={token}" for h in ips + names) if primary:
typer.echo(f"bggpipe web UI: {url}?k={token}") typer.echo(f" on your phone, open: http://{primary}:{port}/?k={token}")
typer.echo(f" from your phone, open: {addresses}") if spares:
typer.echo( typer.echo(
" --lan: no login beyond that key — anyone who has it can run " " (several network interfaces here — if that address "
"stages, change your data, and (once unlocked) drive uploads " "doesn't answer, try: "
"to your BGG account. Use only on a network you trust." + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares)
+ ")"
) )
if not ips: else:
typer.echo( typer.echo(
" couldn't determine this machine's network address — find " " couldn't determine this machine's network address — find "
"it in your network settings and open " "it in your network settings and open "
f"http://<that-ip>:{port}/?k={token}" f"http://<that-ip>:{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."
) )
else: else:
typer.echo( typer.echo(
+10 -6
View File
@@ -1007,17 +1007,21 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
captured = {} captured = {}
monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app)) monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app))
cfg = make_cfg(tmp_path) cfg = make_cfg(tmp_path)
monkeypatch.setattr(wr, "_route_ip", lambda: "192.168.1.5")
monkeypatch.setattr( 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) wr.run_web_review(cfg, port=9999, lan=True)
out = capsys.readouterr().out out = capsys.readouterr().out
assert captured["host"] == "0.0.0.0" assert captured["host"] == "0.0.0.0"
assert "?k=" in out # every printed URL carries the access key # the default-route address leads; other interfaces are fallbacks
assert "http://192.168.1.5:9999/?k=" in out assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
# no phone can reach loopback: it never appears in the phone list fallback = next(line for line in out.splitlines() if "try:" in line)
assert "phone" not in out.split("http://127.0.0.1:9999")[-1].split("\n")[0] assert "192.168.64.1" in fallback and "erics-mac.local" in fallback
assert out.count("http://127.0.0.1:9999") == 1 # the desktop line only # 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 assert "Use only on a network you trust" in out
wr.run_web_review(cfg, port=9999, lan=False) wr.run_web_review(cfg, port=9999, lan=False)
assert captured["host"] == "127.0.0.1" assert captured["host"] == "127.0.0.1"