mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
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:
1 parent
d76d95851f
commit
f6b375f66b
3 files changed
+108
-1
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user