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:
2026-07-21 15:37:00 -04:00
parent 13f29177c1
commit 78f181e924
7 changed files with 373 additions and 186 deletions
+1 -3
View File
@@ -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');
+159 -49
View File
@@ -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:
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,
}
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_stable(quiet=5, timeout=180):
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)
raise TimeoutError(f'display did not settle; last state: {state}')
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
try:
driver.get('http://127.0.0.1:8180/web/?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin&variant=x3')
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}',
)
driver.get(url)
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
WebDriverWait(driver, 8 * 60).until(lambda _: confirm.get_attribute('disabled') is None)
indicator = driver.find_element(By.ID, 'frame-generation')
def generation():
return int(indicator.get_attribute('value') or '-1')
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:
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')
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):
driver.execute_script('arguments[0].scrollIntoView({block: "center"})', button)
before = 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()
time.sleep(0.3)
ActionChains(driver).release(button).perform()
wait_frame(before)
wait_stable()
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) # 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)
press(confirm, 'opening Browse Files')
press(confirm, 'opening Test directory')
press(confirm, 'highlighting example.txt')
press(confirm, 'opening example.txt')
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'))
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')
bad = (
'RuntimeError', 'Guru Meditation', "panic'ed", 'Invalid SD card size',
'FS error', 'SPI:', 'SD:', 'Unexpected response to cmd',
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'
)
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()}')
except Exception:
state = save_artifacts()
print(json.dumps(state, indent=2))
raise
else:
save_artifacts()
finally:
driver.quit()
+149 -74
View File
@@ -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;
for (const entry of entries) {
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;
if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) {
currentSdDirectory = '';
}
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
const target = stillPresent ? selectedSdPath : firstPath;
if (target) {
await selectSdFile(target);
} else {
selectedSdPath = null;
editor.value = 'The SD card is empty.';
editor.readOnly = true;
document.querySelector('#file-save').hidden = true;
document.querySelector('#file-delete').hidden = true;
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 n = childCount(entry.path);
row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`;
} else {
row.insertCell().textContent = formatBytes(entry.size);
}
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;
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);
options.FS.writeFile('/sd.img', sdImage);
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));
}
}
+23 -9
View File
@@ -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 &amp; 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>
<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 &amp; Reboot</button>
</div>
</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>
<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 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>
</div>
</div>
</div>
+13 -39
View File
@@ -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
View File
@@ -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; }