Sync edgetx to Gitea
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
public/*.wasm
|
||||
+145
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
Generated
+1468
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Place simulator.wasm here
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
Vendored
+14
@@ -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>;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user