Skip to content
Open
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,8 @@ packages/registry/*/package.pr.toml
# replacement across cooperating xtask processes. It is intentionally retained
# so waiters never split onto different lock inodes.
.*.kandelo-index.lock

# SDL2 playground: auto-generated TTF→C-byte-array header.
# Regenerated from programs/sdl2/third_party/Inconsolata-Regular.ttf by
# scripts/build-programs.sh on every build.
programs/sdl2/third_party/inconsolata_ttf.h
208 changes: 86 additions & 122 deletions apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import {
type ImageOwnedRuntimeLazyAssets,
} from "../../../lib/init/image-owned-runtime-urls";
import { BrowserInputSource } from "../../../../../host/src/input/browser-input-source";
import sdl2PlasmaFragSrc from "../../../../../programs/sdl2/presets/image/plasma.frag?raw";
import sdl2AudioBarsFragSrc from "../../../../../programs/sdl2/presets/image/audio_bars.frag?raw";
import sdl2TunnelwispFragSrc from "../../../../../programs/sdl2/presets/image/tunnelwisp.frag?raw";
import sdl2SoundSineFragSrc from "../../../../../programs/sdl2/presets/sound/sine.frag?raw";
import sdl2SoundTunnelwispFragSrc from "../../../../../programs/sdl2/presets/sound/tunnelwisp.frag?raw";
import sdl2SoundFmBellFragSrc from "../../../../../programs/sdl2/presets/sound/fm_bell.frag?raw";
import sdl2SoundNoiseSweepFragSrc from "../../../../../programs/sdl2/presets/sound/noise_sweep.frag?raw";
import sdl2SoundChordFragSrc from "../../../../../programs/sdl2/presets/sound/chord.frag?raw";
import {
WORDPRESS_CONFIG_INIT_SCRIPT,
WORDPRESS_URL_MU_PLUGIN,
Expand All @@ -25,8 +33,6 @@ import {
} from "../../../lib/init/wordpress-mariadb-readiness";
import { MemoryFileSystem } from "../../../../../host/src/vfs/memory-fs";
import {
extractZipEntry,
parseZipCentralDirectory,
} from "../../../../../host/src/vfs/zip";
import {
resolveBrowserCorsProxyConfig,
Expand Down Expand Up @@ -175,24 +181,10 @@ const OPTIONAL_BINARY_URLS = {
import: "default",
},
),
...import.meta.glob("../../../../../local-binaries/programs/wasm32/evdev_demo.wasm", {
...import.meta.glob("../../../../../local-binaries/programs/wasm32/sdl2.wasm", {
query: "?url", import: "default",
}),
...import.meta.glob("../../../../../binaries/programs/wasm32/evdev_demo.wasm", {
query: "?url", import: "default",
}),
// espeak-ng publishes a wasm output plus a runtime file, so the resolver
// mirrors its whole closure under the package directory.
...import.meta.glob("../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", {
query: "?url", import: "default",
}),
...import.meta.glob("../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", {
query: "?url", import: "default",
}),
...import.meta.glob("../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", {
query: "?url", import: "default",
}),
...import.meta.glob("../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", {
...import.meta.glob("../../../../../binaries/programs/wasm32/sdl2.wasm", {
query: "?url", import: "default",
}),
} as Record<string, () => Promise<string>>;
Expand Down Expand Up @@ -332,8 +324,7 @@ const LIVE_DEMO_IDS = [
"wordpress-mariadb",
"doom",
"modeset",
"evdev",
"espeak",
"sdl2",
] as const;

type LiveDemoId = (typeof LIVE_DEMO_IDS)[number];
Expand Down Expand Up @@ -432,11 +423,9 @@ const LIVE_DEMO_SPECS: Record<LiveDemoId, LiveDemoSpec> = {
image: "shell",
features: ["kms"],
},
evdev: {
image: "shell",
},
espeak: {
sdl2: {
image: "shell",
features: ["kms"],
},
};

Expand Down Expand Up @@ -496,21 +485,13 @@ interface LiveProfile {
};
framebufferTest: boolean;
/**
* Stage `evdev_demo` into `/usr/local/bin`, attach a `BrowserInputSource`
* to the window so keyboard/pointer events flow into the kernel's
* `/dev/input/event{0,1}`, and run the binary from bash so its event
* log streams to the user's Shell pane.
* Stage the SDL2 GLSL playground at `/usr/local/bin/sdl2` with its
* shader presets, attach a `BrowserInputSource` for the keyboard and
* wheel (the Modeset pane owns the pointer through `sendPointerAbs`),
* and run the binary from bash. Audio rides the /dev/dsp path every
* other sound demo uses.
*/
evdevDemo: boolean;
/**
* Spawn `espeak-ng "..."` from the booted shell. espeak-ng links
* upstream pcaudiolib built with only its OSS backend, so
* `create_audio_device_object` falls through to `/dev/dsp` and a
* single binary invocation produces audible synthesised speech
* without any host-side pipeline. The binary + data dir are baked
* into the image via `stageEspeakRuntime`.
*/
espeakDemo: boolean;
sdl2Demo: boolean;
}

interface WebReadinessState {
Expand Down Expand Up @@ -949,8 +930,7 @@ function customVfsProfile(
shell: "default",
maxVfsByteLength: CUSTOM_VFS_PROFILE_MAX_BYTES,
framebufferTest: fb === "test",
evdevDemo: false,
espeakDemo: false,
sdl2Demo: false,
};
}

Expand Down Expand Up @@ -999,8 +979,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile {
},
},
framebufferTest: fb === "test",
evdevDemo: normalized === "evdev",
espeakDemo: normalized === "espeak",
sdl2Demo: normalized === "sdl2",
};
}

Expand Down Expand Up @@ -1277,16 +1256,11 @@ async function bootProfile(
ensureDirRecursive(buildFs, dirname(profile.init.argv[0]));
writeVfsBinary(buildFs, profile.init.argv[0], new Uint8Array(bytes), 0o755);
}
// Both demos run their binary from a path, so the bytes have to be in the
// The demo runs its binary from a path, so the bytes have to be in the
// image before the worker takes exclusive ownership of the VFS.
if (profile.espeakDemo) {
tick("staging espeak-ng...");
await stageEspeakRuntime(buildFs);
assertCurrent();
}
if (profile.evdevDemo) {
tick("staging evdev_demo...");
await stageEvdevDemo(buildFs);
if (profile.sdl2Demo) {
tick("staging sdl2...");
await stageSdl2Runtime(buildFs);
assertCurrent();
}
ensureDemoHomes(buildFs);
Expand Down Expand Up @@ -1521,46 +1495,46 @@ async function bootProfile(
tick,
assertCurrent,
);
} else if (profile.espeakDemo) {
// The binary and its voice data are already in the image; see
// stageEspeakRuntime. Playback rides the /dev/dsp path every other
// sound demo uses.
void (async () => {
try {
tick("running espeak-ng...");
await host.runShellCommand(
`/usr/bin/espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`,
);
tick("espeak-ng exited");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
tick(`espeak-ng failed: ${msg}`);
}
})();
} else if (profile.evdevDemo) {
} else if (profile.sdl2Demo) {
// autoCommand can't run this: the InputSource must be attached before
// the binary starts polling /dev/input/event{0,1}. The binary itself is
// already in the image; see stageEvdevDemo.
const kernelForEvdev = kernel;
// the binary starts polling /dev/input/event{0,1}. The binary and its
// shader presets are already in the image; see stageSdl2Runtime.
const kernelForSdl2 = kernel;
void (async () => {
try {
tick("attaching input source...");
kernelForEvdev.attachInputSource(new BrowserInputSource(window), {
width: window.innerWidth,
height: window.innerHeight,
});
tick("running evdev_demo...");
// evdev_demo runs forever; runShellCommand resolves when the
// bash prompt reappears (it never will) or rejects after its
// internal 5-minute timeout. Both are expected — log neutrally.
await host.runShellCommand("/usr/local/bin/evdev_demo");
tick("evdev_demo exited");
// Keyboard goes through BrowserInputSource (typing, ESC → evdev
// event0). The POINTER is owned by the Modeset pane, which feeds
// framebuffer-positioned pointer events into evdev event1 via
// `sendPointerAbs` — so this source's pointer feed is disabled
// (its window-relative coordinates would fight the pane's
// correct ones). WHEEL stays enabled: REL_WHEEL carries no
// absolute coordinates, so it doesn't fight the pane, and it
// drives the editor's mouse-scroll (SDL_MOUSEWHEEL).
// The dims set EVIOCGABS's ABS_X/Y.maximum. SDL treats event1
// as a relative mouse (it advertises REL_X/Y) and clamps the
// cursor to the window rather than this range, but the
// framebuffer size (1920×1080, matching
// host/src/dri/kms-registry.ts and the Modeset canvas) keeps
// the bounds sane for any ABS-aware consumer.
const SDL2_FB_W = 1920;
const SDL2_FB_H = 1080;
kernelForSdl2.attachInputSource(
new BrowserInputSource(window, { pointer: false, wheel: true }),
{ width: SDL2_FB_W, height: SDL2_FB_H },
);
tick("running sdl2...");
// The playground runs until ESC; runShellCommand resolves when
// the bash prompt reappears or rejects after its internal
// 5-minute timeout. Both are expected — log neutrally.
await host.runShellCommand("/usr/local/bin/sdl2");
tick("sdl2 exited");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/timed out waiting for PTY prompt/.test(msg)) {
tick("evdev_demo running (long-tail; no further status updates)");
tick("sdl2 running (long-tail; no further status updates)");
} else {
tick(`evdev_demo failed: ${msg}`);
tick(`sdl2 failed: ${msg}`);
}
}
})();
Expand Down Expand Up @@ -1640,51 +1614,41 @@ function stageShellUtilities(
}

/**
* Bake espeak-ng and its voice data into the image.
* Bake the SDL2 GLSL playground and its shader presets into the image.
*
* Both come from the espeak-ng package closure, so the demo consumes the same
* bytes the resolver published. libespeak-ng's PATH_ESPEAK_DATA is fixed to
* /usr/share at build time, so the data tree has to land unpacked there.
* The playground's source-resolution chain is
* 1. /home/shaders/<mode>/current.frag (user-editable)
* 2. /usr/share/shaders/<mode>/<preset>.frag (preset)
* 3. built-in fallback compiled into main.c
* Staging (2) makes the browser path exercise the VFS leg;
* /home/shaders/<mode> is created so Ctrl+S can write (1) without first
* creating directories. tunnelwisp is the boot default for both modes;
* the others are loadable through the editor's Ctrl+L preset browser.
*/
async function stageEspeakRuntime(fs: MemoryFileSystem): Promise<void> {
const binaryUrl = await optionalBinaryUrl([
"../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng.wasm",
"../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng.wasm",
], "espeak-ng.wasm");
const binary = await fetch(binaryUrl)
.then(failOn("espeak-ng.wasm"))
.then((r) => r.arrayBuffer());
ensureDirRecursive(fs, "/usr/bin");
writeVfsBinary(fs, "/usr/bin/espeak-ng", new Uint8Array(binary), 0o755);

const dataUrl = await optionalBinaryUrl([
"../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip",
"../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip",
], "espeak-ng-data.zip");
const data = await fetch(dataUrl)
.then(failOn("espeak-ng-data.zip"))
.then((r) => r.arrayBuffer());
const zipBytes = new Uint8Array(data);
const root = "/usr/share/espeak-ng-data";
ensureDirRecursive(fs, root);
for (const entry of parseZipCentralDirectory(zipBytes)) {
if (entry.isDirectory) continue;
const target = `${root}/${entry.fileName}`;
ensureDirRecursive(fs, target.slice(0, target.lastIndexOf("/")));
writeVfsBinary(fs, target, extractZipEntry(zipBytes, entry), 0o644);
}
}

async function stageEvdevDemo(fs: MemoryFileSystem): Promise<void> {
async function stageSdl2Runtime(fs: MemoryFileSystem): Promise<void> {
const url = await optionalBinaryUrl([
"../../../../../local-binaries/programs/wasm32/evdev_demo.wasm",
"../../../../../binaries/programs/wasm32/evdev_demo.wasm",
], "evdev_demo.wasm");
"../../../../../local-binaries/programs/wasm32/sdl2.wasm",
"../../../../../binaries/programs/wasm32/sdl2.wasm",
], "sdl2.wasm");
const bytes = await fetch(url)
.then(failOn("evdev_demo.wasm"))
.then(failOn("sdl2.wasm"))
.then((r) => r.arrayBuffer());
ensureDirRecursive(fs, "/usr/local/bin");
writeVfsBinary(fs, "/usr/local/bin/evdev_demo", new Uint8Array(bytes), 0o755);
writeVfsBinary(fs, "/usr/local/bin/sdl2", new Uint8Array(bytes), 0o755);

ensureDirRecursive(fs, "/usr/share/shaders/image");
ensureDirRecursive(fs, "/home/shaders/image");
writeVfsFile(fs, "/usr/share/shaders/image/plasma.frag", sdl2PlasmaFragSrc);
writeVfsFile(fs, "/usr/share/shaders/image/audio_bars.frag", sdl2AudioBarsFragSrc);
writeVfsFile(fs, "/usr/share/shaders/image/tunnelwisp.frag", sdl2TunnelwispFragSrc);

ensureDirRecursive(fs, "/usr/share/shaders/sound");
ensureDirRecursive(fs, "/home/shaders/sound");
writeVfsFile(fs, "/usr/share/shaders/sound/tunnelwisp.frag", sdl2SoundTunnelwispFragSrc);
writeVfsFile(fs, "/usr/share/shaders/sound/sine.frag", sdl2SoundSineFragSrc);
writeVfsFile(fs, "/usr/share/shaders/sound/fm_bell.frag", sdl2SoundFmBellFragSrc);
writeVfsFile(fs, "/usr/share/shaders/sound/noise_sweep.frag", sdl2SoundNoiseSweepFragSrc);
writeVfsFile(fs, "/usr/share/shaders/sound/chord.frag", sdl2SoundChordFragSrc);
}

function ensureDemoHomes(fs: MemoryFileSystem): void {
Expand Down
16 changes: 16 additions & 0 deletions apps/browser-demos/pages/kandelo/panes/Modeset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,21 @@ export const Modeset: React.FC<ModesetProps> = ({ crtcId = 1, onDockControlsChan
prevCanvasX = canvasX;
prevCanvasY = canvasY;
};
// Absolute-position pointer feed for evdev consumers (SDL2's
// KMSDRM backend reads `/dev/input/event1`, which the PS/2
// `sendMouseEvent` path above does NOT reach). `toCanvasCoords`
// already maps the OS pointer into framebuffer pixels (0..canvas
// .width), exactly the range SDL expects after we set the kernel's
// ABS_X/Y.maximum to the framebuffer size. modeset.c ignores
// event1 (it reads PS/2 /dev/input/mice), so feeding both is safe.
const sendAbs = (canvasX: number, canvasY: number) => {
handleRef.current?.sendPointerAbs(canvasX, canvasY, buttons);
};
const onPointerEnter = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
const c = toCanvasCoords(e.clientX, e.clientY);
handlePointerAt(c.x, c.y);
sendAbs(c.x, c.y);
};
const onPointerLeave = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
Expand All @@ -161,6 +172,7 @@ export const Modeset: React.FC<ModesetProps> = ({ crtcId = 1, onDockControlsChan
if (e.pointerType === "touch" && e.pointerId !== activeTouchId) return;
const c = toCanvasCoords(e.clientX, e.clientY);
handlePointerAt(c.x, c.y);
sendAbs(c.x, c.y);
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === "touch") {
Expand All @@ -181,7 +193,9 @@ export const Modeset: React.FC<ModesetProps> = ({ crtcId = 1, onDockControlsChan
handlePointerAt(c.x, c.y);
}
buttons |= bit;
const c = toCanvasCoords(e.clientX, e.clientY);
handleRef.current?.sendMouseEvent(0, 0, buttons);
sendAbs(c.x, c.y);
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType === "touch") {
Expand All @@ -194,7 +208,9 @@ export const Modeset: React.FC<ModesetProps> = ({ crtcId = 1, onDockControlsChan
if (bit === 0) return;
e.preventDefault();
buttons &= ~bit;
const c = toCanvasCoords(e.clientX, e.clientY);
handleRef.current?.sendMouseEvent(0, 0, buttons);
sendAbs(c.x, c.y);
};
const onPointerCancel = (e: PointerEvent) => {
if (e.pointerType === "touch" && e.pointerId !== activeTouchId) return;
Expand Down
21 changes: 5 additions & 16 deletions apps/browser-demos/pages/kandelo/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,13 @@ export const PRESET_LIBRARY: Preset[] = [
estimatedUrlBytes: 612,
},
{
id: "evdev",
title: "Evdev input log",
summary: "Keystrokes + pointer motion captured from the DOM and replayed through /dev/input/event{0,1}.",
id: "sdl2",
title: "SDL2 GLSL playground",
summary: "SDL2 GLSL shader playground: a live-coding editor (left) beside a GLES2 fragment shader rendered on a 1920×1080 /dev/dri/card0 surface (right), auto-recompiling 250 ms after you stop typing. F1/F2 switch between the image and sound shader, Ctrl+L cycles presets, Ctrl+S persists, ESC quits.",
base: SHELL_BASE,
packages: ["bash@local", "coreutils@local"],
accent: "#7e57c2",
glyph: "E",
bootCommand: ["bash", "-l", "-i"],
estimatedUrlBytes: 612,
},
{
id: "espeak",
title: "OSS - Espeak-NG",
summary: "The kernel speaks: espeak-ng synthesises text directly through libpcaudio's OSS backend.",
base: SHELL_BASE,
packages: ["bash@local", "coreutils@local"],
accent: "#f48fb1",
glyph: "T",
accent: "#9c27b0",
glyph: "S",
bootCommand: ["bash", "-l", "-i"],
estimatedUrlBytes: 612,
},
Expand Down
Loading