Files
aiturk-hermes-ide/apps/desktop/electron/hud-ipc.ts
T

304 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }
}