Files
qemu-xteink/scripts/xteink/web/index.html
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

425 lines
18 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>xteink emulator</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #20211f; background: #e9e5dc; }
* { box-sizing: border-box; }
body { margin: 0; }
main { width: min(100% - 2rem, 70rem); margin: 0 auto; padding: 2.5rem 0 4rem; }
header { margin-bottom: 1.5rem; }
header p { max-width: 42rem; }
h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1; }
.eyebrow { margin-bottom: .5rem; font-size: .75rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
.boot-panel { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; padding: 1rem; border: 1px solid #c8c2b6; border-radius: .75rem; background: #f7f4ed; }
label { display: grid; gap: .4rem; font-size: .85rem; font-weight: 700; }
input, select, button { font: inherit; }
input, select { min-height: 2.6rem; padding: .5rem; border: 1px solid #aaa398; border-radius: .4rem; background: white; }
button { min-height: 2.75rem; padding: .55rem 1rem; border: 1px solid #55534e; border-radius: .5rem; color: #fff; background: #343532; font-weight: 750; cursor: pointer; touch-action: none; }
button:hover:not(:disabled) { background: #11120f; }
button:focus-visible { outline: 3px solid #d2752b; outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: .4; }
button.active { transform: translateY(2px); background: #d2752b; }
.danger { border-color: #b94a3b; color: #a52d20; background: transparent; }
.danger:hover:not(:disabled) { color: #fff; background: #a52d20; }
.status { min-height: 1.5rem; margin: 1rem 0; }
.status.error { color: #a52d20; font-weight: 700; }
.workspace { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: start; }
.device { display: flex; justify-content: center; }
.device-frame { display: grid; grid-template-columns: auto minmax(14rem, 30rem) auto; grid-template-rows: auto auto auto; gap: .6rem; }
.screen-shell { grid-column: 2; grid-row: 2; padding: 1.1rem; border-radius: 1.2rem; background: #30312e; box-shadow: 0 1rem 2.5rem #5d584c40; }
img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; background: #fff; image-rendering: pixelated; }
.device-frame button { min-height: 0; border-radius: .6rem; font-size: .75rem; }
.btn-power { grid-column: 2; grid-row: 1; height: 1.8rem; }
.btn-side { grid-row: 2; width: 2rem; writing-mode: vertical-rl; }
.btn-left { grid-column: 1; }
.btn-right { grid-column: 3; }
.btn-bottom { grid-column: 2; grid-row: 3; display: grid; grid-template-columns: repeat(4, 1fr); gap: .6rem; }
.btn-bottom button { height: 1.8rem; }
.inspector { min-width: 0; overflow: hidden; border: 1px solid #c8c2b6; border-radius: .75rem; background: #f7f4ed; }
.tabs { display: grid; grid-template-columns: repeat(2, 1fr); border-bottom: 1px solid #c8c2b6; }
.tabs button { border: 0; border-right: 1px solid #c8c2b6; border-radius: 0; color: #56534d; background: transparent; }
.tabs button:last-child { border-right: 0; }
.tabs button[aria-selected="true"] { color: #fff; background: #343532; }
.tab-panel { padding: 1rem; }
.file-toolbar { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
.file-actions { display: flex; justify-content: flex-end; gap: .4rem; margin-top: .6rem; }
.file-button { display: inline-flex; min-height: 1.9rem; align-items: center; padding: .3rem .6rem; border: 1px solid #55534e; border-radius: .4rem; color: #fff; background: #343532; font-size: .8rem; font-weight: 650; cursor: pointer; }
.file-button:hover:not(:disabled) { background: #11120f; }
.file-clear { border-color: #b94a3b; color: #a52d20; background: transparent; }
.file-clear:hover:not(:disabled) { color: #fff; background: #a52d20; }
.file-reboot { margin-left: auto; border-color: #b66020; background: #d2752b; }
.file-reboot:hover:not(:disabled) { background: #b66020; }
.file-location { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; color: #625e55; }
.file-location button { min-height: 1.9rem; padding: .2rem .55rem; font-size: .8rem; }
.file-location code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-list { width: 100%; margin-bottom: .75rem; border: 1px solid #c8c2b6; border-collapse: collapse; border-radius: .6rem; background: #fff; font-size: .82rem; }
.file-list td { padding: .28rem .5rem; border-bottom: 1px solid #eee9df; }
.file-list tr:last-child td { border-bottom: 0; }
.file-list tbody tr:hover { background: #f1ede4; }
.file-list tr.entry { cursor: pointer; }
.file-list td:first-child { width: 1.4rem; text-align: center; }
.file-list tr.directory td:nth-child(2) { font-weight: 700; }
.file-list td:nth-child(3) { text-align: right; color: #8a8479; white-space: nowrap; }
.file-list td:last-child { width: 2rem; padding: 0; text-align: center; }
.file-empty { padding: 1.25rem !important; color: #777269; text-align: center; }
.file-del { min-height: 0; padding: .1rem .35rem; border: 0; border-radius: .3rem; color: #a52d20; background: transparent; font-size: .85rem; line-height: 1; }
.file-del:hover { color: #fff; background: #a52d20; }
.file-preview { width: 100%; min-height: 14rem; max-height: 28rem; overflow: auto; padding: .85rem 1rem; border: 1px solid #c8c2b6; border-radius: .6rem; color: #343532; background: #fff; font-family: ui-monospace, monospace; line-height: 1.45; resize: vertical; }
.file-preview:focus { outline: 3px solid #d2752b; outline-offset: 1px; }
.file-preview:read-only { color: #777269; background: #f1ede4; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
.log-header label { display: flex; gap: .3rem; align-items: center; font-weight: 400; }
.log { margin: 0; height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 48rem) { main { padding-top: 1.5rem; } .workspace { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<header>
<p class="eyebrow">ESP32-C3 emulator</p>
<h1>xteink X3 / X4</h1>
<p>Upload a firmware <code>.bin</code> (release app image or a full 16 MB flash) and boot it in the emulator running on this host. The SD card image persists between boots.</p>
</header>
<form id="boot-form" class="boot-panel">
<label>Firmware (.bin)
<input id="firmware" type="file" accept=".bin,application/octet-stream">
</label>
<label>SD card image (optional)
<input id="sdcard" type="file" accept=".img,.bin,application/octet-stream">
</label>
<label>Device
<select id="variant"><option value="x3">X3</option><option value="x4">X4 (display stub)</option></select>
</label>
<button id="boot" type="submit">Boot firmware</button>
<button id="stop" type="button" class="danger">Stop</button>
</form>
<p id="status" class="status" role="status">Loading.</p>
<div class="workspace">
<section class="device" aria-label="Emulated xteink device">
<div class="device-frame">
<button type="button" class="btn-power" data-button="power" data-hold-ms="2000">power</button>
<button type="button" class="btn-side btn-left" data-button="left">left</button>
<div class="screen-shell"><img id="screen" alt="E-ink display"></div>
<button type="button" class="btn-side btn-right" data-button="right">right</button>
<div class="btn-bottom">
<button type="button" data-button="bottom-1">1</button>
<button type="button" data-button="bottom-2">2</button>
<button type="button" data-button="bottom-3">3</button>
<button type="button" data-button="bottom-4">4</button>
</div>
</div>
</section>
<section class="inspector" aria-label="Emulator inspector">
<div class="tabs" role="tablist">
<button type="button" role="tab" aria-selected="true" data-tab="logs">Logs</button>
<button type="button" role="tab" aria-selected="false" data-tab="files">Files</button>
</div>
<div id="panel-logs" class="tab-panel">
<div class="log-header">
<span>Serial log</span>
<label><input id="follow" type="checkbox" checked> follow</label>
</div>
<pre id="log" class="log" role="log" aria-live="polite"></pre>
</div>
<div id="panel-files" class="tab-panel" hidden>
<div id="files-browse">
<div class="file-location">
<button id="file-back" type="button" aria-label="Go up one folder" disabled></button>
<code id="file-path">/</code>
</div>
<table class="file-list"><tbody id="file-list"></tbody></table>
<div class="file-toolbar">
<label class="file-button">Upload<input id="file-add" type="file" multiple hidden></label>
<button id="file-new" type="button" class="file-button">New File</button>
<button id="file-new-folder" type="button" class="file-button">New Folder</button>
<button id="file-reset" type="button" class="file-button file-clear">Clear</button>
<button id="file-reboot" type="button" class="file-button file-reboot">Apply &amp; Reboot</button>
</div>
</div>
<div id="files-edit" hidden>
<div class="file-location">
<button id="file-close" type="button" aria-label="Back to file list"></button>
<code id="file-edit-name"></code>
<button id="file-delete" type="button" class="file-button file-clear">Delete</button>
</div>
<textarea id="file-editor" class="file-preview" spellcheck="false"></textarea>
<div class="file-actions"><button id="file-save" type="button" class="file-button">Save</button></div>
</div>
</div>
</section>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const status = $("status"), screen = $("screen"), log = $("log"), follow = $("follow");
const deviceButtons = [...document.querySelectorAll("[data-button]")];
let running = false;
function report(message, error) {
status.textContent = message;
status.classList.toggle("error", Boolean(error));
}
async function call(path, options) {
const response = await fetch(path, options);
const text = await response.text();
if (!response.ok) throw new Error(text || response.statusText);
return text;
}
function applyStatus(payload) {
// A fresh QEMU process restarts its frame counter, so drop the cursor across boots.
if (payload.running !== running) generation = null;
running = payload.running;
for (const button of deviceButtons) button.disabled = !running;
$("stop").disabled = !running;
$("variant").value = payload.variant;
const parts = [running ? "Running." : "Stopped."];
if (payload.firmware) parts.push(`firmware: ${payload.firmware}`);
if (payload.sdcard) parts.push(`sd: ${payload.sdcard}`);
report(parts.join(" "), false);
if (running) watchScreen(); else screen.removeAttribute("src");
}
// Long Poll - /screen.png blocks until the panel redraws, so one outstanding request keeps
// the image as fresh as the e-ink hardware itself, with no polling interval to tune.
let watching = false;
let generation = null;
async function watchScreen() {
if (watching) return;
watching = true;
try {
while (running) {
const url = "screen.png" + (generation === null ? "" : "?since=" + generation);
const response = await fetch(url);
if (response.status === 304) continue;
if (!response.ok) throw new Error(await response.text());
generation = response.headers.get("X-Frame-Generation");
const blob = await response.blob();
URL.revokeObjectURL(screen.src);
screen.src = URL.createObjectURL(blob);
}
} catch (error) {
report(String(error), true);
} finally {
watching = false;
}
}
async function refreshStatus() {
try {
applyStatus(JSON.parse(await call("status")));
} catch (error) {
report(String(error), true);
}
}
async function upload(name, input) {
if (!input.files.length) return;
const file = input.files[0];
report(`Uploading ${file.name}...`, false);
await call(`upload?name=${name}&filename=${encodeURIComponent(file.name)}`, {
method: "POST",
body: await file.arrayBuffer(),
});
}
$("boot-form").onsubmit = async (event) => {
event.preventDefault();
$("boot").disabled = true;
try {
await upload("firmware", $("firmware"));
await upload("sdcard", $("sdcard"));
report("Booting...", false);
applyStatus(JSON.parse(await call(`boot?variant=${$("variant").value}`, { method: "POST" })));
} catch (error) {
report(String(error), true);
} finally {
$("boot").disabled = false;
}
};
$("stop").onclick = async () => {
try {
applyStatus(JSON.parse(await call("stop", { method: "POST" })));
} catch (error) {
report(String(error), true);
}
};
for (const button of deviceButtons) {
button.onclick = async () => {
button.classList.add("active");
button.disabled = true;
const hold = button.dataset.holdMs ? `&hold-ms=${button.dataset.holdMs}` : "";
try {
await call(`button?name=${button.dataset.button}${hold}`, { method: "POST" });
} catch (error) {
report(String(error), true);
} finally {
button.classList.remove("active");
button.disabled = !running;
}
};
}
async function refreshLog() {
try {
log.textContent = await call("log");
if (follow.checked) log.scrollTop = log.scrollHeight;
} catch (error) {
report(String(error), true);
}
}
for (const tab of document.querySelectorAll("[data-tab]")) {
tab.onclick = () => {
for (const other of document.querySelectorAll("[data-tab]")) {
other.setAttribute("aria-selected", String(other === tab));
$("panel-" + other.dataset.tab).hidden = other !== tab;
}
if (tab.dataset.tab === "files") refreshFiles();
};
}
let directory = "/";
let editing = null;
function join(base, name) {
return base === "/" ? "/" + name : base + "/" + name;
}
function showBrowser(show) {
$("files-browse").hidden = !show;
$("files-edit").hidden = show;
}
async function refreshFiles() {
showBrowser(true);
$("file-path").textContent = directory;
$("file-back").disabled = directory === "/";
const list = $("file-list");
list.replaceChildren();
let entries;
try {
entries = JSON.parse(await call("files?path=" + encodeURIComponent(directory)));
} catch (error) {
report(String(error), true);
return;
}
if (!entries.length) {
list.innerHTML = '<tr><td colspan="4" class="file-empty">Empty folder</td></tr>';
return;
}
for (const entry of entries) {
const row = list.insertRow();
row.className = entry.directory ? "entry directory" : "entry";
row.insertCell().textContent = entry.directory ? "\u{1F4C1}" : "\u{1F4C4}";
row.insertCell().textContent = entry.name;
row.insertCell().textContent = entry.directory ? "" : `${entry.size} B`;
const remove = document.createElement("button");
remove.className = "file-del";
remove.textContent = "\u2715";
remove.onclick = async (event) => {
event.stopPropagation();
await mutate(`files/delete?path=${encodeURIComponent(join(directory, entry.name))}&directory=${entry.directory}`);
refreshFiles();
};
row.insertCell().append(remove);
row.onclick = () => {
if (entry.directory) {
directory = join(directory, entry.name);
refreshFiles();
} else {
openFile(join(directory, entry.name));
}
};
}
}
async function mutate(path, body) {
try {
applyStatus(JSON.parse(await call(path, { method: "POST", body })));
return true;
} catch (error) {
report(String(error), true);
return false;
}
}
async function openFile(path) {
try {
const file = JSON.parse(await call("files/read?path=" + encodeURIComponent(path)));
editing = path;
$("file-edit-name").textContent = path;
$("file-editor").value = file.binary ? `(binary file, ${file.size} bytes)` : file.text;
$("file-editor").readOnly = file.binary;
$("file-save").hidden = file.binary;
showBrowser(false);
} catch (error) {
report(String(error), true);
}
}
$("file-back").onclick = () => {
directory = directory.replace(/\/[^/]+$/, "") || "/";
refreshFiles();
};
$("file-close").onclick = refreshFiles;
$("file-save").onclick = async () => {
await mutate("files/write?path=" + encodeURIComponent(editing), $("file-editor").value);
refreshFiles();
};
$("file-delete").onclick = async () => {
await mutate(`files/delete?path=${encodeURIComponent(editing)}&directory=false`);
refreshFiles();
};
$("file-add").onchange = async (event) => {
for (const file of event.target.files) {
if (!await mutate("files/write?path=" + encodeURIComponent(join(directory, file.name)), await file.arrayBuffer())) break;
}
event.target.value = "";
refreshFiles();
};
$("file-new").onclick = async () => {
const name = prompt("File name");
if (!name) return;
await mutate("files/write?path=" + encodeURIComponent(join(directory, name)), "");
refreshFiles();
};
$("file-new-folder").onclick = async () => {
const name = prompt("Folder name");
if (!name) return;
await mutate("files/mkdir?path=" + encodeURIComponent(join(directory, name)));
refreshFiles();
};
$("file-reset").onclick = async () => {
if (!confirm("Erase the SD card image?")) return;
directory = "/";
await mutate("files/clear");
refreshFiles();
};
$("file-reboot").onclick = async () => {
report("Booting...", false);
await mutate(`boot?variant=${$("variant").value}`);
};
setInterval(refreshLog, 1000);
setInterval(refreshStatus, 5000);
refreshStatus();
refreshLog();
</script>
</body>
</html>