From da5484b61ff75ac1727136ede9fbd3189935ae08 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 23:50:59 -0500 Subject: [PATCH 1/3] fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WSLg bridges clipboard text but not images — pull host screenshots via PowerShell. Disable titleBarOverlay on plain Linux; gate overlay width per platform in titlebar-overlay-width.cjs. --- apps/desktop/electron/main.cjs | 61 ++++++++-- apps/desktop/electron/wsl-clipboard-image.cjs | 92 ++++++++++++++ .../electron/wsl-clipboard-image.test.cjs | 114 ++++++++++++++++++ apps/desktop/package.json | 2 +- .../src/app/chat/composer/context-menu.tsx | 8 +- apps/desktop/src/app/chat/composer/index.tsx | 10 ++ apps/desktop/src/app/chat/composer/types.ts | 2 +- .../app/chat/hooks/use-composer-actions.ts | 43 ++++--- apps/desktop/src/app/chat/index.tsx | 2 +- apps/desktop/src/app/desktop-controller.tsx | 2 +- 10 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/electron/wsl-clipboard-image.cjs create mode 100644 apps/desktop/electron/wsl-clipboard-image.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 67b31eb4d75..0e20c89d0d2 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -43,6 +43,7 @@ const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-reques const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') +const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { readLiveUpdateMarker } = require('./update-marker.cjs') const { @@ -524,6 +525,14 @@ function getTitleBarOverlayOptions() { return { height: TITLEBAR_HEIGHT } } + // Window Controls Overlay is a Windows/macOS-only Electron feature. On Linux + // (including WSLg, where the RDP host draws its own min/max/close) requesting + // it does nothing useful, so disable it and let the frameless window stand on + // its own (titleBarStyle stays 'hidden'). + if (!IS_WINDOWS) { + return false + } + if (rendererTitleBarTheme) { return { color: rendererTitleBarTheme.background, @@ -541,6 +550,24 @@ function getTitleBarOverlayOptions() { } } +// Push refreshed overlay options to a live window after a theme/appearance +// change. No-op on Linux (incl. WSLg), where the overlay is unsupported and +// getTitleBarOverlayOptions() returns false — calling setTitleBarOverlay there +// can throw on some Electron Linux builds. +function applyTitleBarOverlay(win) { + const options = getTitleBarOverlayOptions() + if (!options || typeof options !== 'object') { + return + } + + try { + win?.setTitleBarOverlay?.(options) + } catch { + // Overlay not supported on this platform/build — leave the frameless + // titlebar as-is. + } +} + const MEDIA_MIME_TYPES = { '.avi': 'video/x-msvideo', '.bmp': 'image/bmp', @@ -3760,11 +3787,15 @@ function getWindowButtonPosition() { } function getNativeOverlayWidth() { - // macOS reports traffic-light coords via windowButtonPosition; the - // titlebarOverlay there doesn't reserve right-edge space. Windows/Linux - // render the native window-controls overlay on the right, so the renderer - // needs to inset its right cluster by this much to clear them. - return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH + // Only Windows paints an Electron Window Controls Overlay on the right that + // the renderer must inset its right cluster to clear. + // - macOS reports traffic-light coords via windowButtonPosition; its + // titleBarOverlay doesn't reserve right-edge space. + // - Linux (incl. WSLg) doesn't support titleBarOverlay at all — Electron + // paints no native controls there, so reserving width just leaves a dead + // gap and, under WSLg (where the RDP host draws its own min/max/close), + // pushes the right tools out of alignment with those host buttons. + return IS_WINDOWS ? NATIVE_OVERLAY_BUTTON_WIDTH : 0 } function getWindowState() { @@ -5820,7 +5851,7 @@ function createWindow() { if (!nativeThemeListenerInstalled) { nativeThemeListenerInstalled = true nativeTheme.on('updated', () => { - mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) + applyTitleBarOverlay(mainWindow) }) } } @@ -6482,11 +6513,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() - if (!image || image.isEmpty()) { - return '' + if (image && !image.isEmpty()) { + return writeComposerImage(image.toPNG(), '.png') } - return writeComposerImage(image.toPNG(), '.png') + // WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the + // Linux clipboard Electron reads, so a host screenshot looks empty above. + // Pull it straight off the Windows clipboard via PowerShell as a fallback. + if (IS_WSL) { + const png = readWslWindowsClipboardImage() + if (png) { + return writeComposerImage(png, '.png') + } + } + + return '' }) ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) => @@ -6506,7 +6547,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { background: payload.background, foreground: payload.foreground } - mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) + applyTitleBarOverlay(mainWindow) }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.cjs new file mode 100644 index 00000000000..c81fe7b2a60 --- /dev/null +++ b/apps/desktop/electron/wsl-clipboard-image.cjs @@ -0,0 +1,92 @@ +// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg +// bridges text but not images). Returns PNG bytes or null; exec injectable. + +const { execFileSync } = require('node:child_process') + +// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException +// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG +// survives stdout's text decoding intact, and write with [Console]::Out.Write +// to avoid a trailing newline. +const PS_SCRIPT = [ + 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing', + '$img = [System.Windows.Forms.Clipboard]::GetImage()', + 'if ($null -eq $img) { exit 0 }', + '$ms = New-Object System.IO.MemoryStream', + '$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)', + '[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))' +].join('\n') + +// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script +// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes, +// brackets, newlines) that plain -Command arguments would mangle. +function encodePowerShellCommand(script) { + return Buffer.from(String(script), 'utf16le').toString('base64') +} + +// Locate powershell.exe. The bare name resolves through WSL's Windows-interop +// PATH on every standard WSL2 setup; the absolute fallback covers a stripped +// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong +// and we fall back to null. +function powershellCandidates() { + return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] +} + +function decodeClipboardImageBase64(stdout) { + const b64 = String(stdout || '').trim() + if (!b64) return null + + let buffer + try { + buffer = Buffer.from(b64, 'base64') + } catch { + return null + } + + // Guard against partial / garbage output: require a real PNG signature. + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + return null + } + + return buffer +} + +// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or +// null when there's no image, PowerShell is unreachable, or output is invalid. +// Linux-only by contract (caller gates on IS_WSL); never throws. +function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { + const encoded = encodePowerShellCommand(PS_SCRIPT) + + for (const ps of candidates) { + try { + const stdout = exec( + ps, + ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], + { + encoding: 'utf8', + windowsHide: true, + timeout: 8000, + // A 4K screenshot base64s to a few MB; give stdout generous headroom. + maxBuffer: 64 * 1024 * 1024, + // PowerShell writes progress/CLIXML noise to stderr — ignore it. + stdio: ['ignore', 'pipe', 'ignore'] + } + ) + const decoded = decodeClipboardImageBase64(stdout) + if (decoded) return decoded + // Empty stdout = no image on the clipboard; stop, don't try fallbacks. + if (String(stdout || '').trim() === '') return null + } catch { + // This powershell.exe candidate is missing/failed — try the next one. + } + } + + return null +} + +module.exports = { + decodeClipboardImageBase64, + encodePowerShellCommand, + powershellCandidates, + readWslWindowsClipboardImage +} diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.cjs new file mode 100644 index 00000000000..343adc1f6d6 --- /dev/null +++ b/apps/desktop/electron/wsl-clipboard-image.test.cjs @@ -0,0 +1,114 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { + decodeClipboardImageBase64, + encodePowerShellCommand, + powershellCandidates, + readWslWindowsClipboardImage +} = require('./wsl-clipboard-image.cjs') + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function fakePngBuffer(extraBytes = 16) { + return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)]) +} + +test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => { + const encoded = encodePowerShellCommand('Write-Output "hi"') + const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le') + assert.equal(roundTripped, 'Write-Output "hi"') +}) + +test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => { + const png = fakePngBuffer() + const decoded = decodeClipboardImageBase64(png.toString('base64')) + assert.ok(Buffer.isBuffer(decoded)) + assert.ok(decoded.equals(png)) +}) + +test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => { + const png = fakePngBuffer() + const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`) + assert.ok(decoded && decoded.equals(png)) +}) + +test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => { + assert.equal(decodeClipboardImageBase64(''), null) + assert.equal(decodeClipboardImageBase64(' \n '), null) + assert.equal(decodeClipboardImageBase64(null), null) + assert.equal(decodeClipboardImageBase64(undefined), null) +}) + +test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => { + // Valid base64, but the decoded bytes are not a PNG. + const notPng = Buffer.from('this is not a png at all').toString('base64') + assert.equal(decodeClipboardImageBase64(notPng), null) +}) + +test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { + const png = fakePngBuffer() + const calls = [] + const exec = (cmd, args) => { + calls.push({ cmd, args }) + return png.toString('base64') + } + + const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) + assert.ok(result && result.equals(png)) + assert.equal(calls.length, 1) + assert.equal(calls[0].cmd, 'powershell.exe') + // -STA is mandatory for System.Windows.Forms.Clipboard. + assert.ok(calls[0].args.includes('-STA')) + assert.ok(calls[0].args.includes('-EncodedCommand')) +}) + +test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { + let count = 0 + const exec = () => { + count += 1 + return '' + } + + const result = readWslWindowsClipboardImage({ + exec, + candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] + }) + assert.equal(result, null) + // Empty stdout means "no image on the clipboard" — don't probe further candidates. + assert.equal(count, 1) +}) + +test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { + const png = fakePngBuffer() + const seen = [] + const exec = cmd => { + seen.push(cmd) + if (cmd === 'powershell.exe') { + throw Object.assign(new Error('not found'), { code: 'ENOENT' }) + } + return png.toString('base64') + } + + const result = readWslWindowsClipboardImage({ + exec, + candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] + }) + assert.ok(result && result.equals(png)) + assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) +}) + +test('readWslWindowsClipboardImage returns null when every candidate throws', () => { + const exec = () => { + throw new Error('boom') + } + + const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] }) + assert.equal(result, null) +}) + +test('powershellCandidates lists the bare name first, then the absolute fallback', () => { + const candidates = powershellCandidates() + assert.equal(candidates[0], 'powershell.exe') + assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe'))) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 141f8219982..8b26d89b3bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 580416dea5b..57c34ebde38 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -73,7 +73,11 @@ export function ContextMenu({ {c.images} - + void onPasteClipboardImage() : undefined} + > {c.pasteImage} @@ -167,7 +171,7 @@ interface ContextMenuItemProps { interface ContextMenuProps { onInsertText: (text: string) => void onOpenUrlDialog: () => void - onPasteClipboardImage?: () => void + onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void onPickFiles?: () => void onPickFolders?: () => void onPickImages?: () => void diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 8a0ec509b0b..cca8f00638f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -784,6 +784,16 @@ export function ChatBar({ if (!pastedText) { event.preventDefault() + // Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to + // the Linux clipboard the DOM paste event reads, so a host screenshot + // arrives as an empty paste (no blobs, no text). Fall back to the main + // process, which pulls the image straight off the Windows clipboard. + // Silent so a genuinely-empty paste doesn't pop a "no image" warning. + if (onPasteClipboardImage) { + triggerHaptic('selection') + void onPasteClipboardImage({ silent: true }) + } + return } diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 6d9444a6d93..59c7c17274c 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -46,7 +46,7 @@ export interface ChatBarProps { onAddUrl?: (url: string) => void onAttachImageBlob?: (blob: Blob) => Promise | boolean | void onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | boolean | void - onPasteClipboardImage?: () => void + onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void onPickFiles?: () => void onPickFolders?: () => void onPickImages?: () => void diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index ddf38340235..26f41864baf 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -411,25 +411,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway } }, [attachImagePath, copy.attachImages, currentCwd, t.composer.images]) - const pasteClipboardImage = useCallback(async () => { - try { - const path = await window.hermesDesktop?.saveClipboardImage() + const pasteClipboardImage = useCallback( + async ({ silent = false }: { silent?: boolean } = {}) => { + try { + const path = await window.hermesDesktop?.saveClipboardImage() - if (!path) { - notify({ - kind: 'warning', - title: copy.clipboard, - message: copy.noClipboardImage - }) + if (!path) { + if (!silent) { + notify({ + kind: 'warning', + title: copy.clipboard, + message: copy.noClipboardImage + }) + } - return + return false + } + + await attachImagePath(path) + + return true + } catch (err) { + if (!silent) { + notifyError(err, copy.clipboardPasteFailed) + } + + return false } - - await attachImagePath(path) - } catch (err) { - notifyError(err, copy.clipboardPasteFailed) - } - }, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]) + }, + [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage] + ) const attachContextFolderPath = useCallback( (folderPath: string) => { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index e4a80e61273..51697829d17 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -75,7 +75,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void - onPasteClipboardImage: () => void + onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise | void onPickFiles: () => void onPickFolders: () => void onPickImages: () => void diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index b656afe0743..d36e63fe74f 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -1191,7 +1191,7 @@ export function DesktopController() { }} onDismissError={dismissError} onEdit={editMessage} - onPasteClipboardImage={() => void composer.pasteClipboardImage()} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} onPickFiles={() => void composer.pickContextPaths('file')} onPickFolders={() => void composer.pickContextPaths('folder')} onPickImages={() => void composer.pickImages()} From 3b1344c18c3dc485d5d89f7a9b061d32e76e9bf9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 23:50:59 -0500 Subject: [PATCH 2/3] fix(desktop): WSL titlebar layout and WSL2 GPU acceleration Live-measure WCO width in the renderer, drop the right rail below the titlebar band, and re-enable GPU compositing under WSLg when /dev/dxg is present. --- apps/desktop/electron/main.cjs | 50 +++++++------- .../electron/titlebar-overlay-width.cjs | 11 +++ .../electron/titlebar-overlay-width.test.cjs | 29 ++++++++ apps/desktop/package.json | 2 +- .../src/app/chat/right-rail/preview.tsx | 6 ++ apps/desktop/src/app/shell/app-shell.tsx | 24 ++++++- .../use-window-controls-overlay-width.ts | 68 +++++++++++++++++++ .../src/app/shell/titlebar-controls.tsx | 2 +- 8 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/electron/titlebar-overlay-width.cjs create mode 100644 apps/desktop/electron/titlebar-overlay-width.test.cjs create mode 100644 apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 0e20c89d0d2..772484bff8c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -44,6 +44,7 @@ const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-ma const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') +const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { readLiveUpdateMarker } = require('./update-marker.cjs') const { @@ -187,6 +188,16 @@ if (REMOTE_DISPLAY_REASON) { ) } +// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag. +// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote +// display already forced software (SSH'd-into-WSL). +if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { + app.commandLine.appendSwitch('ignore-gpu-blocklist') + app.commandLine.appendSwitch('enable-gpu-rasterization') + app.commandLine.appendSwitch('enable-zero-copy') + console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') +} + ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background @@ -399,14 +410,10 @@ const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } -// Width Electron reserves for the Windows/Linux native min/max/close cluster -// when `titleBarOverlay` is enabled. The OS paints these buttons in the -// top-right corner of the renderer; we have to leave that much room on the -// right edge so our system tools (file browser, haptics, settings) don't sit -// underneath them. macOS uses left-side traffic lights instead and reports a -// position via getWindowButtonPosition(), so this width is non-zero only on -// non-macOS platforms. -const NATIVE_OVERLAY_BUTTON_WIDTH = 144 +// Right-edge window-control reservation lives in titlebar-overlay-width.cjs +// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. +// It's only the pre-layout fallback — the renderer measures the exact overlay +// width live via the Window Controls Overlay API. const APP_ICON_PATHS = [ path.join(APP_ROOT, 'public', 'apple-touch-icon.png'), path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'), @@ -525,11 +532,12 @@ function getTitleBarOverlayOptions() { return { height: TITLEBAR_HEIGHT } } - // Window Controls Overlay is a Windows/macOS-only Electron feature. On Linux - // (including WSLg, where the RDP host draws its own min/max/close) requesting - // it does nothing useful, so disable it and let the frameless window stand on - // its own (titleBarStyle stays 'hidden'). - if (!IS_WINDOWS) { + // Window Controls Overlay: Windows paints it natively, and WSLg honors it too + // (it renders through a real compositor), so keep it enabled there — disabling + // it removes the min/max/close buttons entirely. Only plain (non-WSL) Linux, + // where some WMs/builds don't support WCO, falls through to no overlay; the + // frameless titleBarStyle 'hidden' still applies. + if (!IS_WINDOWS && !IS_WSL) { return false } @@ -551,9 +559,9 @@ function getTitleBarOverlayOptions() { } // Push refreshed overlay options to a live window after a theme/appearance -// change. No-op on Linux (incl. WSLg), where the overlay is unsupported and -// getTitleBarOverlayOptions() returns false — calling setTitleBarOverlay there -// can throw on some Electron Linux builds. +// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions() +// returns false; the try/catch additionally guards builds where +// setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() if (!options || typeof options !== 'object') { @@ -3787,15 +3795,7 @@ function getWindowButtonPosition() { } function getNativeOverlayWidth() { - // Only Windows paints an Electron Window Controls Overlay on the right that - // the renderer must inset its right cluster to clear. - // - macOS reports traffic-light coords via windowButtonPosition; its - // titleBarOverlay doesn't reserve right-edge space. - // - Linux (incl. WSLg) doesn't support titleBarOverlay at all — Electron - // paints no native controls there, so reserving width just leaves a dead - // gap and, under WSLg (where the RDP host draws its own min/max/close), - // pushes the right tools out of alignment with those host buttons. - return IS_WINDOWS ? NATIVE_OVERLAY_BUTTON_WIDTH : 0 + return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL }) } function getWindowState() { diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.cjs new file mode 100644 index 00000000000..27a62f1a9f3 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.cjs @@ -0,0 +1,11 @@ +// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right). +// Live width comes from navigator.windowControlsOverlay in the renderer. + +const OVERLAY_FALLBACK_WIDTH = 144 + +/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */ +function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) { + return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0 +} + +module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.cjs new file mode 100644 index 00000000000..a7b8b6f5215 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.test.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs') + +// This static reservation is only the pre-layout FALLBACK. Once laid out the +// renderer reads the exact width from navigator.windowControlsOverlay +// (use-window-controls-overlay-width.ts) and uses these values only when the WCO +// API is unavailable. + +test('Windows reserves the overlay fallback width', () => { + assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('WSLg paints the same WCO, so it reserves the same fallback width', () => { + // The original bug: WSL fell through to 0, so the right tools sat under the + // controls and the title overran into them. + assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('plain Linux and macOS reserve nothing', () => { + assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0) + assert.equal(nativeOverlayWidth(), 0) + assert.equal(nativeOverlayWidth({}), 0) +}) + +test('the fallback width is a sane positive pixel value', () => { + assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8b26d89b3bc..56a7374eeef 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index d13eb088967..bc34e4b2316 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -101,6 +101,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP 'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)', panesFlipped ? 'border-r' : 'border-l' )} + // Windows/WSLg paint Electron's Window Controls Overlay across our + // titlebar band, so the editor-style tab strip (which normally sits IN that + // band) would land under the fixed titlebar tools. --right-rail-top-inset + // (set by AppShell only when the overlay is present) drops the rail one + // titlebar-height so it opens below the band. 0px elsewhere → unchanged. + style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }} >
0 ? `${nativeOverlayWidth}px` : '0.75rem' + // When the native window controls overlay our titlebar band — Windows and + // WSLg both paint Electron's Window Controls Overlay and report + // nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which + // normally lives IN that band) would render at y=0 under the fixed titlebar + // tool cluster and collide with it. Drop the right rail one titlebar-height so + // it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset, + // layout byte-for-byte unchanged. Consumed as --right-rail-top-inset. + const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px' + // The inset clears the top-left titlebar buttons when nothing covers the // window's left edge. Default layout: the sessions sidebar sits there. // Flipped layout: the file browser does instead. Both force-collapse to a @@ -159,6 +174,9 @@ export function AppShell({ '--titlebar-controls-top': `${titlebarControls.top}px`, '--titlebar-tools-right': titlebarToolsRight, '--titlebar-tools-width': titlebarToolsWidth, + // Drops the right rail below the titlebar band when the OS/host paints + // window controls over it (Windows/WSLg); 0px elsewhere. + '--right-rail-top-inset': rightRailTopInset, // Anchor for the pane-tool cluster's right edge in TitlebarControls. // Sourced from the layout store rather than the PaneShell-emitted // --pane-*-width vars because the titlebar is a sibling of PaneShell diff --git a/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts b/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts new file mode 100644 index 00000000000..f2411f99513 --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' + +// Measure the EXACT width of the native window-controls overlay (min/max/close) +// straight from the browser, instead of a hardcoded reservation. +// +// When Electron's Window Controls Overlay is active (native Windows AND WSLg), +// Chromium exposes `navigator.windowControlsOverlay`. Its getTitlebarAreaRect() +// returns the draggable title-bar rect that EXCLUDES the controls, so the +// controls' width on the right is `innerWidth - rect.right`. This is precise and +// self-correcting across DPI / host themes / window states — no magic numbers, +// and it sidesteps the WSLg-vs-Windows footprint guesswork. +// +// Returns null when WCO is unavailable (macOS, plain Linux, or before first +// layout), so callers fall back to the static reservation from the main process. + +interface WindowControlsOverlayLike { + visible: boolean + getTitlebarAreaRect: () => DOMRect + addEventListener: (type: 'geometrychange', cb: () => void) => void + removeEventListener: (type: 'geometrychange', cb: () => void) => void +} + +const overlay = () => + (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay ?? null + +function measure(wco: WindowControlsOverlayLike | null): number | null { + const rect = wco?.visible ? wco.getTitlebarAreaRect() : null + + // No overlay, or it isn't laid out yet. + if (!rect?.width) { + return null + } + + const width = Math.round(window.innerWidth - rect.right) + + return width > 0 ? width : null +} + +/** + * Live width (px) of the right-side native window-controls overlay, or null when + * the platform/build exposes no overlay (caller should use the static fallback). + */ +export function useWindowControlsOverlayWidth(): number | null { + const [width, setWidth] = useState(() => measure(overlay())) + + useEffect(() => { + const wco = overlay() + + if (!wco) { + return + } + + const update = () => setWidth(measure(wco)) + + // Re-measure on overlay geometry changes (maximize/restore, DPI) and on + // window resize (innerWidth feeds the calc). + wco.addEventListener('geometrychange', update) + window.addEventListener('resize', update) + update() + + return () => { + wco.removeEventListener('geometrychange', update) + window.removeEventListener('resize', update) + } + }, []) + + return width +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index d0ace1c8838..fb0cae4307e 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -175,7 +175,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: {visiblePaneTools.length > 0 && (
{visiblePaneTools.map(tool => ( From 76074b214517109f4816ea6e83042ea65712b131 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 23:50:59 -0500 Subject: [PATCH 3/3] fix(desktop): transparent WCO titlebar chrome on Windows/WSLg Use a transparent native overlay so renderer chrome shows through the min/max/close band. Sync window pre-paint bg to the computed chrome mix. --- apps/desktop/electron/main.cjs | 30 ++++++++++-------------- apps/desktop/src/app/shell/app-shell.tsx | 7 ++++++ apps/desktop/src/themes/context.tsx | 12 ++++++++-- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 772484bff8c..5d1f35ba450 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -527,34 +527,30 @@ function getWindowBackgroundColor() { return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7' } +// Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back +// to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch. +const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)' + function getTitleBarOverlayOptions() { if (IS_MAC) { return { height: TITLEBAR_HEIGHT } } - // Window Controls Overlay: Windows paints it natively, and WSLg honors it too - // (it renders through a real compositor), so keep it enabled there — disabling - // it removes the min/max/close buttons entirely. Only plain (non-WSL) Linux, - // where some WMs/builds don't support WCO, falls through to no overlay; the - // frameless titleBarStyle 'hidden' still applies. + // Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden + // titlebar still applies). if (!IS_WINDOWS && !IS_WSL) { return false } - if (rendererTitleBarTheme) { - return { - color: rendererTitleBarTheme.background, - height: TITLEBAR_HEIGHT, - symbolColor: rendererTitleBarTheme.foreground - } - } - - const useDarkColors = nativeTheme.shouldUseDarkColors - return { - color: useDarkColors ? '#111111' : '#f7f7f7', + color: TITLEBAR_OVERLAY_COLOR, height: TITLEBAR_HEIGHT, - symbolColor: useDarkColors ? '#f7f7f7' : '#242424' + symbolColor: + rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) + ? rendererTitleBarTheme.foreground + : nativeTheme.shouldUseDarkColors + ? '#f7f7f7' + : '#242424' } } diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index c8405aadc84..56deb913952 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -189,6 +189,13 @@ export function AppShell({ )} + {nativeOverlayWidth > 0 && ( +
+ )} +
+ mix(background, NEUTRAL_CHROME[isDark ? 'dark' : 'light'], isDark ? 0.26 : 0.08) + const mixesFor = (isDark: boolean): Record => ({ '--theme-mix-chrome': isDark ? '74%' : '92%', '--theme-mix-sidebar': '100%', @@ -222,8 +228,10 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { root.style.setProperty(k, v) } + const chromeBg = chromeBackground(c.background, isDark) + window.hermesDesktop?.setTitleBarTheme?.({ - background: c.background, + background: chromeBg, foreground: c.foreground }) @@ -231,7 +239,7 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { // they let a brand-new window paint the themed background on its very first // frame, before this module has even loaded. try { - window.localStorage.setItem('hermes-boot-background', c.background) + window.localStorage.setItem('hermes-boot-background', chromeBg) window.localStorage.setItem('hermes-boot-color-scheme', rendered) } catch { // Storage may be unavailable (private mode / quota); the inline script