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
359 changes: 355 additions & 4 deletions abi/snapshot.json

Large diffs are not rendered by default.

70 changes: 69 additions & 1 deletion apps/browser-demos/pages/kandelo/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
BootDescriptor,
GalleryItem,
LazyDownloadEvent,
MachineAudioState,
} from "../../../../../web-libs/kandelo-session/src/kernel-host";

type InternalsTab = "syslog" | "procs" | "vfs" | "lazy-load" | "config" | "syscalls";
Expand Down Expand Up @@ -70,12 +71,41 @@ export const App: React.FC = () => {
const [themeOpen, setThemeOpen] = React.useState(false);
const [terminals, setTerminals] = React.useState<ShellTerminal[]>(() => [createShellTerminal(1)]);
const [activeTerminalId, setActiveTerminalId] = React.useState("tty-1");
const [audioState, setAudioState] = React.useState<MachineAudioState>(() => host.getAudioState());
const [audioError, setAudioError] = React.useState<string | null>(null);
const nextTerminalIndex = React.useRef(2);
const autoOpenedDemoGuideKey = React.useRef<string | null>(null);

const desc = host.getBootDescriptor();
const resolvedThemeMode = theme.mode === "auto" ? systemThemeMode : theme.mode;

React.useEffect(
() => host.subscribeAudioState((state) => {
setAudioState(state);
if (state === "running") setAudioError(null);
}),
[host],
);

const activateAudio = React.useCallback(() => {
if (host.getAudioState() === "running") return;
void host.resumeAudio().then(
() => setAudioError(null),
(error) => setAudioError(error instanceof Error ? error.message : String(error)),
);
}, [host]);

// Web Audio starts only after a trusted gesture. Keep activation at the
// machine shell so terminal-only SDL applications use the same PCM sink.
React.useEffect(() => {
window.addEventListener("pointerdown", activateAudio, { capture: true });
window.addEventListener("keydown", activateAudio, { capture: true });
return () => {
window.removeEventListener("pointerdown", activateAudio, { capture: true });
window.removeEventListener("keydown", activateAudio, { capture: true });
};
}, [activateAudio]);

React.useEffect(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => setSystemThemeMode(query.matches ? "dark" : "light");
Expand Down Expand Up @@ -228,7 +258,7 @@ export const App: React.FC = () => {
}, []);

return (
<div className={appClassName} style={appStyle}>
<div className={appClassName} style={appStyle} data-audio-state={audioState}>
<main className={`kmain kdocked-main${isEmpty ? " kmain-flush" : ""}`}>
{isEmpty ? (
<EmptyState
Expand Down Expand Up @@ -276,6 +306,13 @@ export const App: React.FC = () => {
)}

<LazyDownloadToasts downloads={lazyDownloads} />
{surface.status === "running" && audioState !== "running" && (
<AudioStatusToast
state={audioState}
error={audioError}
onEnable={activateAudio}
/>
)}

<Dock
activePane={dockPane}
Expand Down Expand Up @@ -310,6 +347,37 @@ export const App: React.FC = () => {
);
};

const AudioStatusToast: React.FC<{
state: MachineAudioState;
error: string | null;
onEnable: () => void;
}> = ({ state, error, onEnable }) => {
const detail = error ?? (
state === "interrupted"
? "Audio output was interrupted by the browser or operating system."
: state === "unavailable"
? "This browser does not provide the required Web Audio output."
: state === "error"
? "The browser audio sink could not be started."
: "Browser policy pauses audio until you interact with this machine."
);
return (
<aside className="kdownload-toasts kpcm-audio-status" aria-label="Audio status" aria-live="polite">
<div className={`kdownload-toast${error || state === "error" ? " kpcm-audio-error" : ""}`}>
<div className="kdownload-toast-top">
<span className="kdownload-toast-title">Audio {state}</span>
{state !== "unavailable" && state !== "closed" && (
<button type="button" className="kpcm-audio-enable" onClick={onEnable}>
Enable
</button>
)}
</div>
<div className="kdownload-toast-detail">{detail}</div>
</div>
</aside>
);
};

const TerminalDockControls: React.FC<{
terminals: ShellTerminal[];
activeTerminalId: string;
Expand Down
21 changes: 3 additions & 18 deletions apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Framebuffer pane — paints whatever process is bound to /dev/fb0, forwards
// focused keyboard input as Linux input keycodes encoded in MEDIUMRAW, forwards
// pointer-lock mouse input to /dev/input/mice, and drains /dev/dsp audio.
// pointer-lock mouse input to /dev/input/mice. PCM output is machine-level.
//
// Painting: host.attachFramebuffer(canvas) returns a FramebufferHandle; the
// host owns the requestAnimationFrame loop and BGRA→RGBA swizzle (see
Expand All @@ -21,10 +21,7 @@ import {
attachPointerLockMouse,
type PointerLockMouseHandle,
} from "../../../../../host/src/framebuffer/browser-controls";
import type {
AudioOutputHandle,
FramebufferHandle,
} from "../../../../../web-libs/kandelo-session/src/kernel-host";
import type { FramebufferHandle } from "../../../../../web-libs/kandelo-session/src/kernel-host";
import { useFittedCanvasStyle } from "./canvasFit";

export interface FramebufferProps {
Expand All @@ -43,7 +40,6 @@ export const Framebuffer: React.FC<FramebufferProps> = ({ autoFocus = false, onD
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const handleRef = React.useRef<FramebufferHandle | null>(null);
const mouseRef = React.useRef<PointerLockMouseHandle | null>(null);
const audioRef = React.useRef<AudioOutputHandle | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [boundPid, setBoundPid] = React.useState<number | null>(null);
const [focused, setFocused] = React.useState(false);
Expand All @@ -55,27 +51,16 @@ export const Framebuffer: React.FC<FramebufferProps> = ({ autoFocus = false, onD

let handle: FramebufferHandle | null = null;
let offBound: (() => void) | null = null;
let cancelled = false;
try {
handle = host.attachFramebuffer(canvasRef.current);
handleRef.current = handle;
setBoundPid(handle.getBoundPid());
offBound = handle.onBoundPidChange(setBoundPid);
setError(null);
void handle.startAudio().then((audio) => {
if (cancelled) {
audio?.close();
return;
}
audioRef.current = audio;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
return () => {
cancelled = true;
try { audioRef.current?.close(); } catch { /* noop */ }
audioRef.current = null;
try { offBound?.(); } catch { /* noop */ }
try { handle?.close(); } catch { /* noop */ }
handleRef.current = null;
Expand Down Expand Up @@ -151,7 +136,7 @@ export const Framebuffer: React.FC<FramebufferProps> = ({ autoFocus = false, onD

const onCanvasClick = () => {
canvasRef.current?.focus();
void audioRef.current?.resume();
void host.resumeAudio().catch(() => {});
mouseRef.current?.requestCapture();
};

Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/kandelo/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const PRESET_LIBRARY: Preset[] = [
{
id: "doom",
title: "fbDOOM",
summary: "id Software's DOOM rendering directly to /dev/fb0.",
summary: "DOOM on /dev/fb0 with OSS audio through /dev/dsp.",
base: SHELL_BASE,
packages: ["fbdoom@local", "doom-shareware@local", "bash@local", "coreutils@local"],
accent: "#b5301c",
Expand Down
19 changes: 19 additions & 0 deletions apps/browser-demos/pages/kandelo/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,25 @@
animation: kdownload-toast-fade-error 5s ease-out forwards;
}

.kpcm-audio-status {
top: auto;
bottom: calc(var(--kdock-height, 0px) + 14px);
}

.kpcm-audio-error {
border-color: color-mix(in oklch, var(--k-err) 52%, var(--k-border));
}

.kpcm-audio-enable {
margin-left: auto;
border: 1px solid color-mix(in oklch, var(--k-accent) 42%, var(--k-border));
border-radius: 6px;
background: color-mix(in oklch, var(--k-accent) 12%, var(--k-surface));
color: var(--k-accent);
cursor: pointer;
padding: 3px 8px;
}

.kdownload-toast-top {
display: flex;
align-items: center;
Expand Down
1 change: 0 additions & 1 deletion apps/browser-demos/pages/kandelo/views/Config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,6 @@ const CAPS: ReadonlyArray<readonly [
["persistence", "OPFS persistence", "Store writable mounts in the Origin Private File System."],
["clipboard", "Clipboard", "Allow programs to read and write the system clipboard."],
["camera", "Camera", "Expose /dev/video0 backed by getUserMedia."],
["microphone", "Microphone", "Expose /dev/audio backed by getUserMedia."],
["filesystem", "Local files", "Mount the user's File System Access handle into the VFS."],
];

Expand Down
4 changes: 4 additions & 0 deletions apps/browser-demos/pages/test-runner/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
</head>
<body>
<div id="status">Loading kernel...</div>
<div id="audio-controls">
<button id="resume-audio" type="button" disabled>Resume audio</button>
<span id="audio-status">Audio test idle</span>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>
Loading
Loading