262 lines
8.9 KiB
TypeScript
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
|
|
}
|