From b0f6e9e89b9247cc6113c93b824861d014eb9ecc Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:38:28 +0300 Subject: [PATCH] run extensions in a quickjs wasm sandbox replaces new Function() in the main window; per-script and per-call interrupt deadlines, memory and stack limits, graceful skip if the wasm module fails to load. csp script-src drops 'unsafe-eval' for 'wasm-unsafe-eval'. --- ARCHITECTURE.md | 5 +- ARCHITECTURE.ru.md | 5 +- README.md | 4 +- README.ru.md | 4 +- package-lock.json | 28 ++++- package.json | 4 +- src-tauri/extension-sample.js | 6 +- src-tauri/tauri.conf.json | 2 +- src/extensions.test.ts | 187 ++++++++++++++++++++++++++-------- src/extensions.ts | 165 ++++++++++++++++++++++++++++-- src/main.ts | 2 +- 11 files changed, 347 insertions(+), 65 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ad6017c..a0f9d2f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,7 +26,8 @@ src/ app.css themes (light/dark via CSS variables) main.ts app bootstrap: documents, settings, status bar, dnd storage.ts Tauri commands / localStorage fallback - extensions.ts runs user .js files against the engine + extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged + to the engine's numi.* API i18n.ts interface strings (en/ru) updater.ts checks tauri-plugin-updater, installs + relaunches src-tauri/ @@ -263,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder ## Tests -`npm test` runs 152 vitest cases: `src/engine/*.test.ts` covers every +`npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every expression class, both languages, deterministic injected rates, goal seek, historical rates (injected), date-format literals/display (dedicated engines with an explicit `dateFormat`, since the default "system" format depends on diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index a32e64e..30486cf 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -27,7 +27,8 @@ src/ app.css темы (светлая/тёмная через CSS-переменные) main.ts запуск приложения: документы, настройки, строка статуса, dnd storage.ts команды Tauri / запасной вариант на localStorage - extensions.ts запускает пользовательские .js-файлы через движок + extensions.ts запускает пользовательские .js-файлы в песочнице + QuickJS/WASM, мост к API numi.* движка i18n.ts строки интерфейса (en/ru) updater.ts проверка tauri-plugin-updater, установка + перезапуск src-tauri/ @@ -267,7 +268,7 @@ src-tauri/ ## Тесты -`npm test` запускает 152 vitest-кейса: `src/engine/*.test.ts` покрывает все +`npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все классы выражений, оба языка, детерминированные инжектированные курсы, поиск неизвестного, исторические курсы (инжектированные), литералы и отображение форматов дат (отдельные движки с явным `dateFormat` — формат «system» по diff --git a/README.md b/README.md index be5ffd5..bf56fc2 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ auto-derived one) so the reference doesn't break when its first line changes. ## Extensions -> **Note:** extensions run as trusted local scripts with access to your files. Only load code you wrote or fully trust. +> **Note:** extensions run inside a WebAssembly sandbox — no file, network or app access, only the `numi` API below. A script that hangs is cut off; a script that fails is skipped. Drop a `.js` file into the extensions folder (Settings → Extensions folder) and restart. The API is compatible with Numi extensions: @@ -156,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md). ```bash npm install -npm test # engine tests (152 cases) +npm test # engine tests (259 cases) npm run tauri dev # run the app npm run tauri build # build the installer ``` diff --git a/README.ru.md b/README.ru.md index 547d8e8..9ce57f8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -125,7 +125,7 @@ AAPL в USD → $213.55 ## Расширения -> **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете. +> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается. Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти. API совместим с расширениями Numi: @@ -151,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" }, ```bash npm install -npm test # тесты движка (152 кейса) +npm test # тесты движка (259 кейсов) npm run tauri dev # запустить приложение npm run tauri build # собрать установщик ``` diff --git a/package-lock.json b/package-lock.json index 08a992f..b8ca277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/language": "^6.10.0", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.36.0", + "@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0", "@lezer/highlight": "^1.2.0", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.3.0", @@ -21,7 +22,8 @@ "@tauri-apps/plugin-opener": "^2.2.0", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", - "decimal.js": "^10.4.3" + "decimal.js": "^10.4.3", + "quickjs-emscripten-core": "^0.32.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -532,6 +534,21 @@ "node": ">=18" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-singlefile-browser-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-browser-release-sync/-/quickjs-singlefile-browser-release-sync-0.32.0.tgz", + "integrity": "sha512-Hfdl7rh8dzxNWFRiYAYNbhn0RMF1/tO6SMH2mUW0aTibqwaAtqPRbi4WkwaIDlhNz8Z4dksJi1Zjl1R54Jsc/Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1709,6 +1726,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", diff --git a/package.json b/package.json index 4736f61..faa23df 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@codemirror/language": "^6.10.0", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.36.0", + "@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0", "@lezer/highlight": "^1.2.0", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.3.0", @@ -27,7 +28,8 @@ "@tauri-apps/plugin-opener": "^2.2.0", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", - "decimal.js": "^10.4.3" + "decimal.js": "^10.4.3", + "quickjs-emscripten-core": "^0.32.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", diff --git a/src-tauri/extension-sample.js b/src-tauri/extension-sample.js index a54eb0b..3e195e9 100644 --- a/src-tauri/extension-sample.js +++ b/src-tauri/extension-sample.js @@ -1,9 +1,9 @@ -// WARNING: extensions run as trusted local code with full access to your files. -// Only load scripts you wrote yourself or fully trust. - // Summarum extension example. // Drop .js files into this folder and restart the app. // API: numi.setVariable, numi.addUnit, numi.addFunction. +// +// Scripts run in a WebAssembly sandbox: no files, no network, no app internals, +// and no console/setTimeout/fetch — only plain JS and the numi API above. // A global variable usable on any sheet, e.g. `salary * vat` // numi.setVariable("vat", { "double": 20 }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 57e1e57..f7075e2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev" + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev" } }, "bundle": { diff --git a/src/extensions.test.ts b/src/extensions.test.ts index 41691ec..f888ed4 100644 --- a/src/extensions.test.ts +++ b/src/extensions.test.ts @@ -38,71 +38,170 @@ describe("extensions", () => { describe("runExtensions", () => { let consoleErrorSpy: any; + let consoleWarnSpy: any; beforeEach(() => { consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + vi.resetModules(); + vi.doUnmock("quickjs-emscripten-core"); }); - it("runs scripts successfully using numi api", () => { - const scripts = [ - { - name: "script1", - code: "numi.setVariable('var1', 10);", - }, - { - name: "script2", - code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });", - }, - ]; - - runExtensions(mockEngine as SumEngine, scripts); + it("runs scripts successfully using numi api", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "script1", code: "numi.setVariable('var1', 10);" }, + { name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" }, + ]); expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); - expect(mockEngine.addUnit).toHaveBeenCalledWith({ id: "unit1", phrases: "u", baseUnitId: "USD", ratio: 1 }); + expect(mockEngine.addUnit).toHaveBeenCalledWith({ + id: "unit1", + phrases: "u", + baseUnitId: "USD", + ratio: 1, + }); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it("swallows errors and logs to console.error", () => { - const scripts = [ - { - name: "script1", - code: "throw new Error('test error');", - }, - ]; + it("swallows errors and logs to console.error", async () => { + await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]); - runExtensions(mockEngine as SumEngine, scripts); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - "extension script1 failed:", - expect.any(Error) - ); + expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error)); + expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error"); }); - it("continues running other scripts if one fails", () => { - const scripts = [ - { - name: "script1", - code: "numi.setVariable('var1', 10);", - }, - { - name: "script2", - code: "throw new Error('boom');", - }, - { - name: "script3", - code: "numi.setVariable('var3', 30);", - }, - ]; - - runExtensions(mockEngine as SumEngine, scripts); + it("continues running other scripts if one fails", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "script1", code: "numi.setVariable('var1', 10);" }, + { name: "script2", code: "throw new Error('boom');" }, + { name: "script3", code: "numi.setVariable('var3', 30);" }, + ]); expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); + + it("passes unit-carrying values through setVariable", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" }, + ]); + + expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" }); + }); + + it("rejects malformed api arguments as a script error", async () => { + await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]); + + expect(mockEngine.setVariable).not.toHaveBeenCalled(); + expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable"); + }); + + it("registers a function callable with one array of values", async () => { + await runExtensions(mockEngine as SumEngine, [ + { + name: "hyp", + code: "numi.addFunction({ id: 'hyp', phrases: 'hyp' }, function (values) { return { double: Math.hypot(values[0].double, values[1].double) }; });", + }, + ]); + + expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function)); + const fn = mockEngine.addFunction.mock.calls[0][1]; + expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 }); + expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 }); + }); + + it("marshals unitId into the sandbox and back out", async () => { + await runExtensions(mockEngine as SumEngine, [ + { + name: "echo", + code: "numi.addFunction({ id: 'echo', phrases: 'echo' }, function (v) { return { double: v[0].double * 2, unitId: v[0].unitId }; });", + }, + ]); + + const fn = mockEngine.addFunction.mock.calls[0][1]; + expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" }); + }); + + it("has no host globals inside the sandbox", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined') throw new Error('leak');" }, + { name: "log", code: "console.log('hi');" }, + ]); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension log failed:"); + expect(consoleErrorSpy.mock.calls[0][1].message).toMatch(/console/); + }); + + it("interrupts an infinite loop at load time", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "spin", code: "while (true) {}" }, + { name: "after", code: "numi.setVariable('ok', 1);" }, + ]); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:"); + // the runtime survives the interrupt and keeps loading scripts + expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1); + }); + + it("interrupts a hung function on every call, not just the first", async () => { + await runExtensions(mockEngine as SumEngine, [ + { + name: "spin", + code: "numi.addFunction({ id: 'spin', phrases: 'spin' }, function () { while (true) {} });", + }, + { + name: "good", + code: "numi.addFunction({ id: 'good', phrases: 'good' }, function (v) { return v[0].double + 1; });", + }, + ]); + + const spin = mockEngine.addFunction.mock.calls[0][1]; + const good = mockEngine.addFunction.mock.calls[1][1]; + + expect(() => spin([])).toThrow(/spin/); + // deadline is re-armed per call: a second call still times out rather than + // failing instantly, and an unrelated function still works afterwards + expect(() => spin([])).toThrow(/spin/); + expect(good([{ double: 1 }])).toBe(2); + }); + + it("surfaces a bad return value as a function error", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" }, + ]); + + const fn = mockEngine.addFunction.mock.calls[0][1]; + expect(() => fn([])).toThrow(/bad/); + }); + + it("skips extensions when the sandbox fails to load", async () => { + vi.resetModules(); + vi.doMock("quickjs-emscripten-core", () => ({ + newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")), + })); + const { runExtensions: run } = await import("./extensions"); + + await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]); + + expect(mockEngine.setVariable).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "extensions disabled: QuickJS sandbox unavailable:", + expect.any(Error), + ); + }); + + it("does not load the sandbox when there are no scripts", async () => { + await runExtensions(mockEngine as SumEngine, []); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/extensions.ts b/src/extensions.ts index 73967ad..80c20c2 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -1,5 +1,7 @@ -// JS extensions runtime: numi.setVariable, -// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime. +// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. +// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals. +import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core"; +import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync"; import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; interface ExtensionApi { @@ -16,12 +18,163 @@ export function makeApi(engine: SumEngine): ExtensionApi { }; } -export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void { - const numi = makeApi(engine); +// 32 MB is orders of magnitude more than a sheet helper needs, and small enough +// that a runaway allocation fails inside the sandbox instead of the host heap. +const MEMORY_LIMIT = 32 * 1024 * 1024; +// 512 KB — unbounded recursion hits this and throws in the sandbox, never on the JS stack. +const STACK_SIZE = 512 * 1024; +// One-off cost at startup, so it can be generous. +const LOAD_TIMEOUT_MS = 1000; +// Registered functions are called synchronously from evaluateDocument, i.e. on +// every keystroke for every line that uses them — this budget must stay small. +const CALL_TIMEOUT_MS = 50; + +const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + +function toExtValue(v: unknown, what: string): ExtensionValue | number { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (v && typeof v === "object") { + const o = v as { double?: unknown; unitId?: unknown }; + if (typeof o.double === "number" && Number.isFinite(o.double)) { + return typeof o.unitId === "string" ? { double: o.double, unitId: o.unitId } : { double: o.double }; + } + } + throw new Error(`${what}: expected a number or { double, unitId? }`); +} + +function message(e: unknown): string { + if (e instanceof Error) return e.message; + if (e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string") { + return (e as { message: string }).message; + } + return String(e); +} + +// The sandbox has no console/setTimeout/fetch: an extension touching those gets a +// ReferenceError inside QuickJS, which surfaces as a normal per-script failure. +// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app. +export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise { + if (scripts.length === 0) return; + try { + await load(engine, scripts); + } catch (e) { + console.warn("extensions disabled: QuickJS sandbox unavailable:", e); + } +} + +async function load(engine: SumEngine, scripts: { name: string; code: string }[]): Promise { + const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant); + const vm: QuickJSContext = mod.newContext(); + vm.runtime.setMemoryLimit(MEMORY_LIMIT); + vm.runtime.setMaxStackSize(STACK_SIZE); + + // Armed before every entry into the sandbox and disarmed after, so a hung script + // at load time and a hung function call on keystroke N are both bounded. + let deadline = Infinity; + vm.runtime.setInterruptHandler(() => now() > deadline); + + const api = makeApi(engine); + + const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => { + // extension-sample.js convention: the callback takes one array of values. + const arr = vm.newArray(); + try { + values.forEach((v, i) => { + const o = vm.newObject(); + const d = vm.newNumber(v.double); + vm.setProp(o, "double", d); + d.dispose(); + if (v.unitId) { + const u = vm.newString(v.unitId); + vm.setProp(o, "unitId", u); + u.dispose(); + } + vm.setProp(arr, i, o); + o.dispose(); + }); + let res; + deadline = now() + CALL_TIMEOUT_MS; + try { + res = vm.callFunction(fn, vm.undefined, [arr]); + } finally { + deadline = Infinity; + } + if (res.error) { + const err = vm.dump(res.error); + res.error.dispose(); + throw new Error(`${id}: ${message(err)}`); + } + const out = vm.dump(res.value); + res.value.dispose(); + return toExtValue(out, id); + } finally { + arr.dispose(); + } + }; + + const numi = vm.newObject(); + const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => { + const fn = vm.newFunction(name, (...args) => { + try { + impl(...args); + } catch (e) { + return { error: vm.newError(`numi.${name}: ${message(e)}`) }; + } + }); + vm.setProp(numi, name, fn); + fn.dispose(); + }; + + define("setVariable", (nameHandle, valueHandle) => { + const name = vm.dump(nameHandle); + if (typeof name !== "string" || !name) throw new Error("expects a variable name"); + api.setVariable(name, toExtValue(vm.dump(valueHandle), "value")); + }); + + define("addUnit", (specHandle) => { + const spec = vm.dump(specHandle); + if (!spec || typeof spec.id !== "string" || typeof spec.baseUnitId !== "string" || typeof spec.ratio !== "number") { + throw new Error("expects { id, phrases, baseUnitId, ratio }"); + } + api.addUnit({ + id: spec.id, + phrases: typeof spec.phrases === "string" ? spec.phrases : spec.id, + baseUnitId: spec.baseUnitId, + ...(typeof spec.format === "string" ? { format: spec.format } : {}), + ratio: spec.ratio, + }); + }); + + define("addFunction", (specHandle, fnHandle) => { + const spec = vm.dump(specHandle); + if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }"); + if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function"); + // outlives this callback, so it needs its own reference; the context is never disposed + const guestFn = fnHandle.dup(); + const id = spec.id; + api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) => + callGuest(id, guestFn, values), + ); + }); + + vm.setProp(vm.global, "numi", numi); + numi.dispose(); + for (const s of scripts) { try { - const fn = new Function("numi", s.code); - fn(numi); + let res; + deadline = now() + LOAD_TIMEOUT_MS; + try { + res = vm.evalCode(s.code, s.name); + } finally { + deadline = Infinity; + } + if (res.error) { + const err = vm.dump(res.error); + res.error.dispose(); + throw new Error(message(err)); + } + res.value.dispose(); } catch (e) { console.error(`extension ${s.name} failed:`, e); } diff --git a/src/main.ts b/src/main.ts index 218b51e..91b0657 100644 --- a/src/main.ts +++ b/src/main.ts @@ -753,7 +753,7 @@ async function initDataAndEngine(): Promise { if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id; const scripts = await loadExtensionScripts(); - runExtensions(engine, scripts); + await runExtensions(engine, scripts); workspace = new Workspace(engine, () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),