import { ConfigStore } from './ConfigStore' import { exec } from 'child_process' import { promisify } from 'util' import { existsSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs' import { join } from 'path' import https from 'https' import http from 'http' import { createWriteStream } from 'fs' import { sendDownloadProgress } from '../main' const execAsync = promisify(exec) interface PythonVersion { version: string path: string isActive: boolean } interface AvailablePythonVersion { version: string downloadUrl: string type: 'embed' | 'installer' } export class PythonManager { private configStore: ConfigStore constructor(configStore: ConfigStore) { this.configStore = configStore } /** * 获取 Python 基础安装路径 */ getPythonBasePath(): string { return join(this.configStore.getBasePath(), 'python') } /** * 获取指定版本的 Python 路径 */ getPythonPath(version: string): string { return join(this.getPythonBasePath(), `python-${version}`) } /** * 获取已安装的 Python 版本 */ async getInstalledVersions(): Promise { const versions: PythonVersion[] = [] const pythonBasePath = this.getPythonBasePath() const activeVersion = this.configStore.get('activePythonVersion') as string || '' if (!existsSync(pythonBasePath)) { return versions } const dirs = readdirSync(pythonBasePath, { withFileTypes: true }) for (const dir of dirs) { if (dir.isDirectory() && dir.name.startsWith('python-')) { const version = dir.name.replace('python-', '') const pythonPath = join(pythonBasePath, dir.name) const pythonExe = join(pythonPath, 'python.exe') if (existsSync(pythonExe)) { versions.push({ version, path: pythonPath, isActive: version === activeVersion }) } } } return versions.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true })) } /** * 获取可用的 Python 版本列表 * 使用 Python 嵌入式版本(免安装) */ async getAvailableVersions(): Promise { // Python 嵌入式版本下载地址 // https://www.python.org/downloads/windows/ const versions: AvailablePythonVersion[] = [ { version: '3.13.1', downloadUrl: 'https://www.python.org/ftp/python/3.13.1/python-3.13.1-embed-amd64.zip', type: 'embed' }, { version: '3.12.8', downloadUrl: 'https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip', type: 'embed' }, { version: '3.11.11', downloadUrl: 'https://www.python.org/ftp/python/3.11.11/python-3.11.11-embed-amd64.zip', type: 'embed' }, { version: '3.10.16', downloadUrl: 'https://www.python.org/ftp/python/3.10.16/python-3.10.16-embed-amd64.zip', type: 'embed' }, { version: '3.9.21', downloadUrl: 'https://www.python.org/ftp/python/3.9.21/python-3.9.21-embed-amd64.zip', type: 'embed' } ] // 过滤掉已安装的版本 const installed = await this.getInstalledVersions() const installedVersions = installed.map(v => v.version) return versions.filter(v => !installedVersions.includes(v.version)) } /** * 安装 Python */ async install(version: string): Promise<{ success: boolean; message: string }> { try { const available = await this.getAvailableVersions() const versionInfo = available.find(v => v.version === version) if (!versionInfo) { return { success: false, message: `未找到 Python ${version} 版本` } } const pythonPath = this.getPythonPath(version) const tempPath = this.configStore.getTempPath() const zipPath = join(tempPath, `python-${version}.zip`) // 确保目录存在 if (!existsSync(tempPath)) { mkdirSync(tempPath, { recursive: true }) } if (!existsSync(pythonPath)) { mkdirSync(pythonPath, { recursive: true }) } console.log(`开始下载 Python ${version} 从 ${versionInfo.downloadUrl}`) // 下载 Python await this.downloadFile(versionInfo.downloadUrl, zipPath) console.log('下载完成,开始解压...') // 解压 await this.unzip(zipPath, pythonPath) console.log('解压完成') // 删除临时文件 if (existsSync(zipPath)) { unlinkSync(zipPath) } // 配置 pip(嵌入式版本需要额外配置) await this.setupPip(pythonPath, version) // 如果是第一个安装的版本,设为默认 const installed = await this.getInstalledVersions() if (installed.length === 1) { await this.setActive(version) } return { success: true, message: `Python ${version} 安装成功` } } catch (error: any) { console.error('Python 安装失败:', error) return { success: false, message: `安装失败: ${error.message}` } } } /** * 卸载 Python */ async uninstall(version: string): Promise<{ success: boolean; message: string }> { try { const pythonPath = this.getPythonPath(version) if (!existsSync(pythonPath)) { return { success: false, message: `Python ${version} 未安装` } } // 如果是当前活动版本,清除 const activeVersion = this.configStore.get('activePythonVersion') if (activeVersion === version) { await this.removeFromPath(pythonPath) this.configStore.set('activePythonVersion' as any, '') } // 删除目录 this.removeDirectory(pythonPath) return { success: true, message: `Python ${version} 已卸载` } } catch (error: any) { return { success: false, message: `卸载失败: ${error.message}` } } } /** * 设置活动的 Python 版本 */ async setActive(version: string): Promise<{ success: boolean; message: string }> { try { const pythonPath = this.getPythonPath(version) if (!existsSync(pythonPath)) { return { success: false, message: `Python ${version} 未安装` } } const pythonExe = join(pythonPath, 'python.exe') if (!existsSync(pythonExe)) { return { success: false, message: `Python ${version} 安装不完整` } } // 添加到环境变量 await this.addToPath(pythonPath) // 更新配置 this.configStore.set('activePythonVersion' as any, version) return { success: true, message: `Python ${version} 已设置为默认版本\n\n环境变量已更新,新开的终端窗口中将生效。` } } catch (error: any) { return { success: false, message: `设置失败: ${error.message}` } } } /** * 检查系统是否已安装 Python */ async checkSystemPython(): Promise<{ installed: boolean; version?: string; path?: string }> { try { const { stdout } = await execAsync('python --version', { windowsHide: true, timeout: 10000 }) const match = stdout.match(/Python (\d+\.\d+\.\d+)/) try { const { stdout: wherePath } = await execAsync('where python', { windowsHide: true, timeout: 5000 }) const pythonExePath = wherePath.trim().split('\n')[0] return { installed: true, version: match ? match[1] : 'unknown', path: pythonExePath } } catch { return { installed: true, version: match ? match[1] : 'unknown' } } } catch { return { installed: false } } } /** * 获取 pip 信息 */ async getPipInfo(version: string): Promise<{ installed: boolean; version?: string }> { try { const pythonPath = this.getPythonPath(version) const pythonExe = join(pythonPath, 'python.exe') const { stdout } = await execAsync(`"${pythonExe}" -m pip --version`, { windowsHide: true, timeout: 10000 }) const match = stdout.match(/pip (\d+\.\d+(?:\.\d+)?)/) return { installed: true, version: match ? match[1] : 'unknown' } } catch { return { installed: false } } } /** * 安装 pip 包 */ async installPackage(version: string, packageName: string): Promise<{ success: boolean; message: string }> { try { const pythonPath = this.getPythonPath(version) const pythonExe = join(pythonPath, 'python.exe') const { stdout, stderr } = await execAsync( `"${pythonExe}" -m pip install ${packageName}`, { windowsHide: true, timeout: 300000 // 5分钟 } ) console.log('pip install output:', stdout) if (stderr) console.log('pip install stderr:', stderr) return { success: true, message: `${packageName} 安装成功` } } catch (error: any) { return { success: false, message: `安装失败: ${error.message}` } } } // ==================== 私有方法 ==================== /** * 配置 pip(嵌入式版本需要额外配置) */ private async setupPip(pythonPath: string, version: string): Promise { try { // 修改 python*._pth 文件以启用 pip const majorMinor = version.split('.').slice(0, 2).join('') const pthFile = join(pythonPath, `python${majorMinor}._pth`) if (existsSync(pthFile)) { const { readFileSync } = require('fs') let content = readFileSync(pthFile, 'utf-8') // 取消注释 import site content = content.replace(/^#import site/m, 'import site') writeFileSync(pthFile, content) console.log('已启用 site 模块') } // 下载并安装 pip const pythonExe = join(pythonPath, 'python.exe') const getPipUrl = 'https://bootstrap.pypa.io/get-pip.py' const getPipPath = join(pythonPath, 'get-pip.py') console.log('下载 get-pip.py...') await this.downloadFile(getPipUrl, getPipPath) console.log('安装 pip...') try { await execAsync(`"${pythonExe}" "${getPipPath}"`, { cwd: pythonPath, windowsHide: true, timeout: 300000 }) console.log('pip 安装成功') } catch (e: any) { console.log('pip 安装提示:', e.message) // pip 可能已经安装成功,忽略某些错误 } // 清理 if (existsSync(getPipPath)) { unlinkSync(getPipPath) } } catch (error: any) { console.error('pip 配置失败:', error) // 不抛出错误,pip 配置失败不影响 Python 使用 } } private async downloadFile(url: string, dest: string): Promise { return new Promise((resolve, reject) => { const file = createWriteStream(dest) const protocol = url.startsWith('https') ? https : http const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } }, (response) => { if (response.statusCode === 301 || response.statusCode === 302) { const redirectUrl = response.headers.location if (redirectUrl) { file.close() if (existsSync(dest)) unlinkSync(dest) this.downloadFile(redirectUrl, dest).then(resolve).catch(reject) return } } if (response.statusCode !== 200) { file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error(`下载失败,状态码: ${response.statusCode}`)) return } const totalSize = parseInt(response.headers['content-length'] || '0', 10) let downloadedSize = 0 let lastProgressTime = Date.now() response.on('data', (chunk) => { downloadedSize += chunk.length const now = Date.now() if (now - lastProgressTime > 500) { const progress = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 100) : 0 sendDownloadProgress('python', progress, downloadedSize, totalSize) lastProgressTime = now } }) response.pipe(file) file.on('finish', () => { file.close() sendDownloadProgress('python', 100, totalSize, totalSize) resolve() }) file.on('error', (err) => { file.close() if (existsSync(dest)) unlinkSync(dest) reject(err) }) }) request.on('error', (err) => { file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error(`网络错误: ${err.message}`)) }) request.setTimeout(600000, () => { request.destroy() file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error('下载超时')) }) }) } private async unzip(zipPath: string, destPath: string): Promise { const { createReadStream } = await import('fs') const unzipper = await import('unzipper') return new Promise((resolve, reject) => { createReadStream(zipPath) .pipe(unzipper.Extract({ path: destPath })) .on('close', resolve) .on('error', reject) }) } private async addToPath(pythonPath: string): Promise { try { const scriptsPath = join(pythonPath, 'Scripts') const tempScriptPath = join(this.configStore.getTempPath(), 'add_python_path.ps1') mkdirSync(this.configStore.getTempPath(), { recursive: true }) const psScript = ` param([string]$PythonPath, [string]$ScriptsPath) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') if ($userPath -eq $null) { $userPath = '' } $paths = $userPath -split ';' | Where-Object { $_ -ne '' -and $_.Trim() -ne '' } # 移除旧的 Python 路径 $filteredPaths = @() foreach ($p in $paths) { $pathLower = $p.ToLower() if (-not ($pathLower -like '*\\python\\python-*' -or $pathLower -like '*\\python-*\\*')) { $filteredPaths += $p } } # 添加新路径 $allPaths = @($PythonPath, $ScriptsPath) + $filteredPaths $newPath = $allPaths -join ';' [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') Write-Host "SUCCESS: Python path added" ` writeFileSync(tempScriptPath, psScript, 'utf-8') await execAsync( `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -PythonPath "${pythonPath}" -ScriptsPath "${scriptsPath}"`, { windowsHide: true, timeout: 30000 } ) if (existsSync(tempScriptPath)) { unlinkSync(tempScriptPath) } } catch (error: any) { console.error('添加 Python 到 PATH 失败:', error) } } private async removeFromPath(pythonPath: string): Promise { try { const tempScriptPath = join(this.configStore.getTempPath(), 'remove_python_path.ps1') mkdirSync(this.configStore.getTempPath(), { recursive: true }) const psScript = ` param([string]$PythonBasePath) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') if ($userPath -eq $null) { exit 0 } $pythonPathLower = $PythonBasePath.ToLower() $paths = $userPath -split ';' | Where-Object { $_ -ne '' -and -not $_.ToLower().StartsWith($pythonPathLower) } $newPath = $paths -join ';' [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') Write-Host "SUCCESS: Python path removed" ` writeFileSync(tempScriptPath, psScript, 'utf-8') await execAsync( `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -PythonBasePath "${pythonPath}"`, { windowsHide: true, timeout: 30000 } ) if (existsSync(tempScriptPath)) { unlinkSync(tempScriptPath) } } catch (error: any) { console.error('从 PATH 移除 Python 失败:', error) } } private removeDirectory(dir: string): void { if (existsSync(dir)) { const files = readdirSync(dir, { withFileTypes: true }) for (const file of files) { const fullPath = join(dir, file.name) if (file.isDirectory()) { this.removeDirectory(fullPath) } else { unlinkSync(fullPath) } } rmdirSync(dir) } } }