diff --git a/README.md b/README.md
index 1fddaf6..1aa3cb3 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) li
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
```
-The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a link carrying a per-run access key (`?k=...`): open that exact link on the phone once and a cookie remembers it. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
+The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a pairing link carrying an access key (`?k=...`) plus a QR code — point the phone's camera at the terminal and tap. Pairing is one-time per device: the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Titles** (every read off your shelves, alphabetized — and where you proofread them: fix misreads, add cues, split multi-copy lines, remove non-games), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state:
diff --git a/pyproject.toml b/pyproject.toml
index fd8097f..5d18e2c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
"playwright>=1.62.0",
"pydantic>=2.13.4",
"python-multipart>=0.0.32",
+ "qrcode>=8.2",
]
[project.scripts]
diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html
index 3291784..d120179 100644
--- a/src/bggpipe/templates/pages/help.html
+++ b/src/bggpipe/templates/pages/help.html
@@ -61,7 +61,7 @@
Your data, on disk
Everything lives in flat files under data/ — inspectable, hand-editable, and git-friendly. The pipeline artifacts: titles.json (what was read), matches.csv (what it matched), to_add.csv/to_update.csv (what upload will do), upload_log.csv (what it did), games.json (the library). Your curation: title_edits.json, title_splits.json, title_removals.json, unidentified_dismissed.json.
-
Credentials never live in files — only environment variables, set up by bggpipe init. The app serves localhost only, unless started with --lan — that opens it to your network behind a per-run access key printed at startup (no login beyond the key; trusted networks only).
+
Credentials never live in files — only environment variables, set up by bggpipe init. The app serves localhost only, unless started with --lan — that opens it to your network behind an access key: scan the QR code the server prints (or open the printed link) once per device, and a year-long cookie keeps it paired. Delete data/.lan_key to revoke every device. No login beyond the key; trusted networks only.
More depth: the README covers setup and photo technique; docs/bgg-upload-flow.md documents the upload automation.
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py
index dc95b1f..81e821f 100644
--- a/src/bggpipe/webreview.py
+++ b/src/bggpipe/webreview.py
@@ -380,8 +380,15 @@ def create_app(
status_code=303,
headers={"Location": request.url.path or "/"},
)
+ # a YEAR, not a session: pairing a device should be a
+ # one-time act (the key file persists too; delete
+ # data/.lan_key to revoke every paired device)
response.set_cookie(
- LAN_COOKIE, lan_token, httponly=True, samesite="lax"
+ LAN_COOKIE,
+ lan_token,
+ max_age=365 * 24 * 3600,
+ httponly=True,
+ samesite="lax",
)
return response
if request.method not in ("GET", "HEAD", "OPTIONS") and (
@@ -1105,6 +1112,21 @@ def _dev_app() -> FastAPI:
return create_app(load_config(Path(path) if path else None), lan_token=token)
+def _print_qr(url: str) -> None:
+ """Pairing without typing: the phone's camera reads the URL straight
+ off the terminal."""
+ import io as _io
+
+ import qrcode
+
+ qr = qrcode.QRCode(border=1)
+ qr.add_data(url)
+ buffer = _io.StringIO()
+ qr.print_ascii(out=buffer, invert=True)
+ for line in buffer.getvalue().splitlines():
+ typer.echo(f" {line}")
+
+
def _lan_key(cfg: Config) -> str:
"""The --lan access key: created once, reused across restarts so a
phone's cookie keeps working. Delete the file to rotate the key."""
@@ -1143,6 +1165,8 @@ def run_web_review(
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}")
+ typer.echo(" or point its camera at this code:")
+ _print_qr(f"http://{primary}:{port}/?k={token}")
if spares:
typer.echo(
" (several network interfaces here — if that address "
diff --git a/tests/test_webreview.py b/tests/test_webreview.py
index 607063d..774c753 100644
--- a/tests/test_webreview.py
+++ b/tests/test_webreview.py
@@ -926,6 +926,8 @@ def test_lan_token_gates_every_request(tmp_path):
assert first.status_code == 303 # cookie commits BEFORE the document
assert first.headers["location"] == "/titles" # key scrubbed from URL
assert "bggpipe_key" in first.cookies
+ # pairing is one-time per device: a durable cookie, not a session one
+ assert "Max-Age=31536000" in first.headers["set-cookie"]
assert phone.get("/titles").status_code == 200 # cookie carries it now
# cookie carries the session: mutations work from ANY host the phone
# used (no allowlist dependence — DHCP/multi-interface safe), with a
@@ -1029,6 +1031,7 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
assert f"?k={key}" in capsys.readouterr().out # same key after restart
# the default-route address leads; other interfaces are fallbacks
assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
+ assert "▀" in out or "█" in out # the pairing QR rendered
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
diff --git a/uv.lock b/uv.lock
index 6cd3a3c..f4c191c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -65,6 +65,7 @@ dependencies = [
{ name = "playwright" },
{ name = "pydantic" },
{ name = "python-multipart" },
+ { name = "qrcode" },
{ name = "rapidfuzz" },
{ name = "rich" },
{ name = "typer" },
@@ -88,6 +89,7 @@ requires-dist = [
{ name = "playwright", specifier = ">=1.62.0" },
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "python-multipart", specifier = ">=0.0.32" },
+ { name = "qrcode", specifier = ">=8.2" },
{ name = "rapidfuzz", specifier = ">=3.9" },
{ name = "rich", specifier = ">=15.0.0" },
{ name = "typer", specifier = ">=0.12" },
@@ -671,6 +673,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
+[[package]]
+name = "qrcode"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
+]
+
[[package]]
name = "rapidfuzz"
version = "3.14.5"