diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 57e1e57..e37ab6e 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' blob:; style-src 'self'; 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..c5113ba 100644 --- a/src/extensions.test.ts +++ b/src/extensions.test.ts @@ -40,14 +40,37 @@ describe("extensions", () => { let consoleErrorSpy: any; beforeEach(() => { + vi.stubGlobal("window", { __numi_api: undefined }); + vi.stubGlobal("Blob", class Blob { + parts: any[]; + options: any; + constructor(parts: any[], options: any) { this.parts = parts; this.options = options; } + }); + vi.stubGlobal("URL", { + createObjectURL: vi.fn((blob: any) => "blob:" + blob.parts[0]), + revokeObjectURL: vi.fn() + }); + vi.stubGlobal("document", { + createElement: vi.fn(() => ({ src: "", onload: null, onerror: null })), + head: { appendChild: vi.fn((script: any) => { + try { + const code = script.src.replace(/^blob:/, ""); + eval(code); + if (script.onload) script.onload(); + } catch(e) { + if (script.onerror) script.onerror(); + } + }) } + }); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { + vi.unstubAllGlobals(); consoleErrorSpy.mockRestore(); }); - it("runs scripts successfully using numi api", () => { + it("runs scripts successfully using numi api", async () => { const scripts = [ { name: "script1", @@ -59,14 +82,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,7 +97,7 @@ describe("extensions", () => { }, ]; - runExtensions(mockEngine as SumEngine, scripts); + await runExtensions(mockEngine as SumEngine, scripts); expect(consoleErrorSpy).toHaveBeenCalledWith( "extension script1 failed:", @@ -82,7 +105,7 @@ describe("extensions", () => { ); }); - it("continues running other scripts if one fails", () => { + it("continues running other scripts if one fails", async () => { const scripts = [ { name: "script1", @@ -98,7 +121,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..5848e5e 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -16,14 +16,32 @@ 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); + (window as any).__numi_api = numi; for (const s of scripts) { - try { - const fn = new Function("numi", s.code); - fn(numi); - } catch (e) { - console.error(`extension ${s.name} failed:`, e); - } + await new Promise((resolve) => { + try { + const wrappedCode = `try { (function(numi) { ${s.code} })(window.__numi_api); } catch(e) { window.__numi_err = e; }`; + const blob = new Blob([wrappedCode], { type: "text/javascript" }); + const url = URL.createObjectURL(blob); + const script = document.createElement("script"); + script.src = url; + script.onload = () => { + URL.revokeObjectURL(url); + if ((window as any).__numi_err) { + console.error(`extension ${s.name} failed:`, (window as any).__numi_err); + delete (window as any).__numi_err; + } + resolve(); + }; + script.onerror = () => { URL.revokeObjectURL(url); resolve(); }; + document.head.appendChild(script); + } catch (e) { + console.error(`extension ${s.name} failed:`, e); + resolve(); + } + }); } + delete (window as any).__numi_api; } 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] ?? "" })),