From 15afa9acfcc1dd7f04a81b71f44036b92105af44 Mon Sep 17 00:00:00 2001 From: Pluviobyte Date: Sun, 31 May 2026 02:16:03 +0800 Subject: [PATCH 1/2] fix(cli): support WSL2 clipboard image paste --- .../cli/src/ui/utils/clipboardUtils.test.ts | 132 ++++++++++++++++++ packages/cli/src/ui/utils/clipboardUtils.ts | 109 +++++++++++---- 2 files changed, 213 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index cfd9f115ba2..082c32779a0 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -103,8 +103,10 @@ describe('clipboardUtils', () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + process.env = originalEnv; }); describe('clipboardHasImage (Linux)', () => { @@ -190,6 +192,56 @@ describe('clipboardUtils', () => { }); }); + describe('clipboardHasImage (WSL2)', () => { + beforeEach(() => { + mockPlatform('linux'); + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu'); + }); + + it('should return true when PowerShell reports an image', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'True\n', + stderr: '', + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(true); + expect(spawnAsync).toHaveBeenCalledWith('powershell.exe', [ + '-NoProfile', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]); + }); + + it('should return false when PowerShell reports no image', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'False\n', + stderr: '', + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(false); + }); + + it('should not fall through to native Linux clipboard tools', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'False\n', + stderr: '', + }); + + await clipboardUtils.clipboardHasImage(); + + expect(spawnAsync).toHaveBeenCalledTimes(1); + expect(spawnAsync).toHaveBeenCalledWith( + 'powershell.exe', + expect.any(Array), + ); + expect(execSync).not.toHaveBeenCalled(); + }); + }); + describe('saveClipboardImage (Linux)', () => { const mockTargetDir = '/tmp/target'; const mockTempDir = path.join('/tmp/global', 'images'); @@ -346,6 +398,86 @@ describe('clipboardUtils', () => { }); }); + describe('saveClipboardImage (WSL2)', () => { + const mockTargetDir = '/tmp/target'; + const mockTempDir = path.join('/tmp/global', 'images'); + + beforeEach(() => { + mockPlatform('linux'); + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu'); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + }); + + it('should save image via PowerShell interop with wslpath conversion', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: + '\\\\wsl.localhost\\Ubuntu\\tmp\\global\\images\\clipboard-123.png\n', + stderr: '', + }); + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'success\n', + stderr: '', + }); + vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); + + const result = await clipboardUtils.saveClipboardImage(mockTargetDir); + + expect(result).toContain(mockTempDir); + expect(result).toMatch(/clipboard-\d+\.png$/); + expect(spawnAsync).toHaveBeenCalledWith('wslpath', [ + '-w', + expect.stringContaining('clipboard-'), + ]); + expect(spawnAsync).toHaveBeenCalledWith('powershell.exe', [ + '-NoProfile', + '-Command', + expect.stringContaining('ImageFormat]::Png'), + ]); + }); + + it('should return null when wslpath fails', async () => { + vi.mocked(spawnAsync).mockRejectedValueOnce( + new Error('spawn wslpath ENOENT'), + ); + + const result = await clipboardUtils.saveClipboardImage(mockTargetDir); + + expect(result).toBe(null); + }); + + it('should return null when PowerShell does not report success', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: '\\\\wsl.localhost\\Ubuntu\\tmp\\test.png\n', + stderr: '', + }); + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: '', + stderr: '', + }); + + const result = await clipboardUtils.saveClipboardImage(mockTargetDir); + + expect(result).toBe(null); + }); + + it('should escape single quotes in the Windows path for PowerShell', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: "\\\\wsl.localhost\\Ubuntu\\tmp\\User's Files\\test.png\n", + stderr: '', + }); + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'success\n', + stderr: '', + }); + vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); + + await clipboardUtils.saveClipboardImage(mockTargetDir); + + const script = vi.mocked(spawnAsync).mock.calls[1][1][2]; + expect(script).toContain("User''s Files"); + }); + }); + // Stateless functions continue to use static imports describe('cleanupOldClipboardImages', () => { const mockTargetDir = '/tmp/target'; diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index fd46a2c749f..5aee6370240 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -31,6 +31,13 @@ export const IMAGE_EXTENSIONS = [ /** Matches strings that start with a path prefix (/, ~, ., Windows drive letter, or UNC path) */ const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:|\\\\)/; +const isWSL = (): boolean => + Boolean( + process.env['WSL_DISTRO_NAME'] || + process.env['WSLENV'] || + process.env['WSL_INTEROP'], + ); + // Track which tool works on Linux to avoid redundant checks/failures let linuxClipboardTool: 'wl-paste' | 'xclip' | null = null; @@ -163,6 +170,20 @@ async function checkXclipForImage() { */ export async function clipboardHasImage(): Promise { if (process.platform === 'linux') { + if (isWSL()) { + try { + const { stdout } = await spawnAsync('powershell.exe', [ + '-NoProfile', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]); + return stdout.trim() === 'True'; + } catch (error) { + debugLogger.warn('Error checking WSL clipboard for image:', error); + return false; + } + } + const tool = getUserLinuxClipboardTool(); if (tool === 'wl-paste') { if (await checkWlPasteForImage()) return true; @@ -244,6 +265,41 @@ const saveFileWithXclip = async (tempFilePath: string) => { return false; }; +async function saveFileWithPowerShell( + command: 'powershell' | 'powershell.exe', + tempFilePath: string, + powershellPath: string, +): Promise { + const psPath = powershellPath.replace(/'/g, "''"); + + const script = ` + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + if ([System.Windows.Forms.Clipboard]::ContainsImage()) { + $image = [System.Windows.Forms.Clipboard]::GetImage() + $image.Save('${psPath}', [System.Drawing.Imaging.ImageFormat]::Png) + Write-Output "success" + } + `; + + const { stdout } = await spawnAsync(command, [ + '-NoProfile', + '-Command', + script, + ]); + + if (stdout.trim() !== 'success') { + return false; + } + + try { + const stats = await fs.stat(tempFilePath); + return stats.size > 0; + } catch { + return false; + } +} + /** * Gets the directory where clipboard images should be stored for a specific project. * @@ -281,6 +337,27 @@ export async function saveClipboardImage( if (process.platform === 'linux') { const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); + + if (isWSL()) { + try { + const { stdout } = await spawnAsync('wslpath', ['-w', tempFilePath]); + const windowsPath = stdout.trim(); + if ( + windowsPath && + (await saveFileWithPowerShell( + 'powershell.exe', + tempFilePath, + windowsPath, + )) + ) { + return tempFilePath; + } + } catch (error) { + debugLogger.warn('Error saving WSL clipboard image:', error); + } + return null; + } + const tool = getUserLinuxClipboardTool(); if (tool === 'wl-paste') { @@ -296,34 +373,10 @@ export async function saveClipboardImage( if (process.platform === 'win32') { const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - // The path is used directly in the PowerShell script. - const psPath = tempFilePath.replace(/'/g, "''"); - - const script = ` - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - if ([System.Windows.Forms.Clipboard]::ContainsImage()) { - $image = [System.Windows.Forms.Clipboard]::GetImage() - $image.Save('${psPath}', [System.Drawing.Imaging.ImageFormat]::Png) - Write-Output "success" - } - `; - - const { stdout } = await spawnAsync('powershell', [ - '-NoProfile', - '-Command', - script, - ]); - - if (stdout.trim() === 'success') { - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist - } + if ( + await saveFileWithPowerShell('powershell', tempFilePath, tempFilePath) + ) { + return tempFilePath; } return null; } From 519d869c0b2eb463db435daf293b28cfd8ecac7a Mon Sep 17 00:00:00 2001 From: Pluviobyte Date: Sun, 31 May 2026 02:33:02 +0800 Subject: [PATCH 2/2] fix(cli): make clipboard PowerShell checks robust --- .../cli/src/ui/utils/clipboardUtils.test.ts | 27 +++++++++++++++++++ packages/cli/src/ui/utils/clipboardUtils.ts | 4 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 082c32779a0..3e300d1821b 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -214,6 +214,17 @@ describe('clipboardUtils', () => { ]); }); + it('should return true when PowerShell reports an image with extra output', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'Windows PowerShell\nTrue\n', + stderr: '', + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(true); + }); + it('should return false when PowerShell reports no image', async () => { vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: 'False\n', @@ -460,6 +471,22 @@ describe('clipboardUtils', () => { expect(result).toBe(null); }); + it('should save image when PowerShell reports success with extra output', async () => { + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: '\\\\wsl.localhost\\Ubuntu\\tmp\\test.png\n', + stderr: '', + }); + vi.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'Windows PowerShell\nsuccess\n', + stderr: '', + }); + vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); + + const result = await clipboardUtils.saveClipboardImage(mockTargetDir); + + expect(result).toMatch(/clipboard-\d+\.png$/); + }); + it('should escape single quotes in the Windows path for PowerShell', async () => { vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: "\\\\wsl.localhost\\Ubuntu\\tmp\\User's Files\\test.png\n", diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 5aee6370240..25b53c69f81 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -177,7 +177,7 @@ export async function clipboardHasImage(): Promise { '-Command', 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', ]); - return stdout.trim() === 'True'; + return stdout.includes('True'); } catch (error) { debugLogger.warn('Error checking WSL clipboard for image:', error); return false; @@ -288,7 +288,7 @@ async function saveFileWithPowerShell( script, ]); - if (stdout.trim() !== 'success') { + if (!stdout.includes('success')) { return false; }