better-rtplot 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -160,12 +160,26 @@ parser.add_argument(
160
160
  default=1000,
161
161
  )
162
162
 
163
+ parser.add_argument(
164
+ "--password",
165
+ help=(
166
+ "Gate the whole UI behind a shared password (HTTP Basic). Any"
167
+ " username is accepted. Can also be set via RTPLOT_PASSWORD env"
168
+ " var; the flag wins if both are given. Leave unset to serve"
169
+ " without auth."
170
+ ),
171
+ action="store",
172
+ type=str,
173
+ default=None,
174
+ )
175
+
163
176
  args = parser.parse_args()
164
177
 
165
178
  NEW_SUBPLOT_IN_ROW = args.column
166
179
  DEBUG_TEXT_ENABLED = args.debug
167
180
  SKIP_PLOT_DATAPOINTS = args.skip
168
181
  ADAPT_SKIP_PLOT_DATAPOINTS = args.adaptable
182
+ AUTH_PASSWORD = args.password if args.password is not None else os.environ.get("RTPLOT_PASSWORD")
169
183
 
170
184
 
171
185
  ###############################
@@ -942,6 +956,29 @@ async def rename_tab(tab_id: str, new_name: str):
942
956
  await broadcast_tab(tab_id)
943
957
 
944
958
 
959
+ async def reconnect_tab(tab_id: str):
960
+ """Close and reopen ``tab_id``'s sockets, restart its receiver task.
961
+
962
+ The tab's buffer and plot config are preserved — if the sender on
963
+ the other side is still running the same stream we avoid a visible
964
+ flash to empty. A new config from the sender will replace the state
965
+ via the normal RECEIVED_PLOT_UPDATE path.
966
+ """
967
+ t = tabs.get(tab_id)
968
+ if t is None:
969
+ return
970
+ await _cancel_task(t.receiver_task)
971
+ # _open_tab_sockets closes first, then reopens. Status gets set to
972
+ # "idle" on success or "error" + a message on bind/connect failure.
973
+ _open_tab_sockets(t)
974
+ # Reset the data-rate estimate so a stale Hz doesn't mislead the
975
+ # resources panel until new samples arrive.
976
+ t.data_rate_hz = 0.0
977
+ t._last_rx_ts = 0.0
978
+ t.receiver_task = asyncio.create_task(zmq_receiver(t))
979
+ await broadcast_tab(tab_id)
980
+
981
+
945
982
  ###############################
946
983
  # HTTP / WebSocket handlers #
947
984
  ###############################
@@ -1035,6 +1072,11 @@ async def handle_ws(request):
1035
1072
  if tid and tid != BIND_ME_ID:
1036
1073
  await delete_tab(tid)
1037
1074
 
1075
+ elif ptype == "tab_reconnect":
1076
+ tid = payload.get("id")
1077
+ if tid:
1078
+ await reconnect_tab(tid)
1079
+
1038
1080
  elif ptype == "control_button":
1039
1081
  btn_id = payload.get("id")
1040
1082
  tid = ws_tab.get(ws, BIND_ME_ID)
@@ -1310,9 +1352,140 @@ async def on_cleanup(app):
1310
1352
  zmq_ctx.term()
1311
1353
 
1312
1354
 
1355
+ ###############################
1356
+ # Cookie-based password auth #
1357
+ ###############################
1358
+
1359
+ # A single shared password unlocks the UI for every browser. We deliberately
1360
+ # don't model users: the browser's native Basic-Auth popup forces a username
1361
+ # field, which was confusing — one password is enough for LAN deployments.
1362
+ # On successful POST /login, the server mints a random session token, stashes
1363
+ # it in an in-memory set, and drops it as an HttpOnly cookie. All other
1364
+ # routes (including the WebSocket upgrade) require that cookie.
1365
+ import hmac as _hmac
1366
+ import secrets as _secrets
1367
+
1368
+ SESSION_COOKIE = "rtplot_session"
1369
+ _AUTH_REQUIRED = AUTH_PASSWORD is not None
1370
+ _AUTH_EXPECTED = AUTH_PASSWORD.encode("utf-8") if AUTH_PASSWORD else None
1371
+ _valid_tokens: set = set()
1372
+
1373
+
1374
+ _LOGIN_HTML = """<!doctype html>
1375
+ <html lang=\"en\">
1376
+ <head>
1377
+ <meta charset=\"utf-8\" />
1378
+ <title>rtplot \u2014 sign in</title>
1379
+ <style>
1380
+ html, body { margin: 0; padding: 0; min-height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #eef1f5; color: #222; }
1381
+ body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
1382
+ form { background: #fff; border: 1px solid #d2d8e0; border-radius: 8px; padding: 28px 32px; box-shadow: 0 6px 24px rgba(30,40,60,0.08); width: 320px; }
1383
+ h1 { margin: 0 0 6px 0; font-size: 20px; color: #1d3566; }
1384
+ p { margin: 0 0 18px 0; color: #666; font-size: 13px; }
1385
+ label { display: block; font-size: 12px; color: #555; margin-bottom: 6px; }
1386
+ input[type=password] { width: 100%; box-sizing: border-box; padding: 9px 10px; font-size: 14px; border: 1px solid #bfc7d2; border-radius: 4px; font-family: inherit; }
1387
+ input[type=password]:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42,93,176,0.15); }
1388
+ button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; border: none; border-radius: 4px; background: #2a5db0; color: #fff; cursor: pointer; font-weight: 600; }
1389
+ button:hover { background: #244f95; }
1390
+ .err { background: #fce8e8; color: #a11; border: 1px solid #f0c2c2; padding: 8px 10px; border-radius: 4px; font-size: 13px; margin-bottom: 14px; }
1391
+ </style>
1392
+ </head>
1393
+ <body>
1394
+ <form method=\"post\" action=\"/login\" autocomplete=\"on\">
1395
+ <h1>rtplot</h1>
1396
+ <p>Enter the shared password to continue.</p>
1397
+ __ERROR_BLOCK__
1398
+ <label for=\"pw\">Password</label>
1399
+ <input id=\"pw\" type=\"password\" name=\"password\" autofocus required />
1400
+ <button type=\"submit\">Sign in</button>
1401
+ </form>
1402
+ </body>
1403
+ </html>
1404
+ """
1405
+
1406
+
1407
+ def _render_login_page(error: bool = False) -> str:
1408
+ err = '<div class="err">Wrong password. Try again.</div>' if error else ""
1409
+ return _LOGIN_HTML.replace("__ERROR_BLOCK__", err)
1410
+
1411
+
1412
+ def _has_valid_session(request) -> bool:
1413
+ tok = request.cookies.get(SESSION_COOKIE)
1414
+ return bool(tok) and tok in _valid_tokens
1415
+
1416
+
1417
+ async def handle_login(request):
1418
+ # GET /login -> render form
1419
+ # POST /login -> check password, set cookie on success
1420
+ if request.method == "GET":
1421
+ if _has_valid_session(request):
1422
+ raise web.HTTPFound("/")
1423
+ return web.Response(
1424
+ text=_render_login_page(),
1425
+ content_type="text/html",
1426
+ headers={"Cache-Control": "no-store"},
1427
+ )
1428
+
1429
+ if not _AUTH_REQUIRED:
1430
+ raise web.HTTPFound("/")
1431
+ data = await request.post()
1432
+ supplied = (data.get("password") or "").encode("utf-8")
1433
+ if _hmac.compare_digest(supplied, _AUTH_EXPECTED or b""):
1434
+ token = _secrets.token_urlsafe(32)
1435
+ _valid_tokens.add(token)
1436
+ resp = web.HTTPFound("/")
1437
+ resp.set_cookie(
1438
+ SESSION_COOKIE,
1439
+ token,
1440
+ httponly=True,
1441
+ samesite="Lax",
1442
+ max_age=30 * 24 * 3600, # 30 days
1443
+ path="/",
1444
+ )
1445
+ raise resp
1446
+ return web.Response(
1447
+ text=_render_login_page(error=True),
1448
+ content_type="text/html",
1449
+ status=401,
1450
+ headers={"Cache-Control": "no-store"},
1451
+ )
1452
+
1453
+
1454
+ async def handle_logout(request):
1455
+ tok = request.cookies.get(SESSION_COOKIE)
1456
+ if tok:
1457
+ _valid_tokens.discard(tok)
1458
+ resp = web.HTTPFound("/login")
1459
+ resp.del_cookie(SESSION_COOKIE, path="/")
1460
+ raise resp
1461
+
1462
+
1463
+ @web.middleware
1464
+ async def session_auth_middleware(request, handler):
1465
+ if not _AUTH_REQUIRED:
1466
+ return await handler(request)
1467
+ path = request.path
1468
+ # Login page and its form post must stay open; the uPlot assets that
1469
+ # the main page pulls in are gated too, which is consistent with the
1470
+ # rest of the UI.
1471
+ if path == "/login" or path == "/logout":
1472
+ return await handler(request)
1473
+ if _has_valid_session(request):
1474
+ return await handler(request)
1475
+ # WebSocket upgrade can't follow a redirect — respond with 401 so the
1476
+ # browser-side reconnect loop surfaces a clear error.
1477
+ if path == "/ws":
1478
+ return web.Response(status=401, text="Not signed in.\n")
1479
+ raise web.HTTPFound("/login")
1480
+
1481
+
1313
1482
  def build_app():
1314
- app = web.Application()
1483
+ middlewares = [session_auth_middleware] if _AUTH_REQUIRED else []
1484
+ app = web.Application(middlewares=middlewares)
1315
1485
  app.router.add_get("/", handle_index)
1486
+ app.router.add_get("/login", handle_login)
1487
+ app.router.add_post("/login", handle_login)
1488
+ app.router.add_get("/logout", handle_logout)
1316
1489
  app.router.add_get("/ws", handle_ws)
1317
1490
  app.router.add_get("/snapshot.html", handle_snapshot)
1318
1491
  static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
@@ -1341,6 +1514,10 @@ def _detect_lan_ips():
1341
1514
  def main():
1342
1515
  app = build_app()
1343
1516
  print(f"rtplot browser server listening on http://localhost:{args.port}")
1517
+ if _AUTH_REQUIRED:
1518
+ print(" auth: shared password required (browsers see a login page)")
1519
+ else:
1520
+ print(" auth: none (set --password or RTPLOT_PASSWORD to gate the UI)")
1344
1521
  for ip in _detect_lan_ips():
1345
1522
  print(f" also reachable at http://{ip}:{args.port}")
1346
1523
  is_wsl = "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ
@@ -68,6 +68,10 @@ def _settings_path():
68
68
  DEFAULT_SETTINGS = {
69
69
  # Most recent demo-sender targets, newest first, capped at 8.
70
70
  "recent_hosts": [],
71
+ # Optional shared password that gates the browser UI. Empty string
72
+ # means "no auth". Stored in plaintext alongside the exe — the
73
+ # threat model is LAN convenience, not cryptographic secrecy.
74
+ "password": "",
71
75
  }
72
76
 
73
77
  MAX_RECENT_HOSTS = 8
@@ -222,10 +226,31 @@ def _parse_wrapper_args(argv):
222
226
  "machine that only has the exe (no Python install)."
223
227
  ),
224
228
  )
229
+ parser.add_argument(
230
+ "--password",
231
+ default=None,
232
+ help=(
233
+ "Gate the UI behind a shared password (HTTP login page). "
234
+ "Overrides the password saved by the GUI's settings file. "
235
+ "Leave unset to use the saved password (or no auth if none "
236
+ "has been set)."
237
+ ),
238
+ )
225
239
  args, rest = parser.parse_known_args(argv)
226
240
  return args, rest
227
241
 
228
242
 
243
+ def _resolve_password(args) -> str:
244
+ """Return the effective password: CLI flag wins, else saved setting.
245
+
246
+ Empty string means "no auth"; anything else gates the UI.
247
+ """
248
+ if args.password is not None:
249
+ return args.password
250
+ settings = _load_settings()
251
+ return settings.get("password", "") or ""
252
+
253
+
229
254
  def _run_headless(args, rest):
230
255
  """Delegate straight to rtplot.server_browser's __main__."""
231
256
  import runpy
@@ -233,6 +258,9 @@ def _run_headless(args, rest):
233
258
  forwarded = [sys.argv[0], "--port", str(args.port), "--host", args.host]
234
259
  if args.pi_ip:
235
260
  forwarded += ["-p", args.pi_ip]
261
+ pw = _resolve_password(args)
262
+ if pw:
263
+ forwarded += ["--password", pw]
236
264
  forwarded += rest
237
265
  sys.argv = forwarded
238
266
  runpy.run_module("rtplot.server_browser", run_name="__main__")
@@ -397,6 +425,11 @@ def _run_with_gui(args, rest):
397
425
 
398
426
  settings = _load_settings()
399
427
 
428
+ # Password resolution order: CLI flag > saved setting > none. Whatever
429
+ # wins is forwarded to server_browser via --password so the server's
430
+ # auth middleware picks it up at import time.
431
+ effective_password = _resolve_password(args)
432
+
400
433
  # Seed sys.argv so server_browser's module-level argparse sees what we
401
434
  # want. --no-browser is always forced in GUI mode because the window
402
435
  # has its own "Open in browser" button.
@@ -410,6 +443,8 @@ def _run_with_gui(args, rest):
410
443
  ]
411
444
  if args.pi_ip:
412
445
  forwarded += ["-p", args.pi_ip]
446
+ if effective_password:
447
+ forwarded += ["--password", effective_password]
413
448
  forwarded += rest
414
449
  sys.argv = forwarded
415
450
 
@@ -590,6 +625,13 @@ def _run_with_gui(args, rest):
590
625
  style="Subhead.TLabel",
591
626
  ).pack(anchor="w", pady=(4, 10))
592
627
 
628
+ if effective_password:
629
+ ttk.Label(
630
+ main,
631
+ text="\U0001F512 Password protection is on \u2014 viewers need the shared password.",
632
+ style="Muted.TLabel",
633
+ ).pack(anchor="w", pady=(0, 8))
634
+
593
635
  # ---- Big URL display (clickable, copy-able) ----
594
636
  url = f"http://localhost:{args.port}"
595
637
  url_var = tk.StringVar(value=url)
@@ -855,6 +897,124 @@ def _run_with_gui(args, rest):
855
897
 
856
898
  poll_demo_stats()
857
899
 
900
+ # --- Access control sub-section ---
901
+ ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
902
+ ttk.Label(
903
+ advanced_frame, text="Access control", style="Section.TLabel"
904
+ ).pack(anchor="w")
905
+ ttk.Label(
906
+ advanced_frame,
907
+ text=(
908
+ "Set a shared password so the browser UI asks anyone on the\n"
909
+ "network to sign in before they can see your plots."
910
+ ),
911
+ style="Help.TLabel",
912
+ ).pack(anchor="w", pady=(2, 6))
913
+
914
+ pw_state_var = tk.StringVar()
915
+ pw_restart_note_var = tk.StringVar(value="")
916
+
917
+ def _refresh_pw_state():
918
+ if effective_password:
919
+ pw_state_var.set("\U0001F512 Password is set")
920
+ else:
921
+ pw_state_var.set("No password (UI is open to anyone who can reach it)")
922
+
923
+ _refresh_pw_state()
924
+ ttk.Label(advanced_frame, textvariable=pw_state_var, style="Muted.TLabel").pack(
925
+ anchor="w"
926
+ )
927
+
928
+ def _open_password_dialog():
929
+ dlg = tk.Toplevel(root)
930
+ dlg.title("Set password")
931
+ dlg.transient(root)
932
+ dlg.grab_set()
933
+ dlg.resizable(False, False)
934
+ try:
935
+ dlg.configure(bg=BG)
936
+ except tk.TclError:
937
+ pass
938
+
939
+ body = ttk.Frame(dlg, padding=18)
940
+ body.pack(fill="both", expand=True)
941
+
942
+ ttk.Label(
943
+ body,
944
+ text="Shared password",
945
+ style="Section.TLabel",
946
+ ).pack(anchor="w")
947
+ ttk.Label(
948
+ body,
949
+ text=(
950
+ "Everyone who opens the rtplot page will be asked for this\n"
951
+ "password. Leave blank + Save to remove password protection."
952
+ ),
953
+ style="Help.TLabel",
954
+ ).pack(anchor="w", pady=(2, 8))
955
+
956
+ pw_var = tk.StringVar(value=settings.get("password", ""))
957
+ show_var = tk.BooleanVar(value=False)
958
+
959
+ entry = ttk.Entry(body, textvariable=pw_var, show="\u2022", width=32)
960
+ entry.pack(anchor="w", fill="x")
961
+ entry.focus_set()
962
+
963
+ def _toggle_show():
964
+ entry.configure(show="" if show_var.get() else "\u2022")
965
+
966
+ ttk.Checkbutton(
967
+ body, text="Show password", variable=show_var, command=_toggle_show
968
+ ).pack(anchor="w", pady=(6, 10))
969
+
970
+ err_var = tk.StringVar(value="")
971
+ ttk.Label(body, textvariable=err_var, foreground="#a11", background=BG).pack(
972
+ anchor="w"
973
+ )
974
+
975
+ def _save_and_close():
976
+ new_pw = pw_var.get()
977
+ settings["password"] = new_pw
978
+ _save_settings(settings)
979
+ if new_pw == effective_password:
980
+ pw_restart_note_var.set("Password unchanged.")
981
+ else:
982
+ pw_restart_note_var.set(
983
+ "Saved. Close and reopen rtplot-server.exe to apply."
984
+ )
985
+ _refresh_pw_state()
986
+ dlg.destroy()
987
+
988
+ def _cancel():
989
+ dlg.destroy()
990
+
991
+ btn_row = ttk.Frame(body)
992
+ btn_row.pack(fill="x", pady=(10, 0))
993
+ ttk.Button(btn_row, text="Save", command=_save_and_close).pack(side="right")
994
+ ttk.Button(btn_row, text="Cancel", command=_cancel).pack(
995
+ side="right", padx=(0, 6)
996
+ )
997
+ # Enter saves, Escape cancels — matches typical form UX.
998
+ dlg.bind("<Return>", lambda _e: _save_and_close())
999
+ dlg.bind("<Escape>", lambda _e: _cancel())
1000
+ # Center over the main window.
1001
+ dlg.update_idletasks()
1002
+ rx, ry = root.winfo_rootx(), root.winfo_rooty()
1003
+ rw, rh = root.winfo_width(), root.winfo_height()
1004
+ dw, dh = dlg.winfo_reqwidth(), dlg.winfo_reqheight()
1005
+ dlg.geometry(f"+{rx + (rw - dw) // 2}+{ry + (rh - dh) // 2}")
1006
+
1007
+ pw_btn_row = ttk.Frame(advanced_frame)
1008
+ pw_btn_row.pack(anchor="w", pady=(6, 0))
1009
+ ttk.Button(
1010
+ pw_btn_row,
1011
+ text=("Change password" if effective_password else "Set password"),
1012
+ command=_open_password_dialog,
1013
+ ).pack(side="left")
1014
+ ttk.Label(
1015
+ pw_btn_row, textvariable=pw_restart_note_var, style="Muted.TLabel"
1016
+ ).pack(side="left", padx=(8, 0))
1017
+
858
1018
  # --- Log sub-section (further collapsable inside advanced) ---
859
1019
  ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
860
1020
  log_state = {"expanded": False}
@@ -18,8 +18,16 @@
18
18
  .tab .tab-dot.error { background: #e05252; }
19
19
  .tab .tab-dot.connecting { background: #e8b13a; animation: pulse 1s ease-in-out infinite alternate; }
20
20
  @keyframes pulse { from { opacity: 0.5; } to { opacity: 1.0; } }
21
- .tab .tab-name { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
21
+ .tab .tab-labels { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
22
+ .tab .tab-name { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
23
+ .tab .tab-endpoint { font-family: monospace; font-size: calc(10px * var(--ui-scale)); color: #888; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ .tab.active .tab-endpoint { color: #5a6880; }
22
25
  .tab .tab-name-input { border: 1px solid #2a5db0; border-radius: 3px; padding: 1px 4px; font: inherit; color: #111; width: 160px; }
26
+ .tab .tab-reconnect { font-size: calc(13px * var(--ui-scale)); color: #888; cursor: pointer; padding: 1px 5px; border-radius: 2px; opacity: 0.0; line-height: 1; }
27
+ .tab:hover .tab-reconnect, .tab .tab-dot.error ~ .tab-reconnect { opacity: 1.0; }
28
+ .tab .tab-reconnect:hover { background: #e0e6f0; color: #2a5db0; }
29
+ .tab .tab-reconnect.spinning { animation: tab-spin 0.6s linear; }
30
+ @keyframes tab-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
23
31
  .tab .tab-rename { font-size: calc(11px * var(--ui-scale)); color: #888; cursor: pointer; padding: 1px 4px; border-radius: 2px; opacity: 0.0; }
24
32
  .tab:hover .tab-rename { opacity: 1.0; }
25
33
  .tab .tab-rename:hover { background: #e0e0e0; color: #333; }
@@ -916,10 +924,37 @@
916
924
  if (t.error) dot.title = t.error;
917
925
  el.appendChild(dot);
918
926
 
927
+ // Stacked labels: name on top, endpoint on a muted subline so
928
+ // users can see at a glance which tab is wired to which device.
929
+ const labels = document.createElement('div');
930
+ labels.className = 'tab-labels';
919
931
  const name = document.createElement('span');
920
932
  name.className = 'tab-name';
921
933
  name.textContent = t.name || id;
922
- el.appendChild(name);
934
+ labels.appendChild(name);
935
+ const ep = document.createElement('span');
936
+ ep.className = 'tab-endpoint';
937
+ ep.textContent = (t.mode === 'bind')
938
+ ? `listening on ${t.endpoint || '*:5555'}`
939
+ : `connected to ${t.endpoint || '?'}`;
940
+ labels.appendChild(ep);
941
+ el.appendChild(labels);
942
+
943
+ const reconnect = document.createElement('span');
944
+ reconnect.className = 'tab-reconnect';
945
+ reconnect.textContent = '\u21BB';
946
+ reconnect.title = 'Reconnect';
947
+ reconnect.addEventListener('click', (e) => {
948
+ e.stopPropagation();
949
+ sendCtrl({ type: 'tab_reconnect', id: id });
950
+ // Visual feedback: the icon spins once so users see the click
951
+ // registered even when the server returns the same status
952
+ // (e.g. reconnect succeeded, no dot color change).
953
+ reconnect.classList.remove('spinning');
954
+ void reconnect.offsetWidth; // force reflow to restart animation
955
+ reconnect.classList.add('spinning');
956
+ });
957
+ el.appendChild(reconnect);
923
958
 
924
959
  if (id !== 'bind_me') {
925
960
  const ren = document.createElement('span');
File without changes
File without changes