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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
devicectl/web/server.py
ADDED
|
@@ -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
|
+
]
|