fix(tui): use base64 encoding for PowerShell clipboard writes to preserve UTF-8

When writing text to the clipboard via PowerShell (WSL2 and native Windows),
the previous implementation piped text through stdin using `Set-Clipboard
-Value $input`. PowerShell reads stdin using the Windows system's default
ANSI code page (e.g. CP936 for Chinese Windows), causing all non-ASCII
characters (CJK, emoji, accented) to become garbled.

Fix: encode the text as base64 in Node.js and pass it as a command argument.
PowerShell decodes it from base64 using explicit UTF-8, bypassing the code
page issue entirely.

Fixes #35107
This commit is contained in:
annguyenNous
2026-05-30 10:45:57 +07:00
committed by Teknium
parent b4cf114f68
commit 64998fa93e
2 changed files with 74 additions and 16 deletions

View File

@ -269,7 +269,14 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wsl text')
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
expect(commandIdx).toBeGreaterThan(-1)
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wsl text', 'utf8').toString('base64'))
})
it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
@ -300,7 +307,13 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wslg text')
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wslg text', 'utf8').toString('base64'))
})
it('uses PowerShell on Windows', async () => {
@ -325,5 +338,32 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
})
it('preserves CJK text via base64 encoding in PowerShell on WSL', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
const cjkText = '你好世界,测试中文 🎉'
await expect(writeClipboardText(cjkText, 'linux', start as any, { WSL_INTEROP: '/tmp/socket' })).resolves.toBe(true)
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain(Buffer.from(cjkText, 'utf8').toString('base64'))
expect(script).toContain('UTF8.GetString')
})
})

View File

@ -91,33 +91,44 @@ export async function readClipboardText(
return null
}
type WriteCmd = { args: readonly string[]; cmd: string } & (
| { stdin: true }
| { stdin: false; psScript: (b64: string) => string }
)
function _powershellWriteScript(b64: string): string {
return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`
}
function writeClipboardCommands(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): Array<{ args: readonly string[]; cmd: string }> {
): WriteCmd[] {
if (platform === 'darwin') {
return [{ cmd: 'pbcopy', args: [] }]
return [{ cmd: 'pbcopy', args: [], stdin: true }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive'], stdin: false, psScript: _powershellWriteScript }]
}
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
const attempts: WriteCmd[] = []
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({
cmd: 'powershell.exe',
args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']
args: ['-NoProfile', '-NonInteractive'],
stdin: false,
psScript: _powershellWriteScript
})
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'], stdin: true })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'], stdin: true })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'], stdin: true })
return attempts
}
@ -144,14 +155,21 @@ export async function writeClipboardText(
): Promise<boolean> {
const candidates = writeClipboardCommands(platform, env)
for (const { cmd, args } of candidates) {
for (const cmdEntry of candidates) {
try {
const ok = await new Promise<boolean>(resolve => {
const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', code => resolve(code === 0))
child.stdin?.end(text)
if (cmdEntry.stdin) {
const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
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 = cmdEntry.psScript(b64)
const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
}
})
if (ok) {