feat(web): improve SD file browser
Add directory navigation, folder creation, inline deletion, clear controls, and a focused editor view. Include the opt-in FAT16 vvfat experiment and fail-fast browser diagnostics; raw FAT32 remains the default.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
||||
import { BUTTONS, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
||||
import { buildFat32Image, FAT32_IMAGE_SIZE } from '../web/fat32.js';
|
||||
|
||||
// Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions.
|
||||
@@ -15,8 +15,6 @@ assert.equal(flash[0x10000], 0xe9);
|
||||
assert.equal(flash[0x20000], 0xff);
|
||||
assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/);
|
||||
|
||||
assert.equal(DEFAULT_SD_FILES.get('Test/example.txt'), EXAMPLE_SD_TEXT);
|
||||
assert.equal(EXAMPLE_SD_TEXT.split('Lorem ipsum').length - 1, 10);
|
||||
assert.equal(formatBytes(512), '512 B');
|
||||
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB');
|
||||
|
||||
|
||||
+156
-46
@@ -1,73 +1,183 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VVFAT = bool(os.environ.get('XTEINK_VVFAT'))
|
||||
MODE = 'vvfat' if VVFAT else 'raw-sd'
|
||||
ARTIFACT = ROOT / f'_scratch/browser-{MODE}'
|
||||
BAD_MARKERS = (
|
||||
'RuntimeError', 'Aborted()', 'index out of bounds',
|
||||
'unaligned memory access', 'SSI_PERIPHERAL:', 'Guru Meditation',
|
||||
"panic'ed", 'Invalid SD card size', 'FS error', 'SPI:', 'SD:',
|
||||
'Unexpected response to cmd', 'Error writing to qcow backend',
|
||||
'write error on host side',
|
||||
)
|
||||
|
||||
options = Options()
|
||||
options.add_argument('-headless')
|
||||
options.accept_insecure_certs = True
|
||||
driver = webdriver.Firefox(options=options)
|
||||
driver.set_page_load_timeout(60)
|
||||
|
||||
|
||||
def page_state():
|
||||
try:
|
||||
driver.get('http://127.0.0.1:8180/web/?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin&variant=x3')
|
||||
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
|
||||
WebDriverWait(driver, 8 * 60).until(lambda _: confirm.get_attribute('disabled') is None)
|
||||
return driver.execute_script('''
|
||||
return {
|
||||
status: document.querySelector('#status')?.textContent || '',
|
||||
log: document.querySelector('#log')?.textContent || '',
|
||||
generation: Number(document.querySelector('#frame-generation')?.value || -1),
|
||||
runtimeErrors: window.__xteinkRuntimeErrors || [],
|
||||
wasmHeap: window.__xteinkModule?.HEAPU8?.byteLength || 0,
|
||||
};
|
||||
''')
|
||||
except WebDriverException as error:
|
||||
return {
|
||||
'status': 'WebDriver lost the page',
|
||||
'log': '',
|
||||
'generation': -1,
|
||||
'runtimeErrors': [str(error)],
|
||||
'wasmHeap': 0,
|
||||
}
|
||||
|
||||
indicator = driver.find_element(By.ID, 'frame-generation')
|
||||
|
||||
def generation():
|
||||
return int(indicator.get_attribute('value') or '-1')
|
||||
def failure(state):
|
||||
text = '\n'.join([
|
||||
state.get('status', ''),
|
||||
state.get('log', ''),
|
||||
*map(str, state.get('runtimeErrors', [])),
|
||||
])
|
||||
matched = [marker for marker in BAD_MARKERS if marker in text]
|
||||
if matched:
|
||||
return f'emulator failure detected: {matched}'
|
||||
if state.get('runtimeErrors'):
|
||||
return f'browser runtime error: {state["runtimeErrors"]}'
|
||||
return None
|
||||
|
||||
|
||||
def wait_until(predicate, description, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
last = None
|
||||
while time.monotonic() < deadline:
|
||||
last = page_state()
|
||||
problem = failure(last)
|
||||
if problem:
|
||||
raise RuntimeError(f'{problem} while waiting for {description}')
|
||||
if predicate(last):
|
||||
return last
|
||||
time.sleep(0.25)
|
||||
raise TimeoutError(f'timed out waiting for {description}; last state: {last}')
|
||||
|
||||
def wait_frame(previous, timeout=180):
|
||||
WebDriverWait(driver, timeout).until(lambda _: generation() > previous)
|
||||
|
||||
def wait_stable(quiet=5, timeout=180):
|
||||
deadline = time.time() + timeout
|
||||
last = generation()
|
||||
stable_since = time.time()
|
||||
while time.time() < deadline:
|
||||
deadline = time.monotonic() + timeout
|
||||
state = page_state()
|
||||
last_generation = state['generation']
|
||||
stable_since = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
state = page_state()
|
||||
problem = failure(state)
|
||||
if problem:
|
||||
raise RuntimeError(f'{problem} while waiting for display stability')
|
||||
if state['generation'] != last_generation:
|
||||
last_generation = state['generation']
|
||||
stable_since = time.monotonic()
|
||||
elif time.monotonic() - stable_since >= quiet:
|
||||
return state
|
||||
time.sleep(0.25)
|
||||
current = generation()
|
||||
if current != last:
|
||||
last = current
|
||||
stable_since = time.time()
|
||||
elif time.time() - stable_since >= quiet:
|
||||
return current
|
||||
raise RuntimeError('display did not settle')
|
||||
raise TimeoutError(f'display did not settle; last state: {state}')
|
||||
|
||||
wait_stable()
|
||||
|
||||
def press(button):
|
||||
driver.execute_script('arguments[0].scrollIntoView({block: "center"})', button)
|
||||
before = wait_stable()
|
||||
ActionChains(driver).click_and_hold(button).perform()
|
||||
time.sleep(0.3)
|
||||
ActionChains(driver).release(button).perform()
|
||||
wait_frame(before)
|
||||
wait_stable()
|
||||
def save_artifacts():
|
||||
state = page_state()
|
||||
ARTIFACT.with_suffix('.json').write_text(json.dumps(state, indent=2))
|
||||
ARTIFACT.with_suffix('.log').write_text(state.get('log', ''))
|
||||
try:
|
||||
driver.save_screenshot(str(ARTIFACT.with_suffix('.png')))
|
||||
except WebDriverException:
|
||||
pass
|
||||
return state
|
||||
|
||||
press(confirm) # Browse Files
|
||||
press(confirm) # Test directory
|
||||
press(confirm) # highlight example.txt (browser indexes a preview)
|
||||
press(confirm) # Open example.txt into the reader
|
||||
wait_stable(quiet=10, timeout=600)
|
||||
|
||||
status = driver.find_element(By.ID, 'status').text
|
||||
log = driver.find_element(By.ID, 'log').text
|
||||
(ROOT / '_scratch/browser-raw-sd.log').write_text(log)
|
||||
driver.save_screenshot(str(ROOT / '_scratch/browser-raw-sd.png'))
|
||||
|
||||
bad = (
|
||||
'RuntimeError', 'Guru Meditation', "panic'ed", 'Invalid SD card size',
|
||||
'FS error', 'SPI:', 'SD:', 'Unexpected response to cmd',
|
||||
try:
|
||||
suffix = '&vvfat' if VVFAT else ''
|
||||
url = os.environ.get(
|
||||
'XTEINK_TEST_URL',
|
||||
'http://127.0.0.1:8180/web/'
|
||||
'?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin'
|
||||
f'&variant=x3{suffix}',
|
||||
)
|
||||
matched = [marker for marker in bad if marker in log or marker in status]
|
||||
if matched:
|
||||
raise RuntimeError(f'emulator failure detected: {matched}')
|
||||
print(f'browser raw-SD test passed at frame generation {generation()}')
|
||||
driver.get(url)
|
||||
|
||||
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
|
||||
if os.environ.get('XTEINK_INJECT_FAILURE'):
|
||||
driver.execute_script("window.dispatchEvent(new ErrorEvent('error', {message: 'RuntimeError: injected test failure'}))")
|
||||
wait_until(
|
||||
lambda _: confirm.get_dom_attribute('disabled') is None,
|
||||
'automatic cold boot and enabled controls',
|
||||
240,
|
||||
)
|
||||
wait_stable()
|
||||
|
||||
def press(button, description):
|
||||
driver.execute_script(
|
||||
'arguments[0].scrollIntoView({block: "center"})', button
|
||||
)
|
||||
before = wait_stable()['generation']
|
||||
ActionChains(driver).click_and_hold(button).perform()
|
||||
try:
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
ActionChains(driver).release(button).perform()
|
||||
wait_until(
|
||||
lambda state: state['generation'] > before,
|
||||
f'frame after {description}',
|
||||
180,
|
||||
)
|
||||
return wait_stable()
|
||||
|
||||
press(confirm, 'opening Browse Files')
|
||||
press(confirm, 'opening Test directory')
|
||||
press(confirm, 'highlighting example.txt')
|
||||
press(confirm, 'opening example.txt')
|
||||
|
||||
down = driver.find_element(By.CSS_SELECTOR, '[data-button="down"]')
|
||||
up = driver.find_element(By.CSS_SELECTOR, '[data-button="up"]')
|
||||
press(down, 'turning to reader page 2')
|
||||
press(down, 'turning to reader page 3')
|
||||
press(up, 'turning back to reader page 2')
|
||||
|
||||
back = driver.find_element(By.CSS_SELECTOR, '[data-button="back"]')
|
||||
press(back, 'closing the reader')
|
||||
press(confirm, 'reopening example.txt')
|
||||
final_state = wait_stable(quiet=10, timeout=600)
|
||||
|
||||
problem = failure(final_state)
|
||||
if problem:
|
||||
raise RuntimeError(problem)
|
||||
if final_state['generation'] < 10:
|
||||
raise RuntimeError(
|
||||
f'reader workflow produced too few frames: {final_state["generation"]}'
|
||||
)
|
||||
|
||||
print(
|
||||
f'browser {MODE} test passed at frame generation '
|
||||
f'{final_state["generation"]}, '
|
||||
f'wasm heap {final_state["wasmHeap"] / 1024 / 1024:.1f} MiB'
|
||||
)
|
||||
except Exception:
|
||||
state = save_artifacts()
|
||||
print(json.dumps(state, indent=2))
|
||||
raise
|
||||
else:
|
||||
save_artifacts()
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/qemu updated: cbeba3fbe9...962fc6e71d
+145
-70
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
listSdFiles, readSdFile, writeSdFile, deleteSdFile, readAllSdFiles,
|
||||
seedDefaultsIfEmpty, saveBootState, loadBootState,
|
||||
listSdFiles, readSdFile, writeSdFile, mkdirSd, deleteSdFile, clearSdFiles, readAllSdFiles,
|
||||
} from './sdstore.js';
|
||||
import { buildFat32Image } from './fat32.js';
|
||||
|
||||
@@ -44,10 +43,6 @@ async function fetchBin(url) {
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
||||
export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n');
|
||||
export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]);
|
||||
|
||||
// A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table.
|
||||
async function prepareFirmware(bytes, assetRoot) {
|
||||
if (bytes.length === FLASH_SIZE) {
|
||||
@@ -105,6 +100,7 @@ function drawFrame(module, canvas) {
|
||||
function startDisplayLoop(module, canvas, setStatus, onReady) {
|
||||
let generation = -1;
|
||||
let powerHeld = false;
|
||||
let heldFrames = 0;
|
||||
let ready = false;
|
||||
const update = () => {
|
||||
const nextGeneration = module._xteink_wasm_frame_generation();
|
||||
@@ -117,7 +113,7 @@ function startDisplayLoop(module, canvas, setStatus, onReady) {
|
||||
powerHeld = true;
|
||||
setButton(module, 'power', true);
|
||||
setStatus('Holding power for cold boot…');
|
||||
} else if (!ready) {
|
||||
} else if (!ready && ++heldFrames >= 2) {
|
||||
setButton(module, 'power', false);
|
||||
ready = true;
|
||||
setStatus('Running.');
|
||||
@@ -178,6 +174,9 @@ const logBuffer = [];
|
||||
const LOG_MAX_LINES = 1000;
|
||||
const DEBUG_LOG_KEY = 'xteink-debug-log';
|
||||
let activeModule = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__xteinkRuntimeErrors = [];
|
||||
}
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
@@ -195,17 +194,27 @@ export function formatBytes(bytes) {
|
||||
|
||||
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
||||
let selectedSdPath = null;
|
||||
let currentSdDirectory = '';
|
||||
|
||||
function isTextPath(path) {
|
||||
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
||||
}
|
||||
|
||||
function showEditor(show) {
|
||||
document.querySelector('#files-browse').hidden = show;
|
||||
document.querySelector('#files-edit').hidden = !show;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
selectedSdPath = null;
|
||||
showEditor(false);
|
||||
}
|
||||
|
||||
async function selectSdFile(path) {
|
||||
selectedSdPath = path;
|
||||
const editor = document.querySelector('#file-editor');
|
||||
const save = document.querySelector('#file-save');
|
||||
const del = document.querySelector('#file-delete');
|
||||
del.hidden = false;
|
||||
document.querySelector('#file-edit-name').textContent = path.split('/').pop();
|
||||
try {
|
||||
const bytes = await readSdFile(path);
|
||||
if (isTextPath(path)) {
|
||||
@@ -222,49 +231,82 @@ async function selectSdFile(path) {
|
||||
editor.readOnly = true;
|
||||
save.hidden = true;
|
||||
}
|
||||
for (const button of document.querySelectorAll('#file-list button')) {
|
||||
button.classList.toggle('selected', button.dataset.path === path);
|
||||
}
|
||||
showEditor(true);
|
||||
}
|
||||
|
||||
async function renderFiles() {
|
||||
const list = document.querySelector('#file-list');
|
||||
const editor = document.querySelector('#file-editor');
|
||||
const entries = await listSdFiles();
|
||||
list.replaceChildren();
|
||||
let firstPath = null;
|
||||
if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) {
|
||||
currentSdDirectory = '';
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const prefix = currentSdDirectory ? `${currentSdDirectory}/` : '';
|
||||
const visibleEntries = entries.filter(entry => {
|
||||
if (!entry.path.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const relativePath = entry.path.slice(prefix.length).replace(/\/$/, '');
|
||||
return relativePath && !relativePath.includes('/');
|
||||
});
|
||||
|
||||
document.querySelector('#file-path').textContent = `/sdcard${currentSdDirectory ? `/${currentSdDirectory}` : ''}`;
|
||||
document.querySelector('#file-back').disabled = !currentSdDirectory;
|
||||
|
||||
const body = list.querySelector('tbody');
|
||||
body.replaceChildren();
|
||||
|
||||
if (!visibleEntries.length) {
|
||||
const row = body.insertRow();
|
||||
const cell = row.insertCell();
|
||||
cell.colSpan = 4;
|
||||
cell.className = 'file-empty';
|
||||
cell.textContent = 'This folder is empty';
|
||||
return;
|
||||
}
|
||||
|
||||
const childCount = dirPrefix => entries.filter(e => {
|
||||
const rest = e.path.slice(dirPrefix.length).replace(/\/$/, '');
|
||||
return e.path.startsWith(dirPrefix) && rest && !rest.includes('/');
|
||||
}).length;
|
||||
|
||||
for (const entry of visibleEntries) {
|
||||
const name = entry.path.slice(prefix.length).replace(/\/$/, '');
|
||||
const row = body.insertRow();
|
||||
row.className = entry.directory ? 'directory' : 'file';
|
||||
row.insertCell().textContent = entry.directory ? '\u{1F4C1}' : '\u{1F4C4}';
|
||||
row.insertCell().textContent = name;
|
||||
if (entry.directory) {
|
||||
const directory = document.createElement('div');
|
||||
directory.className = 'file-directory';
|
||||
directory.textContent = entry.path;
|
||||
list.append(directory);
|
||||
continue;
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.path = entry.path;
|
||||
const path = document.createElement('span');
|
||||
path.textContent = entry.path;
|
||||
const size = document.createElement('small');
|
||||
size.textContent = formatBytes(entry.size);
|
||||
button.append(path, size);
|
||||
button.addEventListener('click', () => selectSdFile(entry.path));
|
||||
list.append(button);
|
||||
firstPath ||= entry.path;
|
||||
const n = childCount(entry.path);
|
||||
row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`;
|
||||
} else {
|
||||
row.insertCell().textContent = formatBytes(entry.size);
|
||||
}
|
||||
|
||||
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
|
||||
const target = stillPresent ? selectedSdPath : firstPath;
|
||||
if (target) {
|
||||
await selectSdFile(target);
|
||||
} else {
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'file-del';
|
||||
del.textContent = '\u2715';
|
||||
del.title = `Delete ${name}`;
|
||||
del.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Delete ${name}?`)) {
|
||||
return;
|
||||
}
|
||||
await deleteSdFile(entry.path);
|
||||
await renderFiles();
|
||||
});
|
||||
row.insertCell().append(del);
|
||||
|
||||
row.addEventListener('click', () => {
|
||||
if (entry.directory) {
|
||||
currentSdDirectory = entry.path.replace(/\/$/, '');
|
||||
selectedSdPath = null;
|
||||
editor.value = 'The SD card is empty.';
|
||||
editor.readOnly = true;
|
||||
document.querySelector('#file-save').hidden = true;
|
||||
document.querySelector('#file-delete').hidden = true;
|
||||
renderFiles();
|
||||
} else {
|
||||
selectSdFile(entry.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,26 +355,54 @@ function initializeInspector() {
|
||||
});
|
||||
}
|
||||
wireFileControls();
|
||||
seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles);
|
||||
renderFiles();
|
||||
renderDebug();
|
||||
setInterval(renderDebug, 1000);
|
||||
}
|
||||
|
||||
function wireFileControls() {
|
||||
const inDir = name => (currentSdDirectory ? `${currentSdDirectory}/${name}` : name);
|
||||
document.querySelector('#file-add').addEventListener('change', async event => {
|
||||
for (const file of event.target.files) {
|
||||
await writeSdFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
||||
await writeSdFile(inDir(file.name), new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
event.target.value = '';
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-new').addEventListener('click', async () => {
|
||||
const name = prompt('New file path (e.g. Notes/todo.txt):');
|
||||
const name = prompt('New file name:')?.replace(/^\/+|\/+$/g, '');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
await writeSdFile(name, '');
|
||||
selectedSdPath = name;
|
||||
const path = inDir(name);
|
||||
await writeSdFile(path, '');
|
||||
await renderFiles();
|
||||
await selectSdFile(path);
|
||||
});
|
||||
document.querySelector('#file-new-folder').addEventListener('click', async () => {
|
||||
const name = prompt('New folder name:')?.replace(/^\/+|\/+$/g, '');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
await mkdirSd(inDir(name));
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-close').addEventListener('click', () => {
|
||||
closeEditor();
|
||||
renderFiles();
|
||||
});
|
||||
document.querySelector('#file-back').addEventListener('click', async () => {
|
||||
currentSdDirectory = currentSdDirectory.split('/').slice(0, -1).join('/');
|
||||
selectedSdPath = null;
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-reset').addEventListener('click', async () => {
|
||||
if (!confirm('Delete all files from the SD card?')) {
|
||||
return;
|
||||
}
|
||||
await clearSdFiles();
|
||||
currentSdDirectory = '';
|
||||
selectedSdPath = null;
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-save').addEventListener('click', async () => {
|
||||
@@ -340,14 +410,13 @@ function wireFileControls() {
|
||||
return;
|
||||
}
|
||||
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-delete').addEventListener('click', async () => {
|
||||
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
|
||||
return;
|
||||
}
|
||||
await deleteSdFile(selectedSdPath);
|
||||
selectedSdPath = null;
|
||||
closeEditor();
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-reboot').addEventListener('click', () => location.reload());
|
||||
@@ -382,11 +451,13 @@ async function boot(file, variant, setStatus) {
|
||||
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
||||
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
||||
]);
|
||||
await seedDefaultsIfEmpty(DEFAULT_SD_FILES);
|
||||
const sdFiles = await readAllSdFiles();
|
||||
setStatus(`Building SD card image from ${sdFiles.length} browser file(s)…`);
|
||||
const sdImage = buildFat32Image(sdFiles);
|
||||
const sdMessage = `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
||||
const useVvfat = new URL(location.href).searchParams.has('vvfat');
|
||||
setStatus(`${useVvfat ? 'Staging' : 'Building SD card image from'} ${sdFiles.length} browser file(s)…`);
|
||||
const sdImage = useVvfat ? null : buildFat32Image(sdFiles);
|
||||
const sdMessage = useVvfat
|
||||
? `Using experimental vvfat backed by ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`
|
||||
: `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
||||
console.log(sdMessage);
|
||||
logLine(sdMessage);
|
||||
const { default: createQemu } = await import(qemuUrl);
|
||||
@@ -405,7 +476,9 @@ async function boot(file, variant, setStatus) {
|
||||
'-nic', 'none',
|
||||
'-d', qemuLogFlags,
|
||||
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
||||
'-drive', 'file=/sd.img,if=sd,format=raw',
|
||||
'-drive', useVvfat
|
||||
? 'file=fat:16:rw:/sdcard,if=sd,format=raw'
|
||||
: 'file=/sd.img,if=sd,format=raw',
|
||||
],
|
||||
locateFile: path => new URL(path, artifactRoot).href,
|
||||
mainScriptUrlOrBlob: qemuUrl.href,
|
||||
@@ -418,7 +491,16 @@ async function boot(file, variant, setStatus) {
|
||||
options.FS.mkdirTree('/var/tmp');
|
||||
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
||||
options.FS.writeFile('/flash.bin', firmware);
|
||||
if (useVvfat) {
|
||||
for (const { path, bytes } of sdFiles) {
|
||||
const slash = path.lastIndexOf('/');
|
||||
const directory = slash < 0 ? '/sdcard' : `/sdcard/${path.slice(0, slash)}`;
|
||||
options.FS.mkdirTree(directory);
|
||||
options.FS.writeFile(`/sdcard/${path}`, bytes);
|
||||
}
|
||||
} else {
|
||||
options.FS.writeFile('/sd.img', sdImage);
|
||||
}
|
||||
}];
|
||||
|
||||
setStatus('Starting emulator…');
|
||||
@@ -450,6 +532,14 @@ function initialize() {
|
||||
status.textContent = message;
|
||||
status.classList.toggle('error', error);
|
||||
};
|
||||
const recordRuntimeError = value => {
|
||||
const message = value instanceof Error ? value.stack || value.message : String(value);
|
||||
window.__xteinkRuntimeErrors.push(message);
|
||||
logLine(`[runtime] ${message}`);
|
||||
setStatus(`Runtime error: ${message}`, true);
|
||||
};
|
||||
window.addEventListener('error', event => recordRuntimeError(event.message || event.error || 'Unknown worker error'));
|
||||
window.addEventListener('unhandledrejection', event => recordRuntimeError(event.reason || 'Unhandled rejection'));
|
||||
|
||||
const start = async (file, variant = variantInput.value) => {
|
||||
bootButton.disabled = true;
|
||||
@@ -480,7 +570,6 @@ function initialize() {
|
||||
setStatus('Choose a firmware image first.', true);
|
||||
return;
|
||||
}
|
||||
await saveBootState(file, variantInput.value);
|
||||
start(file);
|
||||
});
|
||||
|
||||
@@ -493,21 +582,7 @@ function initialize() {
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(async blob => {
|
||||
const variant = params.get('variant') || 'x3';
|
||||
const firmware = new File([blob], 'firmware.bin');
|
||||
await saveBootState(firmware, variant);
|
||||
await start(firmware, variant);
|
||||
})
|
||||
.catch(error => setStatus(error.message || String(error), true));
|
||||
} else {
|
||||
loadBootState()
|
||||
.then(saved => {
|
||||
if (saved) {
|
||||
variantInput.value = saved.variant;
|
||||
start(saved.firmware, saved.variant);
|
||||
}
|
||||
})
|
||||
.then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3'))
|
||||
.catch(error => setStatus(error.message || String(error), true));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -66,16 +66,30 @@
|
||||
</div>
|
||||
|
||||
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
|
||||
<div class="file-toolbar">
|
||||
<label class="file-button">Add files<input id="file-add" type="file" multiple hidden></label>
|
||||
<button id="file-new" type="button" class="file-button">New text file</button>
|
||||
<button id="file-reboot" type="button" class="file-button file-reboot">Apply & reboot</button>
|
||||
<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">/sdcard</code>
|
||||
</div>
|
||||
<div id="file-list" class="file-list" aria-label="SD card files"></div>
|
||||
<textarea id="file-editor" class="file-preview" spellcheck="false" readonly>Select a file to view it.</textarea>
|
||||
<table id="file-list" class="file-list" aria-label="SD card files"><tbody></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 & 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" readonly></textarea>
|
||||
<div class="file-actions">
|
||||
<button id="file-save" type="button" class="file-button" hidden>Save</button>
|
||||
<button id="file-delete" type="button" class="file-button" hidden>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+13
-39
@@ -4,7 +4,6 @@
|
||||
// MEMFS /sdcard on the next boot. Guest-side writes remain ephemeral.
|
||||
|
||||
const SD_DIR = 'sdcard';
|
||||
const STATE_DIR = 'emulator';
|
||||
|
||||
async function sdRoot() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
@@ -54,11 +53,24 @@ export async function writeSdFile(path, data) {
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
// ponytail: empty dirs live only in OPFS; readAllSdFiles skips them, so they vanish on reboot. Add a placeholder file if guest-side empty dirs ever matter.
|
||||
export async function mkdirSd(path) {
|
||||
let dir = await sdRoot();
|
||||
for (const part of path.split('/').filter(Boolean)) {
|
||||
dir = await dir.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSdFile(path) {
|
||||
const [dir, name] = await resolveParent(path, false);
|
||||
await dir.removeEntry(name, { recursive: true });
|
||||
}
|
||||
|
||||
export async function clearSdFiles() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
await root.removeEntry(SD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Flatten to [{ path, bytes }] for seeding the guest's MEMFS /sdcard at boot.
|
||||
export async function readAllSdFiles() {
|
||||
const out = [];
|
||||
@@ -69,41 +81,3 @@ export async function readAllSdFiles() {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function seedDefaultsIfEmpty(defaults) {
|
||||
const entries = await listSdFiles();
|
||||
if (entries.some(entry => !entry.directory)) {
|
||||
return;
|
||||
}
|
||||
for (const [path, contents] of defaults) {
|
||||
await writeSdFile(path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBootState(firmware, variant) {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const state = await root.getDirectoryHandle(STATE_DIR, { create: true });
|
||||
const firmwareHandle = await state.getFileHandle('firmware.bin', { create: true });
|
||||
const firmwareWriter = await firmwareHandle.createWritable();
|
||||
await firmwareWriter.write(firmware);
|
||||
await firmwareWriter.close();
|
||||
const settingsHandle = await state.getFileHandle('settings.json', { create: true });
|
||||
const settingsWriter = await settingsHandle.createWritable();
|
||||
await settingsWriter.write(JSON.stringify({ variant, name: firmware.name || 'firmware.bin' }));
|
||||
await settingsWriter.close();
|
||||
}
|
||||
|
||||
export async function loadBootState() {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const state = await root.getDirectoryHandle(STATE_DIR);
|
||||
const settings = JSON.parse(await (await state.getFileHandle('settings.json')).getFile().then(file => file.text()));
|
||||
const firmware = await (await state.getFileHandle('firmware.bin')).getFile();
|
||||
return { firmware: new File([firmware], settings.name || 'firmware.bin'), variant: settings.variant || 'x3' };
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFoundError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-11
@@ -80,17 +80,33 @@ canvas { display: block; width: 100%; height: auto; background: #fff; image-rend
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
|
||||
.log-header button { min-height: auto; padding: .25rem .75rem; font-size: .8rem; }
|
||||
.log { margin: 0; max-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; }
|
||||
.file-toolbar, .file-actions { display: flex; gap: .5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.file-actions { margin: .75rem 0 0; }
|
||||
.file-button { display: inline-flex; align-items: center; padding: .4rem .8rem; border-radius: .5rem; cursor: pointer; }
|
||||
.file-reboot { margin-left: auto; }
|
||||
.file-list { display: grid; gap: .4rem; margin-bottom: 1rem; }
|
||||
.file-directory { padding: .35rem .55rem; color: #625e55; font-weight: 700; }
|
||||
.file-list button { display: flex; justify-content: space-between; gap: 1rem; width: 100%; min-height: 2.4rem; text-align: left; }
|
||||
.file-list button.selected { outline: 2px solid #625e55; }
|
||||
.file-list small { color: #cbc5b9; font-weight: 400; }
|
||||
.file-preview { min-height: 12rem; max-height: 28rem; overflow: auto; margin: 0; padding: .75rem 1rem; border-radius: .6rem; background: #fff; white-space: pre-wrap; }
|
||||
textarea.file-preview { width: 100%; box-sizing: border-box; resize: vertical; font-family: inherit; }
|
||||
.file-toolbar { display: flex; gap: .4rem; align-items: center; margin: 0; 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; background: #d2752b; border-color: #b66020; }
|
||||
.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%; border-collapse: collapse; margin-bottom: .75rem; border: 1px solid #c8c2b6; 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.file, .file-list tr.directory { 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 { text-align: center; color: #777269; padding: 1.25rem !important; }
|
||||
.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; margin: 0; padding: .85rem 1rem; border: 1px solid #c8c2b6; border-radius: .6rem; color: #343532; background: #fff; line-height: 1.45; white-space: pre-wrap; }
|
||||
textarea.file-preview { box-sizing: border-box; resize: vertical; font-family: ui-monospace, monospace; }
|
||||
textarea.file-preview:focus { outline: 3px solid #d2752b; outline-offset: 1px; }
|
||||
textarea.file-preview:read-only { color: #777269; background: #f1ede4; }
|
||||
.debug-stats { display: grid; gap: .65rem; margin: 0; }
|
||||
.debug-stats div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; }
|
||||
.debug-stats dt { font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user