Boot: - Editor now picks local vs server mode based on URL flag, sign-in state, and a stale local-mode flag. Signed-in users are no longer bounced to IndexedDB if they had previously clicked "Use locally". Local mode: - New LocalWorkspaceClient (src/static/local-workspace.js) with pluggable IndexedDB and File System Access backends. Picked folder handles persist across reloads with a Reconnect button when the permission lapses. - Static-only host: scripts/serve_static_editor.py serves src/static/ with COOP/COEP so SharedArrayBuffer-backed sims keep working. - Bundled MicroPython stubs ship under src/static/bundled-lib/ for static hosting; FastAPI also exposes them at /api/public/lib-bundle. Workspace import / export: - Zero-dep ZIP encoder + reader (STORE + DEFLATE via DecompressionStream). Export/Import buttons in the workspace badge work in both local and server modes; imports are confined to code/. Pin / ADC / Serial simulation: - machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven by SharedArrayBuffer when cross-origin isolated and falling back to postMessage + [pin-out] stdout markers otherwise — pins, ADC slider, and serial input now keep working over plain HTTP / LAN-IP origins. - NeoPixel pins are claimed via a [pin-claim] marker and dropped from the Pins panel so the data line doesn't flicker per write(). - New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py. Lib layout: - Single source of truth at repo lib/; workspace/lib/ caching layer removed and the directory deleted. Filesystem service reads stubs directly from PROJECT_ROOT/lib. UI: - Home page slimmed to "Sign in" + "Use locally" with optional editor / manage-users links. Admin user/invite UI moved to /users. - Workspace badge gains storage indicator, Folder…/Reconnect, Export, Import, and Exit controls. - Mobile-friendly tweaks: safer-area padding, larger touch targets, iOS-zoom-proof serial input, file-tree highlight fix. Tests: - test_auth.py patches PROJECT_ROOT for the lib-shared test so the repo-root lib refactor stays green. test_api.py asserts the new "LED Editor" branding. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
9.4 KiB
JavaScript
269 lines
9.4 KiB
JavaScript
/* global importScripts, loadPyodide, self */
|
|
importScripts('https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js');
|
|
|
|
const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
|
|
|
let pyodide = null;
|
|
let loadingPromise = null;
|
|
|
|
async function ensurePyodide() {
|
|
if (pyodide) {
|
|
return pyodide;
|
|
}
|
|
if (!loadingPromise) {
|
|
loadingPromise = (async () => {
|
|
const p = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
|
|
p.setStdout({
|
|
batched: (txt) => self.postMessage({ type: 'io', stream: 'stdout', text: txt }),
|
|
});
|
|
p.setStderr({
|
|
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
|
|
});
|
|
await p.loadPackage('micropip');
|
|
await p.runPythonAsync(`
|
|
import micropip
|
|
await micropip.install("jedi")
|
|
`);
|
|
return p;
|
|
})();
|
|
}
|
|
pyodide = await loadingPromise;
|
|
return pyodide;
|
|
}
|
|
|
|
self.onmessage = async (event) => {
|
|
const { id, type, payload } = event.data || {};
|
|
try {
|
|
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
|
|
for pin input + ADC slider values. We update the worker-local
|
|
Int32Array so Python's next read sees the new value, with no
|
|
Pyodide round-trip and no reply expected. */
|
|
if (type === 'pinIn') {
|
|
const view = self.__pin_in_view;
|
|
const pin = payload && Number(payload.pin);
|
|
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
|
|
try {
|
|
view[pin] = (payload.value | 0) ? 1 : 0;
|
|
} catch (_e) {
|
|
// ignore
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (type === 'adcSet') {
|
|
const view = self.__adc_view;
|
|
const pin = payload && Number(payload.pin);
|
|
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
|
|
const value = Math.max(0, Math.min(65535, payload.value | 0));
|
|
try {
|
|
view[pin] = value;
|
|
} catch (_e) {
|
|
// ignore
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (type === 'init') {
|
|
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
|
|
can pick up live slider values without yielding. */
|
|
const adcSab = payload && payload.adcSab;
|
|
if (adcSab && typeof adcSab !== 'string') {
|
|
try {
|
|
self.__adc_view = new Int32Array(adcSab);
|
|
} catch (_e) {
|
|
self.__adc_view = null;
|
|
}
|
|
}
|
|
/* No SAB? Fall back to a worker-local Int32Array; the main thread
|
|
then delivers slider drags via `postMessage({type:'adcSet'})`. */
|
|
if (!self.__adc_view) {
|
|
try {
|
|
self.__adc_view = new Int32Array(64);
|
|
} catch (_e) {
|
|
self.__adc_view = null;
|
|
}
|
|
}
|
|
/* Pin output state: Int32Array[64] — Python writes packed
|
|
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
|
|
them every frame to drive the Pins panel indicators. */
|
|
const pinOutSab = payload && payload.pinOutSab;
|
|
if (pinOutSab && typeof pinOutSab !== 'string') {
|
|
try {
|
|
self.__pin_out_view = new Int32Array(pinOutSab);
|
|
} catch (_e) {
|
|
self.__pin_out_view = null;
|
|
}
|
|
}
|
|
if (!self.__pin_out_view) {
|
|
try {
|
|
self.__pin_out_view = new Int32Array(64);
|
|
} catch (_e) {
|
|
self.__pin_out_view = null;
|
|
}
|
|
}
|
|
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
|
|
Python's `Pin.value()` for IN mode reads the matching slot. */
|
|
const pinInSab = payload && payload.pinInSab;
|
|
if (pinInSab && typeof pinInSab !== 'string') {
|
|
try {
|
|
self.__pin_in_view = new Int32Array(pinInSab);
|
|
} catch (_e) {
|
|
self.__pin_in_view = null;
|
|
}
|
|
}
|
|
if (!self.__pin_in_view) {
|
|
try {
|
|
self.__pin_in_view = new Int32Array(64);
|
|
} catch (_e) {
|
|
self.__pin_in_view = null;
|
|
}
|
|
}
|
|
/* Tell Python whether SAB was actually wired up so machine.py can
|
|
emit `[pin-out]` print markers as a fallback for the live UI. */
|
|
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
|
|
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
|
|
followed by `capacity` bytes of payload data. */
|
|
const serialSab = payload && payload.serialSab;
|
|
if (serialSab && typeof serialSab !== 'string') {
|
|
try {
|
|
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
|
|
const capacity = serialSab.byteLength - 8;
|
|
self.__serial_in_capacity = capacity;
|
|
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
|
|
} catch (_e) {
|
|
self.__serial_in_indices = null;
|
|
self.__serial_in_data = null;
|
|
self.__serial_in_capacity = 0;
|
|
}
|
|
}
|
|
await ensurePyodide();
|
|
self.postMessage({ id, type: 'init', ok: true });
|
|
return;
|
|
}
|
|
|
|
const p = await ensurePyodide();
|
|
|
|
if (type === 'complete') {
|
|
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
|
const vpath = `/workspace/${rel}`;
|
|
p.globals.set('__cm_code', String(payload.content ?? ''));
|
|
p.globals.set('__cm_path', vpath);
|
|
p.globals.set('__cm_line', Number(payload.line) || 1);
|
|
p.globals.set('__cm_col', Number(payload.column) || 0);
|
|
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
|
|
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
|
|
const raw = p.runPython(`
|
|
import json, os, sys
|
|
import jedi
|
|
|
|
extra = json.loads(__cm_extra_json)
|
|
os.makedirs("/workspace", exist_ok=True)
|
|
for rel_path, body in extra.items():
|
|
rel_path = str(rel_path).lstrip("/")
|
|
full = os.path.join("/workspace", rel_path)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "w", encoding="utf-8") as fh:
|
|
fh.write(str(body))
|
|
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
|
|
with open(__cm_path, "w", encoding="utf-8") as fh:
|
|
fh.write(__cm_code)
|
|
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
|
if entry not in sys.path:
|
|
sys.path.insert(0, entry)
|
|
proj = jedi.Project(
|
|
"/workspace",
|
|
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
|
)
|
|
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
|
|
items = s.complete(line=__cm_line, column=__cm_col)
|
|
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
|
|
json.dumps(out)
|
|
`);
|
|
const completions = JSON.parse(String(raw));
|
|
self.postMessage({ id, type: 'complete', ok: true, completions });
|
|
return;
|
|
}
|
|
|
|
if (type === 'diagnostics') {
|
|
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
|
const vpath = `/workspace/${rel}`;
|
|
p.globals.set('__diag_code', String(payload.content ?? ''));
|
|
p.globals.set('__diag_path', vpath);
|
|
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
|
|
const raw = p.runPython(`
|
|
import json, os, sys
|
|
import jedi
|
|
|
|
extra = json.loads(__diag_extra_json)
|
|
os.makedirs("/workspace", exist_ok=True)
|
|
for rel_path, body in extra.items():
|
|
rel_path = str(rel_path).lstrip("/")
|
|
full = os.path.join("/workspace", rel_path)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "w", encoding="utf-8") as fh:
|
|
fh.write(str(body))
|
|
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
|
with open(__diag_path, "w", encoding="utf-8") as fh:
|
|
fh.write(__diag_code)
|
|
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
|
if entry not in sys.path:
|
|
sys.path.insert(0, entry)
|
|
proj = jedi.Project(
|
|
"/workspace",
|
|
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
|
)
|
|
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
|
errs = s.get_syntax_errors()
|
|
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
|
|
json.dumps(out)
|
|
`);
|
|
const diagnostics = JSON.parse(String(raw));
|
|
self.postMessage({ id, type: 'diagnostics', ok: true, diagnostics });
|
|
return;
|
|
}
|
|
|
|
if (type === 'run') {
|
|
const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
|
|
const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
|
|
const argsList = Array.isArray(payload.args) ? payload.args.map(String) : [];
|
|
p.globals.set('__run_files_json', JSON.stringify(files));
|
|
p.globals.set('__run_main', `/workspace/${mainRel}`);
|
|
p.globals.set('__run_args', p.toPy(argsList));
|
|
await p.runPythonAsync(`
|
|
import json, os, shutil, sys, runpy
|
|
|
|
files = json.loads(__run_files_json)
|
|
shutil.rmtree('/workspace', ignore_errors=True)
|
|
os.makedirs('/workspace', exist_ok=True)
|
|
for rel, body in files.items():
|
|
rel = str(rel).lstrip("/")
|
|
full = os.path.join("/workspace", rel)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "w", encoding="utf-8") as fh:
|
|
fh.write(str(body))
|
|
|
|
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
|
if entry not in sys.path:
|
|
sys.path.insert(0, entry)
|
|
|
|
os.chdir("/workspace")
|
|
main = __run_main
|
|
sys.argv = [main] + list(__run_args)
|
|
runpy.run_path(main, run_name="__main__")
|
|
`);
|
|
self.postMessage({ id, type: 'run', ok: true });
|
|
return;
|
|
}
|
|
|
|
self.postMessage({ id, type, ok: false, error: `Unknown message type: ${type}` });
|
|
} catch (err) {
|
|
self.postMessage({
|
|
id,
|
|
type,
|
|
ok: false,
|
|
error: err && err.message ? String(err.message) : String(err),
|
|
});
|
|
}
|
|
};
|