// electron/modules/utils.ts - 工具函数 import axios, { AxiosError, CancelTokenSource } from 'axios' import * as os from 'os' import * as fs from 'fs' import * as path from 'path' import { execa } from 'execa' import { REQUEST_TIMEOUT, MAX_RETRIES, RETRY_DELAY, ERROR_MESSAGES, SOURCE_UPDATE_CACHE_TTL } from './constants' // 版本缓存 const versionCache = new Map() // 包管理器源更新缓存(记录上次更新时间) const sourceUpdateCache = new Map() // 创建 axios 实例 const axiosInstance = axios.create({ headers: { 'User-Agent': 'ApqInstaller/2.0' }, timeout: REQUEST_TIMEOUT }) /** * 验证 URL 是否合法 */ export function isValidUrl(url: string): boolean { try { const parsed = new URL(url) return ['http:', 'https:'].includes(parsed.protocol) } catch { return false } } /** * 带超时和重试的 HTTP GET 请求(使用 axios) */ export async function httpsGet( url: string, options: { timeout?: number retries?: number retryDelay?: number } = {} ): Promise { const { timeout = REQUEST_TIMEOUT, retries = MAX_RETRIES, retryDelay = RETRY_DELAY } = options let lastError: Error | null = null for (let attempt = 1; attempt <= retries; attempt++) { try { const response = await axiosInstance.get(url, { timeout, maxRedirects: 5 }) return response.data } catch (error) { lastError = error as Error const axiosError = error as AxiosError if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) { console.log(`请求超时,${retryDelay}ms 后重试... (剩余 ${retries - attempt} 次)`) } else { console.log(`请求失败: ${axiosError.message},${retryDelay}ms 后重试... (剩余 ${retries - attempt} 次)`) } if (attempt < retries) { await delay(retryDelay) } } } if (lastError) { const axiosError = lastError as AxiosError if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) { throw new Error(ERROR_MESSAGES.TIMEOUT_ERROR) } throw new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + lastError.message) } throw new Error(ERROR_MESSAGES.NETWORK_ERROR) } /** * 获取 Windows 系统最新的 PATH 环境变量 * 安装软件后,系统 PATH 会更新,但当前进程的 PATH 不会自动更新 * 通过 PowerShell 从注册表读取最新的 PATH * 同时添加 pnpm 和 npm 的全局 bin 目录,确保能找到全局安装的命令 */ async function getRefreshedWindowsPath(): Promise { try { const result = await execa('powershell', [ '-NoProfile', '-Command', `[Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')` ]) let newPath = result.stdout.trim() // 添加 pnpm 全局 bin 目录(如果不在 PATH 中) // pnpm 在 Windows 上的默认全局 bin 目录是 %LOCALAPPDATA%\pnpm const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') const pnpmGlobalBin = path.join(localAppData, 'pnpm') if (!newPath.toLowerCase().includes(pnpmGlobalBin.toLowerCase())) { newPath = `${pnpmGlobalBin};${newPath}` } // 添加 npm 全局 bin 目录(如果不在 PATH 中) // npm 在 Windows 上的默认全局 bin 目录是 %APPDATA%\npm const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') const npmGlobalBin = path.join(appData, 'npm') if (!newPath.toLowerCase().includes(npmGlobalBin.toLowerCase())) { newPath = `${npmGlobalBin};${newPath}` } return newPath } catch { // 如果失败,返回当前进程的 PATH,并添加常用的全局 bin 目录 let fallbackPath = process.env.PATH || '' const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') fallbackPath = `${path.join(localAppData, 'pnpm')};${path.join(appData, 'npm')};${fallbackPath}` return fallbackPath } } /** * 获取刷新后的 PATH 环境变量(跨平台) * Windows: 从注册表读取最新的 PATH * 其他平台: 返回当前进程的 PATH */ export async function getRefreshedPath(): Promise { if (os.platform() === 'win32') { return await getRefreshedWindowsPath() } return process.env.PATH || '' } /** * 检测命令是否存在 * Windows: 会尝试使用刷新后的 PATH 来检测新安装的软件 */ export async function commandExists(command: string, refreshPath = false): Promise { try { const platform = os.platform() if (platform === 'win32') { if (refreshPath) { // 使用刷新后的 PATH 执行 where 命令 const newPath = await getRefreshedWindowsPath() await execa('where', [command], { env: { ...process.env, PATH: newPath } }) } else { await execa('where', [command]) } } else { await execa('which', [command]) } return true } catch { return false } } /** * 使用刷新后的 PATH 检测命令是否存在 * Windows: 直接从注册表读取最新的 PATH 来检测 * 其他平台: 使用当前 PATH */ export async function commandExistsWithRefresh(command: string): Promise { const platform = os.platform() if (platform === 'win32') { // Windows 上直接使用刷新后的 PATH 检测 return await commandExists(command, true) } return await commandExists(command, false) } /** * 从 Windows 注册表获取 VS Code 安装路径 * VS Code 安装后会在多个注册表位置写入信息 * 使用 reg query 命令直接查询,比 PowerShell 更快更可靠 * @returns VS Code 的 code.cmd 路径,如果找不到则返回 null */ async function getVscodePathFromRegistry(): Promise { if (os.platform() !== 'win32') return null // VS Code 可能的注册表位置(使用固定的 GUID 键,与 ipc-handlers.ts 保持一致) const registryPaths = [ // 系统安装 (64位) { key: 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1', value: 'InstallLocation' }, // 系统安装 (32位 on 64位系统) { key: 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1', value: 'InstallLocation' }, // 用户安装 { key: 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1', value: 'InstallLocation' }, // VS Code Insiders 系统安装 { key: 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1', value: 'InstallLocation' }, // VS Code Insiders 用户安装 { key: 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{217B4C08-948D-4276-BFBB-BEE930AE5A2C}_is1', value: 'InstallLocation' }, ] for (const reg of registryPaths) { try { const result = await execa('reg', ['query', reg.key, '/v', reg.value], { encoding: 'utf8', timeout: 5000 }) // 解析注册表输出,格式如: " InstallLocation REG_SZ C:\Program Files\Microsoft VS Code\" const match = result.stdout.match(/InstallLocation\s+REG_SZ\s+(.+)/i) if (match) { const installLocation = match[1].trim() // 构建 code.cmd 的完整路径 const codeCmd = path.join(installLocation, 'bin', 'code.cmd') if (fs.existsSync(codeCmd)) { return codeCmd } // 也检查 code.exe (某些版本可能直接使用 exe) const codeExe = path.join(installLocation, 'bin', 'code.exe') if (fs.existsSync(codeExe)) { return codeExe } } } catch { // 该注册表项不存在,继续尝试下一个 } } return null } /** * 获取命令的完整路径 * Windows: 使用刷新后的 PATH 执行 where 命令 * macOS/Linux: 使用 which 命令 * 注意:对于 VS Code,请使用专门的 getVscodeCliPath 函数 * @param command 命令名称 * @returns 命令的完整路径,如果找不到则返回 null */ export async function getCommandPath(command: string): Promise { try { const platform = os.platform() if (platform === 'win32') { // 使用刷新后的 PATH 执行 where 命令 const newPath = await getRefreshedWindowsPath() const result = await execa('where', [command], { env: { ...process.env, PATH: newPath } }) // where 可能返回多行(多个路径) // 优先选择 .cmd 或 .exe 文件,因为这些是 Windows 上可直接执行的 const paths = result.stdout.trim().split(/\r?\n/) const cmdPath = paths.find(p => p.toLowerCase().endsWith('.cmd')) const exePath = paths.find(p => p.toLowerCase().endsWith('.exe')) // 优先返回 .cmd,其次 .exe,最后返回第一个结果 return cmdPath || exePath || paths[0] || null } else { const result = await execa('which', [command]) // which 通常只返回一个路径 return result.stdout.trim() || null } } catch { return null } } /** * 获取 VS Code CLI (code) 命令的路径 * 优先级:PATH 中的 code 命令 > 注册表路径 > 常见安装路径 * @returns VS Code CLI 的完整路径,如果都找不到则返回 'code' */ export async function getVscodeCliPath(): Promise { const platform = os.platform() // 首先检查 code 命令是否在 PATH 中 try { await execa('code', ['--version'], { timeout: 5000 }) return 'code' } catch { // code 命令不在 PATH 中,尝试其他方法 } if (platform === 'win32') { // Windows: 优先从注册表获取安装路径 const registryPath = await getVscodePathFromRegistry() if (registryPath) { return registryPath } // 注册表找不到,尝试常见安装路径作为后备 const fallbackPaths = [ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Microsoft VS Code', 'bin', 'code.cmd'), path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'), ] for (const codePath of fallbackPaths) { if (fs.existsSync(codePath)) { return codePath } } } else if (platform === 'darwin') { // macOS: 使用 mdfind 查找应用程序 try { const result = await execa('mdfind', ['kMDItemCFBundleIdentifier == "com.microsoft.VSCode"'], { encoding: 'utf8', timeout: 5000 }) const appPath = result.stdout.trim().split('\n')[0] if (appPath) { const codePath = path.join(appPath, 'Contents', 'Resources', 'app', 'bin', 'code') if (fs.existsSync(codePath)) { return codePath } } } catch { // mdfind 失败,尝试常见路径 } const fallbackPaths = [ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', path.join(os.homedir(), 'Applications', 'Visual Studio Code.app', 'Contents', 'Resources', 'app', 'bin', 'code'), ] for (const codePath of fallbackPaths) { if (fs.existsSync(codePath)) { return codePath } } } else { // Linux: 使用 which 或检查常见路径 try { const result = await execa('which', ['code'], { encoding: 'utf8', timeout: 5000 }) const codePath = result.stdout.trim() if (codePath && fs.existsSync(codePath)) { return codePath } } catch { // which 失败 } const fallbackPaths = [ '/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', ] for (const codePath of fallbackPaths) { if (fs.existsSync(codePath)) { return codePath } } } // 如果都找不到,返回 'code' 让系统尝试 return 'code' } /** * 获取命令版本 * @param command 命令名称 * @param versionArgs 版本参数 * @param refreshPath 是否使用刷新后的 PATH(仅 Windows) */ export async function getCommandVersion( command: string, versionArgs: string[] = ['--version'], refreshPath = false ): Promise { try { let result if (refreshPath && os.platform() === 'win32') { const newPath = await getRefreshedWindowsPath() result = await execa(command, versionArgs, { env: { ...process.env, PATH: newPath } }) } else { result = await execa(command, versionArgs) } const output = result.stdout || result.stderr const match = output.match(/(\d+\.\d+\.\d+)/) return match ? match[1] : null } catch { return null } } /** * 使用刷新后的 PATH 获取命令版本 * Windows: 直接从注册表读取最新的 PATH 来执行命令 * 其他平台: 使用当前 PATH */ export async function getCommandVersionWithRefresh( command: string, versionArgs: string[] = ['--version'] ): Promise { const platform = os.platform() if (platform === 'win32') { // Windows 上直接使用刷新后的 PATH return await getCommandVersion(command, versionArgs, true) } return await getCommandVersion(command, versionArgs, false) } /** * 设置版本缓存 */ export function setCache(key: string, data: unknown, ttl: number): void { versionCache.set(key, { data, expiry: Date.now() + ttl }) } /** * 获取版本缓存 */ export function getCache(key: string): T | null { const cached = versionCache.get(key) if (cached && cached.expiry > Date.now()) { return cached.data as T } versionCache.delete(key) return null } /** * 清除版本缓存 */ export function clearCache(key?: string): void { if (key) { versionCache.delete(key) } else { versionCache.clear() } } /** * 延迟函数 */ export function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * 检测网络连接 */ export async function checkNetworkConnection(): Promise { try { await httpsGet('https://www.baidu.com', { timeout: 5000, retries: 1 }) return true } catch { try { await httpsGet('https://www.google.com', { timeout: 5000, retries: 1 }) return true } catch { return false } } } /** * 下载进度回调类型 */ export type DownloadProgressCallback = (downloaded: number, total: number, percent: number) => void /** * 下载任务信息 */ interface DownloadTask { cancelSource: CancelTokenSource tempPath: string destPath: string } // 活跃的下载任务(支持多个并行下载) const activeDownloads = new Map() /** * 取消指定下载并删除临时文件 * @param downloadId 下载ID(通常是目标文件路径) * @returns 是否成功取消 */ export function cancelDownload(downloadId: string): boolean { const task = activeDownloads.get(downloadId) if (task) { task.cancelSource.cancel('用户取消下载') // 删除临时文件 fs.unlink(task.tempPath, () => {}) activeDownloads.delete(downloadId) return true } return false } /** * 取消所有下载并删除临时文件 * @returns 取消的下载数量 */ export function cancelAllDownloads(): number { let count = 0 for (const [downloadId, task] of activeDownloads) { task.cancelSource.cancel('用户取消下载') fs.unlink(task.tempPath, () => {}) activeDownloads.delete(downloadId) count++ } return count } /** * 取消当前下载并删除临时文件(兼容旧接口) * @returns 是否有下载被取消 */ export function cancelCurrentDownload(): boolean { return cancelAllDownloads() > 0 } /** * 获取已下载的文件大小 */ function getDownloadedSize(tempPath: string): number { try { if (fs.existsSync(tempPath)) { return fs.statSync(tempPath).size } } catch { // 忽略错误 } return 0 } /** * 下载文件到指定路径(支持断点续传和多文件并行下载) * @param url 下载地址 * @param destPath 目标文件路径 * @param onProgress 进度回调 * @param options 下载选项 */ export async function downloadFile( url: string, destPath: string, onProgress?: DownloadProgressCallback, options: { timeout?: number } = {} ): Promise { const { timeout = 60000 } = options // 临时文件路径(用于断点续传) const tempPath = destPath + '.downloading' // 创建取消令牌 const cancelSource = axios.CancelToken.source() // 注册下载任务 const downloadId = destPath activeDownloads.set(downloadId, { cancelSource, tempPath, destPath }) try { // 获取已下载的文件大小 const downloadedSize = getDownloadedSize(tempPath) // 构建请求头,支持断点续传 const headers: Record = {} if (downloadedSize > 0) { headers['Range'] = `bytes=${downloadedSize}-` console.log(`断点续传: 从 ${(downloadedSize / 1024 / 1024).toFixed(1)}MB 处继续下载`) } // 发起请求 const response = await axiosInstance.get(url, { headers, timeout, responseType: 'stream', cancelToken: cancelSource.token, maxRedirects: 5, // 不验证状态码,手动处理 validateStatus: () => true }) // 处理 HTTP 错误状态码 if (response.status >= 400) { // 416 表示 Range 请求无效(可能文件已完成或服务器不支持) if (response.status === 416 && downloadedSize > 0) { console.log('文件可能已下载完成,尝试验证...') try { if (fs.existsSync(destPath)) { fs.unlinkSync(destPath) } fs.renameSync(tempPath, destPath) activeDownloads.delete(downloadId) return destPath } catch { // 如果重命名失败,删除临时文件重新下载 fs.unlinkSync(tempPath) } } throw new Error(`HTTP ${response.status}: 下载失败`) } // 206 表示部分内容(断点续传成功) const isPartialContent = response.status === 206 // 计算总大小 let totalSize = 0 if (isPartialContent) { // 从 Content-Range 头获取总大小: bytes 0-999/1000 const contentRange = response.headers['content-range'] if (contentRange) { const match = contentRange.match(/\/(\d+)$/) if (match) { totalSize = parseInt(match[1], 10) } } } else { // 新下载,获取 Content-Length totalSize = parseInt(response.headers['content-length'] || '0', 10) // 如果是新下载但存在临时文件,说明服务器不支持断点续传,删除重新下载 if (downloadedSize > 0) { console.log('服务器不支持断点续传,重新下载') fs.unlinkSync(tempPath) } } // 确保目标目录存在 const dir = path.dirname(destPath) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } // 以追加模式打开文件(断点续传)或创建新文件 const fileStream = fs.createWriteStream(tempPath, { flags: isPartialContent ? 'a' : 'w' }) let currentDownloaded = isPartialContent ? downloadedSize : 0 // 返回 Promise return new Promise((resolve, reject) => { response.data.on('data', (chunk: Buffer) => { currentDownloaded += chunk.length if (onProgress && totalSize > 0) { const percent = Math.round((currentDownloaded / totalSize) * 100) onProgress(currentDownloaded, totalSize, percent) } }) response.data.on('error', (err: Error) => { fileStream.close() activeDownloads.delete(downloadId) reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message)) }) response.data.pipe(fileStream) fileStream.on('finish', () => { fileStream.close() // 验证文件大小 const finalSize = getDownloadedSize(tempPath) if (totalSize > 0 && finalSize < totalSize) { // 文件不完整,保留临时文件以便下次续传 console.log(`下载不完整: ${finalSize}/${totalSize} 字节,可点击重新安装继续下载`) activeDownloads.delete(downloadId) reject(new Error('下载不完整,请重试')) return } // 重命名临时文件为最终文件 try { if (fs.existsSync(destPath)) { fs.unlinkSync(destPath) } fs.renameSync(tempPath, destPath) activeDownloads.delete(downloadId) resolve(destPath) } catch (err) { activeDownloads.delete(downloadId) reject(err) } }) fileStream.on('error', (err) => { // 保留临时文件以便续传 activeDownloads.delete(downloadId) reject(err) }) }) } catch (error) { activeDownloads.delete(downloadId) if (axios.isCancel(error)) { // 用户取消,删除临时文件 fs.unlink(tempPath, () => {}) throw new Error(ERROR_MESSAGES.INSTALL_CANCELLED || '下载已取消') } const axiosError = error as AxiosError if (axiosError.code === 'ECONNABORTED' || axiosError.message?.includes('timeout')) { // 超时,保留临时文件以便续传 throw new Error(ERROR_MESSAGES.TIMEOUT_ERROR) } // 其他错误,保留临时文件以便续传 throw error } } /** * 获取临时目录路径 */ export function getTempDir(): string { return os.tmpdir() } /** * 检查包管理器源是否需要更新 * @param manager 包管理器名称 (apt, brew) * @returns 是否需要更新 */ export function needsSourceUpdate(manager: string): boolean { const lastUpdate = sourceUpdateCache.get(manager) if (!lastUpdate) { return true } return Date.now() - lastUpdate > SOURCE_UPDATE_CACHE_TTL } /** * 标记包管理器源已更新 * @param manager 包管理器名称 */ export function markSourceUpdated(manager: string): void { sourceUpdateCache.set(manager, Date.now()) } /** * 清除源更新缓存 * @param manager 可选,指定清除某个包管理器的缓存,不传则清除所有 */ export function clearSourceUpdateCache(manager?: string): void { if (manager) { sourceUpdateCache.delete(manager) } else { sourceUpdateCache.clear() } }