Files
hermes-agent/apps/desktop/electron/translucency.ts
T
brooklyn! 6e64e6b9c6 fix(desktop): Windows glass windows stop rendering when they lose focus (#91307)
* fix(desktop): stop layering Windows glass windows, and don't make them transparent

A Windows chat window under glass rendered while focused and went dead the
moment it lost it. Two things put it on a compositing path DWM will not draw
acrylic behind, both no-ops that looked free:

`opacity: windowOpacity()` was passed on every window. Under glass on Windows
fade is 0, so the value is always 1 — but Electron's `SetOpacity` calls
`SetLayered()` and `SetLayeredWindowAttributes(..., LWA_ALPHA)` before it looks
at the value, and nothing ever takes `WS_EX_LAYERED` back off. A layered window
composites through the legacy redirection surface, which Windows documents as
mutually exclusive with `UpdateLayeredWindow`. Opacity is now only passed when
the state actually fades, and the runtime path keeps setting it for a window
that is already faded so it can still come back to opaque.

`transparent: true` was set on every glass-capable Windows chat window, on the
premise that DWM materials only reach the client area that way (electron#49443,
which was closed as need-info against an EOL Electron 28). They do not need it:
`IsTranslucent` answers yes off `background_material_` alone, which is what
gives the page its transparent default backing, and `SetBackgroundMaterial`
flips widget translucency live. Its one gate is a frameless window, and
`titleBarStyle: 'hidden'` already satisfies it. What `transparent` did add was
permanent — the widget pinned to kTranslucent for the window's whole life, so
even glass-OFF windows paid a DirectComposition redraw per frame
(electron#39895), plus the documented transparent-window limits, including that
a resizable transparent window is unsupported and breaks (electron#48421).

Both landed latent in #89837 and only surfaced when #90587 turned glass on by
default and dropped the opaque backing that had been hiding them.

* docs(desktop): name the one thing the opacity guard cannot undo

Electron exposes no way back off WS_EX_LAYERED, so a Windows window that has
been faded once keeps the layered compositing path until it is recreated. Not
opening the door on the default path is the whole of the fix; say so where the
guard lives rather than leaving a reviewer to work out the gap.
2026-08-21 05:57:14 +00:00

112 lines
4.4 KiB
TypeScript

/**
* Main-process side of window translucency.
*
* The mapping itself (modes, clamping, the clear-mode opacity ramp) lives in
* apps/shared so the renderer and the main process cannot drift; this module
* adds only what needs a BrowserWindow to mean anything.
*
* The import is relative rather than `@hermes/shared/translucency`: the
* electron bundle is built by esbuild with no tsconfig path resolution (see
* scripts/bundle-electron-main.mjs), so a bare specifier would typecheck and
* then fail to bundle.
*/
import { glassActive, type TranslucencyState, windowOpacityFor } from '../../shared/src/translucency'
export {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
defaultTranslucencyState,
defaultTranslucencyValues,
GLASS_MATERIALS,
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeBook,
normalizeMaterial,
normalizeMode,
normalizeScope,
normalizeState,
resolveTranslucency,
setTranslucencyValues,
TRANSLUCENCY_CURVE,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD,
type WindowsBackgroundMaterial
} from '../../shared/src/translucency'
/**
* BrowserWindow constructor options for a chat window's backing, given the
* translucency state at creation time.
*
* Glass active → OMIT `backgroundColor` entirely. Electron reads a window as
* translucent when it carries a vibrancy or a backdrop material, and hands a
* translucent window a transparent default backing, so the platform material
* shows through the page from the first frame. Passing an alpha color instead
* does NOT work — constructor alpha needs `transparent: true`, and `#00000000`
* on a normal window is quietly treated as opaque.
*
* Glass inactive → the opaque themed backing (anti-flash paint before the
* renderer's first paint, and what clear mode fades against).
*
* A runtime `setBackgroundColor` swap (see applyWindowTranslucency in main)
* only settles reliably on a window that has been compositing for a while —
* measured on macOS 26 / Electron 40, swaps issued during roughly the first
* seconds of a fresh process were lost, including from 'ready-to-show' and
* 'did-finish-load' — so creation must not rely on a post-creation fixup.
*/
export function windowBackingOptions(state: TranslucencyState, themedColor: string): { backgroundColor?: string } {
return glassActive(state) ? {} : { backgroundColor: themedColor }
}
/**
* Whether a window's native opacity is worth setting at all.
*
* Fully opaque is what a window already is, so asking for it looks free. On
* Windows it is the opposite of free: `setOpacity` puts `WS_EX_LAYERED` on the
* window and calls `SetLayeredWindowAttributes(..., LWA_ALPHA)` before it even
* looks at the value, and nothing ever takes the style back off
* (`NativeWindowViews::SetOpacity` → `SetLayered`). A layered window
* composites through the legacy redirection surface — which Windows documents
* as mutually exclusive with `UpdateLayeredWindow`, and which DWM will not
* draw a system backdrop behind. So `opacity: 1` on a glass window buys
* nothing and costs it its acrylic.
*
* `current` is the way back: a window that is already faded has already paid
* for the layering, and it has to be able to return to opaque — so it keeps
* getting the call even when the value it is going to is 1.
*
* What this cannot do is un-layer. Electron offers no way back off
* `WS_EX_LAYERED`, so a Windows window that has been faded once keeps the
* layered compositing path until it is recreated. Not opening the door on the
* default path is the whole of the fix; someone who deliberately fades and
* then returns to glass still wants a restart.
*/
export function opacityNeedsSetting(next: number, current = 1): boolean {
return next < 1 || current < 1
}
/**
* BrowserWindow constructor options for a chat window's native opacity. Empty
* unless the state actually asks the window to fade — see opacityNeedsSetting.
*/
export function windowOpacityOptions(state: TranslucencyState): { opacity?: number } {
const opacity = windowOpacityFor(state)
return opacityNeedsSetting(opacity) ? { opacity } : {}
}