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:
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user