devicectl-core 0.1.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,1013 @@
1
+ """The local web server: routing, guards, the event stream and the static app.
2
+
3
+ Deliberately the standard library and nothing else. Everything behind it is
4
+ synchronous -- one connection on one worker thread -- so a thread-per-
5
+ connection server is the shape that fits: an event stream is a handler
6
+ thread blocking on a queue, and there is no async bridge to build.
7
+
8
+ What the transport layer has to get right, since there is no framework
9
+ doing it for us:
10
+
11
+ * **Streaming.** SSE responses carry no ``Content-Length``, so they close
12
+ the connection when they end and say so up front.
13
+ * **Who may connect.** Off loopback the server requires a token, which
14
+ arrives once in the URL and is then kept in a ``SameSite=Strict`` cookie.
15
+ * **DNS rebinding.** A page on the open internet can point a name it
16
+ controls at ``127.0.0.1`` and drive a local server from the victim's
17
+ browser. Names are therefore refused outright: the ``Host`` header must
18
+ be an IP literal, ``localhost``, or a name the user allowed explicitly.
19
+ * **Cross-site POSTs.** Cookie auth alone would let another origin submit a
20
+ form here, so every write also needs the :data:`UI_HEADER`, which a
21
+ cross-origin form cannot set without a preflight we never answer.
22
+
23
+ A local server that can reconfigure a device and flash its firmware is
24
+ worth attacking, which is why all four are here rather than only the first.
25
+
26
+ Everything a program has to say for itself is in :class:`Branding`; nothing
27
+ else here knows what kind of device is on the other end.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import errno
33
+ import ipaddress
34
+ import json
35
+ import os
36
+ import secrets
37
+ import signal
38
+ import socket
39
+ import sys
40
+ import threading
41
+ import time
42
+ from collections.abc import Callable, Mapping, Sequence
43
+ from dataclasses import dataclass, field
44
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
45
+ from pathlib import Path
46
+ from typing import Any, Protocol, cast
47
+ from urllib.parse import urlsplit
48
+
49
+ from devicectl.cli.exits import EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK
50
+ from devicectl.errors import DeviceError, format_traceback
51
+ from devicectl.web.agents import name_watchers
52
+ from devicectl.web.events import HEARTBEAT_INTERVAL_S, Broadcaster
53
+ from devicectl.web.http import (
54
+ HTTP_BAD_REQUEST,
55
+ HTTP_FORBIDDEN,
56
+ HTTP_METHOD_NOT_ALLOWED,
57
+ HTTP_NOT_FOUND,
58
+ HTTP_OK,
59
+ HTTP_PAYLOAD_TOO_LARGE,
60
+ HTTP_SEE_OTHER,
61
+ HTTP_SERVER_ERROR,
62
+ HTTP_UNAVAILABLE,
63
+ ApiError,
64
+ Request,
65
+ Response,
66
+ Route,
67
+ parse_query,
68
+ )
69
+
70
+ DEFAULT_HOST = "127.0.0.1"
71
+
72
+ # The header a same-origin fetch sets and a cross-origin form cannot. It is
73
+ # the same in every program on purpose: its value is that a non-simple header
74
+ # forces a preflight nothing here answers, and a per-app spelling would buy
75
+ # nothing while reaching into the JS.
76
+ UI_HEADER = "X-UI-Request"
77
+
78
+ # The token, for a caller that has no cookie jar -- a script, or `curl`.
79
+ TOKEN_HEADER = "X-Device-Token"
80
+
81
+ # Concurrent event streams we will hold open. Each costs a thread; a
82
+ # handful of browsers with a tab each stays far below this, and the cap
83
+ # keeps a client that reconnects in a loop from exhausting the process.
84
+ MAX_STREAMS = 24
85
+
86
+ # How long a browser waits before reconnecting a stream that dropped. Kept
87
+ # short deliberately: a tab left open from an earlier run is how a restart
88
+ # finds out there is already a window to raise instead of opening another.
89
+ STREAM_RETRY_MS = 1000
90
+
91
+ # How long a starting server waits for such a tab to come back before it
92
+ # gives up and opens a new one.
93
+ BROWSER_GRACE_S = 2.0
94
+
95
+ # How often that wait looks to see whether a tab has arrived.
96
+ BROWSER_POLL_S = 0.05
97
+
98
+ # Largest request body we will read at all. Uploads are capped lower still
99
+ # by the API layer; this is only the guard against a body that never ends,
100
+ # so a program that takes larger images raises it in its `Branding`.
101
+ DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024
102
+
103
+ # How often the accept loop looks up to see whether it has been shut down;
104
+ # this is the floor on how long Ctrl+C takes to reach the prompt.
105
+ SHUTDOWN_POLL_S = 0.05
106
+
107
+ # How long the token cookie lives: a week, so a shared link keeps working
108
+ # across a few days without the token going back into the address bar.
109
+ COOKIE_MAX_AGE_S = 604800
110
+
111
+ CONTENT_TYPES = {
112
+ ".html": "text/html; charset=utf-8",
113
+ ".js": "text/javascript; charset=utf-8",
114
+ ".css": "text/css; charset=utf-8",
115
+ ".json": "application/json; charset=utf-8",
116
+ ".svg": "image/svg+xml",
117
+ ".png": "image/png",
118
+ ".ico": "image/x-icon",
119
+ ".woff2": "font/woff2",
120
+ ".map": "application/json",
121
+ }
122
+
123
+ LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
124
+ ANY_HOSTS = ("0.0.0.0", "::", "")
125
+
126
+ # Where the shared frontend is served from, so an app's own static tree and
127
+ # this package's cannot collide however either grows.
128
+ CORE_PREFIX = "/core/"
129
+ CORE_STATIC = Path(__file__).with_name("static")
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Branding:
134
+ """Everything the shared server has to say in a program's own voice."""
135
+
136
+ name: str
137
+ """Printed on start-up, and the app id ``/api/focus`` answers with."""
138
+
139
+ version: str
140
+
141
+ token_cookie: str
142
+ """Per app, and it must stay that way: cookies are scoped by host, not
143
+ by port, so two programs served on ``localhost`` would otherwise
144
+ overwrite each other's token the moment both were open."""
145
+
146
+ default_port: int
147
+
148
+ static_dir: Path
149
+ """The program's own ``web/static``, served at the root."""
150
+
151
+ read_only_note: str
152
+ """What ``--read-only`` refuses, in this program's nouns."""
153
+
154
+ max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
155
+
156
+ links: Mapping[str, str] = field(default_factory=dict)
157
+ """The project's own URLs, by loose label -- ``homepage``, ``releases``,
158
+ ``license``. Read out of the packaging metadata by
159
+ :func:`devicectl.meta.project_links` rather than written down again here,
160
+ and answered to the page at ``GET /api/about``, which is where the
161
+ wordmark, the version beside it and the licence footer get their hrefs.
162
+ A program that is running from a checkout nobody installed has none, and
163
+ the page then draws the same three things without links."""
164
+
165
+
166
+ # Told about every API request this server serves: once when it arrives,
167
+ # with ``status`` of ``None``, and once when it has been answered. It is
168
+ # how a program with a recording in it gets the half of a fault report the
169
+ # wire cannot carry -- which button was pressed, and what came back to the
170
+ # page. Whatever it raises is swallowed: watching must never break a
171
+ # request.
172
+ Watcher = Callable[[Request, int | None, str], None]
173
+
174
+
175
+ def _said(exc: BaseException) -> str:
176
+ """Render a failure as its own sentence, or as the kind of failure it is."""
177
+ return str(exc).strip() or exc.__class__.__name__
178
+
179
+
180
+ class Stoppable(Protocol):
181
+ """The one thing the server needs of a worker: that it can be told to stop."""
182
+
183
+ def stop(self, timeout: float | None = None) -> None:
184
+ """Stand down, without necessarily waiting for it to finish."""
185
+
186
+
187
+ @dataclass
188
+ class Settings:
189
+ """How this server was started, as opposed to what it is serving."""
190
+
191
+ read_only: bool = False
192
+ debug: bool = False
193
+
194
+
195
+ class UIServer(ThreadingHTTPServer):
196
+ """The HTTP server, plus everything the handlers need to reach."""
197
+
198
+ daemon_threads = True
199
+ allow_reuse_address = True
200
+
201
+ def __init__(
202
+ self,
203
+ address: tuple[str, int],
204
+ *,
205
+ branding: Branding,
206
+ routes: Mapping[tuple[str, str], Route[Any]],
207
+ context: Any,
208
+ events: Broadcaster,
209
+ settings: Settings,
210
+ token: str,
211
+ allowed_hosts: frozenset[str],
212
+ watch: Watcher | None = None,
213
+ ) -> None:
214
+ """Bind the server and record its shared state."""
215
+ self.branding = branding
216
+ self.routes = routes
217
+ self.context = context
218
+ self.events = events
219
+ self.settings = settings
220
+ self.watch = watch
221
+ self.token = token
222
+ self.allowed_hosts = allowed_hosts
223
+ self.stopping = threading.Event()
224
+ self.streams = 0
225
+ self._stream_lock = threading.Lock()
226
+ super().__init__(address, UIHandler)
227
+
228
+ def claim_stream(self) -> bool:
229
+ """Take one of the event-stream slots, if any is left."""
230
+ with self._stream_lock:
231
+ if self.streams >= MAX_STREAMS:
232
+ return False
233
+ self.streams += 1
234
+ return True
235
+
236
+ def release_stream(self) -> None:
237
+ """Give an event-stream slot back."""
238
+ with self._stream_lock:
239
+ self.streams = max(0, self.streams - 1)
240
+
241
+ def handle_error(self, request: Any, client_address: Any) -> None:
242
+ """Swallow the noise a browser makes when it walks away mid-request.
243
+
244
+ A closed tab, a cancelled fetch or a reload all show up here as a
245
+ broken pipe or a reset; the default handler prints a full traceback
246
+ per occurrence, which would bury the one line the user wants.
247
+
248
+ This belongs on the server, not on the handler: socketserver calls
249
+ it on whatever accepted the connection.
250
+ """
251
+ exc = sys.exc_info()[1]
252
+ if isinstance(exc, (BrokenPipeError, ConnectionResetError, TimeoutError)):
253
+ return
254
+ if self.settings.debug:
255
+ super().handle_error(request, client_address)
256
+
257
+
258
+ class UIHandler(BaseHTTPRequestHandler):
259
+ """One request (or one long-lived event stream)."""
260
+
261
+ protocol_version = "HTTP/1.1"
262
+ sys_version = ""
263
+
264
+ @property
265
+ def ui(self) -> UIServer:
266
+ """The server, typed: socketserver types this as the base class."""
267
+ return cast(UIServer, self.server)
268
+
269
+ def version_string(self) -> str:
270
+ """Name the program in ``Server:``, rather than the Python version."""
271
+ return self.ui.branding.name
272
+
273
+ def log_message(self, format: str, *args: Any) -> None: # stdlib's own name
274
+ """Keep request logging off the terminal unless --debug asked for it.
275
+
276
+ The parameter keeps the base class's name, builtin or not, so a
277
+ caller passing it by keyword still reaches this override.
278
+ """
279
+ if self.ui.settings.debug:
280
+ sys.stderr.write(f"[debug] -- ui {self.address_string()} {format % args}\n")
281
+
282
+ # --- guards ------------------------------------------------------------------
283
+
284
+ def _host_allowed(self) -> bool:
285
+ """Refuse a Host header that is a name we were not told to expect."""
286
+ host = urlsplit(f"//{self.headers.get('Host', '')}").hostname or ""
287
+ host = host.strip("[]").lower()
288
+ if not host:
289
+ return False
290
+ if host in self.ui.allowed_hosts:
291
+ return True
292
+ try:
293
+ ipaddress.ip_address(host)
294
+ except ValueError:
295
+ return False
296
+ return True # an IP literal cannot be re-pointed by DNS
297
+
298
+ def _token_ok(self) -> tuple[bool, str | None]:
299
+ """Check the access token; returns (allowed, token to set as a cookie)."""
300
+ if not self.ui.token:
301
+ return True, None
302
+ supplied = parse_query(urlsplit(self.path).query).get("token")
303
+ if supplied and secrets.compare_digest(supplied, self.ui.token):
304
+ return True, supplied
305
+ cookies = self.headers.get("Cookie", "")
306
+ for part in cookies.split(";"):
307
+ name, _, value = part.strip().partition("=")
308
+ if name == self.ui.branding.token_cookie and secrets.compare_digest(
309
+ value, self.ui.token
310
+ ):
311
+ return True, None
312
+ if secrets.compare_digest(self.headers.get(TOKEN_HEADER, ""), self.ui.token):
313
+ return True, None
314
+ return False, None
315
+
316
+ # --- dispatch ----------------------------------------------------------------
317
+
318
+ def do_GET(self) -> None: # BaseHTTPRequestHandler's spelling
319
+ """Serve the app, the event stream, or a read endpoint."""
320
+ self._dispatch("GET")
321
+
322
+ def do_POST(self) -> None:
323
+ """Serve a write endpoint."""
324
+ self._dispatch("POST")
325
+
326
+ def do_HEAD(self) -> None:
327
+ """Answer a HEAD like a GET without the body."""
328
+ self._dispatch("GET", head_only=True)
329
+
330
+ def _dispatch(self, method: str, *, head_only: bool = False) -> None:
331
+ split = urlsplit(self.path)
332
+ path = split.path.rstrip("/") or "/"
333
+ if not self._host_allowed():
334
+ self._send_error_page(
335
+ HTTP_FORBIDDEN,
336
+ "This server only answers to an IP address or 'localhost'. "
337
+ "Start it with --allow-host NAME to use a hostname.",
338
+ )
339
+ return
340
+ allowed, set_cookie = self._token_ok()
341
+ if not allowed:
342
+ self._send_error_page(
343
+ HTTP_FORBIDDEN,
344
+ "Missing or wrong access token. Open the link the server printed.",
345
+ )
346
+ return
347
+ if set_cookie is not None and not path.startswith("/api/"):
348
+ # The token arrived in the URL: stash it and reload without it,
349
+ # so it stops appearing in the address bar and in referrers.
350
+ self._redirect(split.path or "/", set_cookie)
351
+ return
352
+ try:
353
+ if path == "/api/events":
354
+ self._serve_events()
355
+ return
356
+ if path.startswith("/api/"):
357
+ self._serve_api(method, path, split.query)
358
+ return
359
+ if method != "GET":
360
+ self._send_error_page(HTTP_METHOD_NOT_ALLOWED, "method not allowed")
361
+ return
362
+ self._serve_static(path, head_only=head_only)
363
+ except (BrokenPipeError, ConnectionResetError):
364
+ pass # the browser went away mid-reply; nothing to report
365
+
366
+ # --- API ---------------------------------------------------------------------
367
+
368
+ def _serve_api(self, method: str, path: str, query: str) -> None:
369
+ if method == "POST" and not self.headers.get(UI_HEADER):
370
+ self._send_json(HTTP_FORBIDDEN, {"error": f"missing {UI_HEADER} header"})
371
+ return
372
+ # These three are about the server rather than the device, and the
373
+ # first has to answer before a second copy of the program has any
374
+ # context to route with -- that is how it finds the tab to raise.
375
+ if (method, path) == ("POST", "/api/focus"):
376
+ self._serve_focus()
377
+ return
378
+ if (method, path) == ("GET", "/api/clients"):
379
+ self._serve_clients()
380
+ return
381
+ if (method, path) == ("GET", "/api/about"):
382
+ self._serve_about()
383
+ return
384
+ request = Request(method=method, path=path, query=parse_query(query))
385
+ route = self.ui.routes.get((method, path))
386
+ if route is None:
387
+ self._refuse(request, HTTP_NOT_FOUND, f"no such endpoint: {method} {path}")
388
+ return
389
+ if route.write and self.ui.settings.read_only:
390
+ self._refuse(
391
+ request,
392
+ HTTP_FORBIDDEN,
393
+ "this server is running read-only; " + self.ui.branding.read_only_note,
394
+ )
395
+ return
396
+ body = self._read_body()
397
+ if body is None:
398
+ return
399
+ request.body = body
400
+ self._watched(request, None)
401
+ self._run_route(route, request)
402
+
403
+ def _refuse(self, request: Request, status: int, message: str) -> None:
404
+ """Turn a request down before it reaches a handler, and record that."""
405
+ self._watched(request, status, message)
406
+ self._send_json(status, {"error": message})
407
+
408
+ def _watched(self, request: Request, status: int | None, error: str = "") -> None:
409
+ """Tell the program's watcher what just happened, if it has one.
410
+
411
+ Before the reply goes out rather than after it, so that whatever is
412
+ watching has been told by the time the client has its answer. A
413
+ browser that fetches a recording immediately after the request it is
414
+ interested in would otherwise be racing the handler that serves it.
415
+ """
416
+ watch = self.ui.watch
417
+ if watch is None:
418
+ return
419
+ try:
420
+ watch(request, status, error)
421
+ except Exception: # noqa: BLE001 - watching must never break a request
422
+ pass
423
+
424
+ def _run_route(self, route: Route, request: Request) -> None:
425
+ """Run a handler, and turn whatever it raises into a reply.
426
+
427
+ This is the only place a failure becomes a status code, so no handler
428
+ has to translate its own.
429
+ """
430
+ try:
431
+ response = route.handler(self.ui.context, request)
432
+ except ApiError as exc:
433
+ self._watched(request, exc.status, exc.message)
434
+ self._send_json(exc.status, {"error": exc.message})
435
+ except DeviceError as exc:
436
+ # The device or an input said no. That is the client's problem
437
+ # to fix, not a server fault, so it does not deserve a 500.
438
+ self._watched(request, status_of(exc), str(exc))
439
+ said: dict[str, Any] = {"error": str(exc)}
440
+ if exc.traceable:
441
+ said["traceable"] = True
442
+ self._send_json(status_of(exc), said)
443
+ except OSError as exc:
444
+ # A port that vanished, a socket that will not open: the same
445
+ # kind of answer, from a layer that raises the stdlib's error.
446
+ self._watched(request, HTTP_BAD_REQUEST, str(exc))
447
+ self._send_json(HTTP_BAD_REQUEST, {"error": str(exc)})
448
+ except Exception as exc: # noqa: BLE001 - every failure becomes a reply
449
+ self._watched(request, HTTP_SERVER_ERROR, _said(exc))
450
+ self._report_failure(exc)
451
+ else:
452
+ self._watched(request, response.status)
453
+ self._send_response(response)
454
+
455
+ def _serve_focus(self) -> None:
456
+ """Ask every open tab to bring the page it already has to the front.
457
+
458
+ This is how a second ``<program> ui`` avoids opening a second tab:
459
+ it finds the port taken, asks whoever holds it to raise the page,
460
+ and stops. Nothing on the device changes, so a read-only server
461
+ answers it too -- and the reply names the app, which is how the
462
+ caller knows it reached another copy of itself rather than
463
+ something else listening on that port.
464
+ """
465
+ events = self.ui.events
466
+ events.publish("focus", {"at": time.time()})
467
+ self._send_json(
468
+ HTTP_OK,
469
+ {
470
+ "app": self.ui.branding.name,
471
+ "version": self.ui.branding.version,
472
+ "clients": events.subscriber_count,
473
+ # The caller is a second copy with no stream of its own, so
474
+ # it can only name the tabs it just raised if we name them.
475
+ "watching": name_watchers(events.clients()),
476
+ },
477
+ )
478
+
479
+ def _serve_about(self) -> None:
480
+ """Answer what this program is, and where its own pages live.
481
+
482
+ The name, the version and the project's URLs, in one answer the
483
+ shared header asks for once per page. It is about the program and
484
+ not about the device, so it is served here rather than from a route
485
+ every program would have had to declare -- and it is the same three
486
+ facts in both, which is exactly what stops being true the moment
487
+ each page carries its own copy.
488
+ """
489
+ branding = self.ui.branding
490
+ self._send_json(
491
+ HTTP_OK,
492
+ {
493
+ "app": branding.name,
494
+ "version": branding.version,
495
+ "links": dict(branding.links),
496
+ },
497
+ )
498
+
499
+ def _serve_clients(self) -> None:
500
+ """Who is watching: one row per open event stream."""
501
+ from devicectl.web.agents import client_json
502
+
503
+ rows = [client_json(c) for c in self.ui.events.clients()]
504
+ self._send_json(HTTP_OK, {"clients": rows})
505
+
506
+ def _report_failure(self, exc: BaseException) -> None:
507
+ """Turn an unexpected failure into a 500 (and a traceback in debug)."""
508
+ if self.ui.settings.debug:
509
+ sys.stderr.write(f"[debug] -- ui {format_traceback(exc)}\n")
510
+ message = _said(exc)
511
+ # A bug, but one met while doing something to a device -- which is
512
+ # when a recording of what went over the wire shows where it was.
513
+ self._send_json(
514
+ HTTP_SERVER_ERROR,
515
+ {"error": message, "kind": exc.__class__.__name__, "traceable": True},
516
+ )
517
+
518
+ def _read_body(self) -> bytes | None:
519
+ """Read the request body, or answer an error and return None."""
520
+ try:
521
+ length = int(self.headers.get("Content-Length", "0") or 0)
522
+ except ValueError:
523
+ self._send_json(HTTP_BAD_REQUEST, {"error": "bad Content-Length"})
524
+ return None
525
+ if length < 0 or length > self.ui.branding.max_body_bytes:
526
+ self._send_json(HTTP_PAYLOAD_TOO_LARGE, {"error": "request body too large"})
527
+ return None
528
+ return self.rfile.read(length) if length else b""
529
+
530
+ # --- event stream ------------------------------------------------------------
531
+
532
+ def _serve_events(self) -> None:
533
+ """Hold one Server-Sent Events connection open until it goes away."""
534
+ if not self.ui.claim_stream():
535
+ self._send_json(HTTP_UNAVAILABLE, {"error": "too many open event streams"})
536
+ return
537
+ self.close_connection = True # no Content-Length: the body ends at close
538
+ self.send_response(HTTP_OK)
539
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
540
+ self.send_header("Cache-Control", "no-store")
541
+ self.send_header("X-Accel-Buffering", "no")
542
+ self.send_header("Connection", "close")
543
+ self.end_headers()
544
+ try:
545
+ # Reconnect quickly: this is what lets a tab from an earlier run
546
+ # be found and raised, rather than a second one being opened.
547
+ self._write_chunk(f"retry: {STREAM_RETRY_MS}\n\n".encode())
548
+ with self.ui.events.subscribe(self._describe_client()) as subscription:
549
+ # The stream's first event tells this browser which of the
550
+ # watchers on /api/clients is itself; nothing else can, since
551
+ # several tabs share one address and one user agent.
552
+ payload = json.dumps({"clientId": subscription.client.get("id")})
553
+ self._write_chunk(f"event: hello\ndata: {payload}\n\n".encode())
554
+ while not self.ui.stopping.is_set():
555
+ event = subscription.get(HEARTBEAT_INTERVAL_S)
556
+ if subscription.closed:
557
+ break
558
+ if event is None:
559
+ self._write_chunk(b": ping\n\n") # prove the socket is alive
560
+ continue
561
+ payload = json.dumps(event.data, default=str)
562
+ self._write_chunk(
563
+ f"event: {event.name}\nid: {event.seq}\n"
564
+ f"data: {payload}\n\n".encode()
565
+ )
566
+ except (BrokenPipeError, ConnectionResetError, OSError):
567
+ pass # the tab was closed
568
+ finally:
569
+ self.ui.release_stream()
570
+
571
+ def _describe_client(self) -> dict[str, Any]:
572
+ """Who is opening this stream, as far as the request can say."""
573
+ # Annotated wide on purpose: `client_address` is a pair for the
574
+ # sockets this server listens on, but the base class does not promise
575
+ # one, so the shape is checked rather than assumed.
576
+ peer: tuple[Any, ...] = (
577
+ self.client_address if isinstance(self.client_address, tuple) else ()
578
+ )
579
+ return {
580
+ "address": str(peer[0]) if len(peer) > 0 else "",
581
+ "port": peer[1] if len(peer) > 1 else None,
582
+ "agent": self.headers.get("User-Agent", "") or "",
583
+ "since": time.time(),
584
+ }
585
+
586
+ def _write_chunk(self, data: bytes) -> None:
587
+ """Write one piece of the stream and push it out immediately."""
588
+ self.wfile.write(data)
589
+ self.wfile.flush()
590
+
591
+ # --- static files ------------------------------------------------------------
592
+
593
+ def _serve_static(self, path: str, *, head_only: bool = False) -> None:
594
+ """Serve the app, falling back to index.html for the app's own routes.
595
+
596
+ Anything under ``/core/`` comes from this package rather than from
597
+ the program: that is the one prefix the shared frontend owns, so a
598
+ program's own tree can grow without ever colliding with it.
599
+ """
600
+ if path.startswith(CORE_PREFIX):
601
+ root, relative = CORE_STATIC, path[len(CORE_PREFIX) :]
602
+ fallback = None
603
+ else:
604
+ root, relative = self.ui.branding.static_dir, path.lstrip("/")
605
+ fallback = root / "index.html"
606
+ target = (root / (relative or "index.html")).resolve()
607
+ try:
608
+ target.relative_to(root.resolve())
609
+ except ValueError:
610
+ self._send_error_page(HTTP_FORBIDDEN, "forbidden")
611
+ return
612
+ if not target.is_file():
613
+ if fallback is None:
614
+ self._send_error_page(HTTP_NOT_FOUND, f"no such file: {path}")
615
+ return
616
+ target = fallback
617
+ if not target.is_file():
618
+ self._send_error_page(
619
+ HTTP_SERVER_ERROR,
620
+ "The web UI files are missing from this installation "
621
+ f"(expected them in {root}).",
622
+ )
623
+ return
624
+ body = target.read_bytes()
625
+ self._send_response(
626
+ Response(
627
+ status=HTTP_OK,
628
+ body=b"" if head_only else body,
629
+ content_type=CONTENT_TYPES.get(
630
+ target.suffix, "application/octet-stream"
631
+ ),
632
+ headers={"Cache-Control": "no-cache", "Content-Length": str(len(body))},
633
+ )
634
+ )
635
+
636
+ # --- replies -----------------------------------------------------------------
637
+
638
+ def _redirect(self, location: str, token: str) -> None:
639
+ """Send the token to a cookie and reload the page without it."""
640
+ cookie = self.ui.branding.token_cookie
641
+ self.send_response(HTTP_SEE_OTHER)
642
+ self.send_header("Location", location or "/")
643
+ self.send_header(
644
+ "Set-Cookie",
645
+ f"{cookie}={token}; Path=/; SameSite=Strict; Max-Age={COOKIE_MAX_AGE_S}",
646
+ )
647
+ self.send_header("Content-Length", "0")
648
+ self.end_headers()
649
+
650
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None:
651
+ """Send one JSON reply."""
652
+ self._send_response(
653
+ Response(status=status, body=json.dumps(payload).encode("utf-8"))
654
+ )
655
+
656
+ def _send_error_page(self, status: int, message: str) -> None:
657
+ """Send a refusal a person can read in a browser tab."""
658
+ self._send_response(
659
+ Response(
660
+ status=status,
661
+ body=f"{self.ui.branding.name} ui: {message}\n".encode(),
662
+ content_type="text/plain; charset=utf-8",
663
+ )
664
+ )
665
+
666
+ def _send_response(self, response: Response) -> None:
667
+ """Write a complete reply."""
668
+ self.send_response(response.status)
669
+ self.send_header("Content-Type", response.content_type)
670
+ if "Content-Length" not in response.headers:
671
+ self.send_header("Content-Length", str(len(response.body)))
672
+ self.send_header("X-Content-Type-Options", "nosniff")
673
+ self.send_header("Referrer-Policy", "no-referrer")
674
+ for name, value in response.headers.items():
675
+ self.send_header(name, value)
676
+ self.end_headers()
677
+ if response.body and self.command != "HEAD":
678
+ self.wfile.write(response.body)
679
+
680
+
681
+ def status_of(exc: DeviceError) -> int:
682
+ """Return the status a program's own error answers with.
683
+
684
+ An error that carries one -- a worker refusing because it is busy says
685
+ 409 -- keeps it; everything else is the client's input to fix, and 400.
686
+ """
687
+ status = getattr(exc, "status", None)
688
+ return status if isinstance(status, int) else HTTP_BAD_REQUEST
689
+
690
+
691
+ def parse_listen(text: str, *, default_host: str, default_port: int) -> tuple[str, int]:
692
+ """Split a ``--listen`` argument into a host and a port.
693
+
694
+ Takes ``HOST``, ``HOST:PORT``, ``[v6]:PORT`` or a bare ``PORT``, so
695
+ ``--listen 9000`` and ``--listen 0.0.0.0`` both mean what they look like.
696
+ The program passes its own defaults, since the port a page opens on is
697
+ the one thing about the server each program picks for itself.
698
+ """
699
+ raw = text.strip()
700
+ if not raw:
701
+ return default_host, default_port
702
+ if raw.isdigit():
703
+ return default_host, int(raw)
704
+ if raw.startswith("["): # [::1] or [::1]:PORT
705
+ host, _, rest = raw[1:].partition("]")
706
+ port = rest.lstrip(":")
707
+ return host, int(port) if port else default_port
708
+ host, sep, port = raw.rpartition(":")
709
+ if not sep or not port.isdigit():
710
+ return raw, default_port
711
+ return host or default_host, int(port)
712
+
713
+
714
+ def local_addresses() -> list[str]:
715
+ """Best guess at the addresses others could reach this machine on."""
716
+ found: list[str] = []
717
+ try:
718
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
719
+ probe.settimeout(0.2)
720
+ probe.connect(("192.0.2.1", 9)) # TEST-NET-1: routed nowhere
721
+ found.append(probe.getsockname()[0])
722
+ except OSError:
723
+ pass
724
+ return found
725
+
726
+
727
+ def raise_open_tab(
728
+ app: str, host: str, port: int, *, timeout: float = 1.0
729
+ ) -> dict[str, Any] | None:
730
+ """Ask a copy already serving this port to raise its browser tab.
731
+
732
+ Returns the reply -- which names the app and the browsers it reached --
733
+ or ``None`` if nobody answered as ``app``. The reply has to name it:
734
+ something else entirely may be listening on that port, and a stranger's
735
+ 200 is not a reason to say the UI is already open.
736
+ """
737
+ import http.client
738
+
739
+ try:
740
+ conn = http.client.HTTPConnection(host, port, timeout=timeout)
741
+ try:
742
+ conn.request(
743
+ "POST",
744
+ "/api/focus",
745
+ body=b"{}",
746
+ headers={"Content-Type": "application/json", UI_HEADER: "1"},
747
+ )
748
+ response = conn.getresponse()
749
+ doc = json.loads(response.read() or b"{}")
750
+ finally:
751
+ conn.close()
752
+ except (OSError, ValueError, http.client.HTTPException):
753
+ return None
754
+ if response.status == HTTP_OK and doc.get("app") == app:
755
+ return doc
756
+ return None
757
+
758
+
759
+ def show_the_page(
760
+ url: str, events: Broadcaster, *, grace: float = BROWSER_GRACE_S
761
+ ) -> bool:
762
+ """Raise the tab that already has this page open, or open a new one.
763
+
764
+ No browser can be told from the outside to switch to a tab it already
765
+ has, so the page is told to raise itself instead: a tab left over from an
766
+ earlier run reconnects to the stream within :data:`STREAM_RETRY_MS`, and
767
+ a ``focus`` event reaches it there. Only if nothing has reconnected by
768
+ the end of the grace period is a new tab opened. Returns whether an
769
+ existing tab was found.
770
+ """
771
+ import webbrowser
772
+
773
+ deadline = time.monotonic() + grace
774
+ while time.monotonic() < deadline:
775
+ if events.subscriber_count:
776
+ events.publish("focus", {"at": time.time()})
777
+ print(
778
+ " a browser already has this page open -- raising that tab", flush=True
779
+ )
780
+ for name in name_watchers(events.clients()):
781
+ print(f" {name}", flush=True)
782
+ return True
783
+ time.sleep(BROWSER_POLL_S)
784
+ webbrowser.open(url)
785
+ return False
786
+
787
+
788
+ def _begin_teardown(server: UIServer, worker: Stoppable) -> None:
789
+ """Start everything that has to end, without waiting for any of it.
790
+
791
+ Runs off the signal handler rather than in it: each of these takes a
792
+ lock some other thread may be holding, and a handler runs on the main
793
+ thread, where blocking on one is how a process hangs on Ctrl+C instead
794
+ of stopping on it.
795
+ """
796
+ worker.stop(timeout=0) # tell it to stand down; do not wait here
797
+ server.events.shutdown() # end the event streams, waking their threads
798
+ server.shutdown() # stop accepting, and wait for the loop to notice
799
+
800
+
801
+ def _install_stop_handlers(server: UIServer, worker: Stoppable) -> Callable[[], None]:
802
+ """Make Ctrl+C (and SIGTERM) stop the server; returns a restore callback.
803
+
804
+ ``serve_forever`` waits in ``selectors.select``, and an interrupt there is
805
+ not guaranteed to surface as ``KeyboardInterrupt`` -- on some platforms it
806
+ does not, and the server keeps the port. So the signal is handled
807
+ explicitly instead. ``shutdown`` blocks until the loop has stopped and
808
+ would deadlock if called from the loop's own thread, which is exactly where
809
+ a signal handler runs, hence the throwaway thread.
810
+
811
+ Everything that can be started here is started here rather than in
812
+ ``serve``'s ``finally``, because they are independent and the slow one
813
+ should not be waiting on its turn: the event streams end, the accept
814
+ loop stops and the worker is told to stand down all at once. A second
815
+ Ctrl+C is taken to mean the first one was not fast enough and leaves
816
+ immediately, which is the usual contract for one.
817
+ """
818
+ stopping = threading.Event()
819
+
820
+ def handler(signum: int, frame: Any) -> None:
821
+ if stopping.is_set():
822
+ # Asked twice: stop asking politely. Nothing here writes to
823
+ # disk, so there is nothing a second pass could corrupt.
824
+ os._exit(EXIT_INTERRUPTED)
825
+ stopping.set()
826
+ print("\nStopping...", flush=True)
827
+ server.stopping.set()
828
+ threading.Thread(target=_begin_teardown, args=(server, worker)).start()
829
+
830
+ previous: list[tuple[int, Any]] = []
831
+ for name in ("SIGINT", "SIGTERM"):
832
+ signum = getattr(signal, name, None)
833
+ if signum is None:
834
+ continue
835
+ try:
836
+ previous.append((signum, signal.signal(signum, handler)))
837
+ except ValueError:
838
+ # Not the main thread -- the embedding process owns the signals.
839
+ pass
840
+
841
+ def restore() -> None:
842
+ for signum, old in previous:
843
+ try:
844
+ signal.signal(signum, old)
845
+ except (ValueError, TypeError):
846
+ pass
847
+
848
+ return restore
849
+
850
+
851
+ @dataclass
852
+ class Serving:
853
+ """What a program hands the shared server to run."""
854
+
855
+ branding: Branding
856
+ routes: Mapping[tuple[str, str], Route[Any]]
857
+ context: Any
858
+ events: Broadcaster
859
+ worker: Stoppable
860
+ notes: Sequence[str] = field(default_factory=tuple)
861
+ """Extra lines printed under the URL, in the program's own words."""
862
+
863
+ watch: Watcher | None = None
864
+ """Told about every API request, before and after it is served."""
865
+
866
+
867
+ def _cannot_listen(
868
+ exc: OSError,
869
+ name: str,
870
+ *,
871
+ host: str,
872
+ shown_host: str,
873
+ port: int,
874
+ open_browser: bool,
875
+ ) -> int:
876
+ """Report a port we could not have, raising the tab already on it if we can.
877
+
878
+ The usual reason the port is taken is that this is the second
879
+ ``<program> ui``, so before failing, ask the first one to bring its
880
+ browser tab forward -- which is what the user wanted anyway.
881
+ """
882
+ taken = getattr(exc, "errno", None) == errno.EADDRINUSE
883
+ reply = raise_open_tab(name, shown_host, port) if taken and open_browser else None
884
+ if reply is None:
885
+ print(f"Cannot listen on {host}:{port}: {exc}", file=sys.stderr)
886
+ return EXIT_ERROR
887
+ print(f"{name} ui is already serving on http://{shown_host}:{port}/")
888
+ print(" raised the browser tab it had already opened")
889
+ # Named by the other process, which is the only one that can see who is
890
+ # on its stream.
891
+ for watching in reply.get("watching") or []:
892
+ print(f" {watching}")
893
+ return EXIT_OK
894
+
895
+
896
+ def _announce(
897
+ serving: Serving,
898
+ url: str,
899
+ port: int,
900
+ *,
901
+ read_only: bool,
902
+ loopback: bool,
903
+ token: str,
904
+ ) -> None:
905
+ """Print where the server can be reached, and on what terms."""
906
+ print(f"{serving.branding.name} ui is serving on {url}", flush=True)
907
+ if read_only:
908
+ print(f" read-only: {serving.branding.read_only_note}")
909
+ for note in serving.notes:
910
+ print(f" {note}")
911
+ if not loopback:
912
+ suffix = f"?token={token}" if token else ""
913
+ for address in local_addresses():
914
+ print(f" shareable: http://{address}:{port}/{suffix}")
915
+ if token:
916
+ print(" the link includes an access token -- share it deliberately")
917
+ print(" press Ctrl+C to stop", flush=True)
918
+
919
+
920
+ def serve(
921
+ serving: Serving,
922
+ *,
923
+ host: str = DEFAULT_HOST,
924
+ port: int | None = None,
925
+ token: str | None = None,
926
+ read_only: bool = False,
927
+ open_browser: bool = True,
928
+ allow_hosts: Sequence[str] = (),
929
+ debug: bool = False,
930
+ ) -> int:
931
+ """Run the web UI until interrupted; returns a process exit code."""
932
+ branding = serving.branding
933
+ port = branding.default_port if port is None else port
934
+ loopback = host in LOOPBACK_HOSTS
935
+ if token is None:
936
+ token = "" if loopback else secrets.token_urlsafe(16)
937
+
938
+ allowed = frozenset(
939
+ {"localhost", *(h.strip().lower() for h in allow_hosts if h.strip())}
940
+ )
941
+ shown_host = DEFAULT_HOST if host in ANY_HOSTS else host
942
+ try:
943
+ server = UIServer(
944
+ (host, port),
945
+ branding=branding,
946
+ routes=serving.routes,
947
+ context=serving.context,
948
+ events=serving.events,
949
+ settings=Settings(read_only=read_only, debug=debug),
950
+ token=token,
951
+ allowed_hosts=allowed,
952
+ watch=serving.watch,
953
+ )
954
+ except OSError as exc:
955
+ serving.worker.stop()
956
+ return _cannot_listen(
957
+ exc,
958
+ branding.name,
959
+ host=host,
960
+ shown_host=shown_host,
961
+ port=port,
962
+ open_browser=open_browser,
963
+ )
964
+
965
+ suffix = f"?token={token}" if token else ""
966
+ url = f"http://{shown_host}:{server.server_port}/{suffix}"
967
+ _announce(
968
+ serving,
969
+ url,
970
+ server.server_port,
971
+ read_only=read_only,
972
+ loopback=loopback,
973
+ token=token,
974
+ )
975
+
976
+ if open_browser:
977
+ threading.Thread(
978
+ target=show_the_page, args=(url, serving.events), daemon=True
979
+ ).start()
980
+
981
+ stop = _install_stop_handlers(server, serving.worker)
982
+ try:
983
+ server.serve_forever(poll_interval=SHUTDOWN_POLL_S)
984
+ except KeyboardInterrupt:
985
+ print("\nStopping...", flush=True)
986
+ finally:
987
+ stop()
988
+ server.stopping.set()
989
+ serving.events.shutdown()
990
+ server.shutdown()
991
+ server.server_close()
992
+ serving.worker.stop()
993
+ return EXIT_OK
994
+
995
+
996
+ __all__ = [
997
+ "Branding",
998
+ "CORE_PREFIX",
999
+ "DEFAULT_HOST",
1000
+ "Serving",
1001
+ "Settings",
1002
+ "Stoppable",
1003
+ "TOKEN_HEADER",
1004
+ "UIServer",
1005
+ "UI_HEADER",
1006
+ "Watcher",
1007
+ "local_addresses",
1008
+ "parse_listen",
1009
+ "raise_open_tab",
1010
+ "serve",
1011
+ "show_the_page",
1012
+ "status_of",
1013
+ ]