Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 162 additions & 38 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ import {
} from './ssh-connection'
import { createStreamThrottle } from './stream-throttle'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import {
glassActive,
normalizeState as normalizeTranslucency,
vibrancyFor as vibrancyForTranslucency,
windowBackingOptions,
windowOpacityFor
} from './translucency'
import {
compareApiUrl,
parseCompareBehindCount,
Expand Down Expand Up @@ -871,57 +878,121 @@ function writePersistedThemeSource(mode) {
nativeTheme.themeSource = readPersistedThemeSource()

// Window translucency (see-through window). One lever, 0–100; 0 = off (the
// default). Mapped to the native window opacity so the desktop shows through
// the whole window. Persisted so a cold launch applies it at window creation,
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
// a no-op on Linux. See store/translucency.
// default). Two modes share the lever (see electron/translucency.ts and
// store/translucency): 'clear' maps it to the native window opacity so the
// desktop shows through the whole window; 'glass' (macOS) keeps the window
// opaque and lets the renderer thin its surfaces over the vibrancy material
// instead — a matte blur with full-contrast text. Persisted so a cold launch
// applies it at window creation, before the renderer reports its value.
// macOS + Windows only; `setOpacity` is a no-op on Linux.
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')

function clampIntensity(value) {
const n = Math.round(Number(value))

return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
}

function readPersistedTranslucency() {
try {
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), IS_MAC)
} catch {
return 0
return normalizeTranslucency(null, IS_MAC)
}
}

function writePersistedTranslucency(intensity) {
function writePersistedTranslucency(state) {
try {
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify(state, null, 2), 'utf8')
} catch (error) {
rememberLog(`[translucency] write failed: ${error.message}`)
}
}

let translucencyIntensity = readPersistedTranslucency()
let translucencyState = readPersistedTranslucency()

// Chat windows whose webContents backing follows translucency (primary,
// instance peers, session windows). The HUD / pet overlay / quick entry /
// wake indicator are `transparent: true` windows that own their backgrounds —
// painting a themed backing onto them would turn them into opaque rectangles.
const translucencyBackedWindows = new WeakSet()

// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
// setting is still usable rather than nearly invisible. 0 → fully opaque.
function windowOpacity() {
return 1 - (translucencyIntensity / 100) * 0.7
return windowOpacityFor(translucencyState)
}

// Re-apply translucency to a live window (runtime toggle, no recreation).
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
function applyWindowTranslucency(win) {
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
// The backing swap is the glass half: Chromium composites the page against
// the window backing BEFORE macOS composites the window, so glass needs the
// backing dropped for the vibrancy material to reach a transparent page, and
// every other state needs the opaque themed backing (anti-flash, and it is
// what makes clear mode fade to the desktop instead of to black).
//
// `changed` says which native properties actually need touching. Dragging the
// intensity slider emits ~100 updates, and in glass mode NONE of them change
// anything native — the effect is painted by the renderer and windowOpacityFor
// returns 1 throughout. Re-issuing setVibrancy on every tick restarts its
// 150ms animation before macOS can settle the material, which reads as jank
// and flattens the frost levels into each other.
//
// CAUTION (measured, macOS 26 / Electron 40): a runtime
// setBackgroundColor('#00000000') is silently LOST on a window whose
// compositor hasn't been up for a few seconds — including calls from
// 'ready-to-show' and 'did-finish-load'. Cold launches therefore must not
// rely on this path: windows are BORN with the right backing
// (windowBackingOptions at each creation site). This path only has to cover
// live toggles from Settings, where the window is long settled.
function applyWindowTranslucency(win, changed = { backing: true, material: true, opacity: true }) {
if (!win || win.isDestroyed()) {
return
}

try {
win.setOpacity(windowOpacity())
// Backing swap + material are scoped to registered chat windows (see
// translucencyBackedWindows above).
if (translucencyBackedWindows.has(win)) {
if (changed.backing && typeof win.setBackgroundColor === 'function') {
win.setBackgroundColor(glassActive(translucencyState) ? '#00000000' : getWindowBackgroundColor())
}

// Glass frost level = the vibrancy material (macOS has no blur-radius
// knob). Animate the hop so a deliberate frost switch feels continuous —
// which only works if we don't re-issue it on unrelated updates.
if (changed.material && IS_MAC && typeof win.setVibrancy === 'function') {
win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 })
}
}

if (changed.opacity && typeof win.setOpacity === 'function') {
win.setOpacity(windowOpacity())
}
} catch (error) {
rememberLog(`[translucency] apply failed: ${error.message}`)
}
}

// Constructor options every chat window shares for its translucency surface:
// the vibrancy material, the native opacity, and the webContents backing under
// the CURRENT state. Glass omits backgroundColor so vibrancy shows from the
// first frame (a non-transparent window silently ignores constructor alpha,
// and runtime swaps are lost early in a window's life — see
// applyWindowTranslucency); otherwise the opaque themed anti-flash backing.
//
// Call sites also register the window in translucencyBackedWindows so a live
// toggle can re-apply. The HUD, pet overlay, quick entry and wake indicator
// are `transparent: true` windows that own their backgrounds and are
// deliberately not chat windows.
function chatWindowSurfaceOptions() {
return {
vibrancy: IS_MAC ? vibrancyForTranslucency(translucencyState) : undefined,
// Pin the material to its ACTIVE appearance: several NSVisualEffectView
// materials collapse to a shared inactive look when the window blurs
// (measured on macOS 26: sidebar, popover and under-window composited
// pixel-identically once unfocused), which would quietly erase the
// user's frost choice whenever they click elsewhere. Only observable
// under glass — everywhere else the page buries the material.
visualEffectState: IS_MAC ? ('active' as const) : undefined,
opacity: windowOpacity(),
...windowBackingOptions(translucencyState, getWindowBackgroundColor())
}
}

function isHexColor(value) {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
}
Expand Down Expand Up @@ -10640,8 +10711,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
...chatWindowSurfaceOptions(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
Expand All @@ -10650,10 +10720,13 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})

// Chat-surface registration: applyWindowTranslucency swaps this window's
// backing between opaque-themed and alpha-0 when glass toggles.
translucencyBackedWindows.add(win)

if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
Expand Down Expand Up @@ -10740,16 +10813,17 @@ function createInstanceWindow() {
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
...chatWindowSurfaceOptions(),
icon,
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})

instanceWindows.add(win)

// Chat-surface registration: see applyWindowTranslucency.
translucencyBackedWindows.add(win)

if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
Expand Down Expand Up @@ -11601,14 +11675,12 @@ function createWindow() {
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
...chatWindowSurfaceOptions(),
icon,
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
// `backgroundColor` and follows the OS appearance) can't flash a light
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences);
// stream-aware throttling is applied per-window via streamThrottle so a
// live answer keeps painting while the window is blurred or minimized,
Expand All @@ -11619,6 +11691,9 @@ function createWindow() {

const createdMainWindow = mainWindow

// Chat-surface registration: see applyWindowTranslucency.
translucencyBackedWindows.add(mainWindow)

if (IS_MAC) {
mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)

Expand Down Expand Up @@ -13681,20 +13756,69 @@ ipcMain.on('hermes:native-theme', (_event, mode) => {
}
})

// See-through window translucency. Persist + re-apply opacity to every open
// window at runtime (no recreation, so caching/sessions are untouched).
// See-through window translucency. Persist + re-apply to every open window at
// runtime (no recreation, so caching/sessions are untouched).
//
// The intensity slider is a HOT path: ~100 updates per drag. Two things make
// that cheap. Native work is diffed, so an intensity-only change under glass
// touches nothing (it's painted by the renderer). And the disk write is
// coalesced onto a trailing timer, because writePersistedTranslucency is a
// synchronous writeFileSync and doing one per tick blocks the main process
// mid-drag. Only a cold launch reads that file, so it just has to be correct
// once the hand comes off the slider.
let translucencyWriteTimer = null

function scheduleTranslucencyWrite() {
if (translucencyWriteTimer) {
clearTimeout(translucencyWriteTimer)
}

translucencyWriteTimer = setTimeout(() => {
translucencyWriteTimer = null
writePersistedTranslucency(translucencyState)
}, 250)
}

// Flush a pending write before the process can exit, so a quit landing inside
// the debounce window doesn't lose the setting.
app.on('before-quit', () => {
if (translucencyWriteTimer) {
clearTimeout(translucencyWriteTimer)
translucencyWriteTimer = null
writePersistedTranslucency(translucencyState)
}
})

ipcMain.on('hermes:translucency', (_event, payload) => {
const next = clampIntensity(payload && payload.intensity)
const next = normalizeTranslucency(payload, IS_MAC)
const previous = translucencyState

if (next === translucencyIntensity) {
if (
next.intensity === previous.intensity &&
next.mode === previous.mode &&
next.material === previous.material &&
next.scope === previous.scope
) {
return
}

translucencyIntensity = next
writePersistedTranslucency(next)
translucencyState = next

for (const win of BrowserWindow.getAllWindows()) {
applyWindowTranslucency(win)
// Which native properties actually moved. `scope` is renderer-only (which
// surfaces thin), so it never appears here.
const changed = {
// The backing follows whether glass is ON, not the intensity behind it.
backing: glassActive(previous) !== glassActive(next),
material: vibrancyForTranslucency(previous) !== vibrancyForTranslucency(next),
opacity: windowOpacityFor(previous) !== windowOpacityFor(next)
}

scheduleTranslucencyWrite()

if (changed.backing || changed.material || changed.opacity) {
for (const win of BrowserWindow.getAllWindows()) {
applyWindowTranslucency(win, changed)
}
}
})

Expand Down
Loading
Loading