utils.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. // electron/modules/utils.ts - 工具函数
  2. import axios, { AxiosError, CancelTokenSource } from 'axios'
  3. import * as os from 'os'
  4. import * as fs from 'fs'
  5. import * as path from 'path'
  6. import { execa } from 'execa'
  7. import { REQUEST_TIMEOUT, MAX_RETRIES, RETRY_DELAY, ERROR_MESSAGES, SOURCE_UPDATE_CACHE_TTL } from './constants'
  8. // 版本缓存
  9. const versionCache = new Map<string, { data: unknown; expiry: number }>()
  10. // 包管理器源更新缓存(记录上次更新时间)
  11. const sourceUpdateCache = new Map<string, number>()
  12. // 创建 axios 实例
  13. const axiosInstance = axios.create({
  14. headers: { 'User-Agent': 'ApqInstaller/2.0' },
  15. timeout: REQUEST_TIMEOUT
  16. })
  17. /**
  18. * 验证 URL 是否合法
  19. */
  20. export function isValidUrl(url: string): boolean {
  21. try {
  22. const parsed = new URL(url)
  23. return ['http:', 'https:'].includes(parsed.protocol)
  24. } catch {
  25. return false
  26. }
  27. }
  28. /**
  29. * 带超时和重试的 HTTP GET 请求(使用 axios)
  30. */
  31. export async function httpsGet<T = unknown>(
  32. url: string,
  33. options: {
  34. timeout?: number
  35. retries?: number
  36. retryDelay?: number
  37. } = {}
  38. ): Promise<T> {
  39. const {
  40. timeout = REQUEST_TIMEOUT,
  41. retries = MAX_RETRIES,
  42. retryDelay = RETRY_DELAY
  43. } = options
  44. let lastError: Error | null = null
  45. for (let attempt = 1; attempt <= retries; attempt++) {
  46. try {
  47. const response = await axiosInstance.get<T>(url, {
  48. timeout,
  49. maxRedirects: 5
  50. })
  51. return response.data
  52. } catch (error) {
  53. lastError = error as Error
  54. const axiosError = error as AxiosError
  55. if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) {
  56. console.log(`请求超时,${retryDelay}ms 后重试... (剩余 ${retries - attempt} 次)`)
  57. } else {
  58. console.log(`请求失败: ${axiosError.message},${retryDelay}ms 后重试... (剩余 ${retries - attempt} 次)`)
  59. }
  60. if (attempt < retries) {
  61. await delay(retryDelay)
  62. }
  63. }
  64. }
  65. if (lastError) {
  66. const axiosError = lastError as AxiosError
  67. if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) {
  68. throw new Error(ERROR_MESSAGES.TIMEOUT_ERROR)
  69. }
  70. throw new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + lastError.message)
  71. }
  72. throw new Error(ERROR_MESSAGES.NETWORK_ERROR)
  73. }
  74. /**
  75. * 获取 Windows 系统最新的 PATH 环境变量
  76. * 安装软件后,系统 PATH 会更新,但当前进程的 PATH 不会自动更新
  77. * 通过 PowerShell 从注册表读取最新的 PATH
  78. * 同时添加 pnpm 和 npm 的全局 bin 目录,确保能找到全局安装的命令
  79. */
  80. async function getRefreshedWindowsPath(): Promise<string> {
  81. try {
  82. const result = await execa('powershell', [
  83. '-NoProfile',
  84. '-Command',
  85. `[Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')`
  86. ])
  87. let newPath = result.stdout.trim()
  88. // 添加 pnpm 全局 bin 目录(如果不在 PATH 中)
  89. // pnpm 在 Windows 上的默认全局 bin 目录是 %LOCALAPPDATA%\pnpm
  90. const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
  91. const pnpmGlobalBin = path.join(localAppData, 'pnpm')
  92. if (!newPath.toLowerCase().includes(pnpmGlobalBin.toLowerCase())) {
  93. newPath = `${pnpmGlobalBin};${newPath}`
  94. }
  95. // 添加 npm 全局 bin 目录(如果不在 PATH 中)
  96. // npm 在 Windows 上的默认全局 bin 目录是 %APPDATA%\npm
  97. const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
  98. const npmGlobalBin = path.join(appData, 'npm')
  99. if (!newPath.toLowerCase().includes(npmGlobalBin.toLowerCase())) {
  100. newPath = `${npmGlobalBin};${newPath}`
  101. }
  102. return newPath
  103. } catch {
  104. // 如果失败,返回当前进程的 PATH,并添加常用的全局 bin 目录
  105. let fallbackPath = process.env.PATH || ''
  106. const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
  107. const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
  108. fallbackPath = `${path.join(localAppData, 'pnpm')};${path.join(appData, 'npm')};${fallbackPath}`
  109. return fallbackPath
  110. }
  111. }
  112. /**
  113. * 获取刷新后的 PATH 环境变量(跨平台)
  114. * Windows: 从注册表读取最新的 PATH
  115. * 其他平台: 返回当前进程的 PATH
  116. */
  117. export async function getRefreshedPath(): Promise<string> {
  118. if (os.platform() === 'win32') {
  119. return await getRefreshedWindowsPath()
  120. }
  121. return process.env.PATH || ''
  122. }
  123. /**
  124. * 检测命令是否存在
  125. * Windows: 会尝试使用刷新后的 PATH 来检测新安装的软件
  126. */
  127. export async function commandExists(command: string, refreshPath = false): Promise<boolean> {
  128. try {
  129. const platform = os.platform()
  130. if (platform === 'win32') {
  131. if (refreshPath) {
  132. // 使用刷新后的 PATH 执行 where 命令
  133. const newPath = await getRefreshedWindowsPath()
  134. await execa('where', [command], { env: { ...process.env, PATH: newPath } })
  135. } else {
  136. await execa('where', [command])
  137. }
  138. } else {
  139. await execa('which', [command])
  140. }
  141. return true
  142. } catch {
  143. return false
  144. }
  145. }
  146. /**
  147. * 使用刷新后的 PATH 检测命令是否存在
  148. * Windows: 直接从注册表读取最新的 PATH 来检测
  149. * 其他平台: 使用当前 PATH
  150. */
  151. export async function commandExistsWithRefresh(command: string): Promise<boolean> {
  152. const platform = os.platform()
  153. if (platform === 'win32') {
  154. // Windows 上直接使用刷新后的 PATH 检测
  155. return await commandExists(command, true)
  156. }
  157. return await commandExists(command, false)
  158. }
  159. /**
  160. * 从 Windows 注册表获取 VS Code 安装路径
  161. * VS Code 安装后会在多个注册表位置写入信息
  162. * 使用 reg query 命令直接查询,比 PowerShell 更快更可靠
  163. * @returns VS Code 的 code.cmd 路径,如果找不到则返回 null
  164. */
  165. async function getVscodePathFromRegistry(): Promise<string | null> {
  166. if (os.platform() !== 'win32') return null
  167. // VS Code 可能的注册表位置(使用固定的 GUID 键,与 ipc-handlers.ts 保持一致)
  168. const registryPaths = [
  169. // 系统安装 (64位)
  170. { key: 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1', value: 'InstallLocation' },
  171. // 系统安装 (32位 on 64位系统)
  172. { key: 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1', value: 'InstallLocation' },
  173. // 用户安装
  174. { key: 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1', value: 'InstallLocation' },
  175. // VS Code Insiders 系统安装
  176. { key: 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1', value: 'InstallLocation' },
  177. // VS Code Insiders 用户安装
  178. { key: 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{217B4C08-948D-4276-BFBB-BEE930AE5A2C}_is1', value: 'InstallLocation' },
  179. ]
  180. for (const reg of registryPaths) {
  181. try {
  182. const result = await execa('reg', ['query', reg.key, '/v', reg.value], {
  183. encoding: 'utf8',
  184. timeout: 5000
  185. })
  186. // 解析注册表输出,格式如: " InstallLocation REG_SZ C:\Program Files\Microsoft VS Code\"
  187. const match = result.stdout.match(/InstallLocation\s+REG_SZ\s+(.+)/i)
  188. if (match) {
  189. const installLocation = match[1].trim()
  190. // 构建 code.cmd 的完整路径
  191. const codeCmd = path.join(installLocation, 'bin', 'code.cmd')
  192. if (fs.existsSync(codeCmd)) {
  193. return codeCmd
  194. }
  195. // 也检查 code.exe (某些版本可能直接使用 exe)
  196. const codeExe = path.join(installLocation, 'bin', 'code.exe')
  197. if (fs.existsSync(codeExe)) {
  198. return codeExe
  199. }
  200. }
  201. } catch {
  202. // 该注册表项不存在,继续尝试下一个
  203. }
  204. }
  205. return null
  206. }
  207. /**
  208. * 获取命令的完整路径
  209. * Windows: 使用刷新后的 PATH 执行 where 命令
  210. * macOS/Linux: 使用 which 命令
  211. * 注意:对于 VS Code,请使用专门的 getVscodeCliPath 函数
  212. * @param command 命令名称
  213. * @returns 命令的完整路径,如果找不到则返回 null
  214. */
  215. export async function getCommandPath(command: string): Promise<string | null> {
  216. try {
  217. const platform = os.platform()
  218. if (platform === 'win32') {
  219. // 使用刷新后的 PATH 执行 where 命令
  220. const newPath = await getRefreshedWindowsPath()
  221. const result = await execa('where', [command], { env: { ...process.env, PATH: newPath } })
  222. // where 可能返回多行(多个路径)
  223. // 优先选择 .cmd 或 .exe 文件,因为这些是 Windows 上可直接执行的
  224. const paths = result.stdout.trim().split(/\r?\n/)
  225. const cmdPath = paths.find(p => p.toLowerCase().endsWith('.cmd'))
  226. const exePath = paths.find(p => p.toLowerCase().endsWith('.exe'))
  227. // 优先返回 .cmd,其次 .exe,最后返回第一个结果
  228. return cmdPath || exePath || paths[0] || null
  229. } else {
  230. const result = await execa('which', [command])
  231. // which 通常只返回一个路径
  232. return result.stdout.trim() || null
  233. }
  234. } catch {
  235. return null
  236. }
  237. }
  238. /**
  239. * 获取 VS Code CLI (code) 命令的路径
  240. * 优先级:PATH 中的 code 命令 > 注册表路径 > 常见安装路径
  241. * @returns VS Code CLI 的完整路径,如果都找不到则返回 'code'
  242. */
  243. export async function getVscodeCliPath(): Promise<string> {
  244. const platform = os.platform()
  245. // 首先检查 code 命令是否在 PATH 中
  246. try {
  247. await execa('code', ['--version'], { timeout: 5000 })
  248. return 'code'
  249. } catch {
  250. // code 命令不在 PATH 中,尝试其他方法
  251. }
  252. if (platform === 'win32') {
  253. // Windows: 优先从注册表获取安装路径
  254. const registryPath = await getVscodePathFromRegistry()
  255. if (registryPath) {
  256. return registryPath
  257. }
  258. // 注册表找不到,尝试常见安装路径作为后备
  259. const fallbackPaths = [
  260. path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Microsoft VS Code', 'bin', 'code.cmd'),
  261. path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'),
  262. ]
  263. for (const codePath of fallbackPaths) {
  264. if (fs.existsSync(codePath)) {
  265. return codePath
  266. }
  267. }
  268. } else if (platform === 'darwin') {
  269. // macOS: 使用 mdfind 查找应用程序
  270. try {
  271. const result = await execa('mdfind', ['kMDItemCFBundleIdentifier == "com.microsoft.VSCode"'], {
  272. encoding: 'utf8',
  273. timeout: 5000
  274. })
  275. const appPath = result.stdout.trim().split('\n')[0]
  276. if (appPath) {
  277. const codePath = path.join(appPath, 'Contents', 'Resources', 'app', 'bin', 'code')
  278. if (fs.existsSync(codePath)) {
  279. return codePath
  280. }
  281. }
  282. } catch {
  283. // mdfind 失败,尝试常见路径
  284. }
  285. const fallbackPaths = [
  286. '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
  287. path.join(os.homedir(), 'Applications', 'Visual Studio Code.app', 'Contents', 'Resources', 'app', 'bin', 'code'),
  288. ]
  289. for (const codePath of fallbackPaths) {
  290. if (fs.existsSync(codePath)) {
  291. return codePath
  292. }
  293. }
  294. } else {
  295. // Linux: 使用 which 或检查常见路径
  296. try {
  297. const result = await execa('which', ['code'], { encoding: 'utf8', timeout: 5000 })
  298. const codePath = result.stdout.trim()
  299. if (codePath && fs.existsSync(codePath)) {
  300. return codePath
  301. }
  302. } catch {
  303. // which 失败
  304. }
  305. const fallbackPaths = [
  306. '/usr/bin/code',
  307. '/usr/share/code/bin/code',
  308. '/snap/bin/code',
  309. ]
  310. for (const codePath of fallbackPaths) {
  311. if (fs.existsSync(codePath)) {
  312. return codePath
  313. }
  314. }
  315. }
  316. // 如果都找不到,返回 'code' 让系统尝试
  317. return 'code'
  318. }
  319. /**
  320. * 获取命令版本
  321. * @param command 命令名称
  322. * @param versionArgs 版本参数
  323. * @param refreshPath 是否使用刷新后的 PATH(仅 Windows)
  324. */
  325. export async function getCommandVersion(
  326. command: string,
  327. versionArgs: string[] = ['--version'],
  328. refreshPath = false
  329. ): Promise<string | null> {
  330. try {
  331. let result
  332. if (refreshPath && os.platform() === 'win32') {
  333. const newPath = await getRefreshedWindowsPath()
  334. result = await execa(command, versionArgs, { env: { ...process.env, PATH: newPath } })
  335. } else {
  336. result = await execa(command, versionArgs)
  337. }
  338. const output = result.stdout || result.stderr
  339. const match = output.match(/(\d+\.\d+\.\d+)/)
  340. return match ? match[1] : null
  341. } catch {
  342. return null
  343. }
  344. }
  345. /**
  346. * 使用刷新后的 PATH 获取命令版本
  347. * Windows: 直接从注册表读取最新的 PATH 来执行命令
  348. * 其他平台: 使用当前 PATH
  349. */
  350. export async function getCommandVersionWithRefresh(
  351. command: string,
  352. versionArgs: string[] = ['--version']
  353. ): Promise<string | null> {
  354. const platform = os.platform()
  355. if (platform === 'win32') {
  356. // Windows 上直接使用刷新后的 PATH
  357. return await getCommandVersion(command, versionArgs, true)
  358. }
  359. return await getCommandVersion(command, versionArgs, false)
  360. }
  361. /**
  362. * 设置版本缓存
  363. */
  364. export function setCache(key: string, data: unknown, ttl: number): void {
  365. versionCache.set(key, {
  366. data,
  367. expiry: Date.now() + ttl
  368. })
  369. }
  370. /**
  371. * 获取版本缓存
  372. */
  373. export function getCache<T = unknown>(key: string): T | null {
  374. const cached = versionCache.get(key)
  375. if (cached && cached.expiry > Date.now()) {
  376. return cached.data as T
  377. }
  378. versionCache.delete(key)
  379. return null
  380. }
  381. /**
  382. * 清除版本缓存
  383. */
  384. export function clearCache(key?: string): void {
  385. if (key) {
  386. versionCache.delete(key)
  387. } else {
  388. versionCache.clear()
  389. }
  390. }
  391. /**
  392. * 延迟函数
  393. */
  394. export function delay(ms: number): Promise<void> {
  395. return new Promise((resolve) => setTimeout(resolve, ms))
  396. }
  397. /**
  398. * 检测网络连接
  399. */
  400. export async function checkNetworkConnection(): Promise<boolean> {
  401. try {
  402. await httpsGet('https://www.baidu.com', { timeout: 5000, retries: 1 })
  403. return true
  404. } catch {
  405. try {
  406. await httpsGet('https://www.google.com', { timeout: 5000, retries: 1 })
  407. return true
  408. } catch {
  409. return false
  410. }
  411. }
  412. }
  413. /**
  414. * 下载进度回调类型
  415. */
  416. export type DownloadProgressCallback = (downloaded: number, total: number, percent: number) => void
  417. /**
  418. * 下载任务信息
  419. */
  420. interface DownloadTask {
  421. cancelSource: CancelTokenSource
  422. tempPath: string
  423. destPath: string
  424. }
  425. // 活跃的下载任务(支持多个并行下载)
  426. const activeDownloads = new Map<string, DownloadTask>()
  427. /**
  428. * 取消指定下载并删除临时文件
  429. * @param downloadId 下载ID(通常是目标文件路径)
  430. * @returns 是否成功取消
  431. */
  432. export function cancelDownload(downloadId: string): boolean {
  433. const task = activeDownloads.get(downloadId)
  434. if (task) {
  435. task.cancelSource.cancel('用户取消下载')
  436. // 删除临时文件
  437. fs.unlink(task.tempPath, () => {})
  438. activeDownloads.delete(downloadId)
  439. return true
  440. }
  441. return false
  442. }
  443. /**
  444. * 取消所有下载并删除临时文件
  445. * @returns 取消的下载数量
  446. */
  447. export function cancelAllDownloads(): number {
  448. let count = 0
  449. for (const [downloadId, task] of activeDownloads) {
  450. task.cancelSource.cancel('用户取消下载')
  451. fs.unlink(task.tempPath, () => {})
  452. activeDownloads.delete(downloadId)
  453. count++
  454. }
  455. return count
  456. }
  457. /**
  458. * 取消当前下载并删除临时文件(兼容旧接口)
  459. * @returns 是否有下载被取消
  460. */
  461. export function cancelCurrentDownload(): boolean {
  462. return cancelAllDownloads() > 0
  463. }
  464. /**
  465. * 获取已下载的文件大小
  466. */
  467. function getDownloadedSize(tempPath: string): number {
  468. try {
  469. if (fs.existsSync(tempPath)) {
  470. return fs.statSync(tempPath).size
  471. }
  472. } catch {
  473. // 忽略错误
  474. }
  475. return 0
  476. }
  477. /**
  478. * 下载文件到指定路径(支持断点续传和多文件并行下载)
  479. * @param url 下载地址
  480. * @param destPath 目标文件路径
  481. * @param onProgress 进度回调
  482. * @param options 下载选项
  483. */
  484. export async function downloadFile(
  485. url: string,
  486. destPath: string,
  487. onProgress?: DownloadProgressCallback,
  488. options: {
  489. timeout?: number
  490. } = {}
  491. ): Promise<string> {
  492. const { timeout = 60000 } = options
  493. // 临时文件路径(用于断点续传)
  494. const tempPath = destPath + '.downloading'
  495. // 创建取消令牌
  496. const cancelSource = axios.CancelToken.source()
  497. // 注册下载任务
  498. const downloadId = destPath
  499. activeDownloads.set(downloadId, {
  500. cancelSource,
  501. tempPath,
  502. destPath
  503. })
  504. try {
  505. // 获取已下载的文件大小
  506. const downloadedSize = getDownloadedSize(tempPath)
  507. // 构建请求头,支持断点续传
  508. const headers: Record<string, string> = {}
  509. if (downloadedSize > 0) {
  510. headers['Range'] = `bytes=${downloadedSize}-`
  511. console.log(`断点续传: 从 ${(downloadedSize / 1024 / 1024).toFixed(1)}MB 处继续下载`)
  512. }
  513. // 发起请求
  514. const response = await axiosInstance.get(url, {
  515. headers,
  516. timeout,
  517. responseType: 'stream',
  518. cancelToken: cancelSource.token,
  519. maxRedirects: 5,
  520. // 不验证状态码,手动处理
  521. validateStatus: () => true
  522. })
  523. // 处理 HTTP 错误状态码
  524. if (response.status >= 400) {
  525. // 416 表示 Range 请求无效(可能文件已完成或服务器不支持)
  526. if (response.status === 416 && downloadedSize > 0) {
  527. console.log('文件可能已下载完成,尝试验证...')
  528. try {
  529. if (fs.existsSync(destPath)) {
  530. fs.unlinkSync(destPath)
  531. }
  532. fs.renameSync(tempPath, destPath)
  533. activeDownloads.delete(downloadId)
  534. return destPath
  535. } catch {
  536. // 如果重命名失败,删除临时文件重新下载
  537. fs.unlinkSync(tempPath)
  538. }
  539. }
  540. throw new Error(`HTTP ${response.status}: 下载失败`)
  541. }
  542. // 206 表示部分内容(断点续传成功)
  543. const isPartialContent = response.status === 206
  544. // 计算总大小
  545. let totalSize = 0
  546. if (isPartialContent) {
  547. // 从 Content-Range 头获取总大小: bytes 0-999/1000
  548. const contentRange = response.headers['content-range']
  549. if (contentRange) {
  550. const match = contentRange.match(/\/(\d+)$/)
  551. if (match) {
  552. totalSize = parseInt(match[1], 10)
  553. }
  554. }
  555. } else {
  556. // 新下载,获取 Content-Length
  557. totalSize = parseInt(response.headers['content-length'] || '0', 10)
  558. // 如果是新下载但存在临时文件,说明服务器不支持断点续传,删除重新下载
  559. if (downloadedSize > 0) {
  560. console.log('服务器不支持断点续传,重新下载')
  561. fs.unlinkSync(tempPath)
  562. }
  563. }
  564. // 确保目标目录存在
  565. const dir = path.dirname(destPath)
  566. if (!fs.existsSync(dir)) {
  567. fs.mkdirSync(dir, { recursive: true })
  568. }
  569. // 以追加模式打开文件(断点续传)或创建新文件
  570. const fileStream = fs.createWriteStream(tempPath, {
  571. flags: isPartialContent ? 'a' : 'w'
  572. })
  573. let currentDownloaded = isPartialContent ? downloadedSize : 0
  574. // 返回 Promise
  575. return new Promise<string>((resolve, reject) => {
  576. response.data.on('data', (chunk: Buffer) => {
  577. currentDownloaded += chunk.length
  578. if (onProgress && totalSize > 0) {
  579. const percent = Math.round((currentDownloaded / totalSize) * 100)
  580. onProgress(currentDownloaded, totalSize, percent)
  581. }
  582. })
  583. response.data.on('error', (err: Error) => {
  584. fileStream.close()
  585. activeDownloads.delete(downloadId)
  586. reject(new Error(ERROR_MESSAGES.NETWORK_ERROR + ': ' + err.message))
  587. })
  588. response.data.pipe(fileStream)
  589. fileStream.on('finish', () => {
  590. fileStream.close()
  591. // 验证文件大小
  592. const finalSize = getDownloadedSize(tempPath)
  593. if (totalSize > 0 && finalSize < totalSize) {
  594. // 文件不完整,保留临时文件以便下次续传
  595. console.log(`下载不完整: ${finalSize}/${totalSize} 字节,可点击重新安装继续下载`)
  596. activeDownloads.delete(downloadId)
  597. reject(new Error('下载不完整,请重试'))
  598. return
  599. }
  600. // 重命名临时文件为最终文件
  601. try {
  602. if (fs.existsSync(destPath)) {
  603. fs.unlinkSync(destPath)
  604. }
  605. fs.renameSync(tempPath, destPath)
  606. activeDownloads.delete(downloadId)
  607. resolve(destPath)
  608. } catch (err) {
  609. activeDownloads.delete(downloadId)
  610. reject(err)
  611. }
  612. })
  613. fileStream.on('error', (err) => {
  614. // 保留临时文件以便续传
  615. activeDownloads.delete(downloadId)
  616. reject(err)
  617. })
  618. })
  619. } catch (error) {
  620. activeDownloads.delete(downloadId)
  621. if (axios.isCancel(error)) {
  622. // 用户取消,删除临时文件
  623. fs.unlink(tempPath, () => {})
  624. throw new Error(ERROR_MESSAGES.INSTALL_CANCELLED || '下载已取消')
  625. }
  626. const axiosError = error as AxiosError
  627. if (axiosError.code === 'ECONNABORTED' || axiosError.message?.includes('timeout')) {
  628. // 超时,保留临时文件以便续传
  629. throw new Error(ERROR_MESSAGES.TIMEOUT_ERROR)
  630. }
  631. // 其他错误,保留临时文件以便续传
  632. throw error
  633. }
  634. }
  635. /**
  636. * 获取临时目录路径
  637. */
  638. export function getTempDir(): string {
  639. return os.tmpdir()
  640. }
  641. /**
  642. * 检查包管理器源是否需要更新
  643. * @param manager 包管理器名称 (apt, brew)
  644. * @returns 是否需要更新
  645. */
  646. export function needsSourceUpdate(manager: string): boolean {
  647. const lastUpdate = sourceUpdateCache.get(manager)
  648. if (!lastUpdate) {
  649. return true
  650. }
  651. return Date.now() - lastUpdate > SOURCE_UPDATE_CACHE_TTL
  652. }
  653. /**
  654. * 标记包管理器源已更新
  655. * @param manager 包管理器名称
  656. */
  657. export function markSourceUpdated(manager: string): void {
  658. sourceUpdateCache.set(manager, Date.now())
  659. }
  660. /**
  661. * 清除源更新缓存
  662. * @param manager 可选,指定清除某个包管理器的缓存,不传则清除所有
  663. */
  664. export function clearSourceUpdateCache(manager?: string): void {
  665. if (manager) {
  666. sourceUpdateCache.delete(manager)
  667. } else {
  668. sourceUpdateCache.clear()
  669. }
  670. }