diff --git a/.changeset/fix-wsl-image-paste.md b/.changeset/fix-wsl-image-paste.md new file mode 100644 index 0000000000..324e9308e8 --- /dev/null +++ b/.changeset/fix-wsl-image-paste.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix image paste from the Windows clipboard when Kimi Code runs in WSL. diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 6aae761c44..9f96f2b0fb 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -82,6 +82,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; +const WSL_CLIPBOARD_IMAGE_ENV = 'KIMI_WSL_CLIPBOARD_IMAGE_PATH'; +const WSL_CLIPBOARD_IMAGE_WSLENV_ENTRY = `${WSL_CLIPBOARD_IMAGE_ENV}/w`; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -128,8 +130,7 @@ function selectPreferredImageMimeType(candidates: string[]): string | null { const match = normalized.find((t) => t.base === preferred); if (match !== undefined) return match.raw; } - const anyImage = normalized.find((t) => t.base.startsWith('image/')); - return anyImage?.raw ?? null; + return null; } function videoMimeFromPath(path: string): string | null { @@ -241,8 +242,8 @@ function runCommand(command: string, args: string[], options?: RunCommandOptions }); } -function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardFileMediaViaWlPaste(run: RunCommand): ClipboardMedia | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -251,12 +252,12 @@ function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']); + const uris = run('wl-paste', ['--type', uriType, '--no-newline']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaWlPaste(): ClipboardImage | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardImageViaWlPaste(run: RunCommand): ClipboardImage | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -264,13 +265,13 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null { const selected = selectPreferredImageMimeType(parseTargetList(list.stdout)); if (selected === null) return null; - const data = runCommand('wl-paste', ['--type', selected, '--no-newline']); + const data = run('wl-paste', ['--type', selected, '--no-newline']); if (!data.ok || data.stdout.length === 0) return null; return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) }; } -function readClipboardFileMediaViaXclip(): ClipboardMedia | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardFileMediaViaXclip(run: RunCommand): ClipboardMedia | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!targets.ok) return null; @@ -279,12 +280,12 @@ function readClipboardFileMediaViaXclip(): ClipboardMedia | null { const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); + const uris = run('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaXclip(): ClipboardImage | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardImageViaXclip(run: RunCommand): ClipboardImage | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); @@ -296,7 +297,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null { : [...SUPPORTED_IMAGE_MIME_TYPES]; for (const mime of tryTypes) { - const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); + const data = run('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); if (data.ok && data.stdout.length > 0) { return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) }; } @@ -310,10 +311,10 @@ function readClipboardImageViaXclip(): ClipboardImage | null { * we round-trip via a temp PNG because binary stdout is unreliable * across the WSL interop boundary. */ -function readClipboardImageViaPowerShell(): ClipboardImage | null { +function readClipboardImageViaPowerShell(run: RunCommand, env: NodeJS.ProcessEnv): ClipboardImage | null { const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`); try { - const winPathResult = runCommand('wslpath', ['-w', tmpFile], { + const winPathResult = run('wslpath', ['-w', tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!winPathResult.ok) return null; @@ -323,14 +324,48 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null { const psScript = [ 'Add-Type -AssemblyName System.Windows.Forms', 'Add-Type -AssemblyName System.Drawing', - '$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH', + `$path = $env:${WSL_CLIPBOARD_IMAGE_ENV}`, '$img = [System.Windows.Forms.Clipboard]::GetImage()', "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", ].join('; '); - const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], { + const wslEnvEntries = env['WSLENV'] + ?.split(':') + .filter((entry) => entry.length > 0) ?? []; + const wslEnvOutputEntries: string[] = []; + let wslClipboardImageEnvEntryIndex: number | null = null; + let wslClipboardImageEnvFlags = ''; + for (const entry of wslEnvEntries) { + const [name, flags = ''] = entry.split('/'); + if (name !== WSL_CLIPBOARD_IMAGE_ENV) { + wslEnvOutputEntries.push(entry); + continue; + } + wslClipboardImageEnvEntryIndex ??= wslEnvOutputEntries.push('') - 1; + for (const flag of flags) { + if (!wslClipboardImageEnvFlags.includes(flag)) { + wslClipboardImageEnvFlags += flag; + } + } + } + if (!wslClipboardImageEnvFlags.includes('w')) { + wslClipboardImageEnvFlags += 'w'; + } + if (wslClipboardImageEnvEntryIndex === null) { + wslEnvOutputEntries.push(WSL_CLIPBOARD_IMAGE_WSLENV_ENTRY); + } else { + wslEnvOutputEntries[wslClipboardImageEnvEntryIndex] = `${WSL_CLIPBOARD_IMAGE_ENV}/${wslClipboardImageEnvFlags}`; + } + const wslEnv = wslEnvOutputEntries.join(':'); + + const result = run('powershell.exe', ['-NoProfile', '-Command', psScript], { timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, - env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath }, + env: { + ...process.env, + ...env, + [WSL_CLIPBOARD_IMAGE_ENV]: winPath, + WSLENV: wslEnv, + }, }); if (!result.ok) return null; if (result.stdout.toString('utf-8').trim() !== 'ok') return null; @@ -419,12 +454,12 @@ export async function readClipboardMedia(options?: { const wsl = isWSL(env); if (wayland || wsl) { - const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip(); + const fileMedia = readClipboardFileMediaViaWlPaste(run) ?? readClipboardFileMediaViaXclip(run); if (fileMedia !== null) return fileMedia; - image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + image = readClipboardImageViaWlPaste(run) ?? readClipboardImageViaXclip(run); } if (image === null && wsl) { - image = readClipboardImageViaPowerShell(); + image = readClipboardImageViaPowerShell(run, env); } if (image === null && !wayland) { const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts index 7e802db6ad..1faaf97cde 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; +import type { RunCommand } from '#/utils/clipboard/clipboard-common'; import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; function png(width: number, height: number): Uint8Array { @@ -36,6 +37,33 @@ function noMacOsPaths(): { stdout: Buffer; ok: boolean } { return { stdout: Buffer.alloc(0), ok: false }; } +function wslPowerShellImageRunner(imageBytes: Uint8Array): { + runCommand: RunCommand; + getPowerShellEnv: () => NodeJS.ProcessEnv | null; +} { + let linuxPath: string | null = null; + let powerShellEnv: NodeJS.ProcessEnv | null = null; + const runCommand: RunCommand = (command, args, options) => { + if (command === 'wslpath') { + const candidate = args[1]; + linuxPath = candidate ?? null; + return { stdout: Buffer.from('C:\\Users\\example\\clip.png\n'), ok: linuxPath !== null }; + } + if (command === 'powershell.exe') { + powerShellEnv = options?.env ?? null; + if (linuxPath !== null) { + writeFileSync(linuxPath, imageBytes); + } + return { stdout: Buffer.from('ok\n'), ok: true }; + } + if (command === 'wl-paste' || command === 'xclip') { + return { stdout: Buffer.alloc(0), ok: false }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }; + return { runCommand, getPowerShellEnv: () => powerShellEnv }; +} + describe('readClipboardMedia', () => { it('reads a copied image file from its real path instead of the Finder preview icon', async () => { const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-')); @@ -158,4 +186,115 @@ describe('readClipboardMedia', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('reads a WSL Wayland BMP clipboard through PowerShell when wl-paste exposes only image/bmp', async () => { + const imageBytes = png(16, 17); + const { runCommand: runPowerShellImageCommand } = wslPowerShellImageRunner(imageBytes); + const runCommand: RunCommand = (command, args, options) => { + if (command === 'wl-paste' && args.includes('--list-types')) { + return { stdout: Buffer.from('image/bmp\n'), ok: true }; + } + if (command === 'wl-paste' && args.includes('image/bmp')) { + return { stdout: Buffer.from([0x42, 0x4d, 0x01]), ok: true }; + } + return runPowerShellImageCommand(command, args, options); + }; + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu', WAYLAND_DISPLAY: 'wayland-0' }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' }); + }); + + it('reads a WSL clipboard image through PowerShell when WSLENV needs the image path entry added', async () => { + const imageBytes = png(8, 9); + const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu', WSLENV: 'HOME/p:KIMI_EXISTING/u' }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' }); + expect(getPowerShellEnv()).toMatchObject({ + KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png', + WSLENV: 'HOME/p:KIMI_EXISTING/u:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w', + }); + }); + + it('reads a WSL clipboard image through PowerShell when WSLENV already has the image path entry once', async () => { + const imageBytes = png(10, 11); + const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { + WSL_DISTRO_NAME: 'Ubuntu', + WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w:KIMI_EXISTING/u', + }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' }); + expect(getPowerShellEnv()).toMatchObject({ + KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png', + WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w:KIMI_EXISTING/u', + }); + }); + + it('reads a WSL clipboard image through PowerShell when WSLENV has duplicate image path entries, merges flags and adds w', async () => { + const imageBytes = png(12, 13); + const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { + WSL_DISTRO_NAME: 'Ubuntu', + WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/u:KIMI_EXISTING/u:KIMI_WSL_CLIPBOARD_IMAGE_PATH/l', + }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' }); + expect(getPowerShellEnv()).toMatchObject({ + KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png', + WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/ulw:KIMI_EXISTING/u', + }); + }); + + it('reads a WSL clipboard image through PowerShell when explicit env omits WSLENV, ignores host WSLENV', async () => { + const previousWslEnv = process.env['WSLENV']; + process.env['WSLENV'] = 'HOST_SENTINEL/p'; + try { + const imageBytes = png(14, 15); + const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' }); + expect(getPowerShellEnv()).toMatchObject({ + KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png', + WSLENV: 'KIMI_WSL_CLIPBOARD_IMAGE_PATH/w', + }); + } finally { + if (previousWslEnv === undefined) { + delete process.env['WSLENV']; + } else { + process.env['WSLENV'] = previousWslEnv; + } + } + }); });