diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs index 6f18bf44ad..1835c961e1 100644 --- a/apps/frontend/scripts/download-python.cjs +++ b/apps/frontend/scripts/download-python.cjs @@ -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}`); + } + } + + // 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. @@ -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); diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index e73f60ea54..96114b597b 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -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; }) }); @@ -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'); + } expect(manager.isRunning('task-1')).toBe(false); }); diff --git a/apps/frontend/src/main/__tests__/python-env-manager.test.ts b/apps/frontend/src/main/__tests__/python-env-manager.test.ts new file mode 100644 index 0000000000..90c5302ecf --- /dev/null +++ b/apps/frontend/src/main/__tests__/python-env-manager.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; + +// Mock fs module before importing the module under test +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +import * as fs from 'fs'; + +// Mock electron's app module +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn().mockReturnValue('/mock/user/data'), + getAppPath: vi.fn().mockReturnValue('/mock/app'), + on: vi.fn(), + }, +})); + +// Mock python-detector +vi.mock('../python-detector', () => ({ + findPythonCommand: vi.fn().mockReturnValue('python'), + getBundledPythonPath: vi.fn().mockReturnValue(null), +})); + +// Import after mocking +import { PythonEnvManager } from '../python-env-manager'; + +describe('PythonEnvManager', () => { + let manager: PythonEnvManager; + + beforeEach(() => { + manager = new PythonEnvManager(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getPythonEnv', () => { + it('should return basic Python environment variables', () => { + const env = manager.getPythonEnv(); + + expect(env.PYTHONDONTWRITEBYTECODE).toBe('1'); + expect(env.PYTHONIOENCODING).toBe('utf-8'); + expect(env.PYTHONNOUSERSITE).toBe('1'); + }); + + it('should exclude PYTHONHOME from environment', () => { + // Use vi.stubEnv for cleaner environment variable mocking + vi.stubEnv('PYTHONHOME', '/some/python/home'); + + const env = manager.getPythonEnv(); + expect(env.PYTHONHOME).toBeUndefined(); + + vi.unstubAllEnvs(); + }); + + it('should exclude PYTHONSTARTUP from environment on Windows', () => { + const originalPlatform = process.platform; + + // Mock Windows platform + Object.defineProperty(process, 'platform', { value: 'win32' }); + + // Use vi.stubEnv for cleaner environment variable mocking + vi.stubEnv('PYTHONSTARTUP', '/some/external/startup.py'); + + try { + const env = manager.getPythonEnv(); + // Should not inherit the external PYTHONSTARTUP value + // It should either be undefined (no sitePackagesPath) or our bootstrap script path + expect(env.PYTHONSTARTUP).not.toBe('/some/external/startup.py'); + + // More explicit: without sitePackagesPath, PYTHONSTARTUP should be undefined + // (because Windows-specific env vars are only set when sitePackagesPath exists) + expect(env.PYTHONSTARTUP).toBeUndefined(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.unstubAllEnvs(); + } + }); + }); + + describe('Windows pywin32 DLL loading fix', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + // Mock Windows platform + Object.defineProperty(process, 'platform', { value: 'win32' }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should add pywin32_system32 to PATH on Windows when sitePackagesPath is set', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private property for testing + (manager as any).sitePackagesPath = sitePackagesPath; + + // Mock existsSync to return true for the startup script + vi.mocked(fs.existsSync).mockReturnValue(true); + + const env = manager.getPythonEnv(); + + // Should include pywin32_system32 in PATH + const expectedPath = path.join(sitePackagesPath, 'pywin32_system32'); + expect(env.PATH).toContain(expectedPath); + }); + + it('should set PYTHONSTARTUP to bootstrap script on Windows', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private property for testing + (manager as any).sitePackagesPath = sitePackagesPath; + + // Mock existsSync to return true for the startup script + vi.mocked(fs.existsSync).mockReturnValue(true); + + const env = manager.getPythonEnv(); + + // Should set PYTHONSTARTUP to our bootstrap script + expect(env.PYTHONSTARTUP).toBe( + path.join(sitePackagesPath, '_auto_claude_startup.py') + ); + }); + + it('should include win32 and win32/lib in PYTHONPATH on Windows', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private property for testing + (manager as any).sitePackagesPath = sitePackagesPath; + + // Mock existsSync to return true + vi.mocked(fs.existsSync).mockReturnValue(true); + + const env = manager.getPythonEnv(); + + // PYTHONPATH should include site-packages, win32, and win32/lib + expect(env.PYTHONPATH).toContain(sitePackagesPath); + expect(env.PYTHONPATH).toContain(path.join(sitePackagesPath, 'win32')); + expect(env.PYTHONPATH).toContain( + path.join(sitePackagesPath, 'win32', 'lib') + ); + }); + + it('should create bootstrap script if it does not exist', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private property for testing + (manager as any).sitePackagesPath = sitePackagesPath; + + // Mock existsSync: + // - First call (in getPythonEnv to check if script exists): false + // - Second call (in getPythonEnv after creation attempt): true + vi.mocked(fs.existsSync) + .mockReturnValueOnce(false) // getPythonEnv check + .mockReturnValue(true); // after creation check + + // Mock writeFileSync to succeed (file doesn't exist, so no EEXIST error) + vi.mocked(fs.writeFileSync).mockReturnValue(undefined); + + const env = manager.getPythonEnv(); + + // Should have tried to create the script using 'wx' flag + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(sitePackagesPath, '_auto_claude_startup.py'), + expect.stringContaining('_bootstrap_pywin32'), + { encoding: 'utf-8', flag: 'wx' } + ); + + // Should have set PYTHONSTARTUP after creating the script + expect(env.PYTHONSTARTUP).toBe( + path.join(sitePackagesPath, '_auto_claude_startup.py') + ); + }); + + it('should not add Windows-specific env vars on non-Windows platforms', () => { + // Restore non-Windows platform + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + const sitePackagesPath = '/test/site-packages'; + + // Access private property for testing + (manager as any).sitePackagesPath = sitePackagesPath; + + const env = manager.getPythonEnv(); + + // Should not have PYTHONSTARTUP set + expect(env.PYTHONSTARTUP).toBeUndefined(); + + // PYTHONPATH should just be the site-packages (no win32 additions) + expect(env.PYTHONPATH).toBe(sitePackagesPath); + }); + }); + + describe('Bootstrap script content', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should generate bootstrap script with os.add_dll_directory call', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private method for testing + const ensureScript = (manager as any).ensurePywin32StartupScript.bind( + manager + ); + + let writtenContent = ''; + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation( + (filePath: any, content: any) => { + writtenContent = content as string; + } + ); + + ensureScript(sitePackagesPath); + + // Verify the script contains critical elements + expect(writtenContent).toContain('os.add_dll_directory'); + expect(writtenContent).toContain('pywin32_system32'); + expect(writtenContent).toContain('site.addsitedir'); + expect(writtenContent).toContain('_bootstrap_pywin32'); + }); + + it('should not overwrite existing bootstrap script (EEXIST handled)', () => { + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Access private method for testing + const ensureScript = (manager as any).ensurePywin32StartupScript.bind( + manager + ); + + // Mock writeFileSync to throw EEXIST error (file already exists) + // This simulates the atomic 'wx' flag behavior when file exists + const eexistError = new Error('EEXIST: file already exists') as NodeJS.ErrnoException; + eexistError.code = 'EEXIST'; + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw eexistError; + }); + + // Should not throw - EEXIST is expected and handled silently + expect(() => ensureScript(sitePackagesPath)).not.toThrow(); + + // writeFileSync WAS called (with 'wx' flag), but threw EEXIST which was handled + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(sitePackagesPath, '_auto_claude_startup.py'), + expect.stringContaining('_bootstrap_pywin32'), + { encoding: 'utf-8', flag: 'wx' } + ); + }); + }); +}); + +describe('pywin32 bootstrap script integration', () => { + it('should generate bootstrap script with proper error handling', () => { + // This test verifies that the generated bootstrap script contains + // critical elements for safe pywin32 initialization. + // We test the actual generated content rather than duplicating the script, + // ensuring tests stay in sync with implementation. + + const manager = new PythonEnvManager(); + const sitePackagesPath = 'C:\\test\\site-packages'; + + // Mock fs to capture written content + let writtenContent = ''; + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation( + (filePath: any, content: any) => { + writtenContent = content as string; + } + ); + + // Trigger script generation + (manager as any).ensurePywin32StartupScript(sitePackagesPath); + + // Verify critical safety markers in the generated script: + // 1. Check for add_dll_directory availability (Python 3.8+ only) + expect(writtenContent).toContain("hasattr(os, 'add_dll_directory')"); + + // 2. Proper error handling for DLL directory addition + expect(writtenContent).toContain('except OSError:'); + + // 3. Proper error handling for site module operations + expect(writtenContent).toContain('except Exception:'); + + // 4. Uses site.addsitedir for .pth file processing + expect(writtenContent).toContain('site.addsitedir'); + + // 5. References pywin32_system32 directory + expect(writtenContent).toContain('pywin32_system32'); + }); +}); diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 807d882b0e..1fa623af70 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -24,6 +24,7 @@ import type { AppSettings } from '../../shared/types/settings'; import { getOAuthModeClearVars } from './env-utils'; import { getAugmentedEnv } from '../env-utils'; import { getToolInfo } from '../cli-tool-manager'; +import { isWindows } from '../platform'; function deriveGitBashPath(gitExePath: string): string | null { @@ -696,15 +697,39 @@ export class AgentProcessManager { // Mark this specific spawn as killed so its exit handler knows to ignore this.state.markSpawnAsKilled(agentProcess.spawnId); - // Send SIGTERM first for graceful shutdown - agentProcess.process.kill('SIGTERM'); - - // Force kill after timeout - setTimeout(() => { - if (!agentProcess.process.killed) { - agentProcess.process.kill('SIGKILL'); + if (isWindows()) { + // Windows: .kill() then taskkill fallback + // SIGTERM/SIGKILL are ignored on Windows, so we use taskkill /f /t + try { + agentProcess.process.kill(); + if (agentProcess.process.pid) { + const pid = agentProcess.process.pid; + setTimeout(() => { + try { + // taskkill /f = force kill, /t = kill child processes tree + spawn('taskkill', ['/pid', pid.toString(), '/f', '/t'], { + stdio: 'ignore', + detached: true + }).unref(); + } catch { + // Process may already be dead + } + }, 5000); + } + } catch { + // Process may already be dead } - }, 5000); + } else { + // Unix: SIGTERM then SIGKILL + agentProcess.process.kill('SIGTERM'); + setTimeout(() => { + try { + agentProcess.process.kill('SIGKILL'); + } catch { + // Process may already be dead + } + }, 5000); + } this.state.deleteProcess(taskId); return true; @@ -716,15 +741,39 @@ export class AgentProcessManager { } /** - * Kill all running processes + * Kill all running processes and wait for them to exit */ async killAllProcesses(): Promise { + const KILL_TIMEOUT_MS = 10000; // 10 seconds max wait + const killPromises = this.state.getRunningTaskIds().map((taskId) => { return new Promise((resolve) => { + const agentProcess = this.state.getProcess(taskId); + + if (!agentProcess) { + resolve(); + return; + } + + // Set up timeout to not block forever + const timeoutId = setTimeout(() => { + resolve(); + }, KILL_TIMEOUT_MS); + + // Listen for exit event if the process supports it + // (process.once is available on real ChildProcess objects, but may not be in test mocks) + if (typeof agentProcess.process.once === 'function') { + agentProcess.process.once('exit', () => { + clearTimeout(timeoutId); + resolve(); + }); + } + + // Kill the process this.killProcess(taskId); - resolve(); }); }); + await Promise.all(killPromises); } diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index 8bdbd7fa3e..ffab241ed0 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -38,6 +38,9 @@ autoUpdater.autoInstallOnAppQuit = true; // Automatically install on app quit // Update channels: 'latest' for stable, 'beta' for pre-release type UpdateChannel = 'latest' | 'beta'; +// Store interval ID for cleanup during shutdown +let periodicCheckIntervalId: ReturnType | null = null; + /** * Set the update channel for electron-updater. * - 'latest': Only receive stable releases (default) @@ -189,7 +192,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) const FOUR_HOURS = 4 * 60 * 60 * 1000; console.warn(`[app-updater] Periodic checks scheduled every ${FOUR_HOURS / 1000 / 60 / 60} hours`); - setInterval(() => { + periodicCheckIntervalId = setInterval(() => { console.warn('[app-updater] Performing periodic update check'); autoUpdater.checkForUpdates().catch((error) => { console.error('[app-updater] ❌ Periodic update check failed:', error.message); @@ -492,3 +495,14 @@ export async function downloadStableVersion(): Promise { autoUpdater.allowDowngrade = false; } } + +/** + * Stop periodic update checks - called during app shutdown + */ +export function stopPeriodicUpdates(): void { + if (periodicCheckIntervalId) { + clearInterval(periodicCheckIntervalId); + periodicCheckIntervalId = null; + console.warn('[app-updater] Periodic update checks stopped'); + } +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 2c5a86f537..52c264329e 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -35,7 +35,7 @@ import { TerminalManager } from './terminal-manager'; import { pythonEnvManager } from './python-env-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; -import { initializeAppUpdater } from './app-updater'; +import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; @@ -440,6 +440,9 @@ app.on('window-all-closed', () => { // Cleanup before quit app.on('before-quit', async () => { + // Stop periodic update checks + stopPeriodicUpdates(); + // Stop usage monitor const usageMonitor = getUsageMonitor(); usageMonitor.stop(); diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 84c8e77cac..96683806dc 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -1,5 +1,5 @@ import { spawn, execSync, ChildProcess } from 'child_process'; -import { existsSync, readdirSync } from 'fs'; +import { existsSync, readdirSync, writeFileSync } from 'fs'; import path from 'path'; import { EventEmitter } from 'events'; import { app } from 'electron'; @@ -669,7 +669,20 @@ if sys.version_info >= (3, 12): * problematic Python variables removed. This fixes the "Could not find platform * independent libraries " error on Windows when PYTHONHOME is set. * + * For Windows with pywin32, this method handles several critical issues: + * 1. PYTHONPATH must include win32 and win32/lib for module imports + * 2. pywin32_system32 must be in PATH for DLL loading (pre-Python 3.8 fallback) + * 3. os.add_dll_directory() must be called for Python 3.8+ DLL loading + * + * The pywin32 initialization chain is: + * - pywin32.pth → import pywin32_bootstrap → os.add_dll_directory(pywin32_system32) + * - But .pth files are NOT processed when PYTHONPATH is set! + * - So we use PYTHONSTARTUP to run bootstrap code that processes .pth files + * * @see https://github.com/AndyMik90/Auto-Claude/issues/176 + * @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 */ getPythonEnv(): Record { // Start with process.env but explicitly remove problematic Python variables @@ -679,16 +692,70 @@ if sys.version_info >= (3, 12): for (const [key, value] of Object.entries(process.env)) { // Skip PYTHONHOME - it causes the "platform independent libraries" error + // Skip PYTHONSTARTUP - we set our own bootstrap script on Windows // Use case-insensitive check for Windows compatibility (env vars are case-insensitive on Windows) // Skip undefined values (TypeScript type guard) - if (key.toUpperCase() !== 'PYTHONHOME' && value !== undefined) { + const upperKey = key.toUpperCase(); + if (upperKey !== 'PYTHONHOME' && upperKey !== 'PYTHONSTARTUP' && value !== undefined) { baseEnv[key] = value; } } - // Apply our Python configuration on top + // Build PYTHONPATH - for Windows with pywin32, we need to include win32 and win32/lib + // since the .pth file that normally adds these isn't processed when using PYTHONPATH + let pythonPath = this.sitePackagesPath || ''; + if (this.sitePackagesPath && process.platform === 'win32') { + const pathSep = ';'; // Windows path separator + const win32Path = path.join(this.sitePackagesPath, 'win32'); + const win32LibPath = path.join(this.sitePackagesPath, 'win32', 'lib'); + pythonPath = [this.sitePackagesPath, win32Path, win32LibPath].join(pathSep); + } + + // Windows-specific pywin32 DLL loading fix + // On Windows with bundled packages, we need to ensure pywin32 DLLs can be found. + // Python 3.8+ requires os.add_dll_directory() for DLL search paths. + // We achieve this by: + // 1. Adding pywin32_system32 to PATH (fallback for some edge cases) + // 2. Setting PYTHONSTARTUP to bootstrap script that calls os.add_dll_directory() + // and uses site.addsitedir() to process .pth files (which imports pywin32_bootstrap) + let windowsEnv: Record = {}; + if (this.sitePackagesPath && process.platform === 'win32') { + const pywin32System32 = path.join(this.sitePackagesPath, 'pywin32_system32'); + + // Add pywin32_system32 to PATH for DLL loading + // This is a fallback - main fix is via PYTHONSTARTUP bootstrap + const currentPath = baseEnv['PATH'] || baseEnv['Path'] || ''; + if (currentPath && !currentPath.includes(pywin32System32)) { + windowsEnv['PATH'] = `${pywin32System32};${currentPath}`; + } else if (!currentPath) { + windowsEnv['PATH'] = pywin32System32; + } + + // Set PYTHONSTARTUP to our bootstrap script + // This script will: + // 1. Use site.addsitedir() to process .pth files (triggers pywin32_bootstrap) + // 2. Explicitly call os.add_dll_directory() for pywin32_system32 (belt-and-suspenders) + const startupScript = path.join(this.sitePackagesPath, '_auto_claude_startup.py'); + if (existsSync(startupScript)) { + windowsEnv['PYTHONSTARTUP'] = startupScript; + } else { + // If startup script doesn't exist, create it dynamically + // This ensures the fix works even without rebuilding the packages + this.ensurePywin32StartupScript(this.sitePackagesPath); + if (existsSync(startupScript)) { + windowsEnv['PYTHONSTARTUP'] = startupScript; + } else { + // Script creation failed and file still doesn't exist + // Log warning so users know the pywin32 fix may not work + console.warn('[PythonEnvManager] Could not set PYTHONSTARTUP - pywin32 DLL loading may fail. ' + + 'The PATH fallback will be used, but this may not work on Python 3.8+.'); + } + } + } + return { ...baseEnv, + ...windowsEnv, // Don't write bytecode - not needed and avoids permission issues PYTHONDONTWRITEBYTECODE: '1', // Use UTF-8 encoding @@ -696,10 +763,92 @@ if sys.version_info >= (3, 12): // Disable user site-packages to avoid conflicts PYTHONNOUSERSITE: '1', // Override PYTHONPATH if we have bundled packages - ...(this.sitePackagesPath ? { PYTHONPATH: this.sitePackagesPath } : {}), + ...(pythonPath ? { PYTHONPATH: pythonPath } : {}), }; } + /** + * Create the pywin32 bootstrap startup script if it doesn't exist. + * This script is run via PYTHONSTARTUP before the main script and ensures + * pywin32 DLLs can be found on Python 3.8+. + * + * The script: + * 1. Uses site.addsitedir() to process .pth files (including pywin32.pth) + * 2. Explicitly calls os.add_dll_directory() for pywin32_system32 as backup + * + * IMPORTANT: This script content must stay synchronized with fixPywin32() + * in apps/frontend/scripts/download-python.cjs (which creates the script at build time). + * + * @see https://github.com/mhammond/pywin32/blob/main/win32/Lib/pywin32_bootstrap.py + */ + private ensurePywin32StartupScript(sitePackagesPath: string): void { + const startupScript = path.join(sitePackagesPath, '_auto_claude_startup.py'); + + // The startup script content + // This mimics what pywin32_bootstrap.py does, but runs before any imports + const scriptContent = `# 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 { + // 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 + writeFileSync(startupScript, scriptContent, { encoding: 'utf-8', flag: 'wx' }); + console.log(`[PythonEnvManager] Created pywin32 bootstrap script: ${startupScript}`); + } catch (error: unknown) { + // EEXIST means file already exists - that's fine, we wanted to avoid overwriting + const isEexist = error instanceof Error && 'code' in error && error.code === 'EEXIST'; + if (!isEexist) { + // Other errors (e.g., read-only filesystem) - log but don't fail + // The PATH-based fallback may still work for some cases + console.warn(`[PythonEnvManager] Could not create pywin32 bootstrap script: ${error}`); + } + } + } + /** * Get current status */ diff --git a/apps/frontend/src/main/terminal/pty-daemon-client.ts b/apps/frontend/src/main/terminal/pty-daemon-client.ts index abf1fd8982..93155be8c4 100644 --- a/apps/frontend/src/main/terminal/pty-daemon-client.ts +++ b/apps/frontend/src/main/terminal/pty-daemon-client.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { fileURLToPath } from 'url'; import { spawn, ChildProcess } from 'child_process'; import { app } from 'electron'; +import { isWindows } from '../platform'; // ESM-compatible __dirname const __filename = fileURLToPath(import.meta.url); @@ -402,6 +403,36 @@ class PtyDaemonClient { */ shutdown(): void { this.isShuttingDown = true; + + // Kill the daemon process if we spawned it + if (this.daemonProcess && this.daemonProcess.pid) { + try { + if (isWindows()) { + // Windows: use taskkill to force kill process tree + spawn('taskkill', ['/pid', this.daemonProcess.pid.toString(), '/f', '/t'], { + stdio: 'ignore', + detached: true + }).unref(); + } else { + // Unix: SIGTERM then SIGKILL + this.daemonProcess.kill('SIGTERM'); + const daemonProc = this.daemonProcess; + setTimeout(() => { + try { + if (daemonProc) { + daemonProc.kill('SIGKILL'); + } + } catch { + // Process may already be dead + } + }, 2000); + } + } catch { + // Process may already be dead + } + this.daemonProcess = null; + } + this.disconnect(); this.pendingRequests.clear(); this.dataHandlers.clear();