fix(host): prefer pwsh for the Windows directory picker and force DPI awareness

The win32 branch now spawns pwsh.exe (PowerShell 7) first and falls back
to powershell.exe (Windows PowerShell 5.1) only when pwsh is missing
(ENOENT), mirroring the Zenity-KDialog fallback. PowerShell 7 renders the
modern IFileDialog folder picker; the 5.1 fallback keeps the legacy tree
functional. Both runtimes execute the identical script, which opts the
process into system DPI awareness (SetProcessDPIAware) before any window
exists, fixing the blurry bitmap-stretched dialog on scaled displays.
This commit is contained in:
Huanqi Cao
2026-08-05 00:31:43 +08:00
parent 60ba5ef3f6
commit a92ffff10a
2 changed files with 52 additions and 4 deletions
@@ -64,8 +64,15 @@ export async function pickNativeDirectory(
}
if (platform === 'win32') {
// PowerShell 7 renders the modern IFileDialog folder picker, while Windows
// PowerShell 5.1's FolderBrowserDialog is hardwired to the legacy
// SHBrowseForFolder tree; prefer pwsh and fall back only when it is absent.
// Both hosts spawn DPI-unaware, so the script opts the process into system
// DPI awareness before any window is created.
const script = [
"$ErrorActionPreference = 'Stop'",
"Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'",
'[DpiAware]::SetProcessDPIAware() | Out-Null',
'Add-Type -AssemblyName System.Windows.Forms',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
"$dialog.Description = 'Select Workspace Directory'",
@@ -76,6 +83,13 @@ export async function pickNativeDirectory(
' [Console]::WriteLine($dialog.SelectedPath)',
'}',
].join('; ')
try {
const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (!isMissingCommand(error)) throw error
}
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
}
@@ -46,28 +46,62 @@ describe('native directory picker', () => {
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
it('prefers pwsh for the Windows folder dialog and maps empty output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
'pwsh.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
expect.any(AbortSignal),
)
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
const script = run.mock.calls[0]?.[1].at(-1)
expect(script).toContain("$ErrorActionPreference = 'Stop'")
expect(script).toContain('SetProcessDPIAware')
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
})
it('falls back to Windows PowerShell 5.1 only when pwsh is missing', async () => {
const run = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\fallback')
expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
// Both runtimes execute the identical script, so DPI awareness holds either way.
expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1))
const cancelled = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled })).resolves.toBeNull()
const failed = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(2))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed })).rejects.toThrow('command failed')
const brokenPwsh = vi.fn<DirectoryPickerRunner>(async () => { throw failure(7) })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: brokenPwsh })).rejects.toThrow('command failed')
expect(brokenPwsh).toHaveBeenCalledOnce()
})
it('does not fall back when the caller aborted the pwsh spawn', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })).rejects.toThrow('command failed')
expect(run).toHaveBeenCalledOnce()
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('powershell.exe')
expect(command).toBe('pwsh.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)