Skip to content

Commit 1508f4d

Browse files
authored
Merge pull request #17 from F4RAN/fix/issue-16-windows-binary-download
Fix/issue 16 windows binary download
2 parents 91984e2 + 1552e0d commit 1508f4d

4 files changed

Lines changed: 208 additions & 56 deletions

File tree

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cuimp",
3-
"version": "1.4.1",
3+
"version": "1.5.0",
44
"description": "Node wrapper for curl-impersonate (lexiforest) via CLI - Enhanced with raw buffer support and extra curl args",
55
"keywords": [
66
"cuimp",
@@ -68,7 +68,6 @@
6868
"vitest": "^2.1.8"
6969
},
7070
"dependencies": {
71-
"cuimp": "^1.1.2",
7271
"tar": "^7.4.3"
7372
}
7473
}

src/helpers/parser.ts

Lines changed: 116 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,22 @@ const BINARY_SEARCH_PATHS = [
6363
]
6464

6565
// Binary name patterns to search for
66+
// Windows binaries can be .exe or .bat files
6667
const BINARY_PATTERNS = [
6768
'curl-impersonate',
6869
'curl-impersonate.exe',
70+
'curl-impersonate.bat',
71+
'curl_chrome*.exe',
72+
'curl_chrome*.bat',
6973
'curl_chrome*',
74+
'curl_firefox*.exe',
75+
'curl_firefox*.bat',
7076
'curl_firefox*',
77+
'curl_edge*.exe',
78+
'curl_edge*.bat',
7179
'curl_edge*',
80+
'curl_safari*.exe',
81+
'curl_safari*.bat',
7282
'curl_safari*',
7383
]
7484

@@ -135,8 +145,16 @@ const findExistingBinary = (browser: string = ''): string | null => {
135145
const packageDir = getPackageDir()
136146
const packageBinariesDir = path.resolve(packageDir, 'cuimp/binaries')
137147

138-
// Create search paths including both directories
139-
const searchPaths = [homeBinariesDir, packageBinariesDir, ...BINARY_SEARCH_PATHS]
148+
// On Windows, binaries are extracted to a 'bin' subdirectory
149+
// Create search paths including both directories and Windows-specific bin subdirectory
150+
const isWindows = process.platform === 'win32'
151+
const searchPaths = [
152+
homeBinariesDir,
153+
...(isWindows ? [path.resolve(homeBinariesDir, 'bin')] : []),
154+
packageBinariesDir,
155+
...(isWindows ? [path.resolve(packageBinariesDir, 'bin')] : []),
156+
...BINARY_SEARCH_PATHS
157+
]
140158

141159
for (const searchPath of searchPaths) {
142160
for (const pattern of patternsToSearch) {
@@ -211,6 +229,10 @@ const downloadAndExtractBinary = async (
211229
// macos uses specific naming: x86_64-macos, arm64-macos, etc.
212230
const macosArch = architecture === 'x64' ? 'x86_64' : 'arm64'
213231
assetName = `curl-impersonate-${latestVersion}.${macosArch}-macos.tar.gz`
232+
} else if (platform === 'windows') {
233+
// Windows uses libcurl-impersonate prefix and win32 suffix: x86_64-win32, arm64-win32, etc.
234+
const windowsArch = architecture === 'x64' ? 'x86_64' : 'arm64'
235+
assetName = `libcurl-impersonate-${latestVersion}.${windowsArch}-win32.tar.gz`
214236
} else {
215237
// Other platforms use the original naming
216238
assetName = `curl-impersonate-${latestVersion}.${architecture}-${platform}.tar.gz`
@@ -251,45 +273,95 @@ const downloadAndExtractBinary = async (
251273
// Clean up temporary file
252274
fs.unlinkSync(tempFileName)
253275

254-
// Find the extracted binary file in the binaries directory
255-
// The main binary is usually named 'curl-impersonate'
256-
const mainBinaryName = 'curl-impersonate'
257-
const binaryPath = path.resolve(binariesDir, mainBinaryName)
276+
// On Windows, binaries are extracted to a 'bin' subdirectory
277+
// On other platforms, they're extracted directly to binariesDir
278+
const searchDirs = platform === 'windows'
279+
? [path.resolve(binariesDir, 'bin'), binariesDir]
280+
: [binariesDir]
258281

259-
// Check if the main binary was extracted
260-
if (!fs.existsSync(binaryPath)) {
261-
// If main binary not found, look for browser-specific binaries
262-
const browserSpecificPattern = `curl_${browser}*`
263-
const files = fs.readdirSync(binariesDir)
264-
const matchingFiles = files.filter(file => {
265-
const regex = new RegExp(browserSpecificPattern.replace('*', '.*'))
266-
return regex.test(file)
267-
})
282+
// Binary name patterns to search for (Windows uses .exe or .bat extension)
283+
const binaryExtensions = platform === 'windows' ? ['.exe', '.bat', ''] : ['']
284+
const mainBinaryNames = binaryExtensions.map(ext => `curl-impersonate${ext}`)
285+
const browserSpecificPattern = `curl_${browser}*`
286+
287+
let binaryPath: string | null = null
288+
289+
// First, try to find the main binary (curl-impersonate)
290+
for (const searchDir of searchDirs) {
291+
if (!fs.existsSync(searchDir)) continue
268292

269-
if (matchingFiles.length > 0) {
270-
// Use the highest version browser-specific binary
271-
const sortedFiles = matchingFiles.sort((a, b) => {
272-
const versionA = extractVersionNumber(a)
273-
const versionB = extractVersionNumber(b)
274-
return versionB - versionA // Sort in descending order (highest first)
275-
})
276-
const bestMatch = sortedFiles[0]
277-
const browserBinaryPath = path.resolve(binariesDir, bestMatch)
293+
for (const mainBinaryName of mainBinaryNames) {
294+
const candidatePath = path.resolve(searchDir, mainBinaryName)
295+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) {
296+
binaryPath = candidatePath
297+
break
298+
}
299+
}
300+
if (binaryPath) break
301+
}
302+
303+
// If main binary not found, look for browser-specific binaries
304+
if (!binaryPath) {
305+
for (const searchDir of searchDirs) {
306+
if (!fs.existsSync(searchDir)) continue
278307

279-
// Set executable permissions on the browser-specific binary
280-
fs.chmodSync(browserBinaryPath, 0o755)
308+
const files = fs.readdirSync(searchDir)
309+
const matchingFiles = files.filter(file => {
310+
const regex = new RegExp(browserSpecificPattern.replace('*', '.*'))
311+
return regex.test(file) && fs.statSync(path.resolve(searchDir, file)).isFile()
312+
})
281313

282-
return {
283-
binaryPath: browserBinaryPath,
284-
version: actualVersion
314+
if (matchingFiles.length > 0) {
315+
// Use the highest version browser-specific binary
316+
const sortedFiles = matchingFiles.sort((a, b) => {
317+
const versionA = extractVersionNumber(a)
318+
const versionB = extractVersionNumber(b)
319+
return versionB - versionA // Sort in descending order (highest first)
320+
})
321+
const bestMatch = sortedFiles[0]
322+
binaryPath = path.resolve(searchDir, bestMatch)
323+
break
285324
}
286325
}
287-
288-
throw new Error(`Binary not found after extraction. Expected: ${binaryPath}`)
289326
}
290327

291-
// Set executable permissions on the main binary
292-
fs.chmodSync(binaryPath, 0o755)
328+
if (!binaryPath) {
329+
throw new Error(
330+
`Binary not found after extraction. Searched in: ${searchDirs.join(', ')}. ` +
331+
`Expected: curl-impersonate${platform === 'windows' ? '.exe' : ''} or curl_${browser}*`
332+
)
333+
}
334+
335+
// Set executable permissions on the binary (chmod may not work on Windows, but it's safe to try)
336+
try {
337+
fs.chmodSync(binaryPath, 0o755)
338+
} catch (error) {
339+
// On Windows, chmod might fail, but that's okay - the file is still executable
340+
if (platform !== 'windows') {
341+
throw error
342+
}
343+
}
344+
345+
// On Windows, download CA bundle if not present (required for SSL verification)
346+
if (platform === 'windows') {
347+
const binDir = path.dirname(binaryPath)
348+
const caBundlePath = path.join(binDir, 'curl-ca-bundle.crt')
349+
if (!fs.existsSync(caBundlePath)) {
350+
logger.info('Downloading CA certificate bundle for Windows...')
351+
try {
352+
const caResponse = await fetch('https://curl.se/ca/cacert.pem')
353+
if (caResponse.ok) {
354+
const caBundle = await caResponse.text()
355+
fs.writeFileSync(caBundlePath, caBundle)
356+
logger.info(`CA bundle saved to ${caBundlePath}`)
357+
} else {
358+
logger.warn('Failed to download CA bundle - SSL verification may fail')
359+
}
360+
} catch (caError) {
361+
logger.warn(`Failed to download CA bundle: ${caError instanceof Error ? caError.message : String(caError)}`)
362+
}
363+
}
364+
}
293365

294366
return {
295367
binaryPath: binaryPath,
@@ -356,21 +428,18 @@ export const parseDescriptor = async (descriptor: CuimpDescriptor, logger: Logge
356428
if (!forceDownload) {
357429
const existingBinary = findExistingBinary(browser)
358430
if (existingBinary) {
359-
const existingVersion = extractVersionNumber(path.basename(existingBinary)).toString()
360-
361-
// For 'latest', accept any existing binary
362-
// For specific version, check if it matches
363-
const versionMatches = version === 'latest' || existingVersion === version
431+
// Extract browser version from filename (e.g., curl_chrome136 -> 136)
432+
// Note: This is the browser version, not the curl-impersonate release version
433+
const browserVersion = extractVersionNumber(path.basename(existingBinary)).toString()
364434

365-
if (versionMatches) {
366-
logger.info(`Found existing binary: ${existingBinary}`)
367-
return {
368-
binaryPath: existingBinary,
369-
isDownloaded: false,
370-
version: existingVersion || 'unknown'
371-
}
372-
} else {
373-
logger.warn(`Found binary version ${existingVersion}, but version ${version} was requested. Re-downloading...`)
435+
// Accept any existing binary for the requested browser
436+
// The 'version' field in descriptor refers to curl-impersonate release version,
437+
// which we can't easily determine from the cached binary filename
438+
logger.info(`Found existing binary: ${existingBinary}`)
439+
return {
440+
binaryPath: existingBinary,
441+
isDownloaded: false,
442+
version: browserVersion || 'unknown'
374443
}
375444
}
376445
} else {

src/runner.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawn } from 'node:child_process';
22
import { RunResult } from './types/runTypes';
3+
import path from 'node:path';
4+
import fs from 'node:fs';
35

46

57

@@ -9,7 +11,40 @@ export function runBinary(
911
opts?: { timeout?: number; signal?: AbortSignal }
1012
): Promise<RunResult> {
1113
return new Promise((resolve, reject) => {
12-
const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
14+
// On Windows, .bat files need shell: true to execute properly
15+
const isWindows = process.platform === 'win32';
16+
const isBatFile = binPath.toLowerCase().endsWith('.bat');
17+
const needsShell = isWindows && isBatFile;
18+
19+
// On Windows, add --cacert argument if CA bundle exists and not already specified
20+
// This fixes SSL certificate verification issues
21+
let finalArgs = args;
22+
if (isWindows && !args.includes('--cacert') && !args.includes('-k')) {
23+
const binDir = path.dirname(binPath);
24+
const caBundlePath = path.join(binDir, 'curl-ca-bundle.crt');
25+
if (fs.existsSync(caBundlePath)) {
26+
// Prepend --cacert to args so it's processed before the URL
27+
finalArgs = ['--cacert', caBundlePath, ...args];
28+
}
29+
}
30+
31+
// When using shell: true on Windows, we need to properly quote arguments
32+
// to prevent & and other shell metacharacters from being interpreted
33+
if (needsShell) {
34+
finalArgs = finalArgs.map(arg => {
35+
// If arg contains shell metacharacters, wrap in double quotes
36+
// and escape any existing double quotes
37+
if (/[&|<>^"\s]/.test(arg)) {
38+
return `"${arg.replace(/"/g, '\\"')}"`;
39+
}
40+
return arg;
41+
});
42+
}
43+
44+
const child = spawn(binPath, finalArgs, {
45+
stdio: ['ignore', 'pipe', 'pipe'],
46+
shell: needsShell
47+
});
1348

1449
let killedByTimeout = false;
1550
let t: NodeJS.Timeout | undefined;

test/unit/parser.test.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,32 @@ describe('parseDescriptor', () => {
6868
ok: true,
6969
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
7070
})
71+
72+
// Default: directories don't exist, so readdirSync throws (simulating real behavior)
73+
mockFs.readdirSync.mockImplementation(() => {
74+
throw new Error('ENOENT: no such file or directory')
75+
})
76+
77+
// Default: files don't exist
78+
mockFs.existsSync.mockReturnValue(false)
7179
})
7280

7381
afterEach(() => {
7482
vi.restoreAllMocks()
7583
})
7684

7785
it('should always download binary (force download)', async () => {
78-
mockFs.existsSync.mockReturnValue(false)
86+
// Mock: binary exists after extraction, binaries directory exists
87+
mockFs.existsSync.mockImplementation((path: string) => {
88+
// Return true for binaries directory and binary path check after extraction
89+
if (typeof path === 'string' && (path.includes('.cuimp/binaries') || path.includes('curl-impersonate'))) {
90+
return true
91+
}
92+
return false
93+
})
7994
mockGetLatestRelease.mockResolvedValue('v1.0.0')
8095

81-
const descriptor: CuimpDescriptor = { browser: 'chrome' }
96+
const descriptor: CuimpDescriptor = { browser: 'chrome', forceDownload: true }
8297
const result = await parseDescriptor(descriptor)
8398

8499
expect(mockGetLatestRelease).toHaveBeenCalled()
@@ -87,7 +102,14 @@ describe('parseDescriptor', () => {
87102
})
88103

89104
it('should download binary if not found locally', async () => {
90-
mockFs.existsSync.mockReturnValue(false)
105+
// Mock: binary exists after extraction, binaries directory exists
106+
mockFs.existsSync.mockImplementation((path: string) => {
107+
// Return true for binaries directory and binary path check after extraction
108+
if (typeof path === 'string' && (path.includes('.cuimp/binaries') || path.includes('curl-impersonate'))) {
109+
return true
110+
}
111+
return false
112+
})
91113
mockGetLatestRelease.mockResolvedValue('v1.0.0')
92114

93115
const descriptor: CuimpDescriptor = {
@@ -104,7 +126,14 @@ describe('parseDescriptor', () => {
104126
})
105127

106128
it('should handle empty descriptor', async () => {
107-
mockFs.existsSync.mockReturnValue(false)
129+
// Mock: binary exists after extraction, binaries directory exists
130+
mockFs.existsSync.mockImplementation((path: string) => {
131+
// Return true for binaries directory and binary path check after extraction
132+
if (typeof path === 'string' && (path.includes('.cuimp/binaries') || path.includes('curl-impersonate'))) {
133+
return true
134+
}
135+
return false
136+
})
108137
mockGetLatestRelease.mockResolvedValue('v1.0.0')
109138

110139
const descriptor: CuimpDescriptor = {}
@@ -115,7 +144,14 @@ describe('parseDescriptor', () => {
115144
})
116145

117146
it('should handle partial descriptor', async () => {
118-
mockFs.existsSync.mockReturnValue(false)
147+
// Mock: binary exists after extraction, binaries directory exists
148+
mockFs.existsSync.mockImplementation((path: string) => {
149+
// Return true for binaries directory and binary path check after extraction
150+
if (typeof path === 'string' && (path.includes('.cuimp/binaries') || path.includes('curl-impersonate'))) {
151+
return true
152+
}
153+
return false
154+
})
119155
mockGetLatestRelease.mockResolvedValue('v1.0.0')
120156

121157
const descriptor: CuimpDescriptor = { browser: 'chrome' }
@@ -170,14 +206,27 @@ describe('parseDescriptor', () => {
170206
it('should handle missing assets in release', async () => {
171207
mockFs.existsSync.mockReturnValue(false)
172208
mockGetLatestRelease.mockResolvedValue('v1.0.0')
209+
// Mock fetch to return 404
210+
mockFetch.mockResolvedValue({
211+
ok: false,
212+
status: 404,
213+
statusText: 'Not Found'
214+
})
173215

174216
const descriptor: CuimpDescriptor = { browser: 'chrome' }
175217

176218
await expect(parseDescriptor(descriptor)).rejects.toThrow()
177219
})
178220

179221
it('should handle multiple assets and select correct one', async () => {
180-
mockFs.existsSync.mockReturnValue(false)
222+
// Mock: binary exists after extraction, binaries directory exists
223+
mockFs.existsSync.mockImplementation((path: string) => {
224+
// Return true for binaries directory and binary path check after extraction
225+
if (typeof path === 'string' && (path.includes('.cuimp/binaries') || path.includes('curl-impersonate'))) {
226+
return true
227+
}
228+
return false
229+
})
181230
mockGetLatestRelease.mockResolvedValue('v1.0.0')
182231

183232
const descriptor: CuimpDescriptor = {

0 commit comments

Comments
 (0)