diff --git a/AGENTS.md b/AGENTS.md index c3724ae..77bff91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`, `POST /api/apps//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: diff --git a/tools/test_webui.py b/tools/test_webui.py index 3dc5b29..e59d953 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -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) diff --git a/tools/webui.py b/tools/webui.py index c6b18ed..fa4d0fa 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -938,6 +938,14 @@ def render_index(scale: int) -> str: write a .bin into one, then press Multiboot
+
+ + - + 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 +
+
radio LCD @@ -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' ? 'Empty' + : '' + s.state + ' — not an app'; + const size = s.state === 'app' ? s.code_size + ' B' : ''; + tr.innerHTML = 'app ' + s.slot + (s.slot < 8 ? '' : ' (after the menu)') + + '' + what + '' + size + ''; + 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.