Sync edgetx to Gitea

This commit is contained in:
2026-08-03 16:37:20 +08:00
commit 14eb5a3fb9
5364 changed files with 2835009 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
public/*.wasm
+145
View File
@@ -0,0 +1,145 @@
# EdgeTX Web Simulator
Browser-based EdgeTX radio simulator using WebAssembly + WASI threads.
## Prerequisites
- Node.js 20+
- Pre-built `.wasm` modules in `public/` (see [Building WASM Modules](#building-wasm-modules))
## Quick Start
```bash
npm install
npm run dev
```
Open http://localhost:5173 in Chrome, Edge, Safari, or any browser supporting `SharedArrayBuffer` and `Atomics.waitAsync`.
## Building WASM Modules
The build requires [wasi-sdk](https://github.com/WebAssembly/wasi-sdk). The build script resolves it automatically:
1. `$WASI_SDK_PATH` environment variable (if set)
2. `/opt/wasi-sdk/` (default install path, used in CI)
3. Auto-download via `cmake/FetchWasiSDK.cmake` (fetched once, cached across builds)
From the EdgeTX repository root:
```bash
# Build all supported radios
tools/build-wasm-modules.sh
# Build specific radios only
FLAVOR="tx16s;t12;x9dp2019" tools/build-wasm-modules.sh
```
Output `.wasm` files are written to `output/`. Copy them to `web/public/`:
```bash
cp output/*.wasm web/public/
```
### Supported Radios
The radios available in the web UI are defined in `public/radios.json`. This file is **generated** from the authoritative hardware definitions in `radio/src/boards/hw_defs/`. To regenerate it (e.g. after adding a new radio target):
```bash
node web/scripts/gen-radios-json.js
```
The script extracts inputs, switches, trims, keys, and display info from the hw_defs JSON files. Key left/right side placement matches Companion's layout.
## Architecture
```
Browser Main Thread
┌─────────────────────────────────────────────────────┐
│ │
│ App.svelte ──── WasmRunner ──── LcdRenderer │
│ (UI, controls) (loader) (WebGL canvas) │
│ │ │ │
│ │ SharedArrayBuffer │
│ │ (analogs, LCD sync) │
│ │ │ │
│ ▼ ▼ │
│ AudioContext WASM Worker Threads (WASI) │
│ (scheduled ┌──────────────────────────┐ │
│ playback) │ worker.ts + FsProxyClient│ │
│ └────────────┬─────────────┘ │
│ SAB+Atomics (sync I/O) │
│ │ │
│ ┌────────────▼─────────────┐ │
│ │ FS Worker (fs-worker.ts) │ │
│ │ OpfsBackend (OPFS) │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### Lifecycle
1. **Radio selection** — user picks a radio from the dropdown (persisted to localStorage). The FS Worker is spawned and OPFS is scanned for existing data. File uploads are available immediately.
2. **Run** — WASM module is fetched, compiled, and instantiated. Worker threads are spawned with shared memory. The FS Worker's Atomics loop is started. The simulator begins running.
3. **Stop** — firmware shutdown is signalled, worker threads are terminated after a grace period. The FS Worker stays alive for uploads.
4. **Radio switch** — the old instance is torn down (FS Worker included), and a new one is initialized for the selected radio.
### Key Components
- **WasmRunner** — loads WASM, creates shared memory, manages the FS Worker and WASI thread pool. Exposes `initFs()` (spawn FS Worker), `load()` (compile + instantiate WASM), `stopSim()` (terminate threads), and `stopFs()` (terminate FS Worker).
- **FS Worker** (`fs-worker.ts`) — dedicated worker that owns all OPFS state. Serves synchronous filesystem requests from WASM workers via `SharedArrayBuffer` + `Atomics`, and async UI requests (uploads, reads, wipe) via `postMessage`.
- **OpfsBackend** (`opfs-backend.ts`) — in-memory directory tree backed by OPFS `SyncAccessHandle`s. Provides a synchronous Node.js-like filesystem API.
- **FsProxyClient** (`fs-proxy-client.ts`) — worker-side stub that implements `fs.*Sync` methods by writing requests to a shared buffer and blocking on `Atomics.wait` until the FS Worker responds.
- **LcdRenderer** (`lcd-renderer.ts`) — WebGL-based renderer supporting RGB565 (16-bit color), 4-bit grayscale, and 1-bit monochrome (column-major) LCD formats.
- **Audio** — worker threads relay PCM samples via `postMessage`. The main thread schedules them as `AudioBufferSource` nodes for gapless 32 kHz playback.
## Key Files
| File | Description |
|------|-------------|
| `src/App.svelte` | Main UI: radio selector, LCD display, controls, file management |
| `src/lib/wasm-runner.ts` | WASM loader, FS Worker lifecycle, thread management |
| `src/lib/worker.ts` | WASM worker thread entry point (WASI + thread init) |
| `src/lib/fs-worker.ts` | FS Worker: OPFS owner, Atomics dispatch loop, UI file ops |
| `src/lib/opfs-backend.ts` | OPFS-backed synchronous filesystem implementation |
| `src/lib/fs-proxy-client.ts` | Worker-side blocking filesystem proxy |
| `src/lib/fs-proxy-protocol.ts` | Shared protocol constants and serialization |
| `src/lib/lcd-renderer.ts` | LCD framebuffer rendering (RGB565, 4-bit grayscale, 1-bit mono) |
| `public/radios.json` | Radio definitions (generated — do not edit manually) |
| `public/_headers` | COOP/COEP headers for production deployment |
| `scripts/gen-radios-json.js` | Generates `radios.json` from `radio/src/boards/hw_defs/` |
## Browser Requirements
- **SharedArrayBuffer** (requires COOP/COEP headers, configured in `vite.config.ts` and `public/_headers`)
- **Atomics.waitAsync** — Chrome 87+, Safari 16.4+, Edge 87+, Firefox 145+
- **WebAssembly threads** (shared memory)
- **Origin Private File System** (persistent storage across sessions)
## Development
```bash
npm run dev # Start dev server with HMR
npm run build # Production build to dist/
npm run preview # Preview production build
```
### Debugging
- **Filesystem tracing**: Open browser console and run `fsTrace = true` to log all filesystem operations.
- **Trace window**: The simulator UI includes a scrollable trace output showing firmware boot messages and runtime output.
## Production Deployment
The dev server sets the required COOP/COEP headers automatically. For production, `public/_headers` provides them for platforms like Cloudflare Pages. For other hosts, configure your web server to set:
```
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
```
### Cloudflare Pages
```bash
npm run build
npx wrangler pages deploy dist --project-name=edgetx-simulator
```
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EdgeTX Simulator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1468
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "edgetx-simulator-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^20.11.0",
"svelte": "^5.0.0",
"typescript": "^5.3.3",
"vite": "^6.0.0"
},
"dependencies": {
"@emnapi/wasi-threads": "^1.0.0",
"@tybys/wasm-util": "^0.10.1"
}
}
+1
View File
@@ -0,0 +1 @@
Place simulator.wasm here
+3
View File
@@ -0,0 +1,3 @@
/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
//
// Generate web/public/radios.json from the authoritative hardware definition
// JSON files in radio/src/boards/hw_defs/.
//
// Usage: node web/scripts/gen-radios-json.js [flavour1 flavour2 ...]
//
// Flavour names match the hw_defs filenames (e.g. "tx16s", "x9d+2019").
// If no flavours are specified, generates entries for all hw_defs files.
//
// The key left/right side mapping matches Companion's radioKeyDefinitions
// table in companion/src/simulation/simulateduiwidget.cpp.
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const HW_DEFS = join(ROOT, 'radio', 'src', 'boards', 'hw_defs');
const OUTPUT = join(ROOT, 'web', 'public', 'radios.json');
// Key side and order — matches Companion's radioKeyDefinitions table
// (companion/src/simulation/simulateduiwidget.cpp)
const KEY_LAYOUT = {
KEY_SYS: { side: 'L', row: 0 },
KEY_MODEL: { side: 'R', row: 0 },
KEY_PAGEUP: { side: 'L', row: 1 },
KEY_PAGEDN: { side: 'R', row: 1 },
KEY_UP: { side: 'L', row: 2 },
KEY_DOWN: { side: 'R', row: 2 },
KEY_LEFT: { side: 'L', row: 3 },
KEY_RIGHT: { side: 'R', row: 3 },
KEY_MINUS: { side: 'L', row: 4 },
KEY_PLUS: { side: 'R', row: 4 },
KEY_TELE: { side: 'R', row: 5 },
KEY_MENU: { side: 'L', row: 6 },
KEY_SHIFT: { side: 'R', row: 6 },
KEY_EXIT: { side: 'L', row: 7 },
KEY_ENTER: { side: 'R', row: 7 },
};
// Pretty labels for keys (unicode symbols instead of plain text)
const KEY_LABELS = {
KEY_PAGEUP: 'PAGE\u25C0', // PAGE◀
KEY_PAGEDN: 'PAGE\u25B6', // PAGE▶
KEY_UP: '\u25B2', // ▲
KEY_DOWN: '\u25BC', // ▼
KEY_LEFT: '\u25C0', // ◀
KEY_RIGHT: '\u25B6', // ▶
KEY_ENTER: 'Enter \u23CE', // Enter ⏎
};
/** Build display name lookup from fw.json (the authoritative radio name list). */
function loadDisplayNames() {
try {
const fw = JSON.parse(readFileSync(join(ROOT, 'fw.json'), 'utf-8'));
const map = {};
for (const [name, prefix] of fw.targets) {
// prefix is e.g. "tx16s-", "x9dp2019-" — strip trailing dash
const key = prefix.replace(/-$/, '');
map[key] = name;
}
return map;
} catch {
return {};
}
}
const DISPLAY_NAMES = loadDisplayNames();
function getDisplayName(flavour) {
// fw.json uses build target names (x9dp2019), hw_defs uses flavour (x9d+2019)
// Try both the flavour and the p-substituted form
if (DISPLAY_NAMES[flavour]) return DISPLAY_NAMES[flavour];
const pForm = flavour.replace('+', 'p');
if (DISPLAY_NAMES[pForm]) return DISPLAY_NAMES[pForm];
return flavour.toUpperCase();
}
function processFlavour(flavour) {
let jsonFile;
try {
jsonFile = readFileSync(join(HW_DEFS, `${flavour}.json`), 'utf-8');
} catch {
console.warn(` ⚠ Skipping ${flavour}: no hw_defs JSON found`);
return null;
}
const hw = JSON.parse(jsonFile);
// Inputs: sticks + flex (skip VBAT, RTC_BAT, etc.)
const inputs = (hw.adc_inputs?.inputs ?? [])
.filter(i => i.type === 'STICK' || i.type === 'FLEX')
.map(i => {
const entry = { name: i.name, type: i.type };
if (i.label) entry.label = i.label;
else entry.label = i.name;
if (i.default) entry.default = i.default;
return entry;
});
// Switches: hardware type 'ADC' means 3POS (analog-read switch)
const switches = (hw.switches ?? []).map(s => {
const swType = (s.type === '3POS' || s.type === 'ADC') ? '3POS' : '2POS';
return { name: s.name, type: swType, default: s.default || swType };
});
// Trims
const trims = (hw.trims ?? []).map(t => ({ name: t.name }));
// Keys with side mapping, pretty labels, sorted by Companion grid row
const keys = (hw.keys ?? []).map(k => ({
key: k.key,
label: KEY_LABELS[k.key] || k.label || k.name,
side: (KEY_LAYOUT[k.key] || {}).side || 'R',
})).sort((a, b) => {
const ra = (KEY_LAYOUT[a.key] || {}).row ?? 99;
const rb = (KEY_LAYOUT[b.key] || {}).row ?? 99;
return ra - rb;
});
// Display / LCD info
const disp = hw.display ?? {};
const display = {
w: disp.w ?? 480,
h: disp.h ?? 272,
depth: disp.depth ?? 16,
};
// WASM filename uses the flavour name directly
return {
name: getDisplayName(flavour),
wasm: `edgetx-${flavour}-simulator.wasm`,
display,
inputs,
switches,
trims,
keys,
};
}
// Determine which flavours to generate: args, or fw.json targets (supported radios only)
let flavours = process.argv.slice(2);
if (flavours.length === 0) {
// Use fw.json as the source of supported targets
// The prefix in fw.json is the build target name (e.g. "x9dp2019-")
// which may differ from the hw_defs flavour (e.g. "x9d+2019")
const fw = JSON.parse(readFileSync(join(ROOT, 'fw.json'), 'utf-8'));
flavours = fw.targets.map(([, prefix]) => {
const buildTarget = prefix.replace(/-$/, '');
// Check if hw_defs file exists under this name; if not, try + substitution
try {
readFileSync(join(HW_DEFS, `${buildTarget}.json`));
return buildTarget;
} catch {
const plusForm = buildTarget.replace('dp', 'd+');
try {
readFileSync(join(HW_DEFS, `${plusForm}.json`));
return plusForm;
} catch {
return buildTarget; // will be skipped later
}
}
});
}
console.log(`Generating radios.json for ${flavours.length} flavours...`);
const radios = [];
for (const flavour of flavours) {
const entry = processFlavour(flavour);
if (entry) {
radios.push(entry);
console.log(`${flavour}${entry.name}`);
}
}
writeFileSync(OUTPUT, JSON.stringify(radios, null, 2) + '\n');
console.log(`\nWrote ${radios.length} entries to ${OUTPUT}`);
+2302
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+343
View File
@@ -0,0 +1,343 @@
/**
* Worker-thread side of the filesystem proxy.
* Implements a synchronous Node.js-style fs interface by forwarding
* calls to the main thread via SharedArrayBuffer + Atomics.wait.
*/
import {
FsOp,
IDX_REQUEST_FLAG, IDX_RESPONSE_FLAG, IDX_OPCODE,
IDX_ARG1, IDX_ARG2, IDX_ARG3, IDX_ARG4,
IDX_RESULT, IDX_ERROR_CODE, IDX_DATA_LEN,
errorCodeToString, deserializeStat,
} from './fs-proxy-protocol';
class BigIntStatsProxy {
dev: bigint; ino: bigint; mode: bigint; nlink: bigint;
uid: bigint; gid: bigint; rdev: bigint; size: bigint;
blksize: bigint; blocks: bigint;
atimeMs: bigint; mtimeMs: bigint; ctimeMs: bigint; birthtimeMs: bigint;
atimeNs: bigint; mtimeNs: bigint; ctimeNs: bigint; birthtimeNs: bigint;
atime: Date; mtime: Date; ctime: Date; birthtime: Date;
private _flags: number;
constructor(d: ReturnType<typeof deserializeStat>) {
this.dev = d.dev; this.ino = d.ino; this.mode = d.mode; this.nlink = d.nlink;
this.uid = d.uid; this.gid = d.gid; this.rdev = d.rdev; this.size = d.size;
this.blksize = d.blksize; this.blocks = d.blocks;
this.atimeMs = d.atimeMs; this.mtimeMs = d.mtimeMs;
this.ctimeMs = d.ctimeMs; this.birthtimeMs = d.birthtimeMs;
this.atimeNs = d.atimeMs * 1000000n;
this.mtimeNs = d.mtimeMs * 1000000n;
this.ctimeNs = d.ctimeMs * 1000000n;
this.birthtimeNs = d.birthtimeMs * 1000000n;
this.atime = new Date(Number(d.atimeMs));
this.mtime = new Date(Number(d.mtimeMs));
this.ctime = new Date(Number(d.ctimeMs));
this.birthtime = new Date(Number(d.birthtimeMs));
this._flags = d.flags;
}
isFile(): boolean { return !!(this._flags & 1); }
isDirectory(): boolean { return !!(this._flags & 2); }
isSymbolicLink(): boolean { return !!(this._flags & 4); }
isCharacterDevice(): boolean { return !!(this._flags & 8); }
isBlockDevice(): boolean { return !!(this._flags & 16); }
isSocket(): boolean { return !!(this._flags & 32); }
isFIFO(): boolean { return !!(this._flags & 64); }
}
class DirentProxy {
name: string;
private _type: number;
constructor(name: string, type: number) {
this.name = name;
this._type = type;
}
isFile(): boolean { return this._type === 1; }
isDirectory(): boolean { return this._type === 2; }
isSymbolicLink(): boolean { return this._type === 3; }
isCharacterDevice(): boolean { return this._type === 4; }
isBlockDevice(): boolean { return this._type === 5; }
isSocket(): boolean { return this._type === 6; }
isFIFO(): boolean { return this._type === 7; }
}
export class FsProxyClient {
private ctrl: Int32Array;
private data: Uint8Array;
private wake: Int32Array | null;
private encoder = new TextEncoder();
private decoder = new TextDecoder();
constructor(ctrlBuffer: SharedArrayBuffer, dataBuffer: SharedArrayBuffer, wakeBuffer?: SharedArrayBuffer) {
this.ctrl = new Int32Array(ctrlBuffer);
this.data = new Uint8Array(dataBuffer);
this.wake = wakeBuffer ? new Int32Array(wakeBuffer) : null;
}
private call(opcode: FsOp): void {
Atomics.store(this.ctrl, IDX_OPCODE, opcode);
Atomics.store(this.ctrl, IDX_RESPONSE_FLAG, 0);
Atomics.store(this.ctrl, IDX_REQUEST_FLAG, 1);
Atomics.notify(this.ctrl, IDX_REQUEST_FLAG);
if (this.wake) {
Atomics.store(this.wake, 0, 1);
Atomics.notify(this.wake, 0);
}
Atomics.wait(this.ctrl, IDX_RESPONSE_FLAG, 0);
}
private checkError(): void {
const code = Atomics.load(this.ctrl, IDX_ERROR_CODE);
if (code !== 0) {
const len = Atomics.load(this.ctrl, IDX_DATA_LEN);
const msg = this.decoder.decode(this.data.slice(0, len));
const err = new Error(msg);
(err as any).code = errorCodeToString(code);
throw err;
}
}
private writeStr(s: string, offset = 0): number {
const bytes = this.encoder.encode(s);
this.data.set(bytes, offset);
return bytes.length;
}
openSync(path: string, flags: number | string, mode: number = 0o666): number {
if (typeof flags === 'string') {
// Send flags as string: a1=-1 sentinel, data=[pathLen:u32][path][flagsStr]
const pathBytes = this.encoder.encode(path);
const flagsBytes = this.encoder.encode(flags);
const dv = new DataView(this.data.buffer, this.data.byteOffset);
dv.setUint32(0, pathBytes.length, true);
this.data.set(pathBytes, 4);
this.data.set(flagsBytes, 4 + pathBytes.length);
Atomics.store(this.ctrl, IDX_ARG1, -1);
Atomics.store(this.ctrl, IDX_ARG2, mode);
Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + pathBytes.length + flagsBytes.length);
} else {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_ARG1, flags);
Atomics.store(this.ctrl, IDX_ARG2, mode);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
}
this.call(FsOp.OPEN);
this.checkError();
return Atomics.load(this.ctrl, IDX_RESULT);
}
closeSync(fd: number): void {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.CLOSE);
this.checkError();
}
readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null): number {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_ARG2, length);
if (position === null || position === undefined) {
Atomics.store(this.ctrl, IDX_ARG3, 0);
Atomics.store(this.ctrl, IDX_ARG4, -1);
} else {
Atomics.store(this.ctrl, IDX_ARG3, position | 0);
Atomics.store(this.ctrl, IDX_ARG4, Math.floor(position / 0x100000000) | 0);
}
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.READ);
this.checkError();
const bytesRead = Atomics.load(this.ctrl, IDX_RESULT);
buffer.set(this.data.subarray(0, bytesRead), offset);
return bytesRead;
}
writeSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null): number {
this.data.set(buffer.subarray(offset, offset + length), 0);
Atomics.store(this.ctrl, IDX_ARG1, fd);
if (position === null || position === undefined) {
Atomics.store(this.ctrl, IDX_ARG2, 0);
Atomics.store(this.ctrl, IDX_ARG3, -1);
} else {
Atomics.store(this.ctrl, IDX_ARG2, position | 0);
Atomics.store(this.ctrl, IDX_ARG3, Math.floor(position / 0x100000000) | 0);
}
Atomics.store(this.ctrl, IDX_DATA_LEN, length);
this.call(FsOp.WRITE);
this.checkError();
return Atomics.load(this.ctrl, IDX_RESULT);
}
fstatSync(fd: number, _options?: { bigint: boolean }): BigIntStatsProxy {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.FSTAT);
this.checkError();
return new BigIntStatsProxy(deserializeStat(this.data, 0));
}
statSync(path: string, _options?: { bigint: boolean }): BigIntStatsProxy {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.STAT);
this.checkError();
return new BigIntStatsProxy(deserializeStat(this.data, 0));
}
lstatSync(path: string, _options?: { bigint: boolean }): BigIntStatsProxy {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.LSTAT);
this.checkError();
return new BigIntStatsProxy(deserializeStat(this.data, 0));
}
ftruncateSync(fd: number, len: number): void {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_ARG2, len);
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.FTRUNCATE);
this.checkError();
}
futimesSync(fd: number, atime: number, mtime: number): void {
Atomics.store(this.ctrl, IDX_ARG1, fd);
const tv = new DataView(this.data.buffer, this.data.byteOffset, 16);
tv.setFloat64(0, atime, true);
tv.setFloat64(8, mtime, true);
Atomics.store(this.ctrl, IDX_DATA_LEN, 16);
this.call(FsOp.FUTIMES);
this.checkError();
}
utimesSync(path: string, atime: number, mtime: number): void {
const pathLen = this.writeStr(path);
Atomics.store(this.ctrl, IDX_ARG1, pathLen);
const tv = new DataView(this.data.buffer, this.data.byteOffset + pathLen, 16);
tv.setFloat64(0, atime, true);
tv.setFloat64(8, mtime, true);
Atomics.store(this.ctrl, IDX_DATA_LEN, pathLen + 16);
this.call(FsOp.UTIMES);
this.checkError();
}
mkdirSync(path: string): void {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.MKDIR);
this.checkError();
}
readdirSync(path: string, options?: { withFileTypes: boolean }): any[] {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_ARG1, options?.withFileTypes ? 1 : 0);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.READDIR);
this.checkError();
const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
if (options?.withFileTypes) {
const dv = new DataView(this.data.buffer, this.data.byteOffset);
let off = 0;
const count = dv.getUint32(off, true); off += 4;
const result: DirentProxy[] = [];
for (let i = 0; i < count; i++) {
const type = this.data[off++];
const nameLen = dv.getUint32(off, true); off += 4;
const name = this.decoder.decode(this.data.slice(off, off + nameLen));
off += nameLen;
result.push(new DirentProxy(name, type));
}
return result;
} else {
const json = this.decoder.decode(this.data.slice(0, respLen));
return JSON.parse(json);
}
}
renameSync(oldPath: string, newPath: string): void {
const oldBytes = this.encoder.encode(oldPath);
const newBytes = this.encoder.encode(newPath);
const dv = new DataView(this.data.buffer, this.data.byteOffset);
dv.setUint32(0, oldBytes.length, true);
this.data.set(oldBytes, 4);
this.data.set(newBytes, 4 + oldBytes.length);
Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + oldBytes.length + newBytes.length);
this.call(FsOp.RENAME);
this.checkError();
}
rmdirSync(path: string): void {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.RMDIR);
this.checkError();
}
unlinkSync(path: string): void {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.UNLINK);
this.checkError();
}
linkSync(existingPath: string, newPath: string): void {
const oldBytes = this.encoder.encode(existingPath);
const newBytes = this.encoder.encode(newPath);
const dv = new DataView(this.data.buffer, this.data.byteOffset);
dv.setUint32(0, oldBytes.length, true);
this.data.set(oldBytes, 4);
this.data.set(newBytes, 4 + oldBytes.length);
Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + oldBytes.length + newBytes.length);
this.call(FsOp.LINK);
this.checkError();
}
symlinkSync(target: string, path: string): void {
const targetBytes = this.encoder.encode(target);
const pathBytes = this.encoder.encode(path);
const dv = new DataView(this.data.buffer, this.data.byteOffset);
dv.setUint32(0, targetBytes.length, true);
this.data.set(targetBytes, 4);
this.data.set(pathBytes, 4 + targetBytes.length);
Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + targetBytes.length + pathBytes.length);
this.call(FsOp.SYMLINK);
this.checkError();
}
readlinkSync(path: string): string {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.READLINK);
this.checkError();
const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
return this.decoder.decode(this.data.slice(0, respLen));
}
realpathSync(path: string, _encoding?: string): string {
const len = this.writeStr(path);
Atomics.store(this.ctrl, IDX_DATA_LEN, len);
this.call(FsOp.REALPATH);
this.checkError();
const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
return this.decoder.decode(this.data.slice(0, respLen));
}
fdatasyncSync(fd: number): void {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.FDATASYNC);
this.checkError();
}
fsyncSync(fd: number): void {
Atomics.store(this.ctrl, IDX_ARG1, fd);
Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
this.call(FsOp.FSYNC);
this.checkError();
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Shared protocol constants for the filesystem proxy.
* Imported by both main thread (host) and worker (client).
*/
export enum FsOp {
OPEN = 1,
CLOSE,
READ,
WRITE,
FSTAT,
STAT,
LSTAT,
FTRUNCATE,
FUTIMES,
UTIMES,
MKDIR,
READDIR,
RENAME,
RMDIR,
UNLINK,
LINK,
SYMLINK,
READLINK,
REALPATH,
FDATASYNC,
FSYNC,
}
// Control buffer Int32 indices
export const IDX_REQUEST_FLAG = 0;
export const IDX_RESPONSE_FLAG = 1;
export const IDX_OPCODE = 2;
export const IDX_ARG1 = 3;
export const IDX_ARG2 = 4;
export const IDX_ARG3 = 5;
export const IDX_ARG4 = 6;
export const IDX_RESULT = 7;
export const IDX_ERROR_CODE = 8;
export const IDX_DATA_LEN = 9;
export const CTRL_BUFFER_SIZE = 64 * 4; // 64 Int32s
export const DATA_BUFFER_SIZE = 4 * 1024 * 1024; // 4 MB
export const WAKE_BUFFER_SIZE = 4; // single Int32 shared across all channels
// Error code mapping (Node.js errno strings → numeric)
const ERROR_CODE_MAP: Record<string, number> = {
EPERM: 1, ENOENT: 2, ESRCH: 3, EINTR: 4, EIO: 5,
ENXIO: 6, EBADF: 9, EAGAIN: 11, ENOMEM: 12, EACCES: 13,
EEXIST: 17, ENODEV: 19, ENOTDIR: 20, EISDIR: 21,
EINVAL: 22, EMFILE: 24, ENOSPC: 28, EROFS: 30,
ENOTEMPTY: 39, ENOSYS: 38, ELOOP: 40,
};
const CODE_TO_STRING: Record<number, string> = {};
for (const [k, v] of Object.entries(ERROR_CODE_MAP)) {
CODE_TO_STRING[v] = k;
}
export function errorStringToCode(code: string): number {
return ERROR_CODE_MAP[code] ?? 255;
}
export function errorCodeToString(code: number): string {
return CODE_TO_STRING[code] ?? 'EUNKNOWN';
}
// Stat serialization: 128 bytes as Float64Array (16 doubles) + flags byte
export const STAT_SIZE = 15 * 8 + 8; // 128 bytes
export interface StatData {
dev: bigint; ino: bigint; mode: bigint; nlink: bigint;
uid: bigint; gid: bigint; rdev: bigint; size: bigint;
blksize: bigint; blocks: bigint;
atimeMs: bigint; mtimeMs: bigint; ctimeMs: bigint; birthtimeMs: bigint;
flags: number; // packed booleans
}
export function serializeStat(stat: any, buf: Uint8Array, offset: number): void {
const view = new DataView(buf.buffer, buf.byteOffset + offset, STAT_SIZE);
view.setFloat64(0, Number(stat.dev), true);
view.setFloat64(8, Number(stat.ino), true);
view.setFloat64(16, Number(stat.mode), true);
view.setFloat64(24, Number(stat.nlink), true);
view.setFloat64(32, Number(stat.uid), true);
view.setFloat64(40, Number(stat.gid), true);
view.setFloat64(48, Number(stat.rdev), true);
view.setFloat64(56, Number(stat.size), true);
view.setFloat64(64, Number(stat.blksize), true);
view.setFloat64(72, Number(stat.blocks), true);
view.setFloat64(80, Number(stat.atimeMs), true);
view.setFloat64(88, Number(stat.mtimeMs), true);
view.setFloat64(96, Number(stat.ctimeMs), true);
view.setFloat64(104, Number(stat.birthtimeMs), true);
let flags = 0;
if (stat.isFile()) flags |= 1;
if (stat.isDirectory()) flags |= 2;
if (typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink()) flags |= 4;
if (typeof stat.isCharacterDevice === 'function' && stat.isCharacterDevice()) flags |= 8;
if (typeof stat.isBlockDevice === 'function' && stat.isBlockDevice()) flags |= 16;
if (typeof stat.isSocket === 'function' && stat.isSocket()) flags |= 32;
if (typeof stat.isFIFO === 'function' && stat.isFIFO()) flags |= 64;
view.setFloat64(112, flags, true);
}
export function deserializeStat(buf: Uint8Array, offset: number): StatData {
const view = new DataView(buf.buffer, buf.byteOffset + offset, STAT_SIZE);
return {
dev: BigInt(view.getFloat64(0, true)),
ino: BigInt(view.getFloat64(8, true)),
mode: BigInt(view.getFloat64(16, true)),
nlink: BigInt(view.getFloat64(24, true)),
uid: BigInt(view.getFloat64(32, true)),
gid: BigInt(view.getFloat64(40, true)),
rdev: BigInt(view.getFloat64(48, true)),
size: BigInt(view.getFloat64(56, true)),
blksize: BigInt(view.getFloat64(64, true)),
blocks: BigInt(view.getFloat64(72, true)),
atimeMs: BigInt(view.getFloat64(80, true)),
mtimeMs: BigInt(view.getFloat64(88, true)),
ctimeMs: BigInt(view.getFloat64(96, true)),
birthtimeMs: BigInt(view.getFloat64(104, true)),
flags: view.getFloat64(112, true),
};
}
+414
View File
@@ -0,0 +1,414 @@
/**
* Dedicated FS Worker owns all OPFS state and serves filesystem
* requests from WASM workers (via SharedArrayBuffer + Atomics) and
* the main thread (via its own async channel).
*
* Lifecycle:
* 1. Main thread posts 'init' backend scans OPFS, posts 'ready'
* 2. Main thread posts 'channel' × N worker stores channels
* 3. Main thread posts 'start' worker enters Atomics loop
* 4. Main thread posts 'stop' worker exits loop, posts 'stopped'
*/
import { OpfsBackend } from './opfs-backend';
import {
FsOp,
IDX_REQUEST_FLAG, IDX_RESPONSE_FLAG, IDX_OPCODE,
IDX_ARG1, IDX_ARG2, IDX_ARG3, IDX_ARG4,
IDX_RESULT, IDX_ERROR_CODE, IDX_DATA_LEN,
DATA_BUFFER_SIZE, STAT_SIZE,
errorStringToCode, serializeStat,
} from './fs-proxy-protocol';
interface Channel {
ctrl: Int32Array;
data: Uint8Array;
}
let backend: OpfsBackend;
let channels: Channel[] = [];
let wake: Int32Array;
let running = false;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function trace(msg: string) {
self.postMessage({ type: 'trace', text: `[fs-worker] ${msg}\n` });
}
// --- Message handler (init / channel registration / lifecycle) ---
self.onmessage = async (e: MessageEvent) => {
switch (e.data.type) {
case 'init': {
wake = new Int32Array(e.data.wakeBuffer);
backend = new OpfsBackend();
await backend.init(e.data.radioKey);
trace(`init done, radioKey="${e.data.radioKey}", hasContent=${backend.hasContent}`);
self.postMessage({ type: 'ready', hasContent: backend.hasContent });
break;
}
case 'channel': {
channels.push({
ctrl: new Int32Array(e.data.ctrlBuffer),
data: new Uint8Array(e.data.dataBuffer),
});
trace(`channel registered (total: ${channels.length})`);
break;
}
case 'start': {
if (!running) {
running = true;
trace('mainLoop starting');
mainLoop();
} else {
trace('mainLoop already running, ignoring start');
}
break;
}
case 'stop': {
running = false;
if (wake) {
Atomics.store(wake, 0, 1);
Atomics.notify(wake, 0);
}
break;
}
// --- UI operations (used by main thread before/after sim runs) ---
case 'readTextFile': {
try {
const data = await backend.readFile(e.data.path);
const text = new TextDecoder().decode(data);
self.postMessage({ type: 'readTextFileDone', id: e.data.id, text });
} catch (err: any) {
self.postMessage({ type: 'readTextFileDone', id: e.data.id, error: err?.message ?? 'read error' });
}
break;
}
case 'writeFile': {
try {
await backend.writeFile(e.data.path, new Uint8Array(e.data.data));
self.postMessage({ type: 'writeFileDone', id: e.data.id });
} catch (err: any) {
self.postMessage({ type: 'writeFileDone', id: e.data.id, error: err?.message ?? 'write error' });
}
break;
}
case 'wipe': {
await backend.wipe();
self.postMessage({ type: 'wiped', id: e.data.id });
break;
}
case 'listFiles': {
const files = backend.listFiles(e.data.basePath ?? '/');
self.postMessage({ type: 'listFilesDone', id: e.data.id, files });
break;
}
}
};
// --- Main loop ---
async function mainLoop() {
trace(`mainLoop entered, channels=${channels.length}`);
while (running) {
const asyncWork = runSyncBatch();
if (asyncWork) {
await asyncWork();
} else {
// Yield to let the event loop process messages (stop, etc.)
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
}
trace('mainLoop exiting, closing handles');
backend.closeAll();
self.postMessage({ type: 'stopped' });
}
/**
* Process requests synchronously until an async operation is needed
* or we've been idle long enough to yield for the event loop.
*/
function runSyncBatch(): (() => Promise<void>) | null {
let idleCount = 0;
while (running) {
let didWork = false;
for (const ch of channels) {
if (Atomics.load(ch.ctrl, IDX_REQUEST_FLAG) !== 0) {
const asyncNeeded = dispatch(ch);
if (asyncNeeded) return asyncNeeded;
didWork = true;
}
}
if (didWork) {
idleCount = 0;
} else {
idleCount++;
if (idleCount >= 2) return null; // yield after ~10ms idle
Atomics.wait(wake, 0, 0, 5);
Atomics.store(wake, 0, 0);
}
}
return null;
}
// --- Request dispatch ---
function dispatch(ch: Channel): (() => Promise<void>) | null {
const opcode = Atomics.load(ch.ctrl, IDX_OPCODE) as FsOp;
const a1 = Atomics.load(ch.ctrl, IDX_ARG1);
const a2 = Atomics.load(ch.ctrl, IDX_ARG2);
const a3 = Atomics.load(ch.ctrl, IDX_ARG3);
const a4 = Atomics.load(ch.ctrl, IDX_ARG4);
const dataLen = Atomics.load(ch.ctrl, IDX_DATA_LEN);
try {
switch (opcode) {
// ============ Async operations ============
case FsOp.OPEN: {
let path: string;
let flags: number | string;
if (a1 === -1) {
// String flags: [pathLen:u32][path][flagsStr]
const pathLen = new DataView(ch.data.buffer, ch.data.byteOffset, 4).getUint32(0, true);
path = decoder.decode(ch.data.slice(4, 4 + pathLen));
flags = decoder.decode(ch.data.slice(4 + pathLen, dataLen));
} else {
path = decoder.decode(ch.data.slice(0, dataLen));
flags = a1;
}
const mode = a2;
return async () => {
try {
const fd = await backend.open(path, flags, mode);
Atomics.store(ch.ctrl, IDX_RESULT, fd);
respond(ch);
} catch (e: any) { respondError(ch, e); }
};
}
case FsOp.MKDIR: {
const path = decoder.decode(ch.data.slice(0, dataLen));
return async () => {
try {
await backend.mkdir(path);
respond(ch);
} catch (e: any) { respondError(ch, e); }
};
}
case FsOp.RENAME: {
const dv = new DataView(ch.data.buffer, ch.data.byteOffset);
const oldLen = dv.getUint32(0, true);
const oldPath = decoder.decode(ch.data.slice(4, 4 + oldLen));
const newPath = decoder.decode(ch.data.slice(4 + oldLen, dataLen));
return async () => {
try {
await backend.rename(oldPath, newPath);
respond(ch);
} catch (e: any) { respondError(ch, e); }
};
}
case FsOp.RMDIR: {
const path = decoder.decode(ch.data.slice(0, dataLen));
return async () => {
try {
await backend.rmdir(path);
respond(ch);
} catch (e: any) { respondError(ch, e); }
};
}
case FsOp.UNLINK: {
const path = decoder.decode(ch.data.slice(0, dataLen));
return async () => {
try {
await backend.unlink(path);
respond(ch);
} catch (e: any) { respondError(ch, e); }
};
}
// ============ Sync operations ============
case FsOp.CLOSE: {
backend.close(a1);
respond(ch);
return null;
}
case FsOp.READ: {
const len = Math.min(a2, DATA_BUFFER_SIZE);
const pos = a4 === -1 ? null : (a4 * 0x100000000 + (a3 >>> 0));
const { data, bytesRead } = backend.read(a1, len, pos);
ch.data.set(data.subarray(0, bytesRead), 0);
Atomics.store(ch.ctrl, IDX_RESULT, bytesRead);
Atomics.store(ch.ctrl, IDX_DATA_LEN, bytesRead);
respond(ch);
return null;
}
case FsOp.WRITE: {
const pos = a3 === -1 ? null : (a3 * 0x100000000 + (a2 >>> 0));
const writeData = ch.data.slice(0, dataLen);
const written = backend.write(a1, writeData, pos);
Atomics.store(ch.ctrl, IDX_RESULT, written);
respond(ch);
return null;
}
case FsOp.FSTAT: {
const stat = backend.fstat(a1);
serializeStat(stat, ch.data, 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
respond(ch);
return null;
}
case FsOp.STAT: {
const path = decoder.decode(ch.data.slice(0, dataLen));
const stat = backend.stat(path);
serializeStat(stat, ch.data, 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
respond(ch);
return null;
}
case FsOp.LSTAT: {
const path = decoder.decode(ch.data.slice(0, dataLen));
const stat = backend.lstat(path);
serializeStat(stat, ch.data, 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
respond(ch);
return null;
}
case FsOp.FTRUNCATE: {
backend.ftruncate(a1, a2);
respond(ch);
return null;
}
case FsOp.FUTIMES: {
const tv = new DataView(ch.data.buffer, ch.data.byteOffset, 16);
backend.futimes(a1, tv.getFloat64(0, true), tv.getFloat64(8, true));
respond(ch);
return null;
}
case FsOp.UTIMES: {
const path = decoder.decode(ch.data.slice(0, a1));
const tv = new DataView(ch.data.buffer, ch.data.byteOffset + a1, 16);
backend.utimes(path, tv.getFloat64(0, true), tv.getFloat64(8, true));
respond(ch);
return null;
}
case FsOp.READDIR: {
const path = decoder.decode(ch.data.slice(0, dataLen));
if (a1) {
// withFileTypes
const entries = backend.readdir(path, true);
let off = 0;
const dv = new DataView(ch.data.buffer, ch.data.byteOffset);
dv.setUint32(off, entries.length, true); off += 4;
for (const ent of entries) {
let type = 0;
if (ent.isFile()) type = 1;
else if (ent.isDirectory()) type = 2;
ch.data[off++] = type;
const nameBytes = encoder.encode(ent.name);
dv.setUint32(off, nameBytes.length, true); off += 4;
ch.data.set(nameBytes, off); off += nameBytes.length;
}
Atomics.store(ch.ctrl, IDX_DATA_LEN, off);
} else {
const entries = backend.readdir(path, false) as string[];
const json = JSON.stringify(entries);
const bytes = encoder.encode(json);
ch.data.set(bytes, 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, bytes.length);
}
respond(ch);
return null;
}
case FsOp.LINK:
throw Object.assign(new Error('ENOSYS: hardlinks not supported'), { code: 'ENOSYS' });
case FsOp.SYMLINK:
throw Object.assign(new Error('ENOSYS: symlinks not supported'), { code: 'ENOSYS' });
case FsOp.READLINK: {
// OPFS has no symlinks — return EINVAL (not a symlink), which is
// what Node.js returns for non-symlink paths. The WASI library
// calls readlinkSync during path resolution and expects EINVAL to
// mean "not a symlink, continue resolving."
const rlPath = decoder.decode(ch.data.slice(0, dataLen));
throw Object.assign(new Error(`EINVAL: ${rlPath}`), { code: 'EINVAL' });
}
case FsOp.REALPATH: {
const path = decoder.decode(ch.data.slice(0, dataLen));
const resolved = backend.realpath(path);
const bytes = encoder.encode(resolved);
ch.data.set(bytes, 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, bytes.length);
respond(ch);
return null;
}
case FsOp.FDATASYNC: {
backend.fdatasync(a1);
respond(ch);
return null;
}
case FsOp.FSYNC: {
backend.fsync(a1);
respond(ch);
return null;
}
default:
throw Object.assign(new Error(`unsupported fs op: ${opcode}`), { code: 'ENOSYS' });
}
} catch (e: any) {
respondError(ch, e);
return null;
}
}
// --- Response helpers ---
function respond(ch: Channel): void {
Atomics.store(ch.ctrl, IDX_ERROR_CODE, 0);
Atomics.store(ch.ctrl, IDX_REQUEST_FLAG, 0);
Atomics.store(ch.ctrl, IDX_RESPONSE_FLAG, 1);
Atomics.notify(ch.ctrl, IDX_RESPONSE_FLAG);
}
function respondError(ch: Channel, e: any): void {
// Don't log EINVAL from readlink (expected, very frequent) or ENOENT (normal)
if (e?.code !== 'EINVAL' && e?.code !== 'ENOENT') {
trace(`ERROR ${e?.code ?? '?'}: ${e?.message ?? e}`);
}
const code = e?.code ? errorStringToCode(e.code) : 255;
Atomics.store(ch.ctrl, IDX_ERROR_CODE, code);
const msg = encoder.encode(e?.message ?? 'unknown error');
const len = Math.min(msg.length, DATA_BUFFER_SIZE);
ch.data.set(msg.subarray(0, len), 0);
Atomics.store(ch.ctrl, IDX_DATA_LEN, len);
Atomics.store(ch.ctrl, IDX_REQUEST_FLAG, 0);
Atomics.store(ch.ctrl, IDX_RESPONSE_FLAG, 1);
Atomics.notify(ch.ctrl, IDX_RESPONSE_FLAG);
}
+211
View File
@@ -0,0 +1,211 @@
// ---- Shader sources ----
const VERT_SRC = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG_SRC = `
precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uLcdSize; // LCD pixel dimensions (e.g. 212, 64)
uniform int uDotMatrix; // 1 = dot-matrix effect, 0 = plain
uniform vec3 uBacklight; // backlight color (normalised)
uniform float uGridAlpha; // grid line blend strength
void main() {
// Nearest-neighbour sample of the LCD texture
vec2 texel = (floor(vUv * uLcdSize) + 0.5) / uLcdSize;
vec4 pixel = texture2D(uTex, texel);
if (uDotMatrix == 0) {
gl_FragColor = pixel;
return;
}
// Position within the LCD pixel cell (0..1)
vec2 cell = fract(vUv * uLcdSize);
// Distance from cell edge (0 at edge, 0.5 at centre)
vec2 d = 0.5 - abs(cell - 0.5);
// Grid line: smoothstep near the edge
float gridH = 1.0 - smoothstep(0.0, 0.25, d.x);
float gridV = 1.0 - smoothstep(0.0, 0.25, d.y);
float grid = max(gridH, gridV);
// Blend pixel colour toward backlight at grid lines
vec3 color = mix(pixel.rgb, uBacklight, grid * uGridAlpha);
gl_FragColor = vec4(color, 1.0);
}`;
// Backlight color (matches Companion: rgb(47, 123, 227))
const BG_R = 47 / 255, BG_G = 123 / 255, BG_B = 227 / 255;
// ---- Framebuffer decode helpers (CPU → RGBA texture data) ----
/** Decode RGB565 framebuffer into RGBA. */
function decodeRgb565(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
for (let i = 0; i < w * h; i++) {
const rgb565 = view.getUint16(i * 2, true);
const j = i * 4;
out[j] = ((rgb565 >> 11) & 0x1f) << 3;
out[j + 1] = ((rgb565 >> 5) & 0x3f) << 2;
out[j + 2] = (rgb565 & 0x1f) << 3;
out[j + 3] = 255;
}
}
/** Decode 4-bit grayscale framebuffer into RGBA with backlight tint. */
function decode4bit(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const byteIdx = (y >> 1) * w + x;
const nibble = (y & 1) ? (data[byteIdx] >> 4) & 0x0f : data[byteIdx] & 0x0f;
const t = nibble / 15;
const j = (y * w + x) * 4;
out[j] = (BG_R * 255 * (1 - t)) | 0;
out[j + 1] = (BG_G * 255 * (1 - t)) | 0;
out[j + 2] = (BG_B * 255 * (1 - t)) | 0;
out[j + 3] = 255;
}
}
}
/** Decode 1-bit monochrome framebuffer into RGBA with backlight tint. */
function decode1bit(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const byteIdx = (y >> 3) * w + x;
const bit = (data[byteIdx] >> (y & 7)) & 1;
const j = (y * w + x) * 4;
if (bit) {
out[j] = 0; out[j + 1] = 0; out[j + 2] = 0;
} else {
out[j] = BG_R * 255; out[j + 1] = BG_G * 255; out[j + 2] = BG_B * 255;
}
out[j + 3] = 255;
}
}
}
// ---- WebGL LCD Renderer ----
export class LcdRenderer {
private gl: WebGLRenderingContext;
private texture: WebGLTexture;
private texBuf: Uint8Array | null = null;
private texW = 0;
private texH = 0;
// Uniform locations
private uTex: WebGLUniformLocation;
private uLcdSize: WebGLUniformLocation;
private uDotMatrix: WebGLUniformLocation;
private uBacklight: WebGLUniformLocation;
private uGridAlpha: WebGLUniformLocation;
constructor(canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false })!;
if (!gl) throw new Error('WebGL not available');
this.gl = gl;
// Compile shaders
const vs = this.compileShader(gl.VERTEX_SHADER, VERT_SRC);
const fs = this.compileShader(gl.FRAGMENT_SHADER, FRAG_SRC);
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('Shader link: ' + gl.getProgramInfoLog(prog));
}
gl.useProgram(prog);
// Get uniform locations
this.uTex = gl.getUniformLocation(prog, 'uTex')!;
this.uLcdSize = gl.getUniformLocation(prog, 'uLcdSize')!;
this.uDotMatrix = gl.getUniformLocation(prog, 'uDotMatrix')!;
this.uBacklight = gl.getUniformLocation(prog, 'uBacklight')!;
this.uGridAlpha = gl.getUniformLocation(prog, 'uGridAlpha')!;
// Full-screen quad
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1,
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// Create texture
this.texture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
/** Resize the canvas and viewport. Call when LCD dimensions are known. */
resize(canvasWidth: number, canvasHeight: number): void {
const canvas = this.gl.canvas as HTMLCanvasElement;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
this.gl.viewport(0, 0, canvasWidth, canvasHeight);
}
/** Render an LCD frame. */
render(data: Uint8Array, width: number, height: number, depth: number): void {
const gl = this.gl;
// (Re)allocate texture buffer if LCD size changed
if (width !== this.texW || height !== this.texH) {
this.texW = width;
this.texH = height;
this.texBuf = new Uint8Array(width * height * 4);
}
// Decode framebuffer into RGBA
const buf = this.texBuf!;
if (depth === 16) {
decodeRgb565(data, width, height, buf);
} else if (depth === 4) {
decode4bit(data, width, height, buf);
} else {
decode1bit(data, width, height, buf);
}
// Upload texture
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, buf);
// Set uniforms
gl.uniform1i(this.uTex, 0);
gl.uniform2f(this.uLcdSize, width, height);
gl.uniform1i(this.uDotMatrix, depth < 16 ? 1 : 0);
gl.uniform3f(this.uBacklight, BG_R, BG_G, BG_B);
gl.uniform1f(this.uGridAlpha, 0.5);
// Draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
private compileShader(type: number, src: string): WebGLShader {
const gl = this.gl;
const shader = gl.createShader(type)!;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error('Shader compile: ' + gl.getShaderInfoLog(shader));
}
return shader;
}
}
+677
View File
@@ -0,0 +1,677 @@
/**
* OPFS-backed filesystem for the FS Worker.
*
* Maintains a lightweight in-memory directory index (metadata only) for
* synchronous path resolution and readdir. File I/O uses OPFS
* SyncAccessHandles for synchronous read/write without any memfs layer.
*
* Async operations (open, mkdir, unlink, rmdir, rename) need OPFS directory
* mutations which are inherently async. The FS Worker handles these by
* briefly breaking out of its synchronous Atomics loop.
*/
// --- Directory index node types ---
interface FileNode {
name: string;
kind: 'file';
handle: FileSystemFileHandle;
size: number;
mtime: number;
}
interface DirNode {
name: string;
kind: 'directory';
handle: FileSystemDirectoryHandle;
children: Map<string, FileNode | DirNode>;
mtime: number;
}
type FsNode = FileNode | DirNode;
// --- Open file tracking ---
interface OpenFlags {
read: boolean;
write: boolean;
append: boolean;
create: boolean;
truncate: boolean;
exclusive: boolean;
}
/** Shared SyncAccessHandle with refcounting for multiple fds on the same file. */
interface SharedHandle {
syncHandle: FileSystemSyncAccessHandle;
node: FileNode;
refCount: number;
}
interface OpenFd {
/** Shared handle (null for directory fds). */
shared: SharedHandle | null;
/** The filesystem node (file or directory). */
node: FsNode;
/** Current read/write position. */
position: number;
flags: OpenFlags;
}
// --- Flag parsing ---
function parseOpenFlags(flags: number | string): OpenFlags {
if (typeof flags === 'string') {
const f = flags;
const plus = f.includes('+');
return {
read: f[0] === 'r' || plus,
write: f[0] === 'w' || f[0] === 'a' || plus,
append: f[0] === 'a',
create: f[0] === 'w' || f[0] === 'a',
truncate: f[0] === 'w' && !plus,
exclusive: f.includes('x'),
};
}
// Numeric flags (Node.js / Linux O_* values)
const access = flags & 3;
return {
read: access === 0 || access === 2, // O_RDONLY=0, O_RDWR=2
write: access === 1 || access === 2, // O_WRONLY=1, O_RDWR=2
append: !!(flags & 0o2000), // O_APPEND
create: !!(flags & 0o100), // O_CREAT
truncate: !!(flags & 0o1000), // O_TRUNC
exclusive: !!(flags & 0o200), // O_EXCL
};
}
// --- Stat result (compatible with serializeStat in fs-proxy-protocol) ---
class StatResult {
dev = 0;
ino: number;
mode: number;
nlink = 1;
uid = 0;
gid = 0;
rdev = 0;
size: number;
blksize = 4096;
blocks: number;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
private _isFile: boolean;
constructor(isFile: boolean, size: number, mtime: number, ino: number) {
this._isFile = isFile;
this.ino = ino;
this.mode = isFile ? 0o100644 : 0o040755;
this.size = size;
this.blocks = Math.ceil(size / 512);
this.atimeMs = mtime;
this.mtimeMs = mtime;
this.ctimeMs = mtime;
this.birthtimeMs = mtime;
}
isFile() { return this._isFile; }
isDirectory() { return !this._isFile; }
isSymbolicLink() { return false; }
isCharacterDevice() { return false; }
isBlockDevice() { return false; }
isSocket() { return false; }
isFIFO() { return false; }
}
// --- Backend ---
export class OpfsBackend {
private root: DirNode | null = null;
private fdTable = new Map<number, OpenFd>();
/** Path → shared SyncAccessHandle for refcounting across multiple fds. */
private openHandles = new Map<string, SharedHandle>();
private nextFd = 3; // 0-2 reserved for stdin/stdout/stderr
private nextIno = 1;
/**
* Initialize: get the OPFS directory for this radio and build the
* in-memory directory index by scanning the OPFS tree.
*/
async init(radioKey: string): Promise<void> {
const storageRoot = await navigator.storage.getDirectory();
const edgetx = await storageRoot.getDirectoryHandle('edgetx-web', { create: true });
const radioDir = await edgetx.getDirectoryHandle(radioKey, { create: true });
this.root = {
name: '',
kind: 'directory',
handle: radioDir,
children: new Map(),
mtime: Date.now(),
};
await this.scanDir(this.root);
}
/** Whether the OPFS tree has any content for this radio. */
get hasContent(): boolean {
return this.root !== null && this.root.children.size > 0;
}
/** Close all open handles (for shutdown). */
closeAll(): void {
for (const [fd] of this.fdTable) {
try { this.close(fd); } catch { /* best effort */ }
}
}
// --- Async scan ---
private async scanDir(dir: DirNode): Promise<void> {
for await (const [name, handle] of (dir.handle as any).entries()) {
if (handle.kind === 'directory') {
const child: DirNode = {
name,
kind: 'directory',
handle: handle as FileSystemDirectoryHandle,
children: new Map(),
mtime: Date.now(),
};
dir.children.set(name, child);
await this.scanDir(child);
} else {
const file = await (handle as FileSystemFileHandle).getFile();
const child: FileNode = {
name,
kind: 'file',
handle: handle as FileSystemFileHandle,
size: file.size,
mtime: file.lastModified,
};
dir.children.set(name, child);
}
}
}
// --- Synchronous path resolution ---
private normalizePath(path: string): string {
return '/' + path.split('/').filter(Boolean).join('/');
}
private resolve(path: string): FsNode {
if (!this.root) throw this.err('ENOENT', path);
const segments = path.split('/').filter(Boolean);
let node: FsNode = this.root;
for (const seg of segments) {
if (seg === '.') continue;
if (seg === '..') throw this.err('EINVAL', path);
if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
const child = node.children.get(seg);
if (!child) throw this.err('ENOENT', path);
node = child;
}
return node;
}
private resolveParent(path: string): { parent: DirNode; name: string } {
if (!this.root) throw this.err('ENOENT', path);
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) throw this.err('EINVAL', path);
const name = segments.pop()!;
let node: FsNode = this.root;
for (const seg of segments) {
if (seg === '.') continue;
if (seg === '..') throw this.err('EINVAL', path);
if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
const child = node.children.get(seg);
if (!child) throw this.err('ENOENT', path);
node = child;
}
if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
return { parent: node, name };
}
// --- File operations ---
/** Open a file or directory. ASYNC (needs createSyncAccessHandle). */
async open(path: string, flags: number | string, _mode: number): Promise<number> {
const parsed = parseOpenFlags(flags);
const normPath = this.normalizePath(path);
let node: FsNode;
let created = false;
try {
node = this.resolve(path);
} catch (e: any) {
if (e.code === 'ENOENT' && parsed.create) {
node = await this.createFile(path);
created = true;
} else {
throw e;
}
}
if (!created && parsed.create && parsed.exclusive) {
throw this.err('EEXIST', path);
}
// Directory fd: no SyncAccessHandle, just a trackable fd for WASI
if (node.kind === 'directory') {
const fd = this.nextFd++;
this.fdTable.set(fd, { shared: null, node, position: 0, flags: parsed });
return fd;
}
// File fd: reuse existing SyncAccessHandle if already open (refcounting)
let shared = this.openHandles.get(normPath);
if (shared) {
shared.refCount++;
} else {
const syncHandle = await (node as FileNode).handle.createSyncAccessHandle();
shared = { syncHandle, node: node as FileNode, refCount: 1 };
this.openHandles.set(normPath, shared);
}
if (parsed.truncate) {
shared.syncHandle.truncate(0);
shared.node.size = 0;
shared.node.mtime = Date.now();
}
const fd = this.nextFd++;
this.fdTable.set(fd, {
shared,
node,
position: parsed.append ? shared.syncHandle.getSize() : 0,
flags: parsed,
});
return fd;
}
/** Close a file descriptor. Sync. */
close(fd: number): void {
const openFd = this.fdTable.get(fd);
if (!openFd) throw this.err('EBADF', `fd ${fd}`);
if (openFd.shared) {
openFd.shared.refCount--;
if (openFd.shared.refCount <= 0) {
openFd.shared.syncHandle.flush();
openFd.shared.node.size = openFd.shared.syncHandle.getSize();
openFd.shared.syncHandle.close();
// Remove from openHandles
for (const [path, h] of this.openHandles) {
if (h === openFd.shared) {
this.openHandles.delete(path);
break;
}
}
}
}
this.fdTable.delete(fd);
}
/** Read from an open file. Sync. */
read(fd: number, length: number, position: number | null): { data: Uint8Array; bytesRead: number } {
const openFd = this.getFd(fd, 'file');
const pos = position ?? openFd.position;
const buf = new Uint8Array(length);
const bytesRead = openFd.shared!.syncHandle.read(buf, { at: pos });
if (position === null) {
openFd.position += bytesRead;
}
return { data: buf, bytesRead };
}
/** Write to an open file. Sync. */
write(fd: number, data: Uint8Array, position: number | null): number {
const openFd = this.getFd(fd, 'file');
const sh = openFd.shared!;
let pos: number;
if (openFd.flags.append) {
pos = sh.syncHandle.getSize();
} else {
pos = position ?? openFd.position;
}
const written = sh.syncHandle.write(data, { at: pos });
if (position === null || openFd.flags.append) {
openFd.position = pos + written;
}
sh.node.size = sh.syncHandle.getSize();
sh.node.mtime = Date.now();
return written;
}
/** Get file status by fd. Sync. */
fstat(fd: number): StatResult {
const openFd = this.fdTable.get(fd);
if (!openFd) throw this.err('EBADF', `fd ${fd}`);
if (openFd.node.kind === 'directory') {
return new StatResult(false, 0, openFd.node.mtime, this.nextIno++);
}
const size = openFd.shared ? openFd.shared.syncHandle.getSize() : (openFd.node as FileNode).size;
return new StatResult(true, size, openFd.node.mtime, this.nextIno++);
}
/** Get file status by path. Sync. */
stat(path: string): StatResult {
const node = this.resolve(path);
const size = node.kind === 'file' ? node.size : 0;
return new StatResult(node.kind === 'file', size, node.mtime, this.nextIno++);
}
/** Same as stat (no symlink support). Sync. */
lstat(path: string): StatResult {
return this.stat(path);
}
/** Truncate an open file. Sync. */
ftruncate(fd: number, len: number): void {
const openFd = this.getFd(fd, 'file');
openFd.shared!.syncHandle.truncate(len);
openFd.shared!.node.size = len;
openFd.shared!.node.mtime = Date.now();
}
/** Update timestamps by fd. Sync (in-memory only). */
futimes(fd: number, _atime: number, mtime: number): void {
const openFd = this.fdTable.get(fd);
if (!openFd) throw this.err('EBADF', `fd ${fd}`);
openFd.node.mtime = mtime;
}
/** Update timestamps by path. Sync (in-memory only). */
utimes(path: string, _atime: number, mtime: number): void {
const node = this.resolve(path);
node.mtime = mtime;
}
/** Create a directory. ASYNC. */
async mkdir(path: string): Promise<void> {
const { parent, name } = this.resolveParent(path);
if (parent.children.has(name)) {
throw this.err('EEXIST', path);
}
const handle = await parent.handle.getDirectoryHandle(name, { create: true });
parent.children.set(name, {
name,
kind: 'directory',
handle,
children: new Map(),
mtime: Date.now(),
});
}
/** List directory contents. Sync (reads from in-memory index). */
readdir(path: string, withFileTypes: boolean): any[] {
const node = this.resolve(path);
if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
if (withFileTypes) {
return Array.from(node.children.values()).map(child => ({
name: child.name,
isFile: () => child.kind === 'file',
isDirectory: () => child.kind === 'directory',
isSymbolicLink: () => false,
isCharacterDevice: () => false,
isBlockDevice: () => false,
isSocket: () => false,
isFIFO: () => false,
}));
}
return Array.from(node.children.keys());
}
/** Rename / move a file or directory. ASYNC. */
async rename(oldPath: string, newPath: string): Promise<void> {
const oldNode = this.resolve(oldPath);
const { parent: oldParent, name: oldName } = this.resolveParent(oldPath);
const { parent: newParent, name: newName } = this.resolveParent(newPath);
// Reject rename while file is open (SyncAccessHandle holds a lock)
const normOld = this.normalizePath(oldPath);
if (this.openHandles.has(normOld)) {
throw this.err('EBUSY', oldPath);
}
// Remove existing target if present
if (newParent.children.has(newName)) {
const existing = newParent.children.get(newName)!;
if (existing.kind === 'directory' && existing.children.size > 0) {
throw this.err('ENOTEMPTY', newPath);
}
await newParent.handle.removeEntry(newName, { recursive: false });
newParent.children.delete(newName);
}
// Use move() API if available (Chrome 110+, Firefox 129+)
if (typeof (oldNode.handle as any).move === 'function') {
await (oldNode.handle as any).move(newParent.handle, newName);
oldParent.children.delete(oldName);
oldNode.name = newName;
newParent.children.set(newName, oldNode);
} else if (oldNode.kind === 'file') {
// Fallback: read content, create new file, delete old
const file = await oldNode.handle.getFile();
const content = new Uint8Array(await file.arrayBuffer());
const newHandle = await newParent.handle.getFileHandle(newName, { create: true });
const syncHandle = await newHandle.createSyncAccessHandle();
syncHandle.write(content);
syncHandle.flush();
const newSize = syncHandle.getSize();
syncHandle.close();
await oldParent.handle.removeEntry(oldName);
oldParent.children.delete(oldName);
newParent.children.set(newName, {
name: newName,
kind: 'file',
handle: newHandle,
size: newSize,
mtime: Date.now(),
});
} else {
throw this.err('ENOSYS', 'directory rename requires move() API');
}
}
/** Remove an empty directory. ASYNC. */
async rmdir(path: string): Promise<void> {
const { parent, name } = this.resolveParent(path);
const child = parent.children.get(name);
if (!child) throw this.err('ENOENT', path);
if (child.kind !== 'directory') throw this.err('ENOTDIR', path);
if (child.children.size > 0) throw this.err('ENOTEMPTY', path);
await parent.handle.removeEntry(name);
parent.children.delete(name);
}
/** Delete a file. ASYNC. */
async unlink(path: string): Promise<void> {
const { parent, name } = this.resolveParent(path);
const child = parent.children.get(name);
if (!child) throw this.err('ENOENT', path);
if (child.kind === 'directory') throw this.err('EISDIR', path);
// Force-close any open SyncAccessHandle for this file
const normPath = this.normalizePath(path);
const existing = this.openHandles.get(normPath);
if (existing) {
existing.syncHandle.close();
this.openHandles.delete(normPath);
}
await parent.handle.removeEntry(name);
parent.children.delete(name);
}
// Unsupported (OPFS has no symlinks / hardlinks)
link(): never { throw this.err('ENOSYS', 'hardlinks not supported'); }
symlink(): never { throw this.err('ENOSYS', 'symlinks not supported'); }
readlink(): never { throw this.err('ENOSYS', 'symlinks not supported'); }
/** Normalize path and verify it exists. Sync. */
realpath(path: string): string {
this.resolve(path);
return this.normalizePath(path);
}
/** Flush file data to OPFS. Sync. */
fdatasync(fd: number): void {
const openFd = this.getFd(fd, 'file');
openFd.shared!.syncHandle.flush();
}
/** Flush file data + metadata. Sync. */
fsync(fd: number): void {
this.fdatasync(fd);
}
// --- Bulk operations (for main-thread UI) ---
/** Recursively delete all content under the OPFS root. ASYNC. */
async wipe(): Promise<void> {
if (!this.root) return;
this.closeAll();
await this.clearDir(this.root);
}
/** Write a file into the OPFS tree, creating parent directories as needed. ASYNC. */
async writeFile(path: string, data: Uint8Array): Promise<void> {
// Ensure parent directories exist
const segments = path.split('/').filter(Boolean);
let dir = this.root!;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
let child = dir.children.get(seg);
if (!child) {
const handle = await dir.handle.getDirectoryHandle(seg, { create: true });
child = { name: seg, kind: 'directory', handle, children: new Map(), mtime: Date.now() };
dir.children.set(seg, child);
}
if (child.kind !== 'directory') throw this.err('ENOTDIR', path);
dir = child;
}
const fileName = segments[segments.length - 1];
let fileNode = dir.children.get(fileName);
let fileHandle: FileSystemFileHandle;
if (fileNode && fileNode.kind === 'file') {
fileHandle = fileNode.handle;
} else {
fileHandle = await dir.handle.getFileHandle(fileName, { create: true });
}
const syncHandle = await fileHandle.createSyncAccessHandle();
syncHandle.truncate(0);
syncHandle.write(data);
syncHandle.flush();
const size = syncHandle.getSize();
syncHandle.close();
dir.children.set(fileName, {
name: fileName,
kind: 'file',
handle: fileHandle,
size,
mtime: Date.now(),
});
}
/** Read a file's full contents. ASYNC. */
async readFile(path: string): Promise<Uint8Array> {
const node = this.resolve(path);
if (node.kind !== 'file') throw this.err('EISDIR', path);
const file = await node.handle.getFile();
return new Uint8Array(await file.arrayBuffer());
}
/** Recursively list all file paths under a directory. Sync. */
listFiles(basePath = '/'): string[] {
const result: string[] = [];
const node = this.resolve(basePath);
if (node.kind === 'directory') {
this.listFilesRec(basePath, node, result);
}
return result;
}
// --- Private helpers ---
private async createFile(path: string): Promise<FileNode> {
const { parent, name } = this.resolveParent(path);
const handle = await parent.handle.getFileHandle(name, { create: true });
const node: FileNode = {
name,
kind: 'file',
handle,
size: 0,
mtime: Date.now(),
};
parent.children.set(name, node);
return node;
}
private async clearDir(dir: DirNode): Promise<void> {
const names = Array.from(dir.children.keys());
for (const name of names) {
await dir.handle.removeEntry(name, { recursive: true });
}
dir.children.clear();
}
private listFilesRec(basePath: string, dir: DirNode, result: string[]): void {
for (const child of dir.children.values()) {
const childPath = basePath === '/' ? `/${child.name}` : `${basePath}/${child.name}`;
if (child.kind === 'directory') {
this.listFilesRec(childPath, child, result);
} else {
result.push(childPath);
}
}
}
/** Get an open fd, throwing EBADF or EISDIR as appropriate. */
private getFd(fd: number, expect: 'file'): OpenFd & { shared: SharedHandle };
private getFd(fd: number, expect?: string): OpenFd;
private getFd(fd: number, expect?: string): OpenFd {
const openFd = this.fdTable.get(fd);
if (!openFd) throw this.err('EBADF', `fd ${fd}`);
if (expect === 'file' && !openFd.shared) throw this.err('EISDIR', `fd ${fd}`);
return openFd;
}
private err(code: string, detail: string): Error {
const e = new Error(`${code}: ${detail}`);
(e as any).code = code;
return e;
}
}
+445
View File
@@ -0,0 +1,445 @@
import { WASIThreads } from '@emnapi/wasi-threads';
import type { WASIInstance } from '@emnapi/wasi-threads';
import { WASI } from '@tybys/wasm-util';
import { CTRL_BUFFER_SIZE, DATA_BUFFER_SIZE, WAKE_BUFFER_SIZE } from './fs-proxy-protocol';
/**
* Minimal fs stub for the main thread's WASI instance.
* The main thread only needs enough to satisfy preopen setup all real
* filesystem I/O happens in worker threads via FsProxyClient FS Worker.
*/
const stubFs = (() => {
let nextFd = 3;
const dirStat = () => ({
dev: 0n, ino: 0n, mode: 0o040755n, nlink: 1n,
uid: 0n, gid: 0n, rdev: 0n, size: 0n,
blksize: 4096n, blocks: 0n,
atimeMs: 0n, mtimeMs: 0n, ctimeMs: 0n, birthtimeMs: 0n,
atimeNs: 0n, mtimeNs: 0n, ctimeNs: 0n, birthtimeNs: 0n,
atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false,
isCharacterDevice: () => false, isBlockDevice: () => false,
isSocket: () => false, isFIFO: () => false,
});
return {
openSync() { return nextFd++; },
closeSync() {},
fstatSync() { return dirStat(); },
statSync() { return dirStat(); },
lstatSync() { return dirStat(); },
readdirSync() { return []; },
readSync() { return 0; },
writeSync(_fd: number, _b: any, _o: number, len: number) { return len; },
mkdirSync() {}, renameSync() {}, rmdirSync() {}, unlinkSync() {},
linkSync() {}, symlinkSync() {},
readlinkSync(p: string) { return p; },
realpathSync(p: string) { return p; },
ftruncateSync() {}, futimesSync() {}, utimesSync() {},
fdatasyncSync() {}, fsyncSync() {},
};
})();
export interface SimulatorExports {
memory: WebAssembly.Memory;
malloc: (size: number) => number;
free: (ptr: number) => void;
simuInit: () => void;
simuStart: (tests: number) => void;
simuStop: () => void;
simuIsRunning: () => number;
simuFatfsSetPaths: (sdPath: number, settingsPath: number) => void;
simuCreateDefaults: () => void;
simuSetKey: (key: number, state: number) => void;
simuSetTrim: (trim: number, state: number) => void;
simuSetSwitch: (swtch: number, state: number) => void;
simuSetTrimValue: (idx: number, value: number) => void;
simuTouchDown: (x: number, y: number) => void;
simuTouchUp: () => void;
simuRotaryEncoderEvent: (steps: number) => void;
simuLcdChanged: () => number;
simuLcdCopy: (buf: number, maxLen: number) => number;
simuLcdGetWidth: () => number;
simuLcdGetHeight: () => number;
simuLcdGetDepth: () => number;
simuLcdFlushed: () => void;
simuGetCapability: (cap: number) => number;
simuAudioGetVolume: () => number;
simuIsChannelUsed: (channel: number) => number;
simuGetChannelsUsed: () => number;
simuGetMixCount: () => number;
simuGetNumCustomSwitches: () => number;
simuGetCustomSwitchState: (idx: number) => number;
simuGetCustomSwitchColor: (idx: number) => number;
}
export type TraceCallback = (text: string) => void;
export type AudioCallback = (samples: Int16Array) => void;
/** Derive a short radio key from a wasm filename. */
export function radioKeyFromWasm(wasmFile: string): string {
const base = wasmFile.replace(/.*\//, '').replace(/\.wasm$/, '');
const m = base.match(/^edgetx-(.+)-simulator$/);
return m ? m[1] : base;
}
/** Parse the WASM binary import section to find the memory import limits. */
function getMemoryImport(bytes: Uint8Array): { initial: number; maximum: number } {
// Minimal WASM binary parser just enough to find the memory import.
let off = 8; // skip magic + version
function readU32Leb(): number {
let result = 0, shift = 0;
while (true) {
const b = bytes[off++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) return result;
shift += 7;
}
}
function readName(): string {
const len = readU32Leb();
const s = new TextDecoder().decode(bytes.subarray(off, off + len));
off += len;
return s;
}
while (off < bytes.length) {
const sectionId = bytes[off++];
const sectionLen = readU32Leb();
const sectionEnd = off + sectionLen;
if (sectionId !== 2) { // not Import section
off = sectionEnd;
continue;
}
const count = readU32Leb();
for (let i = 0; i < count; i++) {
const mod = readName();
const name = readName();
const kind = bytes[off++];
if (kind === 2) {
// Memory import: flags, initial, [maximum]
const flags = readU32Leb();
const initial = readU32Leb();
const maximum = (flags & 1) ? readU32Leb() : 65536;
if (mod === 'env' && name === 'memory') {
return { initial, maximum };
}
} else if (kind === 0) { readU32Leb(); } // func: typeidx
else if (kind === 1) { readU32Leb(); readU32Leb(); readU32Leb(); } // table: reftype + limits
else if (kind === 3) { off += 2; } // global: valtype + mut
else if (kind === 4) { readU32Leb(); } // tag: typeidx
}
break;
}
// Fallback if not found
return { initial: 256, maximum: 32768 };
}
export class WasmRunner {
private wasiThreads!: WASIThreads;
private _exports: SimulatorExports | null = null;
private analogBuffer = new SharedArrayBuffer(32 * 2);
private analogValues = new Int16Array(this.analogBuffer);
/** LCD sync: Int32[0] = frame sequence number, incremented by firmware on each refresh. */
private lcdSyncBuffer = new SharedArrayBuffer(4);
private lcdSync = new Int32Array(this.lcdSyncBuffer);
private onTrace: TraceCallback;
private onAudio: AudioCallback;
private fsWorker: Worker | null = null;
private wakeBuffer: SharedArrayBuffer | null = null;
private nextFsReqId = 0;
/** Persistent WASM-side buffer for simuLcdCopy (allocated on first use). */
private wasmLcdBuf = 0;
private wasmLcdBufSize = 0;
/** Set to true (or type `fsTrace = true` in browser console) to log fs proxy ops. */
fsTrace = false;
constructor(onTrace: TraceCallback, onAudio: AudioCallback) {
this.onTrace = onTrace;
this.onAudio = onAudio;
}
get exports(): SimulatorExports | null {
return this._exports;
}
get hasFsWorker(): boolean {
return this.fsWorker !== null;
}
/**
* Spawn the FS Worker and scan OPFS for existing content.
* This enables file uploads/reads before WASM is loaded.
*/
async initFs(radioKey: string): Promise<{ hasContent: boolean }> {
this.fsWorker = new Worker(new URL('./fs-worker.ts', import.meta.url), { type: 'module' });
this.fsWorker.addEventListener('message', (e) => {
if (e.data?.type === 'trace') this.onTrace(e.data.text);
});
this.wakeBuffer = new SharedArrayBuffer(WAKE_BUFFER_SIZE);
const hasContent = await new Promise<boolean>((resolve, reject) => {
const onMsg = (e: MessageEvent) => {
if (e.data.type === 'ready') {
this.fsWorker!.removeEventListener('message', onMsg);
this.fsWorker!.removeEventListener('error', onErr);
resolve(e.data.hasContent);
}
};
const onErr = (e: ErrorEvent) => {
this.fsWorker!.removeEventListener('message', onMsg);
reject(new Error(e.message));
};
this.fsWorker!.addEventListener('message', onMsg);
this.fsWorker!.addEventListener('error', onErr, { once: true });
this.fsWorker!.postMessage({ type: 'init', radioKey, wakeBuffer: this.wakeBuffer });
});
return { hasContent };
}
/**
* Load and instantiate the WASM module. Requires initFs() first.
*/
async load(wasmPath: string): Promise<void> {
if (!this.fsWorker) throw new Error('initFs() must be called before load()');
// --- 1. Create main-thread WASI (stub fs — real I/O happens in workers) ---
const wasi = new WASI({
version: 'preview1',
fs: stubFs as any,
preopens: { '/': '/' },
print: (s: string) => this.onTrace(s + '\n'),
printErr: (s: string) => this.onTrace(s + '\n'),
});
this.wasiThreads = new WASIThreads({
wasi: wasi as WASIInstance,
reuseWorker: { size: 4, strict: true },
waitThreadStart: typeof window === 'undefined' ? 1000 : false,
onCreateWorker: () => {
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ type: 'analog-buffer', buffer: this.analogBuffer });
worker.postMessage({ type: 'lcd-sync', buffer: this.lcdSyncBuffer });
// Create FS channel for this worker and register with FS Worker
const ctrlBuffer = new SharedArrayBuffer(CTRL_BUFFER_SIZE);
const dataBuffer = new SharedArrayBuffer(DATA_BUFFER_SIZE);
this.fsWorker!.postMessage({ type: 'channel', ctrlBuffer, dataBuffer });
worker.postMessage({ type: 'wake-buffer', buffer: this.wakeBuffer });
worker.postMessage({ type: 'fs-channel', ctrlBuffer, dataBuffer });
worker.addEventListener('message', (e) => {
if (e.data?.type === 'trace') {
this.onTrace(e.data.text);
} else if (e.data?.type === 'audio') {
this.onAudio(e.data.samples);
}
});
return worker;
},
});
// --- 3. Start FS Worker event loop (before preloadWorkers, which triggers FS calls) ---
this.fsWorker.postMessage({ type: 'start' });
// --- 4. Load and compile WASM ---
const response = await fetch(wasmPath);
if (!response.ok) {
throw new Error(`Failed to fetch WASM: ${response.statusText}`);
}
const clone = response.clone();
const reader = response.body!.getReader();
const { value: headerBytes } = await reader.read();
reader.cancel();
const { initial, maximum } = getMemoryImport(headerBytes!);
const memory = new WebAssembly.Memory({ initial, maximum, shared: true });
const readCStr = (ptr: number): string => {
const view = new Uint8Array(memory.buffer);
let end = ptr;
while (view[end] !== 0) end++;
return new TextDecoder('utf-8').decode(view.subarray(ptr, end));
};
const wasiObj = this.wasiThreads.wasi;
const { module, instance } = await WebAssembly.instantiateStreaming(
clone,
{
wasi_snapshot_preview1:
wasiObj.wasiImport as WebAssembly.ModuleImports,
wasi: { ...this.wasiThreads.getImportObject().wasi },
env: {
memory,
simuGetAnalog: (idx: number): number => {
return this.analogValues[idx] ?? 0;
},
simuQueueAudio: (buf: number, len: number): void => {
const view = new Int16Array(memory.buffer, buf, len / 2);
this.onAudio(view);
},
simuTrace: (ptr: number): void => {
this.onTrace(readCStr(ptr));
},
simuLcdNotify: (): void => {
Atomics.add(this.lcdSync, 0, 1);
Atomics.notify(this.lcdSync, 0);
},
},
}
);
this._exports = instance.exports as unknown as SimulatorExports;
this.wasiThreads.initialize(instance, module, memory);
await this.wasiThreads.preloadWorkers();
}
// --- FS Worker message helpers ---
private fsMessage(type: string, payload: Record<string, any> = {}, transfer: Transferable[] = []): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.fsWorker) return reject(new Error('FS Worker not running'));
const id = ++this.nextFsReqId;
const handler = (e: MessageEvent) => {
if (e.data.id === id) {
this.fsWorker!.removeEventListener('message', handler);
if (e.data.error) reject(new Error(e.data.error));
else resolve(e.data);
}
};
this.fsWorker.addEventListener('message', handler);
this.fsWorker.postMessage({ ...payload, type, id }, { transfer });
});
}
async fsReadTextFile(path: string): Promise<string | null> {
try {
const result = await this.fsMessage('readTextFile', { path });
return result.text;
} catch {
return null;
}
}
async fsWriteFile(path: string, data: ArrayBuffer): Promise<void> {
await this.fsMessage('writeFile', { path, data }, [data]);
}
async fsWipe(): Promise<void> {
await this.fsMessage('wipe');
}
async fsListFiles(basePath = '/'): Promise<string[]> {
const result = await this.fsMessage('listFiles', { basePath });
return result.files;
}
/** Terminate WASM worker threads but keep the FS Worker alive. */
stopSim(): void {
if (this.wasiThreads) {
this.wasiThreads.terminateAllThreads();
}
this._exports = null;
// Reset LCD buffer pointer — it belonged to the old WASM memory
this.wasmLcdBuf = 0;
this.wasmLcdBufSize = 0;
}
/** Stop the FS Worker and clean up. */
async stopFs(): Promise<void> {
if (!this.fsWorker) return;
await new Promise<void>((resolve) => {
const handler = (e: MessageEvent) => {
if (e.data.type === 'stopped') {
this.fsWorker!.removeEventListener('message', handler);
resolve();
}
};
this.fsWorker!.addEventListener('message', handler);
this.fsWorker!.postMessage({ type: 'stop' });
});
this.fsWorker.terminate();
this.fsWorker = null;
}
setAnalog(index: number, value: number): void {
if (index >= 0 && index < this.analogValues.length) {
this.analogValues[index] = value;
}
}
/** Allocate a C string in WASM linear memory and return its pointer. */
private allocCStr(s: string): number {
const ex = this._exports!;
const encoded = new TextEncoder().encode(s);
const ptr = ex.malloc(encoded.length + 1);
if (!ptr) throw new Error('malloc failed');
// Re-derive view after malloc (buffer may have grown)
const view = new Uint8Array(ex.memory.buffer);
view.set(encoded, ptr);
view[ptr + encoded.length] = 0;
return ptr;
}
/** Tell the firmware where the SD card / settings directories are. */
setFatfsPaths(sdPath: string, settingsPath: string): void {
const ex = this._exports;
if (!ex) return;
const sdPtr = this.allocCStr(sdPath);
const settingsPtr = this.allocCStr(settingsPath);
ex.simuFatfsSetPaths(sdPtr, settingsPtr);
ex.free(sdPtr);
ex.free(settingsPtr);
}
/**
* Wait for the firmware to signal a new LCD frame.
* Returns true if a frame is ready, false on timeout.
* Uses Atomics.waitAsync so the main thread is not blocked.
*/
async waitForLcdFrame(timeout = 100): Promise<boolean> {
const current = Atomics.load(this.lcdSync, 0);
const result = Atomics.waitAsync(this.lcdSync, 0, current, timeout);
if (result.async) {
const status = await result.value;
return status === 'ok';
}
// 'not-equal' means the value already changed — frame is ready
return true;
}
/** Copy LCD framebuffer from WASM memory into a host-side Uint8Array */
copyLcd(size: number): Uint8Array | null {
const ex = this._exports;
if (!ex) return null;
// Allocate persistent WASM buffer on first use or if size changed
if (!this.wasmLcdBuf || this.wasmLcdBufSize < size) {
if (this.wasmLcdBuf) ex.free(this.wasmLcdBuf);
this.wasmLcdBuf = ex.malloc(size);
this.wasmLcdBufSize = this.wasmLcdBuf ? size : 0;
if (!this.wasmLcdBuf) return null;
}
const copied = ex.simuLcdCopy(this.wasmLcdBuf, size);
if (copied === 0) return null;
const mem = new Uint8Array(ex.memory.buffer, this.wasmLcdBuf, copied);
const result = new Uint8Array(copied);
result.set(mem);
return result;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { ThreadMessageHandler, WASIThreads } from '@emnapi/wasi-threads';
import { WASI } from '@tybys/wasm-util';
import { FsProxyClient } from './fs-proxy-client';
// Catch all errors for debugging
globalThis.addEventListener('error', (e) => {
postMessage({ type: 'trace', text: `[worker error] ${e.message} at ${e.filename}:${e.lineno}\n` });
});
globalThis.addEventListener('unhandledrejection', (e) => {
postMessage({ type: 'trace', text: `[worker rejection] ${e.reason}\n` });
});
// Shared analog values buffer, received from main thread before thread start
let analogValues: Int16Array | null = null;
// LCD sync buffer, received from main thread before thread start
let lcdSync: Int32Array | null = null;
// Filesystem proxy client, received from main thread before thread start
let fsClient: FsProxyClient | null = null;
let wakeBuffer: SharedArrayBuffer | null = null;
const handler = new ThreadMessageHandler({
async onLoad({ wasmModule, wasmMemory }) {
const post = (s: string) => postMessage({ type: 'trace', text: s + '\n' });
if (!fsClient) {
throw new Error('No fs-channel received before thread start');
}
const fs = fsClient;
let wasi: WASI;
try {
wasi = new WASI({
version: 'preview1',
fs: fs as any,
preopens: { '/': '/' },
print: post,
printErr: post,
});
} catch (e: any) {
post('[worker] WASI init error: ' + (e?.stack ?? e?.message ?? e));
throw e;
}
const wasiThreads = new WASIThreads({
wasi: wasi as any,
childThread: true,
});
const instance = await WebAssembly.instantiate(wasmModule, {
env: {
memory: wasmMemory,
simuGetAnalog: (idx: number): number => analogValues?.[idx] ?? 0,
simuQueueAudio: (buf: number, len: number): void => {
const samples = new Int16Array(wasmMemory.buffer, buf, len / 2);
// Copy and relay to main thread for playback
postMessage({ type: 'audio', samples: new Int16Array(samples) });
},
simuTrace: (_ptr: number): void => {},
simuLcdNotify: (): void => {
if (lcdSync) {
Atomics.add(lcdSync, 0, 1);
Atomics.notify(lcdSync, 0);
}
},
},
wasi_snapshot_preview1: wasi.wasiImport,
wasi: { ...wasiThreads.getImportObject().wasi },
});
const initialized = wasiThreads.initialize(
instance,
wasmModule,
wasmMemory
);
return { module: wasmModule, instance: initialized };
},
});
globalThis.onmessage = function (e) {
if (e.data?.type === 'analog-buffer') {
analogValues = new Int16Array(e.data.buffer);
return;
}
if (e.data?.type === 'lcd-sync') {
lcdSync = new Int32Array(e.data.buffer);
return;
}
if (e.data?.type === 'wake-buffer') {
wakeBuffer = e.data.buffer;
return;
}
if (e.data?.type === 'fs-channel') {
fsClient = new FsProxyClient(e.data.ctrlBuffer, e.data.dataBuffer, wakeBuffer ?? undefined);
return;
}
handler.handle(e);
};
+8
View File
@@ -0,0 +1,8 @@
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, {
target: document.getElementById('app')!,
});
export default app;
+14
View File
@@ -0,0 +1,14 @@
/** Type declarations for OPFS SyncAccessHandle APIs (not yet in TypeScript's DOM lib). */
interface FileSystemSyncAccessHandle {
read(buffer: ArrayBufferView, options?: { at?: number }): number;
write(buffer: ArrayBufferView, options?: { at?: number }): number;
truncate(newSize: number): void;
getSize(): number;
flush(): void;
close(): void;
}
interface FileSystemFileHandle {
createSyncAccessHandle(): Promise<FileSystemSyncAccessHandle>;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["svelte"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
define: {
'process.env': '{"NODE_DEBUG_NATIVE": null}',
},
server: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
preview: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
});