Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CSharpWasm/SplashKitBindings.Generated.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -1211,4 +1233,4 @@ public enum PullUpDown{
PudDown = 1,
PudUp = 2,
}
}
}
110 changes: 100 additions & 10 deletions CSharpWasmExpo/main.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,95 @@
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(",")
.map((method) => method.trim().replace("\n", ""))
.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: {
Expand All @@ -39,16 +102,32 @@ const loadDotNet = async () => {

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);

console.log(`${LOG_PREFIX} .NET WASM runtime ready.`);

return exports;
};

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";
Expand All @@ -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);
});
});
Loading