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()}