diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index a8413a4b..7b494acb 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -110,6 +110,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build @@ -178,6 +186,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-arm64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-arm64- + - name: Build application run: cd apps/frontend && npm run build @@ -246,6 +262,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build @@ -290,6 +314,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71d8cdb4..4f889b26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build @@ -112,6 +120,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-arm64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-arm64- + - name: Build application run: cd apps/frontend && npm run build @@ -181,6 +197,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build @@ -226,6 +250,14 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache bundled Python + uses: actions/cache@v4 + with: + path: apps/frontend/python-runtime + key: python-bundle-${{ runner.os }}-x64-3.12.8 + restore-keys: | + python-bundle-${{ runner.os }}-x64- + - name: Build application run: cd apps/frontend && npm run build diff --git a/.gitignore b/.gitignore index 7ba9c4ac..7f53e4c5 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ node_modules/ dist/ out/ *.tsbuildinfo +apps/frontend/python-runtime/ # Cache .cache/ diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index e9729119..fa53ebd5 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -6,6 +6,9 @@ out/ dist/ build/ +# Bundled Python runtime (downloaded during packaging) +python-runtime/ + # Compiled TypeScript (source files are .ts) src/**/*.js src/**/*.js.map diff --git a/apps/frontend/package.json b/apps/frontend/package.json index faaa4abf..bc7545f6 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -28,10 +28,12 @@ "start:mcp": "electron . --remote-debugging-port=9222", "preview": "electron-vite preview", "rebuild": "electron-rebuild", - "package": "electron-vite build && electron-builder", - "package:mac": "electron-vite build && electron-builder --mac", - "package:win": "electron-vite build && electron-builder --win", - "package:linux": "electron-vite build && electron-builder --linux", + "python:download": "node scripts/download-python.cjs", + "python:download:all": "node scripts/download-python.cjs --all", + "package": "npm run python:download && electron-vite build && electron-builder", + "package:mac": "npm run python:download && electron-vite build && electron-builder --mac", + "package:win": "npm run python:download && electron-vite build && electron-builder --win", + "package:linux": "npm run python:download && electron-vite build && electron-builder --linux", "start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app", "start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"", "start:packaged:linux": "./dist/linux-unpacked/auto-claude", @@ -150,6 +152,10 @@ "from": "resources/icon.ico", "to": "icon.ico" }, + { + "from": "python-runtime/${os}-${arch}/python", + "to": "python" + }, { "from": "../backend", "to": "backend", diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs new file mode 100644 index 00000000..a30da931 --- /dev/null +++ b/apps/frontend/scripts/download-python.cjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node +/** + * Download Python from python-build-standalone for bundling with the Electron app. + * + * This script downloads a standalone Python distribution that can be bundled + * with the packaged Electron app, eliminating the need for users to have + * Python installed on their system. + * + * Usage: + * node scripts/download-python.cjs [--platform ] [--arch ] + * + * Platforms: darwin/mac, win32/win, linux + * Architectures: x64, arm64 + * + * If not specified, uses current platform/arch. + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const os = require('os'); +const nodeCrypto = require('crypto'); + +// Python version to bundle (must be 3.10+ for claude-agent-sdk, 3.12+ for full Graphiti support) +const PYTHON_VERSION = '3.12.8'; + +// python-build-standalone release tag +const RELEASE_TAG = '20241219'; + +// Base URL for downloads +const BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${RELEASE_TAG}`; + +// Output directory for downloaded Python (relative to frontend root) +const OUTPUT_DIR = 'python-runtime'; + +// SHA256 checksums for verification (from python-build-standalone release) +// These must be updated when changing PYTHON_VERSION or RELEASE_TAG +// Get checksums from: https://github.com/indygreg/python-build-standalone/releases/download/{RELEASE_TAG}/SHA256SUMS +const CHECKSUMS = { + 'darwin-arm64': 'abe1de2494bb8b243fd507944f4d50292848fa00685d5288c858a72623a16635', + 'darwin-x64': '867c1af10f204224b571f8f2593fc9eb580fe0c2376224d1096ebe855ad8c722', + 'win32-x64': '1a702b3463cf87ec0d2e33902a47e95456053b0178fe96bd673c1dbb554f5d15', + 'linux-x64': '698e53b264a9bcd35cfa15cd680c4d78b0878fa529838844b5ffd0cd661d6bc2', + 'linux-arm64': 'fb983ec85952513f5f013674fcbf4306b1a142c50fcfd914c2c3f00c61a874b0', +}; + +// Map Node.js platform names to electron-builder platform names +function toElectronBuilderPlatform(nodePlatform) { + const map = { + 'darwin': 'mac', + 'win32': 'win', + 'linux': 'linux', + }; + return map[nodePlatform] || nodePlatform; +} + +// Map electron-builder platform names to Node.js platform names (for internal use) +function toNodePlatform(platform) { + const map = { + 'mac': 'darwin', + 'win': 'win32', + 'darwin': 'darwin', + 'win32': 'win32', + 'linux': 'linux', + }; + return map[platform] || platform; +} + +/** + * Get the download URL for a specific platform/arch combination. + * python-build-standalone uses specific naming conventions. + * + * @param {string} platform - Node.js platform (darwin, win32, linux) + * @param {string} arch - Architecture (x64, arm64) + */ +function getDownloadInfo(platform, arch) { + // Normalize platform to Node.js naming for internal lookups + const nodePlatform = toNodePlatform(platform); + const version = PYTHON_VERSION; + + // Map platform/arch to python-build-standalone naming + const configs = { + 'darwin-arm64': { + filename: `cpython-${version}+${RELEASE_TAG}-aarch64-apple-darwin-install_only_stripped.tar.gz`, + extractDir: 'python', + }, + 'darwin-x64': { + filename: `cpython-${version}+${RELEASE_TAG}-x86_64-apple-darwin-install_only_stripped.tar.gz`, + extractDir: 'python', + }, + 'win32-x64': { + filename: `cpython-${version}+${RELEASE_TAG}-x86_64-pc-windows-msvc-install_only_stripped.tar.gz`, + extractDir: 'python', + }, + 'linux-x64': { + filename: `cpython-${version}+${RELEASE_TAG}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz`, + extractDir: 'python', + }, + 'linux-arm64': { + filename: `cpython-${version}+${RELEASE_TAG}-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz`, + extractDir: 'python', + }, + }; + + const key = `${nodePlatform}-${arch}`; + const config = configs[key]; + + if (!config) { + throw new Error(`Unsupported platform/arch combination: ${key}. Supported: ${Object.keys(configs).join(', ')}`); + } + + // Use electron-builder platform naming for output directory + const ebPlatform = toElectronBuilderPlatform(nodePlatform); + + return { + url: `${BASE_URL}/${config.filename}`, + filename: config.filename, + extractDir: config.extractDir, + outputDir: `${ebPlatform}-${arch}`, // e.g., "mac-arm64", "win-x64", "linux-x64" + nodePlatform, // For internal checks (darwin, win32, linux) + checksum: CHECKSUMS[key], + }; +} + +/** + * Download a file from URL to destination path. + * Includes timeout handling, redirect limits, and proper cleanup. + */ +function downloadFile(url, destPath) { + const DOWNLOAD_TIMEOUT = 300000; // 5 minutes + const MAX_REDIRECTS = 10; + + return new Promise((resolve, reject) => { + console.log(`[download-python] Downloading from: ${url}`); + + let file = null; + let redirectCount = 0; + let currentRequest = null; + + const cleanup = () => { + if (file) { + file.close(); + file = null; + } + if (fs.existsSync(destPath)) { + try { + fs.unlinkSync(destPath); + } catch { + // Ignore cleanup errors + } + } + }; + + const request = (urlString) => { + if (++redirectCount > MAX_REDIRECTS) { + cleanup(); + reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`)); + return; + } + + // Create file stream only on first request + if (!file) { + file = fs.createWriteStream(destPath); + } + + currentRequest = https.get(urlString, { timeout: DOWNLOAD_TIMEOUT }, (response) => { + // Handle redirects (GitHub uses them) + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + console.log(`[download-python] Following redirect...`); + response.resume(); // Consume response to free up memory + request(response.headers.location); + return; + } + + if (response.statusCode !== 200) { + cleanup(); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + const totalSize = parseInt(response.headers['content-length'], 10); + let downloadedSize = 0; + let lastPercent = 0; + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + if (totalSize > 0) { + const percent = Math.floor((downloadedSize / totalSize) * 100); + if (percent >= lastPercent + 10) { + console.log(`[download-python] Progress: ${percent}%`); + lastPercent = percent; + } + } + }); + + response.pipe(file); + + file.on('finish', () => { + file.close(); + file = null; + console.log(`[download-python] Download complete: ${destPath}`); + resolve(); + }); + + file.on('error', (err) => { + cleanup(); + reject(err); + }); + }); + + currentRequest.on('error', (err) => { + cleanup(); + reject(err); + }); + + currentRequest.on('timeout', () => { + currentRequest.destroy(); + cleanup(); + reject(new Error(`Download timeout after ${DOWNLOAD_TIMEOUT / 1000} seconds`)); + }); + }; + + request(url); + }); +} + +/** + * Verify file checksum. + */ +function verifyChecksum(filePath, expectedChecksum) { + if (!expectedChecksum) { + console.log(`[download-python] Warning: No checksum available for verification`); + return true; + } + + console.log(`[download-python] Verifying checksum...`); + const fileBuffer = fs.readFileSync(filePath); + const hash = nodeCrypto.createHash('sha256').update(fileBuffer).digest('hex'); + + if (hash !== expectedChecksum) { + throw new Error(`Checksum mismatch! Expected: ${expectedChecksum}, Got: ${hash}`); + } + + console.log(`[download-python] Checksum verified: ${hash.substring(0, 16)}...`); + return true; +} + +/** + * Extract a tar.gz file using spawnSync for safety. + */ +function extractTarGz(archivePath, destDir) { + console.log(`[download-python] Extracting to: ${destDir}`); + + // Ensure destination exists + fs.mkdirSync(destDir, { recursive: true }); + + // Use tar command with array arguments (safer than string interpolation) + const result = spawnSync('tar', ['-xzf', archivePath, '-C', destDir], { + stdio: 'inherit', + }); + + if (result.error) { + throw new Error(`Failed to extract archive: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`Failed to extract archive: tar exited with code ${result.status}`); + } + + console.log(`[download-python] Extraction complete`); +} + +/** + * Verify Python binary works by checking its version. + */ +function verifyPythonBinary(pythonBin) { + const result = spawnSync(pythonBin, ['--version'], { encoding: 'utf-8' }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error(`Python verification failed with exit code ${result.status}`); + } + + // Version output may be on stdout or stderr depending on Python version + const version = (result.stdout || result.stderr || '').trim(); + return version; +} + +/** + * Main function to download and set up Python. + */ +async function downloadPython(targetPlatform, targetArch) { + const platform = targetPlatform || os.platform(); + const arch = targetArch || os.arch(); + + const info = getDownloadInfo(platform, arch); + console.log(`[download-python] Setting up Python ${PYTHON_VERSION} for ${info.outputDir}`); + + const frontendDir = path.join(__dirname, '..'); + const runtimeDir = path.join(frontendDir, OUTPUT_DIR); + const platformDir = path.join(runtimeDir, info.outputDir); + + // Check if already downloaded + const pythonBin = info.nodePlatform === 'win32' + ? path.join(platformDir, 'python', 'python.exe') + : path.join(platformDir, 'python', 'bin', 'python3'); + + if (fs.existsSync(pythonBin)) { + console.log(`[download-python] Python already exists at ${pythonBin}`); + + // Verify it works + try { + const version = verifyPythonBinary(pythonBin); + console.log(`[download-python] Verified: ${version}`); + return { success: true, pythonPath: pythonBin }; + } catch { + console.log(`[download-python] Existing Python is broken, re-downloading...`); + // Remove broken installation + fs.rmSync(platformDir, { recursive: true, force: true }); + } + } + + // Create directories + fs.mkdirSync(platformDir, { recursive: true }); + + // Download + const archivePath = path.join(runtimeDir, info.filename); + let needsDownload = true; + + if (fs.existsSync(archivePath)) { + console.log(`[download-python] Found cached archive: ${archivePath}`); + // Verify cached archive checksum + try { + verifyChecksum(archivePath, info.checksum); + needsDownload = false; + } catch (err) { + console.log(`[download-python] Cached archive failed verification: ${err.message}`); + fs.unlinkSync(archivePath); + } + } + + if (needsDownload) { + await downloadFile(info.url, archivePath); + // Verify downloaded file + verifyChecksum(archivePath, info.checksum); + } + + // Extract + extractTarGz(archivePath, platformDir); + + // Verify binary exists + if (!fs.existsSync(pythonBin)) { + throw new Error(`Python binary not found after extraction: ${pythonBin}`); + } + + // Make executable on Unix + if (info.nodePlatform !== 'win32') { + fs.chmodSync(pythonBin, 0o755); + } + + // Verify it works + const version = verifyPythonBinary(pythonBin); + console.log(`[download-python] Installed: ${version}`); + + return { success: true, pythonPath: pythonBin }; +} + +/** + * Download Python for all platforms (for CI/CD builds). + */ +async function downloadAllPlatforms() { + const platforms = [ + { platform: 'darwin', arch: 'arm64' }, + { platform: 'darwin', arch: 'x64' }, + { platform: 'win32', arch: 'x64' }, + { platform: 'linux', arch: 'x64' }, + { platform: 'linux', arch: 'arm64' }, + ]; + + console.log(`[download-python] Downloading Python for all platforms...`); + + for (const { platform, arch } of platforms) { + try { + await downloadPython(platform, arch); + } catch (error) { + console.error(`[download-python] Failed for ${platform}-${arch}: ${error.message}`); + throw error; + } + } + + console.log(`[download-python] All platforms downloaded successfully!`); +} + +// Valid platforms and architectures (for input validation) +const VALID_PLATFORMS = ['darwin', 'mac', 'win32', 'win', 'linux']; +const VALID_ARCHS = ['x64', 'arm64']; + +/** + * Validate and sanitize CLI input to prevent log injection. + */ +function validateInput(value, validValues, name) { + if (value === null) return null; + + // Remove any control characters or newlines (ASCII 0-31 and 127) + // eslint-disable-next-line no-control-regex + const sanitized = String(value).replace(/[\x00-\x1f\x7f]/g, ''); + + if (!validValues.includes(sanitized)) { + throw new Error(`Invalid ${name}: "${sanitized}". Valid values: ${validValues.join(', ')}`); + } + + return sanitized; +} + +// CLI handling +async function main() { + const args = process.argv.slice(2); + + let platform = null; + let arch = null; + let allPlatforms = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--platform' && args[i + 1]) { + platform = args[++i]; + } else if (args[i] === '--arch' && args[i + 1]) { + arch = args[++i]; + } else if (args[i] === '--all') { + allPlatforms = true; + } else if (args[i] === '--help' || args[i] === '-h') { + console.log(` +Usage: node download-python.cjs [options] + +Options: + --platform Target platform (darwin/mac, win32/win, linux) + --arch Target architecture (x64, arm64) + --all Download for all supported platforms + --help, -h Show this help message + +If no options specified, downloads for the current platform/arch. + +Examples: + node download-python.cjs # Current platform + node download-python.cjs --platform darwin --arch arm64 + node download-python.cjs --platform mac --arch arm64 # Electron-builder style + node download-python.cjs --all # All platforms (for CI) +`); + process.exit(0); + } + } + + try { + // Validate inputs before use + platform = validateInput(platform, VALID_PLATFORMS, 'platform'); + arch = validateInput(arch, VALID_ARCHS, 'arch'); + + if (allPlatforms) { + await downloadAllPlatforms(); + } else { + await downloadPython(platform, arch); + } + console.log('[download-python] Done!'); + } catch (error) { + console.error(`[download-python] Error: ${error.message}`); + process.exit(1); + } +} + +// Export for use in other scripts +module.exports = { downloadPython, downloadAllPlatforms, getDownloadInfo }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index a9046c78..20d8317d 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -1,5 +1,36 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; +import path from 'path'; +import { app } from 'electron'; + +/** + * Get the path to the bundled Python executable. + * For packaged apps, Python is bundled in the resources directory. + * + * @returns The path to bundled Python, or null if not found/not packaged + */ +export function getBundledPythonPath(): string | null { + // Only check for bundled Python in packaged apps + if (!app.isPackaged) { + return null; + } + + const resourcesPath = process.resourcesPath; + const isWindows = process.platform === 'win32'; + + // Bundled Python location in packaged app + const pythonPath = isWindows + ? path.join(resourcesPath, 'python', 'python.exe') + : path.join(resourcesPath, 'python', 'bin', 'python3'); + + if (existsSync(pythonPath)) { + console.log(`[Python] Found bundled Python at: ${pythonPath}`); + return pythonPath; + } + + console.log(`[Python] Bundled Python not found at: ${pythonPath}`); + return null; +} /** * Find the first existing Homebrew Python installation. @@ -13,9 +44,9 @@ function findHomebrewPython(): string | null { '/usr/local/bin/python3' // Intel Mac ]; - for (const path of homebrewPaths) { - if (existsSync(path)) { - return path; + for (const pythonPath of homebrewPaths) { + if (existsSync(pythonPath)) { + return pythonPath; } } @@ -24,13 +55,34 @@ function findHomebrewPython(): string | null { /** * Detect and return the best available Python command. - * Tries multiple candidates and returns the first one that works with Python 3. + * Priority order: + * 1. Bundled Python (for packaged apps) + * 2. System Python (Homebrew on macOS, standard paths on other platforms) * * @returns The Python command to use, or null if none found */ export function findPythonCommand(): string | null { const isWindows = process.platform === 'win32'; + // 1. Check for bundled Python first (packaged apps only) + const bundledPython = getBundledPythonPath(); + if (bundledPython) { + try { + const validation = validatePythonVersion(bundledPython); + if (validation.valid) { + console.log(`[Python] Using bundled Python: ${bundledPython} (${validation.version})`); + return bundledPython; + } else { + console.warn(`[Python] Bundled Python version issue: ${validation.message}`); + } + } catch (err) { + console.warn(`[Python] Bundled Python error: ${err}`); + } + } + + // 2. Fall back to system Python + console.log(`[Python] Searching for system Python...`); + // Build candidate list prioritizing Homebrew Python on macOS let candidates: string[]; if (isWindows) { @@ -47,7 +99,7 @@ export function findPythonCommand(): string | null { // Validate version meets minimum requirement (Python 3.10+) const validation = validatePythonVersion(cmd); if (validation.valid) { - console.log(`[Python] Found valid Python: ${cmd} (${validation.version})`); + console.log(`[Python] Found valid system Python: ${cmd} (${validation.version})`); return cmd; } else { console.warn(`[Python] ${cmd} version too old: ${validation.message}`); @@ -134,11 +186,18 @@ function validatePythonVersion(pythonCmd: string): { /** * Get the default Python command for the current platform. - * This is a synchronous fallback that doesn't test if Python actually exists. + * Prioritizes bundled Python in packaged apps, then falls back to system Python. * * @returns The default Python command for this platform */ export function getDefaultPythonCommand(): string { + // Check for bundled Python first + const bundledPython = getBundledPythonPath(); + if (bundledPython) { + return bundledPython; + } + + // Fall back to system Python if (process.platform === 'win32') { return 'python'; } @@ -151,13 +210,24 @@ export function getDefaultPythonCommand(): string { * * @param pythonPath - The Python command string (e.g., "python3", "py -3", "/path/with spaces/python") * @returns Tuple of [command, baseArgs] ready for use with spawn() + * @throws Error if pythonPath is empty or only whitespace */ export function parsePythonCommand(pythonPath: string): [string, string[]] { // Remove any surrounding quotes first let cleanPath = pythonPath.trim(); + + // Validate input is not empty + if (cleanPath === '') { + throw new Error('Python command cannot be empty'); + } + if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) || (cleanPath.startsWith("'") && cleanPath.endsWith("'"))) { cleanPath = cleanPath.slice(1, -1); + // Validate again after quote removal + if (cleanPath === '') { + throw new Error('Python command cannot be empty'); + } } // If the path points to an actual file, use it directly (handles paths with spaces) @@ -179,8 +249,8 @@ export function parsePythonCommand(pythonPath: string): [string, string[]] { // Otherwise, split on spaces for commands like "py -3" const parts = cleanPath.split(' ').filter(p => p.length > 0); if (parts.length === 0) { - // Return empty string for empty input, not the original uncleaned path - return [cleanPath, []]; + // This shouldn't happen after earlier validation, but guard anyway + throw new Error('Python command cannot be empty'); } const command = parts[0]; const baseArgs = parts.slice(1); diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 00e2737e..726a3b0a 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import path from 'path'; import { EventEmitter } from 'events'; import { app } from 'electron'; -import { findPythonCommand } from './python-detector'; +import { findPythonCommand, getBundledPythonPath } from './python-detector'; export interface PythonEnvStatus { ready: boolean; @@ -97,8 +97,9 @@ export class PythonEnvManager extends EventEmitter { } /** - * Find system Python 3.10+ + * Find Python 3.10+ (bundled or system). * Uses the shared python-detector logic which validates version requirements. + * Priority: bundled Python (packaged apps) > system Python */ private findSystemPython(): string | null { const pythonCmd = findPythonCommand(); @@ -106,6 +107,13 @@ export class PythonEnvManager extends EventEmitter { return null; } + // If this is the bundled Python path, use it directly + const bundledPath = getBundledPythonPath(); + if (bundledPath && pythonCmd === bundledPath) { + console.log(`[PythonEnvManager] Using bundled Python: ${bundledPath}`); + return bundledPath; + } + try { // Get the actual executable path from the command // For commands like "py -3", we need to resolve to the actual executable @@ -130,11 +138,15 @@ export class PythonEnvManager extends EventEmitter { const systemPython = this.findSystemPython(); if (!systemPython) { - this.emit( - 'error', - 'Python 3.10+ not found. Please install Python 3.10 or higher (required by claude-agent-sdk).\n\n' + - 'Download: https://www.python.org/downloads/' - ); + const isPackaged = app.isPackaged; + const errorMsg = isPackaged + ? 'Python not found. The bundled Python may be corrupted.\n\n' + + 'Please try reinstalling the application, or install Python 3.10+ manually:\n' + + 'https://www.python.org/downloads/' + : 'Python 3.10+ not found. Please install Python 3.10 or higher.\n\n' + + 'This is required for development mode. Download from:\n' + + 'https://www.python.org/downloads/'; + this.emit('error', errorMsg); return false; }