Files
qemu-xteink/scripts/xteink/web/__init__.py
T
evan 1174ef0e5d feat(xteink): manage the emulator SD card from the web UI
Browse, edit, upload, and delete files on the FAT32 image with mtools,
mirroring the wasm emulator's Files tab. Mutations stop the emulator
first because QEMU caches the card, so host writes to a live image would
be lost.
2026-07-26 11:36:37 -04:00

216 lines
9.2 KiB
Python

"""Browser front end: boot firmware, drive the buttons, watch the screen and serial log."""
import http.server
import json
import shutil
import subprocess
import urllib.parse
from pathlib import Path
from .. import emu
from . import sdcard
PAGE = Path(__file__).resolve().parent / "index.html"
MAX_UPLOAD = 512 * 1024 * 1024
LONG_POLL_SECONDS = 25
UPLOADS = {"firmware": "firmware.bin", "sdcard": "sdcard.img"}
def settings_path(state):
return state / "web.json"
def settings(state):
try:
return json.loads(settings_path(state).read_text())
except (FileNotFoundError, ValueError):
return {}
def save_settings(state, values):
settings_path(state).write_text(json.dumps(settings(state) | values))
def blank_sdcard(path, size_mb):
"""Create a FAT32 card so the UI works without the user supplying an image."""
mkfs = shutil.which("mkfs.fat")
if not mkfs:
raise RuntimeError("mkfs.fat not found; upload an SD card image instead")
with path.open("wb") as image:
image.truncate(size_mb * 1024 * 1024)
subprocess.run([mkfs, "-F", "32", str(path)], check=True, capture_output=True)
def serve(state, host, port, log_bytes):
state.mkdir(parents=True, exist_ok=True, mode=0o700)
capture = state / "web-screen.png"
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def reply(self, body, content_type="text/plain; charset=utf-8", status=200, headers=()):
if isinstance(body, str):
body = body.encode()
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for name, value in headers:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def reply_json(self, value, status=200):
self.reply(json.dumps(value), "application/json", status)
def query(self):
return urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
def body(self):
length = int(self.headers.get("Content-Length", 0))
if length > MAX_UPLOAD:
raise RuntimeError(f"upload larger than {MAX_UPLOAD // 1024 // 1024} MiB")
return self.rfile.read(length)
def status_payload(self):
stored = settings(state)
return {
"running": emu.is_running(state),
"variant": stored.get("variant", "x3"),
"firmware": stored.get("firmware"),
"sdcard": stored.get("sdcard"),
"buttons": list(emu.BUTTONS),
"variants": list(emu.VARIANTS),
}
def do_GET(self):
route = urllib.parse.urlparse(self.path).path
query = self.query()
try:
if route == "/":
return self.reply(PAGE.read_bytes(), "text/html; charset=utf-8")
if route == "/status":
return self.reply_json(self.status_payload())
if route == "/screen.png":
emu.require_running(state)
# Long Poll - The panel only redraws on a real refresh, so block until the
# guest bumps the frame counter instead of re-capturing on a timer.
since = query.get("since", [None])[0]
generation = emu.frame_generation(state)
if since is not None and str(generation) == since:
generation = emu.await_frame(state, generation, LONG_POLL_SECONDS)
if generation is None:
return self.reply(b"", status=304)
emu.screendump(state, capture)
return self.reply(
capture.read_bytes(),
"image/png",
headers=[("X-Frame-Generation", str(generation))],
)
if route == "/files":
path = query.get("path", ["/"])[0]
return self.reply_json(sdcard.listing(state / UPLOADS["sdcard"], path))
if route == "/files/read":
path = query.get("path", [""])[0]
data = sdcard.read(state / UPLOADS["sdcard"], path)
try:
text = data.decode()
except UnicodeDecodeError:
return self.reply_json({"binary": True, "size": len(data)})
return self.reply_json({"binary": False, "text": text})
if route == "/log":
# Tail only; a long-lived emulator log outgrows what the page can hold.
return self.reply((state / "serial.log").read_bytes()[-log_bytes:])
except (RuntimeError, OSError) as error:
return self.reply(str(error), status=500)
self.send_error(404)
def do_POST(self):
route = urllib.parse.urlparse(self.path).path
query = self.query()
try:
if route == "/upload":
name = query.get("name", [""])[0]
if name not in UPLOADS:
return self.send_error(404)
data = self.body()
if not data:
raise RuntimeError(f"empty {name} upload")
(state / UPLOADS[name]).write_bytes(data)
filename = query.get("filename", [name])[0]
save_settings(state, {name: filename})
return self.reply_json(self.status_payload())
if route == "/sdcard/blank":
size_mb = max(8, min(int(query.get("size-mb", ["64"])[0]), 4096))
blank_sdcard(state / UPLOADS["sdcard"], size_mb)
save_settings(state, {"sdcard": f"blank {size_mb} MiB"})
return self.reply_json(self.status_payload())
if route == "/boot":
variant = query.get("variant", ["x3"])[0]
hold = int(query.get("power-hold-ms", ["2000"])[0])
firmware = state / UPLOADS["firmware"]
card = state / UPLOADS["sdcard"]
if not firmware.is_file():
raise RuntimeError("upload a firmware image first")
if not card.is_file():
blank_sdcard(card, 64)
save_settings(state, {"sdcard": "blank 64 MiB"})
if emu.is_running(state):
emu.shutdown(state)
emu.launch(state, firmware, card, variant=variant, power_hold_ms=hold)
save_settings(state, {"variant": variant})
return self.reply_json(self.status_payload())
if route == "/stop":
if emu.is_running(state):
emu.shutdown(state)
return self.reply_json(self.status_payload())
if route.startswith("/files/"):
# Editing detaches the card from a live guest, mirroring "apply and reboot".
if emu.is_running(state):
emu.shutdown(state)
image = state / UPLOADS["sdcard"]
path = query.get("path", [""])[0]
if route == "/files/write":
sdcard.write(image, path, self.body())
elif route == "/files/mkdir":
sdcard.mkdir(image, path)
elif route == "/files/delete":
sdcard.delete(image, path, query.get("directory", [""])[0] == "true")
elif route == "/files/clear":
size_mb = max(8, image.stat().st_size // 1024 // 1024) if image.is_file() else 64
blank_sdcard(image, size_mb)
save_settings(state, {"sdcard": f"blank {size_mb} MiB"})
else:
return self.send_error(404)
return self.reply_json(self.status_payload())
if route == "/button":
name = query.get("name", [""])[0]
if name not in emu.BUTTONS:
return self.send_error(404)
emu.require_running(state)
edge = query.get("edge", [""])[0]
if edge in ("down", "up"):
emu.set_button(state, name, down=edge == "down")
else:
emu.press(state, name, hold_ms=int(query.get("hold-ms", ["500"])[0]))
return self.reply("ok")
except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error:
return self.reply(str(error), status=500)
self.send_error(404)
def log_message(self, *_):
pass
server = http.server.ThreadingHTTPServer((host, port), Handler)
print(f"http://{host}:{server.server_address[1]}")
print(f"state: {state}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass