From a92ffff10a0f7757327595754c6e3a42655957d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 00:08:54 +0800 Subject: [PATCH] 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. --- .../src/native-picker.ts | 14 +++++++ .../tests/native-picker.spec.ts | 42 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 2c8e236acc..079211f5a3 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -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) } diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 87e25ff877..8d26c6ab36 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -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(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() + .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() + .mockRejectedValueOnce(failure('ENOENT')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled })).resolves.toBeNull() + + const failed = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(2)) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed })).rejects.toThrow('command failed') + + const brokenPwsh = vi.fn(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(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)