mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
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'.
This commit is contained in:
parent
5c4f2db2d2
commit
b0f6e9e89b
11 changed files with 347 additions and 65 deletions
|
|
@ -26,7 +26,8 @@ src/
|
||||||
app.css themes (light/dark via CSS variables)
|
app.css themes (light/dark via CSS variables)
|
||||||
main.ts app bootstrap: documents, settings, status bar, dnd
|
main.ts app bootstrap: documents, settings, status bar, dnd
|
||||||
storage.ts Tauri commands / localStorage fallback
|
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)
|
i18n.ts interface strings (en/ru)
|
||||||
updater.ts checks tauri-plugin-updater, installs + relaunches
|
updater.ts checks tauri-plugin-updater, installs + relaunches
|
||||||
src-tauri/
|
src-tauri/
|
||||||
|
|
@ -263,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
|
||||||
|
|
||||||
## Tests
|
## 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,
|
expression class, both languages, deterministic injected rates, goal seek,
|
||||||
historical rates (injected), date-format literals/display (dedicated engines
|
historical rates (injected), date-format literals/display (dedicated engines
|
||||||
with an explicit `dateFormat`, since the default "system" format depends on
|
with an explicit `dateFormat`, since the default "system" format depends on
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ src/
|
||||||
app.css темы (светлая/тёмная через CSS-переменные)
|
app.css темы (светлая/тёмная через CSS-переменные)
|
||||||
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
||||||
storage.ts команды Tauri / запасной вариант на localStorage
|
storage.ts команды Tauri / запасной вариант на localStorage
|
||||||
extensions.ts запускает пользовательские .js-файлы через движок
|
extensions.ts запускает пользовательские .js-файлы в песочнице
|
||||||
|
QuickJS/WASM, мост к API numi.* движка
|
||||||
i18n.ts строки интерфейса (en/ru)
|
i18n.ts строки интерфейса (en/ru)
|
||||||
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
||||||
src-tauri/
|
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» по
|
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ auto-derived one) so the reference doesn't break when its first line changes.
|
||||||
|
|
||||||
## Extensions
|
## 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)
|
Drop a `.js` file into the extensions folder (Settings → Extensions folder)
|
||||||
and restart. The API is compatible with Numi extensions:
|
and restart. The API is compatible with Numi extensions:
|
||||||
|
|
@ -156,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # engine tests (152 cases)
|
npm test # engine tests (259 cases)
|
||||||
npm run tauri dev # run the app
|
npm run tauri dev # run the app
|
||||||
npm run tauri build # build the installer
|
npm run tauri build # build the installer
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ AAPL в USD → $213.55
|
||||||
|
|
||||||
## Расширения
|
## Расширения
|
||||||
|
|
||||||
> **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете.
|
> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается.
|
||||||
|
|
||||||
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
|
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
|
||||||
API совместим с расширениями Numi:
|
API совместим с расширениями Numi:
|
||||||
|
|
@ -151,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # тесты движка (152 кейса)
|
npm test # тесты движка (259 кейсов)
|
||||||
npm run tauri dev # запустить приложение
|
npm run tauri dev # запустить приложение
|
||||||
npm run tauri build # собрать установщик
|
npm run tauri build # собрать установщик
|
||||||
```
|
```
|
||||||
|
|
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"@codemirror/language": "^6.10.0",
|
"@codemirror/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.36.0",
|
"@codemirror/view": "^6.36.0",
|
||||||
|
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||||
|
|
@ -21,7 +22,8 @@
|
||||||
"@tauri-apps/plugin-opener": "^2.2.0",
|
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
|
@ -532,6 +534,21 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
|
@ -1709,6 +1726,15 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.61.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@codemirror/language": "^6.10.0",
|
"@codemirror/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.36.0",
|
"@codemirror/view": "^6.36.0",
|
||||||
|
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||||
|
|
@ -27,7 +28,8 @@
|
||||||
"@tauri-apps/plugin-opener": "^2.2.0",
|
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Summarum extension example.
|
||||||
// Drop .js files into this folder and restart the app.
|
// Drop .js files into this folder and restart the app.
|
||||||
// API: numi.setVariable, numi.addUnit, numi.addFunction.
|
// 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`
|
// A global variable usable on any sheet, e.g. `salary * vat`
|
||||||
// numi.setVariable("vat", { "double": 20 });
|
// numi.setVariable("vat", { "double": 20 });
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|
|
||||||
|
|
@ -38,71 +38,170 @@ describe("extensions", () => {
|
||||||
|
|
||||||
describe("runExtensions", () => {
|
describe("runExtensions", () => {
|
||||||
let consoleErrorSpy: any;
|
let consoleErrorSpy: any;
|
||||||
|
let consoleWarnSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doUnmock("quickjs-emscripten-core");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs scripts successfully using numi api", () => {
|
it("runs scripts successfully using numi api", async () => {
|
||||||
const scripts = [
|
await runExtensions(mockEngine as SumEngine, [
|
||||||
{
|
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
||||||
name: "script1",
|
{ name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" },
|
||||||
code: "numi.setVariable('var1', 10);",
|
]);
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "script2",
|
|
||||||
code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
runExtensions(mockEngine as SumEngine, scripts);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
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();
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("swallows errors and logs to console.error", () => {
|
it("swallows errors and logs to console.error", async () => {
|
||||||
const scripts = [
|
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
|
||||||
{
|
|
||||||
name: "script1",
|
|
||||||
code: "throw new Error('test error');",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
runExtensions(mockEngine as SumEngine, scripts);
|
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
|
||||||
|
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"extension script1 failed:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("continues running other scripts if one fails", () => {
|
it("continues running other scripts if one fails", async () => {
|
||||||
const scripts = [
|
await runExtensions(mockEngine as SumEngine, [
|
||||||
{
|
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
||||||
name: "script1",
|
{ name: "script2", code: "throw new Error('boom');" },
|
||||||
code: "numi.setVariable('var1', 10);",
|
{ name: "script3", code: "numi.setVariable('var3', 30);" },
|
||||||
},
|
]);
|
||||||
{
|
|
||||||
name: "script2",
|
|
||||||
code: "throw new Error('boom');",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "script3",
|
|
||||||
code: "numi.setVariable('var3', 30);",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
runExtensions(mockEngine as SumEngine, scripts);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// JS extensions runtime: numi.setVariable,
|
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
|
// 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";
|
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||||
|
|
||||||
interface ExtensionApi {
|
interface ExtensionApi {
|
||||||
|
|
@ -16,12 +18,163 @@ export function makeApi(engine: SumEngine): ExtensionApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
|
// 32 MB is orders of magnitude more than a sheet helper needs, and small enough
|
||||||
const numi = makeApi(engine);
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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) {
|
for (const s of scripts) {
|
||||||
try {
|
try {
|
||||||
const fn = new Function("numi", s.code);
|
let res;
|
||||||
fn(numi);
|
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) {
|
} catch (e) {
|
||||||
console.error(`extension ${s.name} failed:`, e);
|
console.error(`extension ${s.name} failed:`, e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -753,7 +753,7 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
|
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
|
||||||
|
|
||||||
const scripts = await loadExtensionScripts();
|
const scripts = await loadExtensionScripts();
|
||||||
runExtensions(engine, scripts);
|
await runExtensions(engine, scripts);
|
||||||
|
|
||||||
workspace = new Workspace(engine, () =>
|
workspace = new Workspace(engine, () =>
|
||||||
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
|
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue