playground: free-form code option + encrypted vault, single-scroll layout
Free-form radio adds a 5th demo slot. When selected, a vault bar appears under the controls: enter a password, "unlock" derives a per-device vault and decrypts (or creates fresh). Edits in the editor auto-save 350ms after typing stops. Reload + same password restores the code. Same Web Crypto stack as /repl/ (PBKDF2 + AES-GCM, vault id = SHA-256(password || device-salt)). Layout: one shared vertical scroller — code pane and output pane both grow with content, the body scrolls. No more independent in-pane scrollers fighting the page. Home page split into "Demo" and "REPL" sections with their own CTAs.
This commit is contained in:
parent
d8ffab5ea6
commit
3d6d5d1010
12 changed files with 572 additions and 44 deletions
|
|
@ -131,6 +131,7 @@ $(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js
|
|||
APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/*.mjs app/demos/*.lsp)
|
||||
|
||||
app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js $(DIST)/worker.mjs \
|
||||
$(DIST)/crypto.js \
|
||||
$(DIST)/lumbda-logo-green.png $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff2 \
|
||||
$(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,19 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|||
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
|
||||
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { openVault } from "./crypto.js";
|
||||
|
||||
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
|
||||
const FREE_FORM_DEFAULT = "; free-form mode — unlock the vault below to persist this code\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
|
||||
|
||||
const demoSources = {};
|
||||
// Vault state. When unlocked, free-form code auto-saves on every edit.
|
||||
const vaultState = { vault: null, freeForm: null, saveTimer: null };
|
||||
|
||||
async function loadDemoSource(name) {
|
||||
if (name === "free-form") {
|
||||
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
|
||||
}
|
||||
if (!demoSources[name]) {
|
||||
const resp = await fetch(`demos/${name}.lsp`);
|
||||
demoSources[name] = await resp.text();
|
||||
|
|
@ -39,7 +46,9 @@ const editorView = new EditorView({
|
|||
StreamLanguage.define(scheme),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
oneDark,
|
||||
EditorView.theme({ "&": { height: "100%" } }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) scheduleFreeFormSave();
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: editorParent,
|
||||
|
|
@ -57,10 +66,84 @@ function getEditorText() {
|
|||
|
||||
async function loadCurrentDemo() {
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
const vaultBar = document.getElementById("vault-bar");
|
||||
vaultBar.hidden = sel !== "free-form";
|
||||
const src = await loadDemoSource(sel);
|
||||
setEditorText(src);
|
||||
}
|
||||
|
||||
// ─── Free-form vault ───────────────────────────────────────────────
|
||||
const vaultBarEl = document.getElementById("vault-bar");
|
||||
const vaultPwEl = document.getElementById("vault-pw");
|
||||
const vaultUnlockBtn = document.getElementById("vault-unlock");
|
||||
const vaultLockBtn = document.getElementById("vault-lock");
|
||||
const vaultStateEl = document.getElementById("vault-state");
|
||||
const vaultNoteEl = document.getElementById("vault-note");
|
||||
|
||||
function setVaultNote(text, isErr) {
|
||||
vaultNoteEl.textContent = text || "";
|
||||
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
async function unlockVault() {
|
||||
const pw = vaultPwEl.value;
|
||||
if (!pw) { setVaultNote("password required", true); return; }
|
||||
setVaultNote("");
|
||||
try {
|
||||
vaultState.vault = await openVault(pw);
|
||||
const data = await vaultState.vault.read();
|
||||
if (data && data.__decryptionFailed) {
|
||||
setVaultNote("vault exists but password is wrong", true);
|
||||
vaultState.vault = null;
|
||||
return;
|
||||
}
|
||||
vaultState.freeForm = (data && typeof data.freeForm === "string")
|
||||
? data.freeForm
|
||||
: FREE_FORM_DEFAULT;
|
||||
vaultStateEl.textContent = "unlocked";
|
||||
vaultStateEl.classList.add("unlocked");
|
||||
vaultUnlockBtn.hidden = true;
|
||||
vaultLockBtn.hidden = false;
|
||||
vaultPwEl.value = "";
|
||||
vaultPwEl.disabled = true;
|
||||
// If free-form is the current program, swap the editor in.
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel === "free-form") setEditorText(vaultState.freeForm);
|
||||
setVaultNote("ok — edits auto-save");
|
||||
} catch (e) {
|
||||
setVaultNote("unlock failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function lockVault() {
|
||||
vaultState.vault = null;
|
||||
vaultState.freeForm = null;
|
||||
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
|
||||
vaultStateEl.textContent = "locked";
|
||||
vaultStateEl.classList.remove("unlocked");
|
||||
vaultUnlockBtn.hidden = false;
|
||||
vaultLockBtn.hidden = true;
|
||||
vaultPwEl.disabled = false;
|
||||
vaultPwEl.value = "";
|
||||
setVaultNote("");
|
||||
// If free-form is current program, swap to the placeholder.
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
|
||||
}
|
||||
|
||||
function scheduleFreeFormSave() {
|
||||
if (!vaultState.vault) return;
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel !== "free-form") return;
|
||||
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
|
||||
vaultState.saveTimer = setTimeout(async () => {
|
||||
const text = getEditorText();
|
||||
vaultState.freeForm = text;
|
||||
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
|
||||
catch (e) { setVaultNote("save failed: " + e.message, true); }
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function selectedTiers() {
|
||||
const sel = document.querySelector('input[name="tier"]:checked').value;
|
||||
return sel === "all" ? ["python", "c", "asm"] : [sel];
|
||||
|
|
@ -232,4 +315,9 @@ document.querySelectorAll('input[name="program"]').forEach((el) => {
|
|||
runBtn.addEventListener("click", runAll);
|
||||
cancelBtn.addEventListener("click", onCancel);
|
||||
cancelBtn.disabled = true;
|
||||
vaultUnlockBtn.addEventListener("click", unlockVault);
|
||||
vaultLockBtn.addEventListener("click", lockVault);
|
||||
vaultPwEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
|
||||
});
|
||||
loadCurrentDemo();
|
||||
|
|
|
|||
104
wasm/app/crypto.js
Normal file
104
wasm/app/crypto.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// wasm/repl/crypto.js
|
||||
// Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js
|
||||
// (priv/static/js/crypto-utils.js) but uses the native Web Crypto API
|
||||
// — no CryptoJS dep — so the playground and the REPL share one tiny
|
||||
// vault primitive.
|
||||
//
|
||||
// Vault layout in localStorage:
|
||||
// lumbda_salt — random 32 bytes, generated once per device
|
||||
// lumbda_vault:<id> — { iv: base64, data: base64 } AES-GCM encrypted JSON
|
||||
//
|
||||
// vaultId = SHA-256(password || salt), hex. Same password yields the same
|
||||
// vault id on this device; different password yields a different (and
|
||||
// independent) vault.
|
||||
|
||||
const SALT_KEY = "lumbda_salt";
|
||||
|
||||
function buf2b64(buf) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
function b642buf(b64) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
function hex(buf) {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getDeviceSalt() {
|
||||
let salt = localStorage.getItem(SALT_KEY);
|
||||
if (!salt) {
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
salt = buf2b64(random);
|
||||
localStorage.setItem(SALT_KEY, salt);
|
||||
}
|
||||
return new Uint8Array(b642buf(salt));
|
||||
}
|
||||
|
||||
async function deriveKey(password, salt) {
|
||||
const enc = new TextEncoder();
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
"raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
export async function getVaultId(password) {
|
||||
const salt = getDeviceSalt();
|
||||
const enc = new TextEncoder();
|
||||
const data = new Uint8Array(password.length + salt.length);
|
||||
data.set(enc.encode(password), 0);
|
||||
data.set(salt, password.length);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return hex(digest);
|
||||
}
|
||||
|
||||
// Open a vault under the given password. Returns { read, write, vaultId }.
|
||||
export async function openVault(password) {
|
||||
const salt = getDeviceSalt();
|
||||
const key = await deriveKey(password, salt);
|
||||
const vaultId = await getVaultId(password);
|
||||
const lsKey = "lumbda_vault:" + vaultId;
|
||||
|
||||
async function read() {
|
||||
const raw = localStorage.getItem(lsKey);
|
||||
if (!raw) return null;
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(raw); } catch { return null; }
|
||||
if (!envelope || !envelope.iv || !envelope.data) return null;
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: b642buf(envelope.iv) },
|
||||
key,
|
||||
b642buf(envelope.data));
|
||||
return JSON.parse(new TextDecoder().decode(plain));
|
||||
} catch {
|
||||
return { __decryptionFailed: true };
|
||||
}
|
||||
}
|
||||
async function write(value) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(value)));
|
||||
localStorage.setItem(lsKey, JSON.stringify({
|
||||
iv: buf2b64(iv),
|
||||
data: buf2b64(cipher),
|
||||
}));
|
||||
}
|
||||
function destroy() { localStorage.removeItem(lsKey); }
|
||||
return { read, write, destroy, vaultId };
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
|
||||
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
|
||||
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
|
||||
<label><input type="radio" name="program" value="free-form"> free form (encrypted)</label>
|
||||
</fieldset>
|
||||
<fieldset class="tier">
|
||||
<legend>tier</legend>
|
||||
|
|
@ -58,6 +59,15 @@
|
|||
<span id="status" class="status"></span>
|
||||
</section>
|
||||
|
||||
<section class="vault-bar" id="vault-bar" hidden>
|
||||
<span class="vault-icon" title="local vault">🔒</span>
|
||||
<span id="vault-state" class="vault-state">locked</span>
|
||||
<input id="vault-pw" type="password" autocomplete="off" placeholder="password to unlock free-form vault">
|
||||
<button id="vault-unlock" class="secondary">unlock</button>
|
||||
<button id="vault-lock" class="secondary" hidden>lock</button>
|
||||
<span id="vault-note" class="vault-note"></span>
|
||||
</section>
|
||||
|
||||
<section class="panes">
|
||||
<div class="pane code-pane">
|
||||
<h2>code</h2>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ html, body {
|
|||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Header ────────────────────────────────────────────────────── */
|
||||
|
|
@ -190,6 +189,42 @@ header code {
|
|||
.controls .status.err { color: var(--err); }
|
||||
.controls .status.ok { color: var(--green); }
|
||||
|
||||
.vault-bar {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.vault-bar .vault-icon { font-size: 1em; }
|
||||
.vault-bar .vault-state { color: var(--muted); }
|
||||
.vault-bar .vault-state.unlocked { color: var(--green); }
|
||||
.vault-bar input[type=password] {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
border-radius: 3px;
|
||||
width: 18rem;
|
||||
}
|
||||
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
|
||||
.vault-bar button {
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vault-bar button:hover { background: var(--code-bg); }
|
||||
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
|
||||
.vault-bar .vault-note.err { color: var(--err); }
|
||||
|
||||
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panes {
|
||||
|
|
@ -197,13 +232,11 @@ header code {
|
|||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--rule);
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 360px;
|
||||
align-items: start;
|
||||
}
|
||||
.pane {
|
||||
background: var(--pane-bg);
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -217,17 +250,14 @@ header code {
|
|||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-editor { height: 100%; font-size: 13px; }
|
||||
.cm-editor { font-size: 13px; }
|
||||
.cm-editor.cm-focused { outline: none; }
|
||||
.cm-scroller { overflow: visible; }
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
border-radius: 2px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ html, body {
|
|||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Header ────────────────────────────────────────────────────── */
|
||||
|
|
@ -190,6 +189,42 @@ header code {
|
|||
.controls .status.err { color: var(--err); }
|
||||
.controls .status.ok { color: var(--green); }
|
||||
|
||||
.vault-bar {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.vault-bar .vault-icon { font-size: 1em; }
|
||||
.vault-bar .vault-state { color: var(--muted); }
|
||||
.vault-bar .vault-state.unlocked { color: var(--green); }
|
||||
.vault-bar input[type=password] {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
border-radius: 3px;
|
||||
width: 18rem;
|
||||
}
|
||||
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
|
||||
.vault-bar button {
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vault-bar button:hover { background: var(--code-bg); }
|
||||
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
|
||||
.vault-bar .vault-note.err { color: var(--err); }
|
||||
|
||||
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panes {
|
||||
|
|
@ -197,13 +232,11 @@ header code {
|
|||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--rule);
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 360px;
|
||||
align-items: start;
|
||||
}
|
||||
.pane {
|
||||
background: var(--pane-bg);
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -217,17 +250,14 @@ header code {
|
|||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-editor { height: 100%; font-size: 13px; }
|
||||
.cm-editor { font-size: 13px; }
|
||||
.cm-editor.cm-focused { outline: none; }
|
||||
.cm-scroller { overflow: visible; }
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
border-radius: 2px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
|
|
|
|||
|
|
@ -88,12 +88,15 @@ make test-all</code></pre>
|
|||
</section>
|
||||
|
||||
<section id="playground">
|
||||
<h2>Try it in your browser</h2>
|
||||
<p>All three tiers compiled to WebAssembly — Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways.</p>
|
||||
<p>
|
||||
<a class="cta" href="playground/">Open the playground →</a>
|
||||
<a class="cta" href="repl/">Open the REPL →</a>
|
||||
</p>
|
||||
<h2>Demo — play with the language</h2>
|
||||
<p>All three tiers compiled to WebAssembly — Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways. Free-form mode saves your custom code to an encrypted local vault.</p>
|
||||
<p><a class="cta" href="playground/">Open the playground →</a></p>
|
||||
</section>
|
||||
|
||||
<section id="repl">
|
||||
<h2>REPL — persistent interactive sessions</h2>
|
||||
<p>Multi-tab REPL with one worker per tier per tab. Defines, set!, hash-table mutations stick across evals within a session. Transcripts persist in encrypted localStorage — supply a password, reload, same password unlocks the same vault. Each input can race all three tiers at once.</p>
|
||||
<p><a class="cta" href="repl/">Open the REPL →</a></p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,19 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|||
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
|
||||
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { openVault } from "./crypto.js";
|
||||
|
||||
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
|
||||
const FREE_FORM_DEFAULT = "; free-form mode — unlock the vault below to persist this code\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
|
||||
|
||||
const demoSources = {};
|
||||
// Vault state. When unlocked, free-form code auto-saves on every edit.
|
||||
const vaultState = { vault: null, freeForm: null, saveTimer: null };
|
||||
|
||||
async function loadDemoSource(name) {
|
||||
if (name === "free-form") {
|
||||
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
|
||||
}
|
||||
if (!demoSources[name]) {
|
||||
const resp = await fetch(`demos/${name}.lsp`);
|
||||
demoSources[name] = await resp.text();
|
||||
|
|
@ -39,7 +46,9 @@ const editorView = new EditorView({
|
|||
StreamLanguage.define(scheme),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
oneDark,
|
||||
EditorView.theme({ "&": { height: "100%" } }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) scheduleFreeFormSave();
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: editorParent,
|
||||
|
|
@ -57,10 +66,84 @@ function getEditorText() {
|
|||
|
||||
async function loadCurrentDemo() {
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
const vaultBar = document.getElementById("vault-bar");
|
||||
vaultBar.hidden = sel !== "free-form";
|
||||
const src = await loadDemoSource(sel);
|
||||
setEditorText(src);
|
||||
}
|
||||
|
||||
// ─── Free-form vault ───────────────────────────────────────────────
|
||||
const vaultBarEl = document.getElementById("vault-bar");
|
||||
const vaultPwEl = document.getElementById("vault-pw");
|
||||
const vaultUnlockBtn = document.getElementById("vault-unlock");
|
||||
const vaultLockBtn = document.getElementById("vault-lock");
|
||||
const vaultStateEl = document.getElementById("vault-state");
|
||||
const vaultNoteEl = document.getElementById("vault-note");
|
||||
|
||||
function setVaultNote(text, isErr) {
|
||||
vaultNoteEl.textContent = text || "";
|
||||
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
async function unlockVault() {
|
||||
const pw = vaultPwEl.value;
|
||||
if (!pw) { setVaultNote("password required", true); return; }
|
||||
setVaultNote("");
|
||||
try {
|
||||
vaultState.vault = await openVault(pw);
|
||||
const data = await vaultState.vault.read();
|
||||
if (data && data.__decryptionFailed) {
|
||||
setVaultNote("vault exists but password is wrong", true);
|
||||
vaultState.vault = null;
|
||||
return;
|
||||
}
|
||||
vaultState.freeForm = (data && typeof data.freeForm === "string")
|
||||
? data.freeForm
|
||||
: FREE_FORM_DEFAULT;
|
||||
vaultStateEl.textContent = "unlocked";
|
||||
vaultStateEl.classList.add("unlocked");
|
||||
vaultUnlockBtn.hidden = true;
|
||||
vaultLockBtn.hidden = false;
|
||||
vaultPwEl.value = "";
|
||||
vaultPwEl.disabled = true;
|
||||
// If free-form is the current program, swap the editor in.
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel === "free-form") setEditorText(vaultState.freeForm);
|
||||
setVaultNote("ok — edits auto-save");
|
||||
} catch (e) {
|
||||
setVaultNote("unlock failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function lockVault() {
|
||||
vaultState.vault = null;
|
||||
vaultState.freeForm = null;
|
||||
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
|
||||
vaultStateEl.textContent = "locked";
|
||||
vaultStateEl.classList.remove("unlocked");
|
||||
vaultUnlockBtn.hidden = false;
|
||||
vaultLockBtn.hidden = true;
|
||||
vaultPwEl.disabled = false;
|
||||
vaultPwEl.value = "";
|
||||
setVaultNote("");
|
||||
// If free-form is current program, swap to the placeholder.
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
|
||||
}
|
||||
|
||||
function scheduleFreeFormSave() {
|
||||
if (!vaultState.vault) return;
|
||||
const sel = document.querySelector('input[name="program"]:checked').value;
|
||||
if (sel !== "free-form") return;
|
||||
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
|
||||
vaultState.saveTimer = setTimeout(async () => {
|
||||
const text = getEditorText();
|
||||
vaultState.freeForm = text;
|
||||
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
|
||||
catch (e) { setVaultNote("save failed: " + e.message, true); }
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function selectedTiers() {
|
||||
const sel = document.querySelector('input[name="tier"]:checked').value;
|
||||
return sel === "all" ? ["python", "c", "asm"] : [sel];
|
||||
|
|
@ -232,4 +315,9 @@ document.querySelectorAll('input[name="program"]').forEach((el) => {
|
|||
runBtn.addEventListener("click", runAll);
|
||||
cancelBtn.addEventListener("click", onCancel);
|
||||
cancelBtn.disabled = true;
|
||||
vaultUnlockBtn.addEventListener("click", unlockVault);
|
||||
vaultLockBtn.addEventListener("click", lockVault);
|
||||
vaultPwEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
|
||||
});
|
||||
loadCurrentDemo();
|
||||
|
|
|
|||
104
www/playground/crypto.js
Normal file
104
www/playground/crypto.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// wasm/repl/crypto.js
|
||||
// Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js
|
||||
// (priv/static/js/crypto-utils.js) but uses the native Web Crypto API
|
||||
// — no CryptoJS dep — so the playground and the REPL share one tiny
|
||||
// vault primitive.
|
||||
//
|
||||
// Vault layout in localStorage:
|
||||
// lumbda_salt — random 32 bytes, generated once per device
|
||||
// lumbda_vault:<id> — { iv: base64, data: base64 } AES-GCM encrypted JSON
|
||||
//
|
||||
// vaultId = SHA-256(password || salt), hex. Same password yields the same
|
||||
// vault id on this device; different password yields a different (and
|
||||
// independent) vault.
|
||||
|
||||
const SALT_KEY = "lumbda_salt";
|
||||
|
||||
function buf2b64(buf) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
function b642buf(b64) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
function hex(buf) {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getDeviceSalt() {
|
||||
let salt = localStorage.getItem(SALT_KEY);
|
||||
if (!salt) {
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
salt = buf2b64(random);
|
||||
localStorage.setItem(SALT_KEY, salt);
|
||||
}
|
||||
return new Uint8Array(b642buf(salt));
|
||||
}
|
||||
|
||||
async function deriveKey(password, salt) {
|
||||
const enc = new TextEncoder();
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
"raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
export async function getVaultId(password) {
|
||||
const salt = getDeviceSalt();
|
||||
const enc = new TextEncoder();
|
||||
const data = new Uint8Array(password.length + salt.length);
|
||||
data.set(enc.encode(password), 0);
|
||||
data.set(salt, password.length);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return hex(digest);
|
||||
}
|
||||
|
||||
// Open a vault under the given password. Returns { read, write, vaultId }.
|
||||
export async function openVault(password) {
|
||||
const salt = getDeviceSalt();
|
||||
const key = await deriveKey(password, salt);
|
||||
const vaultId = await getVaultId(password);
|
||||
const lsKey = "lumbda_vault:" + vaultId;
|
||||
|
||||
async function read() {
|
||||
const raw = localStorage.getItem(lsKey);
|
||||
if (!raw) return null;
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(raw); } catch { return null; }
|
||||
if (!envelope || !envelope.iv || !envelope.data) return null;
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: b642buf(envelope.iv) },
|
||||
key,
|
||||
b642buf(envelope.data));
|
||||
return JSON.parse(new TextDecoder().decode(plain));
|
||||
} catch {
|
||||
return { __decryptionFailed: true };
|
||||
}
|
||||
}
|
||||
async function write(value) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(value)));
|
||||
localStorage.setItem(lsKey, JSON.stringify({
|
||||
iv: buf2b64(iv),
|
||||
data: buf2b64(cipher),
|
||||
}));
|
||||
}
|
||||
function destroy() { localStorage.removeItem(lsKey); }
|
||||
return { read, write, destroy, vaultId };
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
|
||||
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
|
||||
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
|
||||
<label><input type="radio" name="program" value="free-form"> free form (encrypted)</label>
|
||||
</fieldset>
|
||||
<fieldset class="tier">
|
||||
<legend>tier</legend>
|
||||
|
|
@ -58,6 +59,15 @@
|
|||
<span id="status" class="status"></span>
|
||||
</section>
|
||||
|
||||
<section class="vault-bar" id="vault-bar" hidden>
|
||||
<span class="vault-icon" title="local vault">🔒</span>
|
||||
<span id="vault-state" class="vault-state">locked</span>
|
||||
<input id="vault-pw" type="password" autocomplete="off" placeholder="password to unlock free-form vault">
|
||||
<button id="vault-unlock" class="secondary">unlock</button>
|
||||
<button id="vault-lock" class="secondary" hidden>lock</button>
|
||||
<span id="vault-note" class="vault-note"></span>
|
||||
</section>
|
||||
|
||||
<section class="panes">
|
||||
<div class="pane code-pane">
|
||||
<h2>code</h2>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ html, body {
|
|||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Header ────────────────────────────────────────────────────── */
|
||||
|
|
@ -190,6 +189,42 @@ header code {
|
|||
.controls .status.err { color: var(--err); }
|
||||
.controls .status.ok { color: var(--green); }
|
||||
|
||||
.vault-bar {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.vault-bar .vault-icon { font-size: 1em; }
|
||||
.vault-bar .vault-state { color: var(--muted); }
|
||||
.vault-bar .vault-state.unlocked { color: var(--green); }
|
||||
.vault-bar input[type=password] {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
border-radius: 3px;
|
||||
width: 18rem;
|
||||
}
|
||||
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
|
||||
.vault-bar button {
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vault-bar button:hover { background: var(--code-bg); }
|
||||
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
|
||||
.vault-bar .vault-note.err { color: var(--err); }
|
||||
|
||||
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panes {
|
||||
|
|
@ -197,13 +232,11 @@ header code {
|
|||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--rule);
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 360px;
|
||||
align-items: start;
|
||||
}
|
||||
.pane {
|
||||
background: var(--pane-bg);
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -217,17 +250,14 @@ header code {
|
|||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-editor { height: 100%; font-size: 13px; }
|
||||
.cm-editor { font-size: 13px; }
|
||||
.cm-editor.cm-focused { outline: none; }
|
||||
.cm-scroller { overflow: visible; }
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
border-radius: 2px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ html, body {
|
|||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Header ────────────────────────────────────────────────────── */
|
||||
|
|
@ -190,6 +189,42 @@ header code {
|
|||
.controls .status.err { color: var(--err); }
|
||||
.controls .status.ok { color: var(--green); }
|
||||
|
||||
.vault-bar {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.vault-bar .vault-icon { font-size: 1em; }
|
||||
.vault-bar .vault-state { color: var(--muted); }
|
||||
.vault-bar .vault-state.unlocked { color: var(--green); }
|
||||
.vault-bar input[type=password] {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
border-radius: 3px;
|
||||
width: 18rem;
|
||||
}
|
||||
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
|
||||
.vault-bar button {
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vault-bar button:hover { background: var(--code-bg); }
|
||||
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
|
||||
.vault-bar .vault-note.err { color: var(--err); }
|
||||
|
||||
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panes {
|
||||
|
|
@ -197,13 +232,11 @@ header code {
|
|||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--rule);
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 360px;
|
||||
align-items: start;
|
||||
}
|
||||
.pane {
|
||||
background: var(--pane-bg);
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -217,17 +250,14 @@ header code {
|
|||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-editor { height: 100%; font-size: 13px; }
|
||||
.cm-editor { font-size: 13px; }
|
||||
.cm-editor.cm-focused { outline: none; }
|
||||
.cm-scroller { overflow: visible; }
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
border-radius: 2px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue