Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions apps/frontend/scripts/download-python.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,195 @@ function checkForBlockedPackages(requirementsPath) {
return blocked;
}

/**
* Fix pywin32 installation for bundled packages.
*
* When pip installs pywin32 with --target, the post-install script doesn't run,
* and the .pth file isn't processed (since PYTHONPATH doesn't process .pth files).
*
* This means:
* 1. `import pywintypes` fails because pywintypes.py is in win32/lib/, not at root
* 2. `import _win32sysloader` fails because it's in win32/, not at root
* 3. pywin32_system32 needs an __init__.py to be importable as a package
*
* The fix copies the necessary files to site-packages root so they're directly importable.
*/
function fixPywin32(sitePackagesDir) {
const pywin32System32 = path.join(sitePackagesDir, 'pywin32_system32');
const win32Dir = path.join(sitePackagesDir, 'win32');
const win32LibDir = path.join(win32Dir, 'lib');

if (!fs.existsSync(pywin32System32)) {
// pywin32 not installed or not on Windows - nothing to fix
return;
}

console.log(`[download-python] Fixing pywin32 for bundled packages...`);

// 1. Copy pywintypes.py and pythoncom.py from win32/lib/ to root
// These are the Python modules that load the DLLs
const pyModules = ['pywintypes.py', 'pythoncom.py'];
for (const pyModule of pyModules) {
const srcPath = path.join(win32LibDir, pyModule);
const destPath = path.join(sitePackagesDir, pyModule);

if (fs.existsSync(srcPath)) {
try {
fs.copyFileSync(srcPath, destPath);
console.log(`[download-python] Copied ${pyModule} to site-packages root`);
} catch (err) {
console.warn(`[download-python] Failed to copy ${pyModule}: ${err.message}`);
}
}
}

// 2. Copy _win32sysloader.pyd from win32/ to root
// This is required by pywintypes.py to locate and load the DLLs
// Filter for .pyd extension to avoid matching unrelated files
const sysloaderFiles = fs.readdirSync(win32Dir).filter(f => f.startsWith('_win32sysloader') && f.endsWith('.pyd'));
for (const sysloader of sysloaderFiles) {
const srcPath = path.join(win32Dir, sysloader);
const destPath = path.join(sitePackagesDir, sysloader);

try {
fs.copyFileSync(srcPath, destPath);
console.log(`[download-python] Copied ${sysloader} to site-packages root`);
} catch (err) {
console.warn(`[download-python] Failed to copy ${sysloader}: ${err.message}`);
}
}

// 3. Create __init__.py in pywin32_system32/ to make it importable as a package
// pywintypes.py does `import pywin32_system32` and then uses pywin32_system32.__path__
const initPath = path.join(pywin32System32, '__init__.py');
try {
// The __init__.py sets up __path__ so pywintypes.py can find the DLLs
const initContent = `# Auto-generated for bundled pywin32
import os
__path__ = [os.path.dirname(__file__)]
`;
// Use 'wx' flag for atomic exclusive write - fails if file exists (EEXIST)
// This avoids TOCTOU race condition where existsSync + writeFileSync could
// allow another process to create/modify the file between check and write.
// See: https://nodejs.org/api/fs.html#file-system-flags
fs.writeFileSync(initPath, initContent, { flag: 'wx' });
console.log(`[download-python] Created pywin32_system32/__init__.py`);
} catch (err) {
// EEXIST means file already exists - that's fine, we wanted to avoid overwriting
if (err.code !== 'EEXIST') {
console.warn(`[download-python] Failed to create __init__.py: ${err.message}`);
}
}

// 4. Copy DLLs to multiple locations for maximum compatibility
//
// Why we copy DLLs to pywin32_system32/, win32/, AND site-packages root:
// - pywin32_system32/: Primary location, used by os.add_dll_directory() in bootstrap
// - win32/: Fallback for pywintypes.py's __file__-relative search
// - site-packages root: Fallback when other search mechanisms fail
//
// Trade-off: This duplicates DLLs ~3x (~2MB extra), but ensures pywin32 works
// regardless of which DLL search mechanism succeeds. The alternative (single
// location) caused intermittent failures depending on Python version and how
// the process was spawned. Bundle size trade-off is acceptable for reliability.
//
// See: https://github.com/AndyMik90/Auto-Claude/issues/810
const dllFiles = fs.readdirSync(pywin32System32).filter(f => f.endsWith('.dll'));
for (const dll of dllFiles) {
const srcPath = path.join(pywin32System32, dll);
const destPath = path.join(win32Dir, dll);

try {
fs.copyFileSync(srcPath, destPath);
console.log(`[download-python] Copied ${dll} to win32/`);
} catch (err) {
console.warn(`[download-python] Failed to copy ${dll} to win32/: ${err.message}`);
}
}

// 5. Also copy DLLs to site-packages root for maximum compatibility
for (const dll of dllFiles) {
const srcPath = path.join(pywin32System32, dll);
const destPath = path.join(sitePackagesDir, dll);

try {
fs.copyFileSync(srcPath, destPath);
console.log(`[download-python] Copied ${dll} to site-packages root`);
} catch (err) {
console.warn(`[download-python] Failed to copy ${dll}: ${err.message}`);
}
}
Comment on lines +699 to +723
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These two loops for copying DLLs to different destinations contain duplicated logic. To improve maintainability and reduce redundancy, you can refactor this into a single loop that iterates over a list of target destinations.

const dllFiles = fs.readdirSync(pywin32System32).filter(f => f.endsWith('.dll'));
const copyTargets = [
  { dir: win32Dir, name: 'win32/' },
  { dir: sitePackagesDir, name: 'site-packages root' },
];

for (const dll of dllFiles) {
  const srcPath = path.join(pywin32System32, dll);
  for (const target of copyTargets) {
    const destPath = path.join(target.dir, dll);
    try {
      fs.copyFileSync(srcPath, destPath);
      console.log(`[download-python] Copied ${dll} to ${target.name}`);
    } catch (err) {
      console.warn(`[download-python] Failed to copy ${dll} to ${target.name}: ${err.message}`);
    }
  }
}


// 6. Create PYTHONSTARTUP bootstrap script for Python 3.8+ DLL loading
// This script runs before any imports and ensures os.add_dll_directory() is called
// for pywin32_system32. This is necessary because:
// - PYTHONPATH doesn't process .pth files, so pywin32_bootstrap.py never runs
// - Python 3.8+ requires os.add_dll_directory() for DLL search paths
// - PATH environment variable no longer works for DLL loading in Python 3.8+
//
// IMPORTANT: This script content must stay synchronized with ensurePywin32StartupScript()
// in apps/frontend/src/main/python-env-manager.ts (which creates the script at runtime
// if it doesn't exist in bundled packages).
//
// See: https://github.com/AndyMik90/Auto-Claude/issues/810
// See: https://github.com/AndyMik90/Auto-Claude/issues/861
// See: https://github.com/mhammond/pywin32/blob/main/win32/Lib/pywin32_bootstrap.py
const startupScriptPath = path.join(sitePackagesDir, '_auto_claude_startup.py');
const startupScriptContent = `# Auto-Claude pywin32 bootstrap script
# This script runs via PYTHONSTARTUP before the main script.
# It ensures pywin32 DLLs can be found on Python 3.8+ where
# os.add_dll_directory() is required for DLL search paths.
#
# See: https://github.com/AndyMik90/Auto-Claude/issues/810
# See: https://github.com/mhammond/pywin32/blob/main/win32/Lib/pywin32_bootstrap.py

import os
import sys

def _bootstrap_pywin32():
"""Bootstrap pywin32 DLL loading for Python 3.8+"""
# Get the site-packages directory (where this script is located)
site_packages = os.path.dirname(os.path.abspath(__file__))

# 1. Add pywin32_system32 to DLL search path (Python 3.8+ requirement)
# This is the critical fix - without this, pywintypes DLL cannot be loaded
pywin32_system32 = os.path.join(site_packages, 'pywin32_system32')
if os.path.isdir(pywin32_system32):
if hasattr(os, 'add_dll_directory'):
try:
os.add_dll_directory(pywin32_system32)
except OSError:
pass # Directory already added or doesn't exist

# Also add to PATH as fallback for edge cases
current_path = os.environ.get('PATH', '')
if pywin32_system32 not in current_path:
os.environ['PATH'] = pywin32_system32 + os.pathsep + current_path

# 2. Use site.addsitedir() to process .pth files
# This triggers pywin32.pth which imports pywin32_bootstrap
# The bootstrap adds win32, win32/lib to sys.path and calls add_dll_directory
try:
import site
if site_packages not in sys.path:
site.addsitedir(site_packages)
except Exception:
pass # site module issues shouldn't break the app

# Run bootstrap immediately when this script is loaded
_bootstrap_pywin32()
`;

try {
fs.writeFileSync(startupScriptPath, startupScriptContent);
console.log(`[download-python] Created pywin32 bootstrap script: _auto_claude_startup.py`);
} catch (err) {
console.warn(`[download-python] Failed to create bootstrap script: ${err.message}`);
}

console.log(`[download-python] pywin32 fix complete`);
}

/**
* Install Python packages into a site-packages directory.
* Uses pip with optimizations for smaller output.
Expand Down Expand Up @@ -654,6 +843,9 @@ function installPackages(pythonBin, requirementsPath, targetSitePackages) {

console.log(`[download-python] Packages installed successfully`);

// Fix pywin32 for Windows builds (must be done BEFORE stripping)
fixPywin32(targetSitePackages);

// Strip unnecessary files
stripSitePackages(targetSitePackages);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const mockProcess = Object.assign(new EventEmitter(), {
killed: false,
kill: vi.fn(() => {
mockProcess.killed = true;
// Emit exit event synchronously to simulate process termination
// (needed for killAllProcesses wait - using nextTick for more predictable timing)
process.nextTick(() => mockProcess.emit('exit', 0, null));
return true;
})
});
Expand Down Expand Up @@ -290,7 +293,12 @@ describe('Subprocess Spawn Integration', () => {
const result = manager.killTask('task-1');

expect(result).toBe(true);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
// On Windows, kill() is called without arguments; on Unix, kill('SIGTERM') is used
if (process.platform === 'win32') {
expect(mockProcess.kill).toHaveBeenCalled();
} else {
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
}
Comment on lines +296 to +301
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find platform-related utility files
fd -t f -i platform . --extension ts --extension tsx --extension js | head -20

Repository: AndyMik90/Auto-Claude

Length of output: 164


🏁 Script executed:

# Check the test file to understand its structure and imports
head -50 apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts

Repository: AndyMik90/Auto-Claude

Length of output: 1804


🏁 Script executed:

# Search for platform detection functions in the codebase
rg -t ts -t tsx "isWindows|isMacOS|isLinux" --max-count 10

Repository: AndyMik90/Auto-Claude

Length of output: 92


🏁 Script executed:

# Check if process.platform is used elsewhere in tests
rg -t ts "process\.platform" apps/frontend/src/__tests__/ -B 2 -A 2

Repository: AndyMik90/Auto-Claude

Length of output: 622


🏁 Script executed:

# Check the platform abstraction module
cat -n apps/frontend/src/shared/platform.ts

Repository: AndyMik90/Auto-Claude

Length of output: 2080


🏁 Script executed:

# Check the platform tests to see how they handle multi-platform testing
cat -n apps/frontend/src/main/platform/__tests__/platform.test.ts | head -100

Repository: AndyMik90/Auto-Claude

Length of output: 3234


🏁 Script executed:

# Look at the full test file context around the problematic code
sed -n '280,310p' apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts

Repository: AndyMik90/Auto-Claude

Length of output: 1170


🏁 Script executed:

# Check imports in the test file
grep -n "^import\|^from" apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts

Repository: AndyMik90/Auto-Claude

Length of output: 363


Parameterize and mock platform detection to test all three platforms.

This test uses process.platform directly instead of importing platform detection functions from the platform abstraction module. More importantly, it only validates the behavior for the test runner's OS. Per coding guidelines and the established pattern in apps/frontend/src/main/platform/__tests__/platform.test.ts, use parameterized tests or separate test cases that mock process.platform to verify all three behaviors (Windows, macOS, Linux) regardless of the runner's platform.

πŸ€– Prompt for AI Agents
In `@apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts` around
lines 296 - 301, Replace direct reads of process.platform in the test with
parameterized cases that mock platform detection via the platform abstraction
module (the same pattern as in platform.test.ts); for the test around
mockProcess.kill in subprocess-spawn tests, iterate the platforms
['win32','darwin','linux'], stub the platform detection to return each value,
run the shutdown logic that calls mockProcess.kill, and assert for 'win32' that
mockProcess.kill was called with no args and for 'darwin' and 'linux' that
mockProcess.kill was called with 'SIGTERM' (use the existing mockProcess.kill
reference and the subprocess spawn/shutdown function under test to locate where
to run the assertions).

expect(manager.isRunning('task-1')).toBe(false);
});

Expand Down
Loading
Loading