From 8b9366de7b155290557982bb1d7f812ac0c43583 Mon Sep 17 00:00:00 2001 From: Himanshi Gaba Date: Sat, 25 Jul 2026 19:30:17 +1000 Subject: [PATCH] Remove unsafe eval() in C# WASM binding loader, add safe resolution, logging, and partial DrawCircle binding --- CSharpWasm/SplashKitBindings.Generated.cs | 24 ++++- CSharpWasmExpo/main.js | 110 ++++++++++++++++++++-- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/CSharpWasm/SplashKitBindings.Generated.cs b/CSharpWasm/SplashKitBindings.Generated.cs index 75fb5b64..675f9b43 100644 --- a/CSharpWasm/SplashKitBindings.Generated.cs +++ b/CSharpWasm/SplashKitBindings.Generated.cs @@ -131,6 +131,28 @@ public partial class SplashKit [JSImport("SplashKitBackendWASM.process_events", "main.js")] public static partial void ProcessEvents(); + // --- Partial binding: DrawCircle ------------------------------------------------- + // SplashKit's draw_circle(color clr, double x, double y, double radius) takes a + // `color` struct, which the automatic generator above cannot marshal across the + // JS interop boundary (System.Runtime.InteropServices.JavaScript.JSImport only + // supports a fixed set of primitive/array types, not arbitrary C++ structs). + // + // As a partial/interim binding, colour is represented here as a packed RGBA + // uint, built via RgbaColor(r, g, b, a) - this mirrors how rgba_color() is used + // on the SplashKit side and lets DrawCircle be called from C# today. + // + // NOTE for whoever picks this up next: once the runtime is built locally + // (buildAndCopy.sh), confirm the exact exported signature of draw_circle by + // inspecting `draw_circle.toString()` in the browser devtools console, and + // adjust the parameter types below if they don't line up. A proper fix would + // add a real Color struct with a custom JSMarshalAs marshaler so callers can + // use SplashKitSDK.Color the same way the C++ and Python backends do. + [JSImport("SplashKitBackendWASM.rgba_color", "main.js")] + public static partial int RgbaColor(double r, double g, double b, double a); + + [JSImport("SplashKitBackendWASM.draw_circle", "main.js")] + public static partial void DrawCircle(int clr, double x, double y, double radius); + [JSImport("SplashKitBackendWASM.quit_requested", "main.js")] public static partial bool QuitRequested(); @@ -1211,4 +1233,4 @@ public enum PullUpDown{ PudDown = 1, PudUp = 2, } -} +} \ No newline at end of file diff --git a/CSharpWasmExpo/main.js b/CSharpWasmExpo/main.js index b8b414d6..174519e7 100644 --- a/CSharpWasmExpo/main.js +++ b/CSharpWasmExpo/main.js @@ -1,6 +1,35 @@ import { dotnet } from "./wwwroot/_framework/dotnet.js"; import methods from "./splashKitMethods.generated.js"; +// Prefix all runtime logging so it's easy to filter/search for in devtools +const LOG_PREFIX = "[SKO C# Runtime]"; + +/** + * Resolves a single SplashKit binding by name. + * + * Previously this project used eval(name) to look functions up. That has + * two problems: + * 1. Content-Security-Policy (CSP) headers that disallow 'unsafe-eval' + * (a very common, recommended CSP setting) cause every single lookup + * to throw, breaking the C# runtime completely in those environments. + * 2. eval() runs arbitrary strings as code, which is unsafe in general + * even when the input is "trusted" - it's unnecessary risk for what is + * really just a global-variable lookup. + * + * The SplashKit WASM glue (Emscripten) attaches every SplashKit function as + * a plain global, so a direct globalThis[name] lookup finds the exact same + * function without needing eval() at all. + */ +const resolveBinding = (name) => { + const fn = globalThis[name]; + return typeof fn === "function" ? fn : null; +}; + +/** + * Builds the set of JS bindings passed into the .NET runtime, and logs + * which (if any) SplashKit functions could not be found so missing/renamed + * bindings are easy to spot instead of failing silently. + */ const parseMethods = (methods) => { const methodList = methods .split(",") @@ -8,25 +37,59 @@ const parseMethods = (methods) => { .filter(Boolean); const bindingsFunctions = {}; + const missing = []; for (const name of methodList) { - try { - bindingsFunctions[name] = eval(name); - } catch (e) { - console.warn(e); + const fn = resolveBinding(name); + if (fn) { + bindingsFunctions[name] = fn; + } else { + missing.push(name); } } + if (missing.length > 0) { + console.warn( + `${LOG_PREFIX} ${missing.length} binding(s) could not be resolved and will be unavailable to C#:`, + missing, + ); + } + + console.log( + `${LOG_PREFIX} Resolved ${Object.keys(bindingsFunctions).length}/${methodList.length} SplashKit bindings.`, + ); + + return bindingsFunctions; +}; + +/** + * process_events drives SplashKit's event loop (input, window events, etc) + * and is called continuously by most C# programs. If it's missing for any + * reason (runtime not fully loaded yet, a future SplashKit rename, etc.), + * calling it would throw "X is not a function" and crash the user's whole + * program. We substitute a harmless no-op instead and log a clear warning, + * so execution can continue in a degraded-but-stable state. + */ +const withProcessEventsFallback = (bindingsFunctions) => { + if (typeof bindingsFunctions.process_events !== "function") { + console.warn( + `${LOG_PREFIX} process_events could not be resolved - using a no-op fallback. ` + + `Input and window events will not be processed until this is fixed.`, + ); + bindingsFunctions.process_events = () => {}; + } return bindingsFunctions; }; const loadDotNet = async () => { + console.log(`${LOG_PREFIX} Initialising .NET WASM runtime...`); + const { setModuleImports, getAssemblyExports, getConfig } = await dotnet .withDiagnosticTracing(false) .withApplicationArgumentsFromQuery() .create(); - const skFunctions = parseMethods(methods); + const skFunctions = withProcessEventsFallback(parseMethods(methods)); setModuleImports("main.js", { window: { @@ -39,6 +102,9 @@ const loadDotNet = async () => { const config = getConfig(); const exports = await getAssemblyExports(config.mainAssemblyName); + + console.log(`${LOG_PREFIX} .NET WASM runtime ready.`); + return exports; }; @@ -46,9 +112,22 @@ const CompileAndRun = async (code, reportError) => { try { const exports = await loadDotNet(); const result = await exports.CSharpCodeRunner.CompileAndRun(code); - if (result.includes("Compilation failed")) { + + if (result && result.includes("Compilation failed")) { + console.warn(`${LOG_PREFIX} Compilation failed:`, result); + const errors = result.split(":"); - const errorLine = errors[1].split("Line"); + const errorLine = errors[1]?.split("Line"); + + // Guard against unexpected compiler-error formats instead of throwing + // (the original code assumed errors[1] and errorLine[1] always exist). + if (!errorLine || errorLine.length < 2) { + console.error( + `${LOG_PREFIX} Could not parse a line number from the compiler error; reporting without one.`, + ); + reportError("__USERCODE__/code/main.cs", result, null, null, true); + return; + } const indexCorrector = 1; const filePath = "__USERCODE__/code/main.cs"; @@ -61,11 +140,22 @@ const CompileAndRun = async (code, reportError) => { ); } } catch (error) { - console.error("Error during code execution:", error); + console.error(`${LOG_PREFIX} Error during code execution:`, error); + // Surface runtime errors (not just compile errors) back to the IDE too, + // instead of only logging them to the console where a learner may not + // think to look. + reportError?.( + "__USERCODE__/code/main.cs", + `Runtime error: ${error?.message ?? error}`, + null, + null, + true, + ); } }; -// This event will be trigger by the csharp compiler +// This event is triggered by the C# compiler document.addEventListener("compileAndRun", (ev) => { + console.log(`${LOG_PREFIX} Received compileAndRun event.`); CompileAndRun(ev.detail.program[0].source, ev.detail.reportError); -}); +}); \ No newline at end of file