Skip to content
Closed
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
30 changes: 26 additions & 4 deletions ui-tui/src/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,27 @@ describe('readClipboardText', () => {
})

it('reads text from PowerShell on Windows', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from windows\r\n' })
const b64 = Buffer.from('from windows\r\n', 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })

await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n')
expect(run).toHaveBeenCalledWith(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
['-NoProfile', '-NonInteractive', '-Command', '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})

it('tries powershell.exe first on WSL', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' })
const b64 = Buffer.from('from wsl\n', 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })

await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe(
'from wsl\n'
)
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
['-NoProfile', '-NonInteractive', '-Command', '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
Expand Down Expand Up @@ -81,6 +83,16 @@ describe('readClipboardText', () => {
readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)
).resolves.toBeNull()
})

it('preserves CJK text via base64 decoding from PowerShell on WSL', async () => {
const cjkText = '你好世界,测试中文 🎉'
const b64 = Buffer.from(cjkText, 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })

await expect(
readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)
).resolves.toBe(cjkText)
})
})

describe('isUsableClipboardText', () => {
Expand Down Expand Up @@ -109,6 +121,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin: { end: vi.fn() }
}

Expand All @@ -129,6 +142,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand All @@ -152,6 +166,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin: { end: vi.fn() }
}

Expand All @@ -171,6 +186,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand Down Expand Up @@ -201,6 +217,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand Down Expand Up @@ -236,6 +253,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand All @@ -258,6 +276,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand Down Expand Up @@ -290,6 +309,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand Down Expand Up @@ -327,6 +347,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand All @@ -353,6 +374,7 @@ describe('writeClipboardText', () => {

return child
}),
unref: vi.fn(),
stdin
}

Expand Down
23 changes: 18 additions & 5 deletions ui-tui/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)
const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024
const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'] as const
// PowerShell read: base64-encode the clipboard content to avoid ANSI codepage
// corruption (same problem as the write path — see comment at line 94).
const POWERSHELL_READ_ARGS = [
'-NoProfile',
'-NonInteractive',
'-Command',
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'
] as const

type ClipboardRun = typeof execFileAsync

Expand Down Expand Up @@ -33,19 +40,19 @@ export function isUsableClipboardText(text: null | string): text is string {
function readClipboardCommands(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): Array<{ args: readonly string[]; cmd: string }> {
): Array<{ args: readonly string[]; cmd: string; base64?: boolean }> {
if (platform === 'darwin') {
return [{ cmd: 'pbpaste', args: [] }]
}

if (platform === 'win32') {
return [{ cmd: 'powershell', args: POWERSHELL_ARGS }]
return [{ cmd: 'powershell', args: POWERSHELL_READ_ARGS, base64: true }]
}

const attempts: Array<{ args: readonly string[]; cmd: string }> = []
const attempts: Array<{ args: readonly string[]; cmd: string; base64?: boolean }> = []

if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_READ_ARGS, base64: true })
}

if (env.WAYLAND_DISPLAY) {
Expand Down Expand Up @@ -81,6 +88,10 @@ export async function readClipboardText(
})

if (typeof result.stdout === 'string') {
if (attempt.base64) {
return Buffer.from(result.stdout.trim(), 'base64').toString('utf8')
}

return result.stdout
}
} catch {
Expand Down Expand Up @@ -158,13 +169,15 @@ export async function writeClipboardText(
const ok = await new Promise<boolean>(resolve => {
if (cmdEntry.stdin) {
const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.unref()
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
child.stdin?.end(text)
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const script = _powershellWriteScript(b64)
const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
child.unref()
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
}
Expand Down
Loading