Files
DEVICE_CONTACT/src/ui/app.ts
2025-12-18 20:29:45 +02:00

586 lines
18 KiB
TypeScript

import { createWriteStream, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { type CanvasRenderingContext2D } from "@napi-rs/canvas";
import { createBootSequenceUI } from "../bootsequence/questions";
import { GREETD_SOCKET, handoffToGreetd, KNOWN_SESSIONS_FRIENDLY } from "../desktop";
import type { BootsequenceAnswers } from "../types";
import { loadImageAsset } from "../renderer/assets";
import { parseCli } from "../renderer/cli";
import { createDebugHud } from "../renderer/debug-hud";
import { createFpsCounter } from "../renderer/fps";
import { createLayoutCalculator } from "../renderer/layout";
import { createRenderer } from "../renderer/index";
import { SDLWindow } from "../renderer/window";
import { createRendererRegistry } from "../renderers";
import type { RendererInstance, RendererProps } from "../renderers/types";
import { clearLock, readLock } from "./lock";
const BUNVERS = `Bun ${Bun.version} ${process.platform} ${process.arch}`;
const DEFAULT_DEBUG_LOG_FILE = "/tmp/device_contact.debug.log";
const LOG_LIFETIME_MS = 8_000;
const LOG_FADE_MS = 3_000;
const LOG_MAX_LINES = 64;
const debugLogEntries: DebugLogEntry[] = [];
export async function runDeviceContactUI(argv: string[] = process.argv) {
const cli = parseCli(argv);
const isDev = process.env.NODE_ENV === "development";
const debugOptions = {
showGlobal: isDev || cli.debugGlobalHud || process.env.DEBUG_UI === "true",
showRenderer: isDev || cli.debugRendererHud || process.env.DEBUG_RENDERER === "true" || cli.debugGlobalHud,
showCustom: isDev || cli.debugGlobalHud || process.env.DEBUG_UI === "true"
};
const debugLoggingEnabled = debugHudOptionsEnabled(debugOptions);
const debugLogFile =
cli.debugLogFile ??
process.env.DEBUG_LOG_FILE ??
(debugLoggingEnabled ? DEFAULT_DEBUG_LOG_FILE : undefined);
const restoreDebug =
debugLoggingEnabled || debugLogFile
? hookDebugLogs({ filePath: debugLogFile })
: () => { };
console.debug("[debug] ESTABLISHING CONNECTION");
console.debug("[debug]", BUNVERS);
const crashLock = readLock();
const crashDesktop = crashLock?.answers?.desktop;
const isCrashRecovery = !!(cli.crashRecoverySession ?? process.env.CRASH_RECOVERY_SESSION ?? crashDesktop);
const forcedErrorScreen = Boolean(cli.errorScreenRequested);
const requestedRenderer = cli.rendererId ?? "device_contact";
const defaultRendererId = forcedErrorScreen
? "errormessage"
: isCrashRecovery
? "recoverymenu"
: requestedRenderer;
const shouldRunBootSequence = defaultRendererId !== "recoverymenu" && defaultRendererId !== "errormessage";
const isTTY = process.env.SDL_VIDEODRIVER === "kmsdrm";
const isCage = process.env.IS_CAGE === "true";
const windowOptions = {
// DO NOT CHANGE TITLE
title: "DEVICE CONTACT (DELTARUNE Chapter 1)",
width: 1920,
height: 1080,
fullscreen: true
};
const window = new SDLWindow(windowOptions);
window.Window.setFullscreen(true);
// will segfault bun if ran in tty
if (!isTTY && !isCage) {
window.on("keyUp", (e) => {
if (e.key === "f11") {
window.Window.setFullscreen(!window.Window.fullscreen);
}
});
}
const icon = await loadImageAsset("icon.png");
window.setIconFromImage(icon);
window.Window.setResizable(true);
window.Window.setAccelerated(true);
if (isTTY) {
console.debug("[debug] KMSDRM detected, What the fuck?? Deltarune in the TTY?");
}
if (isCage) {
console.debug("[debug] Cage detected, are you trying to make a login manager or something?");
setInterval(() => {
try {
if (!window.Window.fullscreen) window.Window.setFullscreen(true);
} catch { }
}, 100)
}
// Base dim for UI/layout (matches the original background logical size).
const baseWidth = 160;
const baseHeight = 120;
const viewWidth = 1280;
const viewHeight = 960;
const uiScale = 0.6;
const crashRecoverySession =
cli.crashRecoverySession ??
process.env.CRASH_RECOVERY_SESSION ??
(crashDesktop
? KNOWN_SESSIONS_FRIENDLY[crashDesktop] ?? crashDesktop
: undefined);
const renderer = createRenderer({ window });
renderer.ctx.imageSmoothingEnabled = false;
const rendererPropsById: Record<string, RendererProps> = {
recoverymenu: {
question: crashRecoverySession
? `${crashRecoverySession} crashed. Do you want to restart it?`
: "?????",
yesLabel: "Yes",
noLabel: "No",
session: crashRecoverySession,
restoreSession: crashLock ?? undefined
},
errormessage: {
title: cli.errorScreenTitle ?? process.env.ERROR_TITLE ?? "ERROR",
message: cli.errorScreenMessage ?? process.env.ERROR_MESSAGE ?? "An unexpected error occurred.",
hint: cli.errorScreenHint ?? process.env.ERROR_HINT ?? "Switch between VT's with CTRL+ALT+F[0-9]."
}
};
const rendererRegistry = createRendererRegistry({ rendererProps: rendererPropsById });
let activeRenderer: RendererInstance | null = null;
let rendererExit: { id: string; result: unknown } | null = null;
let fatalErrorProps: RendererProps | null = null;
const requireActiveRenderer = () => {
if (!activeRenderer) {
throw new Error("Active renderer not initialized");
}
return activeRenderer;
};
const logSelectedRenderer = () => {
const current = requireActiveRenderer();
console.debug("[debug] renderer selected", current.id);
};
if (crashRecoverySession) {
console.debug("[debug] crash recovery mode", crashRecoverySession);
}
if (debugLogFile) {
console.debug("[debug] writing debug log to", debugLogFile);
}
const debugHud = createDebugHud(debugOptions);
const globalFps = createFpsCounter();
const rendererFps = createFpsCounter();
const getLayout = createLayoutCalculator({
baseWidth,
baseHeight,
viewWidth,
viewHeight
});
let bootUI = shouldRunBootSequence
? await createBootSequenceUI(baseWidth, baseHeight)
: null;
let bootAnswers: BootsequenceAnswers | null = null;
let contactComplete = false;
const rendererIds = Array.from(new Set(rendererRegistry.list().map((r) => r.id)));
let lastFrameMs = Date.now();
const requestRendererExit = () => {
const current = requireActiveRenderer();
if (rendererExit) return;
rendererExit = {
id: current.id,
result: current.getResult ? current.getResult() : undefined
};
renderer.requestStop();
};
const switchRenderer = (id: string) => {
if (activeRenderer && id === activeRenderer.id) return activeRenderer;
rendererExit = null;
const next = rendererRegistry.switchTo(id, rendererPropsById[id]);
renderer.ctx.clearRect(0, 0, renderer.canvas.width, renderer.canvas.height);
activeRenderer = next;
lastFrameMs = Date.now();
return activeRenderer;
};
activeRenderer = switchRenderer(defaultRendererId);
logSelectedRenderer();
if (cli.helpRequested) {
console.log(`Usage: bun run src/ui/app.ts [options]
Options:
--renderer <id> Select renderer by id (default: ${defaultRendererId})
--debug Enable all debug HUD panels
--debug-global Enable global debug HUD
--debug-renderer Enable renderer debug HUD
--error-screen [msg] Start on error screen (optional message)
--error-title <t> Set error screen title
--error-hint <h> Set error screen hint
--debug-log-file <path> Write debug logs to file (default: ${DEFAULT_DEBUG_LOG_FILE})
--crash-recovery [id] Start in crash recovery mode (optional session id)
--help, -h Show this help message`);
process.exit(0);
}
window.on("keyDown", (e) => {
const currentRenderer = requireActiveRenderer();
currentRenderer.handleKey?.(e.key ?? null);
bootUI?.handleKey({
key: e.key ?? null,
scancode: e.scancode ?? 0,
ctrl: e.ctrl ?? 0,
shift: e.shift ?? 0,
alt: e.alt ?? 0,
super: e.super ?? 0
});
});
const drawFrame = async (
_ctx: CanvasRenderingContext2D,
size: { width: number; height: number }
): Promise<void> => {
const currentRenderer = requireActiveRenderer();
const { ctx } = renderer;
ctx.imageSmoothingEnabled = false;
const layout = getLayout(size);
const {
width,
height,
contentScale,
x,
y
} = layout;
const now = Date.now();
const deltaMs = now - lastFrameMs;
lastFrameMs = now;
globalFps.tick(now);
ctx.clearRect(0, 0, width, height);
await currentRenderer.render({ ctx, deltaMs, layout });
rendererFps.tick(now);
if (currentRenderer.shouldExit?.()) {
if (currentRenderer.id === "recoverymenu") {
activeRenderer = switchRenderer("device_contact");
logSelectedRenderer();
if (!bootUI) {
bootUI = await createBootSequenceUI(baseWidth, baseHeight);
}
} else {
requestRendererExit();
}
}
// Text/UI layer: above BG/overlay, below FPS.
ctx.save();
const uiOffsetX = (contentScale - contentScale * uiScale) * baseWidth * 0.5;
const uiOffsetY = (contentScale - contentScale * uiScale) * baseHeight * 0.5;
ctx.translate(x + uiOffsetX, y + uiOffsetY);
ctx.scale(contentScale * uiScale, contentScale * uiScale);
if (bootUI) {
bootUI.update(deltaMs);
bootUI.render(ctx);
if (bootUI.isFinished()) {
if (!bootAnswers) {
bootAnswers = bootUI.getAnswers();
}
contactComplete = true;
renderer.requestStop();
window.Window.destroy();
}
}
ctx.restore();
debugHud.draw(ctx, layout, {
global: [
`${globalFps.value.toFixed(2)} FPS`,
`${window.Window.display.name ? (process.env.MONITOR_SN ? window.Window.display.name.replaceAll((process.env.MONITOR_SN || "") + " ", "") : window.Window.display.name) : "unknown"} ${window.Window.display.frequency}hz [${process.env.SDL_VIDEODRIVER ?? "sdl2"}]`,
`activeRenderer: ${currentRenderer.id}`,
`crashRecoverySession: ${crashRecoverySession ?? "none"}`,
`${BUNVERS}`
],
renderer: {
id: currentRenderer.id,
label: currentRenderer.label,
stats: {
...(currentRenderer.getDebugStats ? currentRenderer.getDebugStats() : {}),
...(currentRenderer.getDebugHudStats ? currentRenderer.getDebugHudStats() : {})
},
fps: rendererFps.value
},
custom: {
greetdSocket: GREETD_SOCKET,
tty: isTTY,
cage: isCage
}
});
if (debugLoggingEnabled) {
drawDebugLogs(ctx, layout, now);
}
};
console.debug("[debug] reached main");
try {
await renderer.run(drawFrame);
if (rendererExit) {
console.debug("[debug] renderer exit requested", rendererExit);
}
} finally {
requireActiveRenderer().unload();
}
if (contactComplete) {
const desktopHintRaw =
(bootAnswers as BootsequenceAnswers | null | undefined)?.desktop ??
crashRecoverySession ??
process.env.DESKTOP_SESSION_FRIENDLY_NAME ??
process.env.XDG_CURRENT_DESKTOP;
const desktopHint = typeof desktopHintRaw === "string" ? desktopHintRaw : undefined;
try {
await handoffToGreetd(desktopHint);
// clearLock();
} catch (error) {
console.error("[ui/app] greetd handoff failed\n", error);
console.error("[ui/app] Press CTRL+ALT+F[0-9] to switch to a different VT");
if (process.env.NODE_ENV !== "development") {
process.exit(1);
}
}
}
if (fatalErrorProps) {
return
}
restoreDebug();
}
if (import.meta.main) {
await runDeviceContactUI();
}
type DebugLogEntry = {
message: string;
ts: number;
};
function debugHudOptionsEnabled(options: { showGlobal: boolean; showRenderer: boolean; showCustom?: boolean }) {
return options.showGlobal || options.showRenderer || Boolean(options.showCustom);
}
function hookDebugLogs(options?: { filePath?: string }): () => void {
const originalDebug = console.debug;
let logStream = createDebugLogStream(options?.filePath, originalDebug);
if (logStream) {
logStream.on("error", (error) => {
originalDebug("[debug] debug log stream error", error);
logStream?.destroy();
logStream = null;
});
}
console.debug = (...args: unknown[]) => {
const message = formatDebugMessage(args);
const now = Date.now();
debugLogEntries.push({ message, ts: now });
pruneOldLogs(now);
if (debugLogEntries.length > 100) {
debugLogEntries.splice(0, debugLogEntries.length - 100);
}
if (logStream) {
try {
logStream.write(`[${new Date(now).toISOString()}] ${message}\n`);
} catch (error) {
originalDebug("[debug] failed to write debug log to file", error);
logStream = null;
}
}
originalDebug(...args);
};
return () => {
console.debug = originalDebug;
debugLogEntries.length = 0;
if (logStream) {
logStream.end();
logStream = null;
}
};
}
function formatDebugMessage(args: unknown[]): string {
return args
.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}`;
}
try {
return JSON.stringify(arg, (_k, v) => {
if (typeof v === "bigint") return v.toString();
return v;
});
} catch {
return String(arg);
}
})
.join(" ");
}
function drawDebugLogs(ctx: CanvasRenderingContext2D, layout: { width: number; height: number }, now: number) {
const padding = 8;
const lineHeight = 18;
pruneOldLogs(now);
const visible = debugLogEntries.slice(-LOG_MAX_LINES);
if (visible.length === 0) return;
ctx.save();
ctx.font = "14px \"JetBrains Mono\", monospace";
ctx.textBaseline = "bottom";
let cursorY = layout.height - padding;
const cursorX = padding;
for (const entry of [...visible].reverse()) {
const age = now - entry.ts;
const fadeStart = LOG_LIFETIME_MS - LOG_FADE_MS;
const alpha = age <= fadeStart ? 1 : Math.max(0, (LOG_LIFETIME_MS - age) / LOG_FADE_MS);
const text = entry.message;
ctx.globalAlpha = alpha;
ctx.fillStyle = "#66ccff";
ctx.fillText(text, cursorX, cursorY);
cursorY -= lineHeight;
}
ctx.restore();
}
function pruneOldLogs(now: number) {
for (let i = debugLogEntries.length - 1; i >= 0; i--) {
if (now - debugLogEntries[i]!.ts >= LOG_LIFETIME_MS) {
debugLogEntries.splice(i, 1);
}
}
}
async function runErrorScreen(
props: RendererProps,
options?: { debugOptions?: { showGlobal: boolean; showRenderer: boolean; showCustom?: boolean } }
) {
const isTTY = process.env.SDL_VIDEODRIVER === "kmsdrm";
const isCage = process.env.IS_CAGE === "true";
const windowOptions = {
// DO NOT CHANGE TITLE
title: "DEVICE CONTACT (DELTARUNE Chapter 1)",
width: 1920,
height: 1080,
fullscreen: true
};
const window = new SDLWindow(windowOptions);
window.Window.setFullscreen(true);
if (!isTTY && !isCage) {
window.on("keyUp", (e) => {
if (e.key === "f11") {
window.Window.setFullscreen(!window.Window.fullscreen);
}
});
}
const icon = await loadImageAsset("icon.png");
window.setIconFromImage(icon);
window.Window.setResizable(true);
window.Window.setAccelerated(true);
const renderer = createRenderer({ window });
const rendererRegistry = createRendererRegistry({ rendererProps: { errormessage: props } });
let activeRenderer: RendererInstance | null = rendererRegistry.switchTo("errormessage");
let rendererExit: { id: string; result: unknown } | null = null;
const requestRendererExit = () => {
const current = activeRenderer;
if (!current || rendererExit) return;
rendererExit = {
id: current.id,
result: current.getResult ? current.getResult() : undefined
};
renderer.requestStop();
};
window.on("keyDown", (e) => {
activeRenderer?.handleKey?.(e.key ?? null);
});
const baseWidth = 160;
const baseHeight = 120;
const viewWidth = 1280;
const viewHeight = 960;
const uiScale = 0.6;
const getLayout = createLayoutCalculator({
baseWidth,
baseHeight,
viewWidth,
viewHeight
});
const debugHud = createDebugHud(
options?.debugOptions ?? {
showGlobal: true,
showRenderer: true,
showCustom: true
}
);
const globalFps = createFpsCounter();
const rendererFps = createFpsCounter();
let lastFrameMs = Date.now();
const drawFrame = async (_ctx: CanvasRenderingContext2D, size: { width: number; height: number }) => {
const currentRenderer = activeRenderer!;
const { ctx } = renderer;
ctx.imageSmoothingEnabled = false;
const layout = getLayout(size);
const { width, height, contentScale, x, y } = layout;
const now = Date.now();
const deltaMs = now - lastFrameMs;
lastFrameMs = now;
globalFps.tick(now);
ctx.clearRect(0, 0, width, height);
await currentRenderer.render({ ctx, deltaMs, layout });
rendererFps.tick(now);
if (currentRenderer.shouldExit?.()) {
requestRendererExit();
}
ctx.save();
const uiOffsetX = (contentScale - contentScale * uiScale) * baseWidth * 0.5;
const uiOffsetY = (contentScale - contentScale * uiScale) * baseHeight * 0.5;
ctx.translate(x + uiOffsetX, y + uiOffsetY);
ctx.scale(contentScale * uiScale, contentScale * uiScale);
ctx.restore();
debugHud.draw(ctx, layout, {
global: [
`${globalFps.value.toFixed(2)} FPS`,
`${window.Window.display.name ? (process.env.MONITOR_SN ? window.Window.display.name.replaceAll((process.env.MONITOR_SN || "") + " ", "") : window.Window.display.name) : "unknown"} ${window.Window.display.frequency}hz [${process.env.SDL_VIDEODRIVER ?? "sdl2"}]`,
`activeRenderer: ${currentRenderer.id}`,
`${BUNVERS}`
],
renderer: {
id: currentRenderer.id,
label: currentRenderer.label,
stats: {
...(currentRenderer.getDebugStats ? currentRenderer.getDebugStats() : {}),
...(currentRenderer.getDebugHudStats ? currentRenderer.getDebugHudStats() : {})
},
fps: rendererFps.value
},
custom: {
greetdSocket: GREETD_SOCKET,
tty: isTTY,
cage: isCage
}
});
if (debugHudOptionsEnabled(options?.debugOptions ?? { showGlobal: true, showRenderer: true, showCustom: true })) {
drawDebugLogs(ctx, layout, now);
}
};
await renderer.run(drawFrame);
if (rendererExit) {
console.debug("[debug] error renderer exit requested", rendererExit);
}
activeRenderer?.unload();
}
function createDebugLogStream(filePath: string | undefined, debug: typeof console.debug) {
if (!filePath) return null;
try {
mkdirSync(dirname(filePath), { recursive: true });
return createWriteStream(filePath, { flags: "a" });
} catch (error) {
debug("[debug] failed to open debug log file", { filePath, error });
return null;
}
}