Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions packages/cli/src/ui/utils/clipboardUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ describe('clipboardUtils', () => {
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
process.env = originalEnv;
});

describe('clipboardHasImage (Linux)', () => {
Expand Down Expand Up @@ -190,6 +192,67 @@ 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 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',
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');
Expand Down Expand Up @@ -346,6 +409,102 @@ 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 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",
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';
Expand Down
109 changes: 81 additions & 28 deletions packages/cli/src/ui/utils/clipboardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -163,6 +170,20 @@ async function checkXclipForImage() {
*/
export async function clipboardHasImage(): Promise<boolean> {
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.includes('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;
Expand Down Expand Up @@ -244,6 +265,41 @@ const saveFileWithXclip = async (tempFilePath: string) => {
return false;
};

async function saveFileWithPowerShell(
command: 'powershell' | 'powershell.exe',
tempFilePath: string,
powershellPath: string,
): Promise<boolean> {
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.includes('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.
*
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
}
Expand Down