From f7b32dab3249a523333dd43f539d3e5db36a0f2c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 17 Jun 2026 18:47:44 +1000 Subject: [PATCH] fix(desktop): preserve Windows backslash paths in custom extension command (#9741) Signed-off-by: Michael Neale --- .../settings/extensions/utils.test.ts | 32 ++++++++++++++++++- .../components/settings/extensions/utils.ts | 12 ++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index ab886aa17..5fd4cd089 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { nameToKey, getDefaultFormData, @@ -346,6 +346,36 @@ describe('Extension Utils', () => { ])('splits %j correctly', (input, expected) => { expect(splitCmdAndArgs(input)).toEqual(expected); }); + + describe('on Windows', () => { + beforeEach(() => { + vi.stubGlobal('window', { electron: { platform: 'win32' } }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves backslashes in a Windows path', () => { + expect(splitCmdAndArgs('C:\\Users\\name\\path\\to\\extension.js')).toEqual({ + cmd: 'C:\\Users\\name\\path\\to\\extension.js', + args: [], + }); + }); + + it('preserves backslashes in cmd and args', () => { + expect(splitCmdAndArgs('node C:\\Users\\name\\ext.js')).toEqual({ + cmd: 'node', + args: ['C:\\Users\\name\\ext.js'], + }); + }); + + it('handles a quoted Windows path containing spaces', () => { + expect(splitCmdAndArgs('"C:\\Program Files\\app\\ext.js"')).toEqual({ + cmd: 'C:\\Program Files\\app\\ext.js', + args: [], + }); + }); + }); }); describe('extractCommand', () => { diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 00e29c1fa..121726102 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -168,13 +168,23 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon } } +function isWindowsPlatform(): boolean { + return typeof window !== 'undefined' && window.electron?.platform === 'win32'; +} + export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } { const trimmed = str.trim(); if (!trimmed) { return { cmd: '', args: [] }; } - const parsed = parseShellQuote(trimmed); + // shell-quote treats `\` as a POSIX escape character, so a Windows path like + // `C:\Users\name\ext.js` would lose its backslashes and become `C:Usersnameext.js`. + // Doubling backslashes on Windows lets them survive parsing (shell-quote unescapes + // `\\` back to `\`), while still honoring quotes for paths containing spaces. + const toParse = isWindowsPlatform() ? trimmed.replace(/\\/g, '\\\\') : trimmed; + + const parsed = parseShellQuote(toParse); const words = parsed.filter((item): item is string => typeof item === 'string').map(String); const cmd = words[0] || '';