mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge remote-tracking branch 'origin/main' into fix/windows-ssh-config-acl
This commit is contained in:
commit
e63dc43c2f
5 files changed
+258
-11
No files matched your search
@@ -88,8 +88,13 @@ counts them while they run.
|
|||||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||||
asks for the sudo password over SSH.
|
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||||
|
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||||
|
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||||
|
sign in with the Developer Mode password. Sleep, restart and
|
||||||
|
shut down open a terminal window because SteamOS asks for the sudo password
|
||||||
|
over SSH.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
|||||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||||
Mac with keyboard, mouse, and clipboard.
|
Mac with keyboard, mouse, and clipboard.
|
||||||
|
|
||||||
|
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||||
|
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||||
|
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||||
|
|
||||||
|
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||||
|
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||||
|
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||||
|
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||||
|
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||||
|
- Closing the client leaves the session running, and the next login
|
||||||
|
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||||
|
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||||
|
gamescope or SteamVR session.
|
||||||
|
|
||||||
## B. Show the Mac's desktop inside the Frame
|
## B. Show the Mac's desktop inside the Frame
|
||||||
|
|
||||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
|
||||||
|
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
|
||||||
|
staying quiet when the page goes away mid-reply, which on Windows is
|
||||||
|
ConnectionAbortedError (WinError 10053).
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import email.message
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def platform(name):
|
||||||
|
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
|
||||||
|
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
|
||||||
|
LINUX=name == "linux")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRdp(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.xrdp = socket.socket()
|
||||||
|
self.xrdp.bind(("127.0.0.1", 0))
|
||||||
|
self.xrdp.listen(4)
|
||||||
|
self.addCleanup(self.xrdp.close)
|
||||||
|
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
|
||||||
|
port.start()
|
||||||
|
self.addCleanup(port.stop)
|
||||||
|
self.spawned = []
|
||||||
|
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
|
||||||
|
spawn.start()
|
||||||
|
self.addCleanup(spawn.stop)
|
||||||
|
cache = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(cache.cleanup)
|
||||||
|
self.cache = Path(cache.name)
|
||||||
|
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
|
||||||
|
where.start()
|
||||||
|
self.addCleanup(where.stop)
|
||||||
|
|
||||||
|
def test_windows_signs_in_as_steamos(self):
|
||||||
|
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
|
||||||
|
with platform("windows"):
|
||||||
|
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertEqual(len(self.spawned), 1)
|
||||||
|
argv = self.spawned[0]
|
||||||
|
self.assertEqual(argv[0], "mstsc.exe")
|
||||||
|
self.assertNotIn("/v:127.0.0.1", argv)
|
||||||
|
rdp = Path(argv[1])
|
||||||
|
self.assertEqual(rdp.suffix, ".rdp")
|
||||||
|
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
|
||||||
|
self.assertNotIn(b"\r\r", data)
|
||||||
|
lines = data.decode("utf-8").split("\r\n")
|
||||||
|
self.assertIn("full address:s:127.0.0.1", lines)
|
||||||
|
self.assertIn("username:s:steamos", lines)
|
||||||
|
self.assertIn("steamos", message)
|
||||||
|
self.assertIn("Developer Mode password", message)
|
||||||
|
self.assertIn("certificate", message)
|
||||||
|
self.assertIn("Connect", message)
|
||||||
|
|
||||||
|
def test_nothing_listening_says_why_and_opens_nothing(self):
|
||||||
|
self.xrdp.close()
|
||||||
|
for name in ("windows", "mac", "linux"):
|
||||||
|
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
|
||||||
|
frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertIn("Developer Mode", str(cm.exception))
|
||||||
|
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_says_which_way_it_failed(self):
|
||||||
|
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||||
|
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||||
|
(socket.timeout("timed out"), "didn't answer"),
|
||||||
|
(OSError(65, "No route to host"), "didn't answer")):
|
||||||
|
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||||
|
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||||
|
frame_host.open_rdp("frame", "frame.local")
|
||||||
|
self.assertIn(says, str(cm.exception))
|
||||||
|
self.assertNotIn("refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_server_says_it_as_the_persons_to_fix(self):
|
||||||
|
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||||
|
self.xrdp.close()
|
||||||
|
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||||
|
self.assertRaises(server.Failure) as cm:
|
||||||
|
server.open_thing({"what": "rdp"})
|
||||||
|
self.assertEqual(cm.exception.status, 400)
|
||||||
|
self.assertIn("Developer Mode", str(cm.exception))
|
||||||
|
|
||||||
|
def test_one_file_per_address(self):
|
||||||
|
with platform("windows"):
|
||||||
|
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||||
|
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||||
|
self.assertEqual(len({a, b, c, d}), 4)
|
||||||
|
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||||
|
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||||
|
|
||||||
|
def test_address_cant_add_lines_to_the_file(self):
|
||||||
|
with platform("windows"), self.assertRaises(frame_host.HostError):
|
||||||
|
frame_host.rdp_file("frame\r\nusername:s:root")
|
||||||
|
self.assertEqual(list(self.cache.iterdir()), [])
|
||||||
|
|
||||||
|
def test_linux_clients_get_the_user(self):
|
||||||
|
with platform("linux"), mock.patch.object(frame_host, "which",
|
||||||
|
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
|
||||||
|
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
|
||||||
|
self.assertIn("steamos", message)
|
||||||
|
|
||||||
|
|
||||||
|
class PageGoneAway(unittest.TestCase):
|
||||||
|
"""The report's server log: the page closed while index.html was being sent, and the
|
||||||
|
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
|
||||||
|
|
||||||
|
def handler(self, path="/"):
|
||||||
|
h = server.Handler.__new__(server.Handler)
|
||||||
|
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
|
||||||
|
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
|
||||||
|
h.headers = email.message.Message()
|
||||||
|
h.headers["Host"] = "127.0.0.1:1"
|
||||||
|
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
|
||||||
|
h.close_connection = True
|
||||||
|
return h
|
||||||
|
|
||||||
|
def test_not_a_server_error(self):
|
||||||
|
h = self.handler()
|
||||||
|
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
|
||||||
|
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
|
||||||
|
h.do_GET()
|
||||||
|
diagnostic.assert_not_called()
|
||||||
|
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
|
||||||
|
|
||||||
|
def test_server_logs_nothing(self):
|
||||||
|
srv = server.LoopbackServer.__new__(server.LoopbackServer)
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch.object(sys, "stderr", err):
|
||||||
|
try:
|
||||||
|
raise server.ClientGone()
|
||||||
|
except server.ClientGone:
|
||||||
|
srv.handle_error(None, ("127.0.0.1", 1))
|
||||||
|
self.assertEqual(err.getvalue(), "")
|
||||||
|
try:
|
||||||
|
raise RuntimeError("real")
|
||||||
|
except RuntimeError:
|
||||||
|
srv.handle_error(None, ("127.0.0.1", 1))
|
||||||
|
self.assertIn("RuntimeError: real", err.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+54
-7
@@ -5,10 +5,12 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
|||||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -34,6 +36,10 @@ class HostError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Unreachable(HostError):
|
||||||
|
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
||||||
|
|
||||||
|
|
||||||
def run_ssh(argv, **kwargs):
|
def run_ssh(argv, **kwargs):
|
||||||
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||||
|
|
||||||
@@ -350,24 +356,65 @@ def open_steam_link():
|
|||||||
return "Steam Link isn't installed; opened its download page"
|
return "Steam Link isn't installed; opened its download page"
|
||||||
|
|
||||||
|
|
||||||
|
RDP_PORT = 3389
|
||||||
|
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
|
||||||
|
# xrdp's certificate is its own, so every client warns about it first.
|
||||||
|
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
||||||
|
"with your Developer Mode password")
|
||||||
|
|
||||||
|
|
||||||
|
def check_rdp(host, timeout=3):
|
||||||
|
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
||||||
|
return
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
||||||
|
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
||||||
|
"then restart it and try again.") from None
|
||||||
|
except socket.gaierror:
|
||||||
|
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
||||||
|
"address on the Devices tab.") from None
|
||||||
|
except OSError as e:
|
||||||
|
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
||||||
|
"switched off or on another network; if it's on, check Developer Mode is on "
|
||||||
|
"in Steam Settings > System.") from None
|
||||||
|
|
||||||
|
|
||||||
|
def rdp_file(host):
|
||||||
|
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
|
||||||
|
computer's Windows account, which xrdp turns away; the file names steamos instead."""
|
||||||
|
if any(c in host for c in "\r\n"):
|
||||||
|
raise HostError("That headset address can't be used for remote desktop")
|
||||||
|
# One file per address, so two launches close together can't swap headsets.
|
||||||
|
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
||||||
|
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def open_rdp(alias, host=None):
|
def open_rdp(alias, host=None):
|
||||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||||
host = host or ssh_hostname(alias)
|
host = host or ssh_hostname(alias)
|
||||||
|
# The client would open either way and then fail on its own, with nothing said here.
|
||||||
|
check_rdp(host)
|
||||||
if MAC:
|
if MAC:
|
||||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||||
return "Opened Windows App"
|
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
||||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||||
return "Windows App isn't installed; opened its App Store page"
|
return "Windows App isn't installed; opened its App Store page"
|
||||||
if WINDOWS:
|
if WINDOWS:
|
||||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||||
return f"Opened Remote Desktop to {host}"
|
# Windows asks about the unsigned connection file first.
|
||||||
|
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||||
if which("remmina"):
|
if which("remmina"):
|
||||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||||
return f"Opened Remmina to {host}"
|
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||||
for name in ("xfreerdp3", "xfreerdp"):
|
for name in ("xfreerdp3", "xfreerdp"):
|
||||||
if which(name):
|
if which(name):
|
||||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||||
return f"Opened FreeRDP to {host}"
|
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -1225,6 +1225,8 @@ def open_thing(body):
|
|||||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||||
frame_host.reveal_path(saved)
|
frame_host.reveal_path(saved)
|
||||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||||
|
except frame_host.Unreachable as e:
|
||||||
|
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
|
||||||
except frame_host.HostError as e:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -2288,6 +2290,11 @@ def push_file(path, dest="Downloads/"):
|
|||||||
return f"Sent {name} to ~/{dest}"
|
return f"Sent {name} to ~/{dest}"
|
||||||
|
|
||||||
|
|
||||||
|
class ClientGone(Exception):
|
||||||
|
"""The page went away (a reload, the app quitting) before its reply was written:
|
||||||
|
nobody to answer, and nothing went wrong here."""
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -2320,8 +2327,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# Nobody may frame the UI (clickjacking).
|
# Nobody may frame the UI (clickjacking).
|
||||||
self.send_header("X-Frame-Options", "DENY")
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
self.end_headers()
|
try:
|
||||||
self.wfile.write(data)
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
|
||||||
|
raise ClientGone() from e
|
||||||
|
|
||||||
def send_json(self, obj, status=200):
|
def send_json(self, obj, status=200):
|
||||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||||
@@ -2364,6 +2374,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
try:
|
try:
|
||||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||||
elif path == "/api/sources/details":
|
elif path == "/api/sources/details":
|
||||||
@@ -2441,6 +2453,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
headers=[("X-Capture-Source", "gamescope")])
|
headers=[("X-Capture-Source", "gamescope")])
|
||||||
else:
|
else:
|
||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_error_json(str(e), e.status, e.apk)
|
self.send_error_json(str(e), e.status, e.apk)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -2475,6 +2489,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||||
result = handler(body)
|
result = handler(body)
|
||||||
self.send_json(result)
|
self.send_json(result)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
if e.status >= 500:
|
if e.status >= 500:
|
||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
@@ -2650,6 +2666,10 @@ class LoopbackServer(ThreadingHTTPServer):
|
|||||||
socketserver.TCPServer.server_bind(self)
|
socketserver.TCPServer.server_bind(self)
|
||||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||||
|
|
||||||
|
def handle_error(self, request, client_address):
|
||||||
|
if not isinstance(sys.exc_info()[1], ClientGone):
|
||||||
|
super().handle_error(request, client_address)
|
||||||
|
|
||||||
|
|
||||||
_ONE_SERVER = None
|
_ONE_SERVER = None
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user