utils.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. // electron/modules/utils.ts - 工具函数
  2. import * as https from 'https'
  3. import * as http from 'http'
  4. import * as os from 'os'
  5. import * as fs from 'fs'
  6. import * as path from 'path'
  7. import { execa } from 'execa'
  8. import { REQUEST_TIMEOUT, MAX_RETRIES, RETRY_DELAY, ERROR_MESSAGES, SOURCE_UPDATE_CACHE_TTL } from './constants'
  9. // 版本缓存
  10. const versionCache = new Map<string, { data: unknown; expiry: number }>()
  11. // 包管理器源更新缓存(记录上次更新时间)
  12. const sourceUpdateCache = new Map<string, number>()
  13. /**
  14. * 验证 URL 是否合法
  15. */
  16. export function isValidUrl(url: string): boolean {
  17. try {
  18. const parsed = new URL(url)
  19. return ['http:', 'https:'].includes(parsed.protocol)
  20. } catch {
  21. return false
  22. }
  23. }
  24. /**
  25. * 带超时和重试的 HTTPS GET 请求
  26. */
  27. export function httpsGet<T = unknown>(
  28. url: string,
  29. options: {
  30. timeout?: number
  31. retries?: number
  32. retryDelay?: number
  33. } = {}
  34. ): Promise<T> {
  35. const {
  36. timeout = REQUEST_TIMEOUT,
  37. retries = MAX_RETRIES,
  38. retryDelay = RETRY_DELAY
  39. } = options
  40. return new Promise((resolve, reject) => {
  41. const attemptRequest = (attemptsLeft: number): void => {
  42. const protocol = url.startsWith('https') ? https : http
  43. const req = protocol.get(
  44. url,
  45. {
  46. headers: { 'User-Agent': 'ApqInstaller/2.0' },
  47. timeout
  48. },
  49. (res) => {
  50. // 处理重定向
  51. if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
  52. httpsGet<T>(res.headers.location, options).then(resolve).catch(reject)
  53. return
  54. }
  55. // 处理 HTTP 错误状态码
  56. if (res.statusCode && res.statusCode >= 400) {
  57. const error = new Error(`HTTP ${res.statusCode}: 请求失败`) as Error & { statusCode: number }
  58. error.statusCode = res.statusCode
  59. reject(error)
  60. return
  61. }
  62. let data = ''
  63. res.on('data', (chunk: Buffer) => {
  64. data += chunk.toString()
  65. })
  66. res.on('end', () => {
  67. try {
  68. resolve(JSON.parse(data) as T)
  69. } catch {
  70. resolve(data as unknown as T)
  71. }
  72. })
  73. }
  74. )
  75. req.on('timeout', () => {
  76. req.destroy()
  77. if (attemptsLeft > 1) {
  78. console.log(`请求超时,${retryDelay}ms 后重试... (剩余 ${attemptsLeft - 1} 次)`)
  79. setTimeout(() => attemptRequest(attemptsLeft - 1), retryDelay)
  80. } else {
  81. reject(new Error(ERROR_MESSAGES.TIMEOUT_ERROR))
  82. }
  83. })
  84. req.on('error', (err: Error) => {
  85. if (attemptsLeft > 1) {
  86. console.log(`请求失败: ${err.message},${retryDelay}ms 后重试... (剩余 ${attemptsLeft - 1} 次)`)
  87. setTimeout(() => attemptRequest(attemptsLeft - 1), retryDelay)
  88. } else {
  89. reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message))
  90. }
  91. })
  92. }
  93. attemptRequest(retries)
  94. })
  95. }
  96. /**
  97. * 获取 Windows 系统最新的 PATH 环境变量
  98. * 安装软件后,系统 PATH 会更新,但当前进程的 PATH 不会自动更新
  99. * 通过 PowerShell 从注册表读取最新的 PATH
  100. * 同时添加 pnpm 和 npm 的全局 bin 目录,确保能找到全局安装的命令
  101. */
  102. async function getRefreshedWindowsPath(): Promise<string> {
  103. try {
  104. const result = await execa('powershell', [
  105. '-NoProfile',
  106. '-Command',
  107. `[Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')`
  108. ])
  109. let newPath = result.stdout.trim()
  110. // 添加 pnpm 全局 bin 目录(如果不在 PATH 中)
  111. // pnpm 在 Windows 上的默认全局 bin 目录是 %LOCALAPPDATA%\pnpm
  112. const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
  113. const pnpmGlobalBin = path.join(localAppData, 'pnpm')
  114. if (!newPath.toLowerCase().includes(pnpmGlobalBin.toLowerCase())) {
  115. newPath = `${pnpmGlobalBin};${newPath}`
  116. }
  117. // 添加 npm 全局 bin 目录(如果不在 PATH 中)
  118. // npm 在 Windows 上的默认全局 bin 目录是 %APPDATA%\npm
  119. const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
  120. const npmGlobalBin = path.join(appData, 'npm')
  121. if (!newPath.toLowerCase().includes(npmGlobalBin.toLowerCase())) {
  122. newPath = `${npmGlobalBin};${newPath}`
  123. }
  124. return newPath
  125. } catch {
  126. // 如果失败,返回当前进程的 PATH,并添加常用的全局 bin 目录
  127. let fallbackPath = process.env.PATH || ''
  128. const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
  129. const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
  130. fallbackPath = `${path.join(localAppData, 'pnpm')};${path.join(appData, 'npm')};${fallbackPath}`
  131. return fallbackPath
  132. }
  133. }
  134. /**
  135. * 获取刷新后的 PATH 环境变量(跨平台)
  136. * Windows: 从注册表读取最新的 PATH
  137. * 其他平台: 返回当前进程的 PATH
  138. */
  139. export async function getRefreshedPath(): Promise<string> {
  140. if (os.platform() === 'win32') {
  141. return await getRefreshedWindowsPath()
  142. }
  143. return process.env.PATH || ''
  144. }
  145. /**
  146. * 检测命令是否存在
  147. * Windows: 会尝试使用刷新后的 PATH 来检测新安装的软件
  148. */
  149. export async function commandExists(command: string, refreshPath = false): Promise<boolean> {
  150. try {
  151. const platform = os.platform()
  152. if (platform === 'win32') {
  153. if (refreshPath) {
  154. // 使用刷新后的 PATH 执行 where 命令
  155. const newPath = await getRefreshedWindowsPath()
  156. await execa('where', [command], { env: { ...process.env, PATH: newPath } })
  157. } else {
  158. await execa('where', [command])
  159. }
  160. } else {
  161. await execa('which', [command])
  162. }
  163. return true
  164. } catch {
  165. return false
  166. }
  167. }
  168. /**
  169. * 使用刷新后的 PATH 检测命令是否存在
  170. * Windows: 直接从注册表读取最新的 PATH 来检测
  171. * 其他平台: 使用当前 PATH
  172. */
  173. export async function commandExistsWithRefresh(command: string): Promise<boolean> {
  174. const platform = os.platform()
  175. if (platform === 'win32') {
  176. // Windows 上直接使用刷新后的 PATH 检测
  177. return await commandExists(command, true)
  178. }
  179. return await commandExists(command, false)
  180. }
  181. /**
  182. * 获取命令的完整路径
  183. * Windows: 使用 where 命令
  184. * macOS/Linux: 使用 which 命令
  185. * @param command 命令名称
  186. * @returns 命令的完整路径,如果找不到则返回 null
  187. */
  188. export async function getCommandPath(command: string): Promise<string | null> {
  189. try {
  190. const platform = os.platform()
  191. let result
  192. if (platform === 'win32') {
  193. // 使用刷新后的 PATH 执行 where 命令
  194. const newPath = await getRefreshedWindowsPath()
  195. result = await execa('where', [command], { env: { ...process.env, PATH: newPath } })
  196. // where 可能返回多行(多个路径)
  197. // 优先选择 .cmd 或 .exe 文件,因为这些是 Windows 上可直接执行的
  198. const paths = result.stdout.trim().split(/\r?\n/)
  199. const cmdPath = paths.find(p => p.toLowerCase().endsWith('.cmd'))
  200. const exePath = paths.find(p => p.toLowerCase().endsWith('.exe'))
  201. // 优先返回 .cmd,其次 .exe,最后返回第一个结果
  202. return cmdPath || exePath || paths[0] || null
  203. } else {
  204. result = await execa('which', [command])
  205. // which 通常只返回一个路径
  206. return result.stdout.trim() || null
  207. }
  208. } catch {
  209. return null
  210. }
  211. }
  212. /**
  213. * 获取命令版本
  214. * @param command 命令名称
  215. * @param versionArgs 版本参数
  216. * @param refreshPath 是否使用刷新后的 PATH(仅 Windows)
  217. */
  218. export async function getCommandVersion(
  219. command: string,
  220. versionArgs: string[] = ['--version'],
  221. refreshPath = false
  222. ): Promise<string | null> {
  223. try {
  224. let result
  225. if (refreshPath && os.platform() === 'win32') {
  226. const newPath = await getRefreshedWindowsPath()
  227. result = await execa(command, versionArgs, { env: { ...process.env, PATH: newPath } })
  228. } else {
  229. result = await execa(command, versionArgs)
  230. }
  231. const output = result.stdout || result.stderr
  232. const match = output.match(/(\d+\.\d+\.\d+)/)
  233. return match ? match[1] : null
  234. } catch {
  235. return null
  236. }
  237. }
  238. /**
  239. * 使用刷新后的 PATH 获取命令版本
  240. * Windows: 直接从注册表读取最新的 PATH 来执行命令
  241. * 其他平台: 使用当前 PATH
  242. */
  243. export async function getCommandVersionWithRefresh(
  244. command: string,
  245. versionArgs: string[] = ['--version']
  246. ): Promise<string | null> {
  247. const platform = os.platform()
  248. if (platform === 'win32') {
  249. // Windows 上直接使用刷新后的 PATH
  250. return await getCommandVersion(command, versionArgs, true)
  251. }
  252. return await getCommandVersion(command, versionArgs, false)
  253. }
  254. /**
  255. * 设置版本缓存
  256. */
  257. export function setCache(key: string, data: unknown, ttl: number): void {
  258. versionCache.set(key, {
  259. data,
  260. expiry: Date.now() + ttl
  261. })
  262. }
  263. /**
  264. * 获取版本缓存
  265. */
  266. export function getCache<T = unknown>(key: string): T | null {
  267. const cached = versionCache.get(key)
  268. if (cached && cached.expiry > Date.now()) {
  269. return cached.data as T
  270. }
  271. versionCache.delete(key)
  272. return null
  273. }
  274. /**
  275. * 清除版本缓存
  276. */
  277. export function clearCache(key?: string): void {
  278. if (key) {
  279. versionCache.delete(key)
  280. } else {
  281. versionCache.clear()
  282. }
  283. }
  284. /**
  285. * 延迟函数
  286. */
  287. export function delay(ms: number): Promise<void> {
  288. return new Promise((resolve) => setTimeout(resolve, ms))
  289. }
  290. /**
  291. * 检测网络连接
  292. */
  293. export async function checkNetworkConnection(): Promise<boolean> {
  294. try {
  295. await httpsGet('https://www.baidu.com', { timeout: 5000, retries: 1 })
  296. return true
  297. } catch {
  298. try {
  299. await httpsGet('https://www.google.com', { timeout: 5000, retries: 1 })
  300. return true
  301. } catch {
  302. return false
  303. }
  304. }
  305. }
  306. /**
  307. * 下载进度回调类型
  308. */
  309. export type DownloadProgressCallback = (downloaded: number, total: number, percent: number) => void
  310. /**
  311. * 下载文件到指定路径
  312. * @param url 下载地址
  313. * @param destPath 目标文件路径
  314. * @param onProgress 进度回调
  315. */
  316. export function downloadFile(
  317. url: string,
  318. destPath: string,
  319. onProgress?: DownloadProgressCallback
  320. ): Promise<string> {
  321. return new Promise((resolve, reject) => {
  322. const doDownload = (downloadUrl: string, redirectCount = 0): void => {
  323. if (redirectCount > 5) {
  324. reject(new Error('重定向次数过多'))
  325. return
  326. }
  327. const protocol = downloadUrl.startsWith('https') ? https : http
  328. const req = protocol.get(
  329. downloadUrl,
  330. {
  331. headers: { 'User-Agent': 'ApqInstaller/2.0' },
  332. timeout: 30000
  333. },
  334. (res) => {
  335. // 处理重定向
  336. if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
  337. doDownload(res.headers.location, redirectCount + 1)
  338. return
  339. }
  340. // 处理 HTTP 错误状态码
  341. if (res.statusCode && res.statusCode >= 400) {
  342. reject(new Error(`HTTP ${res.statusCode}: 下载失败`))
  343. return
  344. }
  345. const totalSize = parseInt(res.headers['content-length'] || '0', 10)
  346. let downloadedSize = 0
  347. // 确保目标目录存在
  348. const dir = path.dirname(destPath)
  349. if (!fs.existsSync(dir)) {
  350. fs.mkdirSync(dir, { recursive: true })
  351. }
  352. const fileStream = fs.createWriteStream(destPath)
  353. res.on('data', (chunk: Buffer) => {
  354. downloadedSize += chunk.length
  355. if (onProgress && totalSize > 0) {
  356. const percent = Math.round((downloadedSize / totalSize) * 100)
  357. onProgress(downloadedSize, totalSize, percent)
  358. }
  359. })
  360. res.pipe(fileStream)
  361. fileStream.on('finish', () => {
  362. fileStream.close()
  363. resolve(destPath)
  364. })
  365. fileStream.on('error', (err) => {
  366. fs.unlink(destPath, () => {}) // 删除不完整的文件
  367. reject(err)
  368. })
  369. }
  370. )
  371. req.on('timeout', () => {
  372. req.destroy()
  373. reject(new Error(ERROR_MESSAGES.TIMEOUT_ERROR))
  374. })
  375. req.on('error', (err: Error) => {
  376. reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message))
  377. })
  378. }
  379. doDownload(url)
  380. })
  381. }
  382. /**
  383. * 获取临时目录路径
  384. */
  385. export function getTempDir(): string {
  386. return os.tmpdir()
  387. }
  388. /**
  389. * 检查包管理器源是否需要更新
  390. * @param manager 包管理器名称 (apt, brew)
  391. * @returns 是否需要更新
  392. */
  393. export function needsSourceUpdate(manager: string): boolean {
  394. const lastUpdate = sourceUpdateCache.get(manager)
  395. if (!lastUpdate) {
  396. return true
  397. }
  398. return Date.now() - lastUpdate > SOURCE_UPDATE_CACHE_TTL
  399. }
  400. /**
  401. * 标记包管理器源已更新
  402. * @param manager 包管理器名称
  403. */
  404. export function markSourceUpdated(manager: string): void {
  405. sourceUpdateCache.set(manager, Date.now())
  406. }
  407. /**
  408. * 清除源更新缓存
  409. * @param manager 可选,指定清除某个包管理器的缓存,不传则清除所有
  410. */
  411. export function clearSourceUpdateCache(manager?: string): void {
  412. if (manager) {
  413. sourceUpdateCache.delete(manager)
  414. } else {
  415. sourceUpdateCache.clear()
  416. }
  417. }