Live H.264 video of the headset view in Frame Control

Live in the headset view now streams video instead of polling stereo
screenshots (~2 fps). SteamVR's steamvr-v4l2cam.service mirrors the headset
view into /dev/video99; ffmpeg on the Frame encodes it with x264 (720p30 by
default, AUD + repeated SPS/PPS), /api/stream relays the raw H.264 over SSH,
and the page splits it on access unit delimiters and decodes it with
WebCodecs into the existing viewer. Capture still takes a stereo still; the
desktop panel keeps capture polling, and the page falls back to it if the
video can't start.

The remote ffmpeg runs under a shell that kills it when the SSH channel
closes, stderr goes to a temp file, and a 10 s stall ends the stream. One
stream at a time; a new one supersedes the last.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 01:11:33 +10:00
1 parent 8b7c46a63a
commit f324aac690
5 files changed
+226 -10

No files matched your search

+6 -4
View File
@@ -105,10 +105,12 @@ the same UI in a browser without packaging:
- **Headset view**: what the lenses show, as SteamVR composites it (the room, - **Headset view**: what the lenses show, as SteamVR composites it (the room,
floating panels, dashboard and controllers). Shows the left eye, like pointing floating panels, dashboard and controllers). Shows the left eye, like pointing
a camera into one lens, or both eyes; single shot or about 2 fps live; saves a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
as PNG. The viewer fits the whole frame; zoom with − / + (or scroll, or is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
double-click), drag to pan, `0` to fit, `F` for full screen. It uses OpenVR's `IVRScreenshots` API through Python `ctypes` headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
(`ui/frame_vrshot.py`), so nothing is installed on the Frame. **Desktop panel** it with WebCodecs. Live video is one eye; Capture still gets both. The viewer fits the whole frame; zoom with − / + (or scroll, or
double-click), drag to pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots` API through Python `ctypes`
(`ui/frame_vrshot.py`). Nothing extra is installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel**
captures gamescope's flat layer instead. captures gamescope's flat layer instead.
- Battery with charging state: charge rate in watts, time to full or empty, - Battery with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature charger type and wattage (for example USB-C PD 20 W), and battery temperature
+1
View File
@@ -30,6 +30,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels | | Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture | | `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` | | The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card | | Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn | | `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) | | The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
+6
View File
@@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/status")[0], 403) self.assertEqual(self.request("GET", "/api/status")[0], 403)
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("GET", "/api/shots")[0], 403) self.assertEqual(self.request("GET", "/api/shots")[0], 403)
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403) self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403) self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
@@ -116,6 +117,11 @@ class ServerGuards(unittest.TestCase):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"}) status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, shot) self.assertEqual(status, 400, shot)
def test_stream_settings_checked_before_ssh(self):
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, query)
def test_bad_bodies(self): def test_bad_bodies(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"}) conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
+115 -4
View File
@@ -321,7 +321,7 @@
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<button class="action" id="shotBtn">Capture</button> <button class="action" id="shotBtn">Capture</button>
<button id="liveBtn" title="Keep capturing">Live</button> <button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
<button id="saveBtn" disabled>Save</button> <button id="saveBtn" disabled>Save</button>
</div> </div>
<div class="viewer" id="viewer"> <div class="viewer" id="viewer">
@@ -575,7 +575,7 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]]; ["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps"; const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
const HINTS = { const HINTS = {
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.", headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.", flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
}; };
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" }; const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
@@ -751,10 +751,12 @@ function render(s) {
// ---- view ---- // ---- view ----
function setView(v) { function setView(v) {
const changed = v !== view;
view = v; view = v;
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v)); document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
$("eyeSeg").style.visibility = v === "headset" ? "visible" : "hidden"; $("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
$("viewHint").textContent = HINTS[v]; $("viewHint").textContent = HINTS[v];
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
} }
function setEye(e) { function setEye(e) {
eye = e; eye = e;
@@ -841,6 +843,7 @@ function isBlank(ctx, w, h) {
return max - min < 6; return max - min < 6;
} }
async function capture() { async function capture() {
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
const gen = viewGen; // a screenshot opened meanwhile wins over this capture const gen = viewGen; // a screenshot opened meanwhile wins over this capture
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
let url = null; let url = null;
@@ -883,7 +886,115 @@ function toggleLive(on) {
liveFailures = 0; liveFailures = 0;
$("liveBtn").classList.toggle("on", live); $("liveBtn").classList.toggle("on", live);
$("liveBadge").hidden = !live; $("liveBadge").hidden = !live;
if (live) liveLoop(); else clearTimeout(liveTimer); clearTimeout(liveTimer);
stopVideo();
if (!live) return;
if (view === "headset" && "VideoDecoder" in window) {
const started = startVideo();
const ctl = video.ctl;
started.catch(e => {
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
log("Live video failed: " + e.message, "e");
toast("Live video failed, using captures instead: " + e.message, true);
stopVideo();
liveLoop();
});
} else liveLoop();
}
// ---- live video of the headset view ----
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
// Every frame starts with an access unit delimiter (NAL type 9), which is how
// the stream is cut into frames for WebCodecs.
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
const video = { ctl: null, dec: null, gen: 0 };
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
function nalTypes(au) {
const types = [];
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
return types;
}
async function startVideo() {
const gen = ++video.gen, ctl = new AbortController();
video.ctl = ctl;
$("eyeSeg").style.visibility = "hidden";
if (!lastImg) $("viewer").classList.add("busy");
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
const c = $("canvas"), ctx = c.getContext("2d");
const dec = video.dec = new VideoDecoder({
output: frame => {
if (gen !== video.gen) return frame.close();
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
c.width = frame.displayWidth; c.height = frame.displayHeight;
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
setZoom(1);
}
ctx.drawImage(frame, 0, 0);
frame.close();
if (!shown++) {
viewGen++; // a capture still in flight mustn't replace the video
lastImg = null; lastSource = "video"; lastShot = null;
$("viewer").classList.remove("busy");
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
$("stamp").hidden = false; $("saveBtn").disabled = false;
}
frames++;
const now = performance.now();
if (now - second >= 1000) {
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
frames = 0; second = now;
}
},
error: e => log("Video decoder: " + e.message, "e"),
});
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const reader = r.body.getReader();
let buf = new Uint8Array(0), scan = 0, auStart = -1;
const onAU = au => {
const types = nalTypes(au), key = types.includes(5);
if (dec.state === "unconfigured") {
const sps = types.indexOf(7);
if (sps < 0) return;
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
if (i + 6 >= au.length) return;
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
}
// If decoding falls behind, drop frames until the next keyframe rather than lag.
if (dec.decodeQueueSize > 3) needKey = true;
if (needKey && !key) return;
needKey = false;
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
ts += 1e6 / STREAM_FPS;
};
for (;;) {
const { value, done } = await reader.read();
if (done || gen !== video.gen) break;
const next = new Uint8Array(buf.length + value.length);
next.set(buf); next.set(value, buf.length);
buf = next;
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
for (; scan + 3 < buf.length; scan++) {
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
auStart = scan;
scan += 3;
}
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
}
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
}
function stopVideo() {
video.gen++;
if (video.ctl) video.ctl.abort();
if (video.dec && video.dec.state !== "closed") video.dec.close();
video.ctl = video.dec = null;
$("viewer").classList.remove("busy");
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
} }
$("shotBtn").onclick = () => capture(); $("shotBtn").onclick = () => capture();
$("liveBtn").onclick = () => toggleLive(!live); $("liveBtn").onclick = () => toggleLive(!live);
+98 -2
View File
@@ -12,6 +12,7 @@ import http.client
import json import json
import os import os
import re import re
import select
import shlex import shlex
import shutil import shutil
import signal import signal
@@ -272,6 +273,41 @@ def save_shots(body):
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n} return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
# WebCodecs. An access unit delimiter starts every frame so the page can split
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
# the background while the shell waits for our stdin to close: when the local
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
# stopped writing (and so would never get SIGPIPE).
STREAM_DEVICE = "/dev/video99"
STREAM_HEIGHTS = (720, 1080)
STREAM_FPS = (30, 60)
STREAM_STALL = 10 # seconds without video before the stream is dropped
_stream_lock = threading.Lock()
_stream_proc = None
def stream_command(query):
q = parse_qs(query)
try:
height = int((q.get("h") or ["720"])[0])
fps = int((q.get("fps") or ["30"])[0])
except ValueError:
raise Failure("h and fps must be integers", 400)
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
rate = 3 if height == 720 else 6 # Mbit/s
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
def launch(body): def launch(body):
appid = str(body.get("appid", "")) appid = str(body.get("appid", ""))
if not APPID.match(appid): if not APPID.match(appid):
@@ -423,7 +459,7 @@ FONT_RANGE = (0.5, 2.0)
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"} KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
# One ADB session at a time: requests are rare, and it keeps adb's state simple. # One ADB session at a time: requests are rare, and it keeps adb's state simple.
_adb_lock = threading.Lock() _adb_lock = threading.Lock()
_live_tunnels = set() # ssh processes to kill if the server stops mid-request _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the server stops mid-request
def adb_path(): def adb_path():
@@ -770,6 +806,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(list_shots()) self.send_json(list_shots())
elif path == "/api/shots/image": elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query)) self.send_bytes(*shot_image(url.query))
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]: elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")]) self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot": elif path == "/api/screenshot":
@@ -808,6 +846,64 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def stream_video(self, query):
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
global _stream_proc
remote = stream_command(query)
ensure_master()
# stderr goes to a file: nothing reads it while streaming, and a full
# pipe would stall ffmpeg. It's only read if the stream fails to start.
errors = tempfile.TemporaryFile()
proc = subprocess.Popen([*SSH, FRAME, remote], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
try:
_live_tunnels.add(proc)
# One viewer at a time: a new stream (another tab, a reload) ends the last one.
with _stream_lock:
old, _stream_proc = _stream_proc, proc
if old and old.poll() is None:
old.terminate()
fd = proc.stdout.fileno()
# Nothing is sent until the first bytes arrive, so a failure to
# start still comes back as a JSON error.
ready, _, _ = select.select([fd], [], [], 20)
first = os.read(fd, 1 << 16) if ready else b""
if not first:
proc.kill()
proc.wait()
errors.seek(0)
err = strip_ansi(errors.read().decode(errors="replace")).strip()
raise Failure(err or "The headset view sent no video for 20 s")
chunk = first
try:
self.send_response(200)
self.send_header("Content-Type", "video/h264")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.end_headers()
self.close_connection = True # the body ends when the connection does
while chunk:
self.wfile.write(chunk)
self.wfile.flush()
# A stalled headset view ends the stream rather than
# holding this thread (and the page) forever.
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
chunk = os.read(fd, 1 << 16) if ready else b""
except OSError:
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
for f in (proc.stdin, proc.stdout, errors):
f.close()
_live_tunnels.discard(proc)
def upload(self): def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only).""" """Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", ""))) name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
@@ -871,7 +967,7 @@ def main():
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True) subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
if _master and _master.poll() is None: if _master and _master.poll() is None:
_master.terminate() _master.terminate()
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None: if proc.poll() is None:
proc.terminate() proc.terminate()