Files

262 lines
8.9 KiB
TypeScript

// hud-game-overlay.ts — is the HUD floating over a fullscreen app (a game)?
//
// Discord's in-game overlay behavior: while a fullscreen app owns the screen
// the HUD steps back to a low-opacity, glanceable state, and steps forward
// again when the user engages it or a reply lands. The DECISION lives here as
// pure functions over the same front-to-back enumeration `window-below.ts`
// uses; main polls while the HUD is open and pushes changes to the HUD
// renderer, which owns the visual treatment (see `data-hud-game` in
// styles.css).
//
// "Fullscreen app" means: the frontmost other-process window on the HUD's
// display covers that display edge-to-edge. Walking the z-order front-to-back
// mirrors how the screen actually reads — if something windowed sits on top of
// the game, the game is not what the HUD is floating over.
//
// A note on what this can and cannot float over, so nobody debugs the wrong
// layer: an always-on-top window covers BORDERLESS fullscreen (the default in
// most modern games) and macOS fullscreen Spaces. True exclusive-fullscreen
// bypasses the compositor entirely — nothing short of injecting into the
// game's render pipeline (what Discord's native overlay does) draws over it.
// Detection still works there; the HUD is simply behind until the user
// alt-tabs, which Windows answers by flipping the game to composited output.
import type { EnumeratedWindow } from './window-below'
export interface GameOverlayState {
active: boolean
/** The fullscreen app's name while active, '' otherwise — the renderer may
* surface it ("over Balatro") and the diff key needs it either way. */
app: string
}
export const INACTIVE_GAME_OVERLAY: GameOverlayState = { active: false, app: '' }
interface Bounds {
x: number
y: number
width: number
height: number
}
/** Allowance for DPI rounding and the 1px oversize some engines use to dodge
* the OS's own "looks fullscreen" heuristics. */
const COVER_EPSILON_PX = 2
/**
* Desktop-shell windows that legitimately report display-sized bounds and must
* never read as a game: the Windows desktop (Progman/WorkerW both belong to
* Explorer), the macOS Dock/desktop layers. Matched on the OWNER name — titles
* are localized, unavailable without permissions on macOS, and empty for most
* of these anyway.
*/
const SHELL_APPS = [
/^windows explorer$/i,
/^explorer(\.exe)?$/i,
/^program manager$/i,
/^finder$/i,
/^dock$/i,
/^window ?server$/i,
/^windowmanager$/i,
/^gnome-shell$/i,
/^plasmashell$/i
]
export const isShellWindow = (app: string): boolean => SHELL_APPS.some(pattern => pattern.test(app.trim()))
/** Whether `bounds` covers `display` edge-to-edge (within the DPI epsilon).
* Work-area coverage is deliberately not enough: a maximized window stops at
* the taskbar/menu bar, a fullscreen one does not — that IS the distinction. */
export const coversDisplay = (bounds: Bounds, display: Bounds, epsilon: number = COVER_EPSILON_PX): boolean =>
bounds.x <= display.x + epsilon &&
bounds.y <= display.y + epsilon &&
bounds.x + bounds.width >= display.x + display.width - epsilon &&
bounds.y + bounds.height >= display.y + display.height - epsilon
const intersects = (a: Bounds, b: Bounds): boolean =>
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
/**
* The fullscreen app the HUD is floating over on `display`, or null.
*
* Front-to-back: skip every window of our own process (all Hermes windows
* share main's pid) and the desktop shell's display-sized layers, then let the
* FIRST window that intersects the display decide — covering it means a
* fullscreen app, anything less means ordinary windows are on top and the
* overlay treatment would just make the HUD illegible over a busy desktop.
* Zero-area rows (minimized windows report those on some platforms) never
* decide either way.
*/
export function detectFullscreenApp(
windows: EnumeratedWindow[],
selfPid: number,
display: Bounds
): EnumeratedWindow | null {
for (const win of windows) {
if (win.pid === selfPid || isShellWindow(win.app)) {
continue
}
if (win.bounds.width <= 0 || win.bounds.height <= 0 || !intersects(win.bounds, display)) {
continue
}
return coversDisplay(win.bounds, display) ? win : null
}
return null
}
/**
* The display-covering app anywhere in the stack, ignoring z-order.
*
* The STAY half of the hysteresis below. Front-to-back order answers "is a
* game what I'm looking at" for ENTERING overlay mode, but it cannot answer
* "am I still over the game" once the user clicks the HUD to type: the game
* stops being foreground, and whatever else they had open (a terminal, an
* editor, a browser) is suddenly above it and vetoes. The game did not go
* anywhere, so neither should the treatment.
*/
export function findFullscreenAppAnywhere(
windows: EnumeratedWindow[],
selfPid: number,
display: Bounds
): EnumeratedWindow | null {
return (
windows.find(
win =>
win.pid !== selfPid &&
!isShellWindow(win.app) &&
win.bounds.width > 0 &&
win.bounds.height > 0 &&
coversDisplay(win.bounds, display)
) ?? null
)
}
export const gameOverlayStateFor = (
windows: EnumeratedWindow[],
selfPid: number,
display: Bounds,
wasActive = false
): GameOverlayState => {
// Hysteresis. ENTERING needs the game to be what the user is actually
// looking at (front-to-back, windowed apps on top veto it). STAYING only
// needs the game to still be there: clicking the HUD to type pushes the game
// out of foreground and floats every other open window above it, which would
// otherwise drop the treatment at exactly the moment the user is reading it.
const fullscreen = wasActive
? findFullscreenAppAnywhere(windows, selfPid, display)
: detectFullscreenApp(windows, selfPid, display)
return fullscreen ? { active: true, app: fullscreen.app } : INACTIVE_GAME_OVERLAY
}
export interface HudGameOverlayWatchDeps {
/** Front-to-back window enumeration; null when the platform cannot answer
* (Wayland, missing native module). Same contract as window-below's. */
enumerate: () => Promise<EnumeratedWindow[] | null>
/** Bounds of the display the HUD currently sits on. */
displayBounds: () => Bounds
selfPid: number
/** Push a CHANGED state to the HUD renderer. */
send: (state: GameOverlayState) => void
intervalMs?: number
/** Injectable timers so tests never wait on a real clock. */
setIntervalFn?: typeof setInterval
clearIntervalFn?: typeof clearInterval
}
/** Consecutive failed enumerations before the watch concludes the platform
* cannot answer and stops burning a subprocess/native call per tick. Two, not
* one: a single null can be a transient failure mid-session. */
const FAILURES_BEFORE_GIVING_UP = 2
/**
* Poll for fullscreen-app changes while the HUD is open. Returns the disposer;
* idempotent, and the caller must invoke it when the HUD closes.
*
* Polling, not events: no OS surfaces a cross-process "a fullscreen app
* appeared" signal to an unprivileged window, and every consumer of this class
* of information (Discord, Steam, GeForce overlays) watches for it. The
* interval is slow enough to be free next to the HUD's own cursor feed.
*/
export function startHudGameOverlayWatch({
enumerate,
displayBounds,
selfPid,
send,
intervalMs = 1500,
setIntervalFn = setInterval,
clearIntervalFn = clearInterval
}: HudGameOverlayWatchDeps): () => void {
let last: GameOverlayState | null = null
let failures = 0
let inFlight = false
let disposed = false
const publish = (state: GameOverlayState) => {
if (last === null || last.active !== state.active || last.app !== state.app) {
last = state
send(state)
}
}
const tick = async () => {
// Enumeration is async and slower than the interval on a bad day (X11
// shells out); overlapping ticks would answer out of order.
if (inFlight || disposed) {
return
}
inFlight = true
try {
const windows = await enumerate()
if (disposed) {
return
}
if (windows === null) {
failures += 1
// The platform cannot answer (and said so twice): settle on inactive
// and stop asking.
if (failures >= FAILURES_BEFORE_GIVING_UP) {
publish(INACTIVE_GAME_OVERLAY)
dispose()
}
return
}
failures = 0
publish(gameOverlayStateFor(windows, selfPid, displayBounds(), last?.active ?? false))
} catch {
// A throwing enumerator counts the same as a null answer.
failures += 1
if (failures >= FAILURES_BEFORE_GIVING_UP && !disposed) {
publish(INACTIVE_GAME_OVERLAY)
dispose()
}
} finally {
inFlight = false
}
}
const timer = setIntervalFn(() => void tick(), intervalMs)
function dispose() {
if (!disposed) {
disposed = true
clearIntervalFn(timer)
}
}
void tick()
return dispose
}