// IPC surface for HUD mode (the chrome-free floating chat band). Extracted // from main.ts; the HUD window handle and session-id latch stay injected // because main.ts owns the window lifecycle and the close broadcast reads the // latch when handing the session back to the app window. import { type BrowserWindow, ipcMain, screen } from 'electron' import { createHudDragSession } from './hud-drag' import { normalizeHudResizeBounds } from './hud-geometry' import { hudWindowingView, resolveHudWindowing } from './hud-windowing' import { hudFrostFor, type TranslucencyState } from './translucency' function hudWindowing() { return resolveHudWindowing(process.platform, process.env, process.argv) } export interface HudIpcDeps { isMac: boolean /** Main's authoritative translucency state (Settings → Appearance). */ getTranslucencyState: () => TranslucencyState getHudWindow: () => BrowserWindow | null openHudWindow: (sessionId: null | string, profile: null | string) => void closeHudWindow: () => void resetHudLayout: () => boolean setHudSessionId: (sessionId: null | string) => void } export function registerHudIpc({ isMac, getTranslucencyState, getHudWindow, openHudWindow, closeHudWindow, resetHudLayout, setHudSessionId }: HudIpcDeps) { const hudDrag = createHudDragSession() // The renderer needs this before first paint so X11 never installs the // Chromium drag region that steals modifier-drag gestures from the WM. // Main answers because it owns the actual Ozone backend selection. ipcMain.on('hermes:hud:native-drag', event => { event.returnValue = hudWindowing().move === 'native-drag' }) ipcMain.on('hermes:hud:windowing', event => { event.returnValue = hudWindowingView(hudWindowing()) }) // X11/KWin window transfer: a renderer-driven grab is temporarily sticky so // the user can keep Ctrl+primary-button held while invoking KDE's desktop // switch shortcut. Clearing sticky on release makes Chromium assign the // window to `_NET_CURRENT_DESKTOP`, exactly like releasing a native titlebar // drag on the destination desktop. Native Wayland owns its move loop and // Windows/macOS stay out of this Linux-specific bridge. ipcMain.on('hermes:hud:workspace-transfer', (event, transferring) => { const hudWindow = getHudWindow() if ( !hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !hudWindowing().workspaceTransfer ) { return } try { hudWindow.setVisibleOnAllWorkspaces(Boolean(transferring)) } catch { // Workspace APIs are window-manager capabilities — best effort. } }) // Whether the band currently covers the window below the bar. The renderer // is the only party that can know this (it measures the transcript), and it // is half of the frost decision — the other half is the user's setting, // which main owns. Latched so a Settings change can re-decide without // waiting for the HUD to report again. let bandShowing = false let applied: null | string = null let appliedTo: BrowserWindow | null = null // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, // because Chromium composites a transparent window's page against nothing and // the desktop is not in its backdrop root. The material IS the window's content // view, so it frosts the whole rectangle; the HUD's layout leaves no dead // margins for that reason, and it only turns on while the band is showing // (idle HUD mode must be the bar and nothing else). // // macOS ONLY. Windows' equivalent (setBackgroundMaterial → the DWM backdrop) // is mutually exclusive with window transparency, so it is not called at all // here — see the note at the bottom of this function. // // Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts // if re-issued, so a repeated call would keep the material from ever settling // (the same churn the chat windows' native-diff contract exists to prevent). // // The diff is keyed to the WINDOW as well as the value. A HUD respawn (the // profile switch in openHudWindow destroys and rebuilds it) hands back a // fresh window carrying no material, and a latch that only remembered the // value would recognise its own last answer and skip — leaving the new HUD // unfrosted until something else happened to change the signature. const applyHudFrost = () => { const hudWindow = getHudWindow() if (!hudWindow || hudWindow.isDestroyed()) { applied = null appliedTo = null return } const frost = hudFrostFor(getTranslucencyState(), bandShowing) const signature = `${frost.vibrancy ?? 'off'}:${frost.backgroundMaterial}` if (applied === signature && appliedTo === hudWindow) { return } applied = signature appliedTo = hudWindow if (isMac && typeof hudWindow.setVibrancy === 'function') { hudWindow.setVibrancy(frost.vibrancy) } // Windows: never touch setBackgroundMaterial on the HUD. Live-verified on // Win11 (Electron 40.10.2, RTX 4090): ANY setBackgroundMaterial call on a // transparent window — including 'none', which is what the idle HUD asks // for — permanently kills per-pixel alpha, and every transparent pixel // composites as opaque white. Neither 'auto' nor a follow-up // setBackgroundColor('#00000000') restores it. The DWM backdrop and window // transparency are mutually exclusive, so the Windows HUD keeps the CSS // tint the sheet already paints and skips the native frost entirely. } ipcMain.handle('hermes:hud:open', async (_event, request) => { openHudWindow( typeof request?.sessionId === 'string' ? request.sessionId : null, typeof request?.profile === 'string' ? request.profile : null ) return { ok: true } }) ipcMain.handle('hermes:hud:frost', (_event, showing) => { bandShowing = Boolean(showing) applyHudFrost() return { ok: true } }) // Let clicks fall through the HUD wherever it isn't really there. An // always-on-top window eats every click inside its rectangle, and most of that // rectangle is a faded-out band over whatever the user is actually working in. // `forward` keeps mousemove flowing so the renderer can re-arm when the cursor // reaches the bar. ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { const hudWindow = getHudWindow() if (!hudWindow || hudWindow.isDestroyed()) { return } // On X11 ignore-mouse is a one-way door: setIgnoreMouseEvents(false) // cannot restore the input region afterwards. Veto the request there so // the HUD stays a normal solid window. Native Wayland and macOS/Windows // keep the per-element path. if (Boolean(ignore) && !hudWindowing().ignoreMouse) { return } hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) }) ipcMain.on('hermes:hud:begin-move', event => { const hudWindow = getHudWindow() if ( !hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !hudWindowing().clientPlacement ) { return } const [x, y] = hudWindow.getPosition() hudDrag.begin(screen.getCursorScreenPoint(), { x, y }) }) ipcMain.on('hermes:hud:end-move', event => { const hudWindow = getHudWindow() if (hudWindow && !hudWindow.isDestroyed() && event.sender !== hudWindow.webContents) { return } hudDrag.end() }) ipcMain.on('hermes:hud:move-by', (event, delta) => { const hudWindow = getHudWindow() if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { return } const width = Number(delta?.width) const height = Number(delta?.height) if (!Number.isFinite(width) || !Number.isFinite(height) || !hudWindowing().clientPlacement) { return } const origin = hudDrag.origin(screen.getCursorScreenPoint()) if (!origin) { return } // Cursor − grab offset in Electron DIP (see hud-drag.ts). setBounds — // NOT setPosition: on Windows, a transparent frameless window silently // grows ~1px per setPosition call (worse at >100% DPI). The renderer // snapshots outerWidth/outerHeight when the composer drag arms and // re-pins to that size on every move (same pattern as the pet overlay). hudWindow.setBounds({ x: origin.x, y: origin.y, width: Math.round(width), height: Math.round(height) }) }) // Resize from the HUD's edge/corner handles. The window is created non-resizable // (see spawnHudWindow — a transparent frameless window must not expose a // system resize hot-zone, or dragging grows it), which on Windows/Linux also // blocks programmatic setBounds sizing — so briefly flip resizable on while // the size actually changes, exactly like the pet overlay's wheel-scale does. ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { const hudWindow = getHudWindow() if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { return } const nextBounds = normalizeHudResizeBounds(bounds) if (!nextBounds) { return } const win = hudWindow const { width, height } = nextBounds const [curW, curH] = win.getSize() const resizing = width !== curW || height !== curH const restoreResizeLock = resizing && !win.isResizable() try { if (restoreResizeLock) { win.setResizable(true) } win.setBounds(nextBounds) } catch { // The window may disappear between validation and the native call. } finally { if (restoreResizeLock && !win.isDestroyed()) { win.setResizable(false) } } }) ipcMain.handle('hermes:hud:reset-layout', event => { const hudWindow = getHudWindow() if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { return { ok: false } } return { ok: resetHudLayout() } }) // The HUD renderer reporting which session it is on, so the close broadcast // can hand it back to the app window (see hudSessionId). ipcMain.on('hermes:hud:session', (event, sessionId) => { const hudWindow = getHudWindow() if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null) } }) ipcMain.handle('hermes:hud:close', async () => { closeHudWindow() return { ok: true } }) // Main re-applies the frost when the translucency SETTING changes, since the // band's own report only fires when the band itself moves. return { applyHudFrost } }