Put the overlay apps on the page: a slot table with install and erase

The endpoints existed; now the page shows them. An Overlay apps block beside the firmware slots lists all 16 app slots with their name, version, shortcut and size, a .app file picker and an Erase button per row, and says where the region is. It reads GET /api/apps and posts to POST /api/apps/<n> and /api/apps/<n>/erase, so installing a game is a file pick where the firmware slots already are -- no WebSerial, no browser permission.

An upload into a slot that holds something which is not an app is refused by the server; the page asks once and retries with ?force=1 rather than either failing quietly or destroying the factory resource data that overlaps this region on a localised image. Front-end tests assert the table and the endpoint are in the served page and that it never asks for a serial port or audio, and TestPageScriptParses keeps checking that the page's script parses.
This commit is contained in:
mckero committed 2026-10-01 17:11:23 +08:00
1 parent d76d95851f
commit f6b375f66b
3 files changed
+108 -1

No files matched your search

+1 -1
View File
@@ -573,7 +573,7 @@ not from a guess -- the first version of the header here was 60 bytes because a
The page writes them through three endpoints (`GET /api/apps`, `POST /api/apps/<n>`,
`POST /api/apps/<n>/erase`), all of which edit the flash image the emulator boots from --
so this is page-operable with no WebSerial, no browser permission and no serial protocol.
so this is page-operable with no WebSerial, no browser permission and no serial protocol. The page shows a table of the 16 slots beside the firmware slots, with a file picker and an Erase button per slot.
Two things measured while wiring it up, both of which changed the code:
+29
View File
@@ -1130,3 +1130,32 @@ class TestAppEndpoints(unittest.TestCase):
r = self.client.post("/api/apps/16", data=self.blob)
self.assertEqual(r.status_code, 400)
self.assertIn("out of range", r.get_json()["error"])
class TestAppsFrontEnd(unittest.TestCase):
"""The page must offer the apps without a serial port or a browser permission."""
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir, True)
self.flash = os.path.join(self.dir, "flash.img")
with open(self.flash, "wb") as fh:
fh.write(b"\xff" * 0x200000)
self.app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000,
flash=FlashSlot(self.flash))
self.page = self.app.test_client().get("/").get_data(as_text=True)
def test_the_page_has_an_apps_table_that_reads_the_endpoint(self):
self.assertIn('id="apptable"', self.page)
self.assertIn("/api/apps", self.page)
self.assertIn("loadApps", self.page)
def test_the_page_says_where_the_apps_live(self):
self.assertIn("0x102000", self.page)
def test_the_page_never_asks_for_a_serial_port_or_audio(self):
# WebSerial is how upstream installs these; here the image is edited directly, so
# a permission prompt would be asking for something this page never uses.
self.assertNotIn("navigator.serial", self.page)
self.assertNotIn("getUserMedia", self.page)
self.assertNotIn("AudioContext", self.page)
+78
View File
@@ -938,6 +938,14 @@ def render_index(scale: int) -> str:
write a .bin into one, then press Multiboot</span>
</div>
<table id="slottable"><tbody></tbody></table>
<div class="fwbar">
<label>Overlay apps</label>
<span id="appstate">-</span>
<span class="hint">the Labs edition's apps live in the same external flash (16 slots
from 0x102000, the firmware's menu lists the first eight). Pick a .app for a slot to
install it — no serial port and no browser permission are involved</span>
</div>
<table id="apptable"><tbody></tbody></table>
<div class="screenwrap" id="screenwrap">
<img id="screen" src="/stream" alt="radio LCD"
width="{128 * scale}" height="{64 * scale}">
@@ -1268,6 +1276,76 @@ if (flashInput) flashInput.addEventListener('change', async () => {{
}});
loadSlots();
setInterval(loadSlots, 15000);
// Overlay apps (Labs edition). The same idea as the slots above, in the other half of
// the external flash: the region the firmware's own App/apps/app_overlay.h defines --
// 16 slots of 8 KiB from 0x102000, a 64-byte FAP1 header at the slot base and the code
// one 4 KiB sector later. Upstream installs them from UVStudio over WebSerial; here the
// bytes go straight into the image the emulator boots, so nothing needs a serial port or
// a browser permission. The firmware's own menu lists the first eight slots.
async function loadApps() {{
const tb = document.querySelector('#apptable tbody');
const state = document.getElementById('appstate');
if (!tb) return;
try {{
const j = await (await fetch('/api/apps')).json();
if (j.error) {{ state.textContent = j.error; return; }}
state.textContent = 'region 0x' + j.region.toString(16) + ' · ' + j.slot_count +
' slots of ' + Math.round(j.stride / 1024) + ' KiB';
tb.innerHTML = '';
for (const s of j.slots) {{
const tr = document.createElement('tr');
const what = s.state === 'app'
? s.name + ' ' + s.version + (s.shortcut && s.shortcut !== 'none'
? ' · ' + s.shortcut : '')
: s.state === 'empty' ? '<i>Empty</i>'
: '<i>' + s.state + '</i> — not an app';
const size = s.state === 'app' ? s.code_size + ' B' : '';
tr.innerHTML = '<td>app ' + s.slot + (s.slot < 8 ? '' : ' (after the menu)') +
'</td><td>' + what + '</td><td>' + size + '</td><td></td>';
const td = tr.lastElementChild;
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.app';
inp.addEventListener('change', async () => {{
const f = inp.files[0];
if (!f) return;
tr.querySelectorAll('input,button').forEach(b => b.disabled = true);
const upload = async (force) => {{
const r = await fetch('/api/apps/' + s.slot + (force ? '?force=1' : ''),
{{ method: 'POST', body: f }});
return [r, await r.json()];
}};
let [r, jj] = await upload(false);
// A slot can already hold something that is not an app: the factory resource
// block of a localised build overlaps this region. The server refuses to destroy
// it in silence, so ask, and say so if the answer is yes.
if (jj.error && /already holds/.test(jj.error)) {{
if (!confirm('app ' + s.slot + ': ' + jj.error + ' -- overwrite it?')) {{
tr.querySelectorAll('input,button').forEach(b => b.disabled = false);
return;
}}
[r, jj] = await upload(true);
}}
if (jj.error) alert('app ' + s.slot + ': ' + jj.error);
await loadApps(); poll();
}});
const er = document.createElement('button');
er.textContent = 'Erase';
er.addEventListener('click', async () => {{
if (!confirm('Erase app slot ' + s.slot + '?')) return;
tr.querySelectorAll('input,button').forEach(b => b.disabled = true);
const r = await fetch('/api/apps/' + s.slot + '/erase', {{ method: 'POST' }});
const jj = await r.json();
if (jj.error) alert('app ' + s.slot + ': ' + jj.error);
await loadApps(); poll();
}});
td.append(inp, er);
tb.appendChild(tr);
}}
}} catch (err) {{ state.textContent = 'apps unavailable: ' + err; }}
}}
loadApps();
setInterval(loadApps, 15000);
// Firmware upload. The file *is* the request body, so the server reads the
// vector table itself and decides the load offset: an application image and a
// full-flash image need different ones, and the wrong one fails silently.