diff --git a/package-lock.json b/package-lock.json index 438d3d1..521473a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "summarum", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "summarum", - "version": "1.2.1", + "version": "1.2.2", "dependencies": { "@codemirror/autocomplete": "^6.18.0", "@codemirror/commands": "^6.8.0", @@ -21,7 +21,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": "^0.32.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -532,6 +533,48 @@ "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-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "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 +1752,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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..1bce3a2 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,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": "^0.32.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", diff --git a/src/extensions.test.ts b/src/extensions.test.ts index 41691ec..5dd4b03 100644 --- a/src/extensions.test.ts +++ b/src/extensions.test.ts @@ -47,7 +47,7 @@ describe("extensions", () => { consoleErrorSpy.mockRestore(); }); - it("runs scripts successfully using numi api", () => { + it("runs scripts successfully using numi api", async () => { const scripts = [ { name: "script1", @@ -59,14 +59,14 @@ describe("extensions", () => { }, ]; - runExtensions(mockEngine as SumEngine, scripts); + await runExtensions(mockEngine as SumEngine, scripts); expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); expect(mockEngine.addUnit).toHaveBeenCalledWith({ id: "unit1", phrases: "u", baseUnitId: "USD", ratio: 1 }); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it("swallows errors and logs to console.error", () => { + it("swallows errors and logs to console.error", async () => { const scripts = [ { name: "script1", @@ -74,15 +74,15 @@ describe("extensions", () => { }, ]; - runExtensions(mockEngine as SumEngine, scripts); + await runExtensions(mockEngine as SumEngine, scripts); expect(consoleErrorSpy).toHaveBeenCalledWith( "extension script1 failed:", - expect.any(Error) + expect.any(Object) ); }); - it("continues running other scripts if one fails", () => { + it("continues running other scripts if one fails", async () => { const scripts = [ { name: "script1", @@ -98,7 +98,7 @@ describe("extensions", () => { }, ]; - runExtensions(mockEngine as SumEngine, scripts); + await runExtensions(mockEngine as SumEngine, scripts); expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30); diff --git a/src/extensions.ts b/src/extensions.ts index 73967ad..bc7213c 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -1,6 +1,7 @@ // JS extensions runtime: numi.setVariable, // numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime. import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; +import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; interface ExtensionApi { setVariable(name: string, value: number | ExtensionValue): void; @@ -16,14 +17,122 @@ export function makeApi(engine: SumEngine): ExtensionApi { }; } -export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void { +export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise { const numi = makeApi(engine); - for (const s of scripts) { + const QuickJS = await getQuickJS(); + const vm = QuickJS.newContext(); + + // Create numi object in QuickJS + const numiHandle = vm.newObject(); + + // Helper to convert QuickJS handle to JS value (simple types and ExtensionValue) + const fromQuickJS = (handle: QuickJSHandle): any => { + const type = vm.typeof(handle); + if (type === "number") return vm.getNumber(handle); + if (type === "string") return vm.getString(handle); + if (type === "boolean") return vm.getNumber(handle) !== 0; // QuickJS booleans can be read as numbers? Wait, let's use dump + return vm.dump(handle); + }; + + // Helper to convert JS value to QuickJS handle + const toQuickJS = (val: any): QuickJSHandle => { + if (typeof val === "number") return vm.newNumber(val); + if (typeof val === "string") return vm.newString(val); + if (typeof val === "boolean") return val ? vm.true : vm.false; + if (typeof val === "object" && val !== null) { + // Only handle simple ExtensionValue objects safely for return values + const obj = vm.newObject(); + if ("double" in val) { + const doubleHandle = vm.newNumber(val.double); + vm.setProp(obj, "double", doubleHandle); + doubleHandle.dispose(); + } + if ("unitId" in val && val.unitId) { + const unitIdHandle = vm.newString(val.unitId); + vm.setProp(obj, "unitId", unitIdHandle); + unitIdHandle.dispose(); + } + return obj; + } + return vm.undefined; + }; + + const setVarHandle = vm.newFunction("setVariable", (nameHandle, valHandle) => { + const name = vm.getString(nameHandle); + const val = fromQuickJS(valHandle); try { - const fn = new Function("numi", s.code); - fn(numi); - } catch (e) { - console.error(`extension ${s.name} failed:`, e); + numi.setVariable(name, val); + } catch (e: any) { + console.error("setVariable failed:", e); + } + return vm.undefined; + }); + vm.setProp(numiHandle, "setVariable", setVarHandle); + setVarHandle.dispose(); + + const addUnitHandle = vm.newFunction("addUnit", (specHandle) => { + const spec = vm.dump(specHandle) as ExtensionUnitSpec; + try { + numi.addUnit(spec); + } catch (e: any) { + console.error("addUnit failed:", e); + } + return vm.undefined; + }); + vm.setProp(numiHandle, "addUnit", addUnitHandle); + addUnitHandle.dispose(); + + const addFunctionHandle = vm.newFunction("addFunction", (specHandle, fnHandle) => { + const spec = vm.dump(specHandle) as { id: string; phrases: string }; + + // We must keep the QuickJS function handle alive so the engine can call it later + // In a real robust system, we would need to clean these up when the engine resets. + // For now, we attach it to the engine's lifetime. + const persistentFnHandle = fnHandle.dup(); + + numi.addFunction(spec, (values: ExtensionValue[]) => { + // Convert arguments to QuickJS Array + const argsArray = vm.newArray(); + for (let i = 0; i < values.length; i++) { + const valHandle = toQuickJS(values[i]); + vm.setProp(argsArray, i, valHandle); + valHandle.dispose(); + } + + const result = vm.callFunction(persistentFnHandle, vm.undefined, argsArray); + argsArray.dispose(); + + if (result.error) { + console.error("Extension function error:", vm.dump(result.error)); + result.error.dispose(); + return 0; // fallback + } + + const out = fromQuickJS(result.value); + result.value.dispose(); + return out; + }); + + return vm.undefined; + }); + vm.setProp(numiHandle, "addFunction", addFunctionHandle); + addFunctionHandle.dispose(); + + vm.setProp(vm.global, "numi", numiHandle); + numiHandle.dispose(); + + for (const s of scripts) { + const result = vm.evalCode(s.code); + if (result.error) { + console.error(`extension ${s.name} failed:`, vm.dump(result.error)); + result.error.dispose(); + } else { + result.value.dispose(); } } + + // We cannot dispose the VM immediately if extensions added functions that need to be called later! + // The SumEngine expects to call these functions during parsing. + // So we deliberately leak/retain the vm instance here, or attach it to the engine. + (engine as any).__qjs_vm = vm; } diff --git a/src/main.ts b/src/main.ts index d0bedf1..6f9d85d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -745,7 +745,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] ?? "" })),