| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- // electron/modules/utils.ts - 工具函数
- import * as https from 'https'
- import * as http from 'http'
- 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<string, { data: unknown; expiry: number }>()
- // 包管理器源更新缓存(记录上次更新时间)
- const sourceUpdateCache = new Map<string, number>()
- /**
- * 验证 URL 是否合法
- */
- export function isValidUrl(url: string): boolean {
- try {
- const parsed = new URL(url)
- return ['http:', 'https:'].includes(parsed.protocol)
- } catch {
- return false
- }
- }
- /**
- * 带超时和重试的 HTTPS GET 请求
- */
- export function httpsGet<T = unknown>(
- url: string,
- options: {
- timeout?: number
- retries?: number
- retryDelay?: number
- } = {}
- ): Promise<T> {
- const {
- timeout = REQUEST_TIMEOUT,
- retries = MAX_RETRIES,
- retryDelay = RETRY_DELAY
- } = options
- return new Promise((resolve, reject) => {
- const attemptRequest = (attemptsLeft: number): void => {
- const protocol = url.startsWith('https') ? https : http
- const req = protocol.get(
- url,
- {
- headers: { 'User-Agent': 'ApqInstaller/2.0' },
- timeout
- },
- (res) => {
- // 处理重定向
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- httpsGet<T>(res.headers.location, options).then(resolve).catch(reject)
- return
- }
- // 处理 HTTP 错误状态码
- if (res.statusCode && res.statusCode >= 400) {
- const error = new Error(`HTTP ${res.statusCode}: 请求失败`) as Error & { statusCode: number }
- error.statusCode = res.statusCode
- reject(error)
- return
- }
- let data = ''
- res.on('data', (chunk: Buffer) => {
- data += chunk.toString()
- })
- res.on('end', () => {
- try {
- resolve(JSON.parse(data) as T)
- } catch {
- resolve(data as unknown as T)
- }
- })
- }
- )
- req.on('timeout', () => {
- req.destroy()
- if (attemptsLeft > 1) {
- console.log(`请求超时,${retryDelay}ms 后重试... (剩余 ${attemptsLeft - 1} 次)`)
- setTimeout(() => attemptRequest(attemptsLeft - 1), retryDelay)
- } else {
- reject(new Error(ERROR_MESSAGES.TIMEOUT_ERROR))
- }
- })
- req.on('error', (err: Error) => {
- if (attemptsLeft > 1) {
- console.log(`请求失败: ${err.message},${retryDelay}ms 后重试... (剩余 ${attemptsLeft - 1} 次)`)
- setTimeout(() => attemptRequest(attemptsLeft - 1), retryDelay)
- } else {
- reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message))
- }
- })
- }
- attemptRequest(retries)
- })
- }
- /**
- * 获取 Windows 系统最新的 PATH 环境变量
- * 安装软件后,系统 PATH 会更新,但当前进程的 PATH 不会自动更新
- * 通过 PowerShell 从注册表读取最新的 PATH
- * 同时添加 pnpm 和 npm 的全局 bin 目录,确保能找到全局安装的命令
- */
- async function getRefreshedWindowsPath(): Promise<string> {
- 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<string> {
- if (os.platform() === 'win32') {
- return await getRefreshedWindowsPath()
- }
- return process.env.PATH || ''
- }
- /**
- * 检测命令是否存在
- * Windows: 会尝试使用刷新后的 PATH 来检测新安装的软件
- */
- export async function commandExists(command: string, refreshPath = false): Promise<boolean> {
- 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<boolean> {
- const platform = os.platform()
- if (platform === 'win32') {
- // Windows 上直接使用刷新后的 PATH 检测
- return await commandExists(command, true)
- }
- return await commandExists(command, false)
- }
- /**
- * 获取命令的完整路径
- * Windows: 使用 where 命令
- * macOS/Linux: 使用 which 命令
- * @param command 命令名称
- * @returns 命令的完整路径,如果找不到则返回 null
- */
- export async function getCommandPath(command: string): Promise<string | null> {
- try {
- const platform = os.platform()
- let result
- if (platform === 'win32') {
- // 使用刷新后的 PATH 执行 where 命令
- const newPath = await getRefreshedWindowsPath()
- 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 {
- result = await execa('which', [command])
- // which 通常只返回一个路径
- return result.stdout.trim() || null
- }
- } catch {
- return null
- }
- }
- /**
- * 获取命令版本
- * @param command 命令名称
- * @param versionArgs 版本参数
- * @param refreshPath 是否使用刷新后的 PATH(仅 Windows)
- */
- export async function getCommandVersion(
- command: string,
- versionArgs: string[] = ['--version'],
- refreshPath = false
- ): Promise<string | null> {
- 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<string | null> {
- 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<T = unknown>(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<void> {
- return new Promise((resolve) => setTimeout(resolve, ms))
- }
- /**
- * 检测网络连接
- */
- export async function checkNetworkConnection(): Promise<boolean> {
- 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
- /**
- * 下载文件到指定路径
- * @param url 下载地址
- * @param destPath 目标文件路径
- * @param onProgress 进度回调
- */
- export function downloadFile(
- url: string,
- destPath: string,
- onProgress?: DownloadProgressCallback
- ): Promise<string> {
- return new Promise((resolve, reject) => {
- const doDownload = (downloadUrl: string, redirectCount = 0): void => {
- if (redirectCount > 5) {
- reject(new Error('重定向次数过多'))
- return
- }
- const protocol = downloadUrl.startsWith('https') ? https : http
- const req = protocol.get(
- downloadUrl,
- {
- headers: { 'User-Agent': 'ApqInstaller/2.0' },
- timeout: 30000
- },
- (res) => {
- // 处理重定向
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- doDownload(res.headers.location, redirectCount + 1)
- return
- }
- // 处理 HTTP 错误状态码
- if (res.statusCode && res.statusCode >= 400) {
- reject(new Error(`HTTP ${res.statusCode}: 下载失败`))
- return
- }
- const totalSize = parseInt(res.headers['content-length'] || '0', 10)
- let downloadedSize = 0
- // 确保目标目录存在
- const dir = path.dirname(destPath)
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true })
- }
- const fileStream = fs.createWriteStream(destPath)
- res.on('data', (chunk: Buffer) => {
- downloadedSize += chunk.length
- if (onProgress && totalSize > 0) {
- const percent = Math.round((downloadedSize / totalSize) * 100)
- onProgress(downloadedSize, totalSize, percent)
- }
- })
- res.pipe(fileStream)
- fileStream.on('finish', () => {
- fileStream.close()
- resolve(destPath)
- })
- fileStream.on('error', (err) => {
- fs.unlink(destPath, () => {}) // 删除不完整的文件
- reject(err)
- })
- }
- )
- req.on('timeout', () => {
- req.destroy()
- reject(new Error(ERROR_MESSAGES.TIMEOUT_ERROR))
- })
- req.on('error', (err: Error) => {
- reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message))
- })
- }
- doDownload(url)
- })
- }
- /**
- * 获取临时目录路径
- */
- 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()
- }
- }
|