mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 12:13:05 +00:00
fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay
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.
This commit is contained in:
parent
5b5c79a8ef
commit
da5484b61f
10 changed files with 304 additions and 32 deletions
|
|
@ -43,6 +43,7 @@ const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-reques
|
||||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||||
|
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||||
const {
|
const {
|
||||||
|
|
@ -524,6 +525,14 @@ function getTitleBarOverlayOptions() {
|
||||||
return { height: TITLEBAR_HEIGHT }
|
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) {
|
if (rendererTitleBarTheme) {
|
||||||
return {
|
return {
|
||||||
color: rendererTitleBarTheme.background,
|
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 = {
|
const MEDIA_MIME_TYPES = {
|
||||||
'.avi': 'video/x-msvideo',
|
'.avi': 'video/x-msvideo',
|
||||||
'.bmp': 'image/bmp',
|
'.bmp': 'image/bmp',
|
||||||
|
|
@ -3760,11 +3787,15 @@ function getWindowButtonPosition() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNativeOverlayWidth() {
|
function getNativeOverlayWidth() {
|
||||||
// macOS reports traffic-light coords via windowButtonPosition; the
|
// Only Windows paints an Electron Window Controls Overlay on the right that
|
||||||
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
|
// the renderer must inset its right cluster to clear.
|
||||||
// render the native window-controls overlay on the right, so the renderer
|
// - macOS reports traffic-light coords via windowButtonPosition; its
|
||||||
// needs to inset its right cluster by this much to clear them.
|
// titleBarOverlay doesn't reserve right-edge space.
|
||||||
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
|
// - 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() {
|
function getWindowState() {
|
||||||
|
|
@ -5820,7 +5851,7 @@ function createWindow() {
|
||||||
if (!nativeThemeListenerInstalled) {
|
if (!nativeThemeListenerInstalled) {
|
||||||
nativeThemeListenerInstalled = true
|
nativeThemeListenerInstalled = true
|
||||||
nativeTheme.on('updated', () => {
|
nativeTheme.on('updated', () => {
|
||||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
applyTitleBarOverlay(mainWindow)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6482,11 +6513,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||||
|
|
||||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||||
const image = clipboard.readImage()
|
const image = clipboard.readImage()
|
||||||
if (!image || image.isEmpty()) {
|
if (image && !image.isEmpty()) {
|
||||||
return ''
|
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) =>
|
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||||
|
|
@ -6506,7 +6547,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||||
background: payload.background,
|
background: payload.background,
|
||||||
foreground: payload.foreground
|
foreground: payload.foreground
|
||||||
}
|
}
|
||||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
applyTitleBarOverlay(mainWindow)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||||
|
|
|
||||||
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
|
|
@ -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')))
|
||||||
|
})
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"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",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ export function ContextMenu({
|
||||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||||
{c.images}
|
{c.images}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
<ContextMenuItem
|
||||||
|
disabled={!onPasteClipboardImage}
|
||||||
|
icon={Clipboard}
|
||||||
|
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
|
||||||
|
>
|
||||||
{c.pasteImage}
|
{c.pasteImage}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||||
|
|
@ -167,7 +171,7 @@ interface ContextMenuItemProps {
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
onInsertText: (text: string) => void
|
onInsertText: (text: string) => void
|
||||||
onOpenUrlDialog: () => void
|
onOpenUrlDialog: () => void
|
||||||
onPasteClipboardImage?: () => void
|
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||||
onPickFiles?: () => void
|
onPickFiles?: () => void
|
||||||
onPickFolders?: () => void
|
onPickFolders?: () => void
|
||||||
onPickImages?: () => void
|
onPickImages?: () => void
|
||||||
|
|
|
||||||
|
|
@ -784,6 +784,16 @@ export function ChatBar({
|
||||||
if (!pastedText) {
|
if (!pastedText) {
|
||||||
event.preventDefault()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export interface ChatBarProps {
|
||||||
onAddUrl?: (url: string) => void
|
onAddUrl?: (url: string) => void
|
||||||
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||||
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||||
onPasteClipboardImage?: () => void
|
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||||
onPickFiles?: () => void
|
onPickFiles?: () => void
|
||||||
onPickFolders?: () => void
|
onPickFolders?: () => void
|
||||||
onPickImages?: () => void
|
onPickImages?: () => void
|
||||||
|
|
|
||||||
|
|
@ -411,25 +411,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||||
}
|
}
|
||||||
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
||||||
|
|
||||||
const pasteClipboardImage = useCallback(async () => {
|
const pasteClipboardImage = useCallback(
|
||||||
try {
|
async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
try {
|
||||||
|
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||||
|
|
||||||
if (!path) {
|
if (!path) {
|
||||||
notify({
|
if (!silent) {
|
||||||
kind: 'warning',
|
notify({
|
||||||
title: copy.clipboard,
|
kind: 'warning',
|
||||||
message: copy.noClipboardImage
|
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)
|
[attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
|
||||||
} catch (err) {
|
)
|
||||||
notifyError(err, copy.clipboardPasteFailed)
|
|
||||||
}
|
|
||||||
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
|
|
||||||
|
|
||||||
const attachContextFolderPath = useCallback(
|
const attachContextFolderPath = useCallback(
|
||||||
(folderPath: string) => {
|
(folderPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||||
maxVoiceRecordingSeconds?: number
|
maxVoiceRecordingSeconds?: number
|
||||||
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||||
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||||
onPasteClipboardImage: () => void
|
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||||
onPickFiles: () => void
|
onPickFiles: () => void
|
||||||
onPickFolders: () => void
|
onPickFolders: () => void
|
||||||
onPickImages: () => void
|
onPickImages: () => void
|
||||||
|
|
|
||||||
|
|
@ -1191,7 +1191,7 @@ export function DesktopController() {
|
||||||
}}
|
}}
|
||||||
onDismissError={dismissError}
|
onDismissError={dismissError}
|
||||||
onEdit={editMessage}
|
onEdit={editMessage}
|
||||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||||
onPickImages={() => void composer.pickImages()}
|
onPickImages={() => void composer.pickImages()}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue