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 = { 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 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 Set error screen title --error-hint Set error screen hint --debug-log-file 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 => { 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; } }