From 80b59fc9de7a4c81bdc94f84812ccf6cbedf3fe5 Mon Sep 17 00:00:00 2001 From: ezequielnick <107352853+ezequielnick@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:35:19 +0800 Subject: [PATCH] refactor: migrate external HTTP requests from Node.js to Chromium network stack (cherry picked from commit ad96c558a3d46c79848e2c9e469673c7e7f68e25) --- .gitignore | 3 +- src/main/config/override.ts | 4 +- src/main/config/profile.ts | 8 +- src/main/core/subStoreApi.ts | 6 +- src/main/resolve/autoUpdater.ts | 6 +- src/main/resolve/gistApi.ts | 8 +- src/main/resolve/server.ts | 10 +- src/main/resolve/theme.ts | 6 +- src/main/utils/chromeRequest.ts | 252 ++++++++++++++++++++++++++++++++ src/main/utils/github.ts | 39 ++--- src/main/utils/image.ts | 6 +- src/main/utils/ipc.ts | 2 +- 12 files changed, 296 insertions(+), 54 deletions(-) create mode 100644 src/main/utils/chromeRequest.ts diff --git a/.gitignore b/.gitignore index 9588d0a..94ebee6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ out *.log* .idea *.ttf -party.md \ No newline at end of file +party.md +CLAUDE.md \ No newline at end of file diff --git a/src/main/config/override.ts b/src/main/config/override.ts index dd20ea6..7e7e1ea 100644 --- a/src/main/config/override.ts +++ b/src/main/config/override.ts @@ -2,7 +2,7 @@ import { overrideConfigPath, overridePath } from '../utils/dirs' import { getControledMihomoConfig } from './controledMihomo' import { readFile, writeFile, rm } from 'fs/promises' import { existsSync } from 'fs' -import axios from 'axios' +import * as chromeRequest from '../utils/chromeRequest' import { parse, stringify } from '../utils/yaml' let overrideConfig: IOverrideConfig // override.yaml @@ -70,7 +70,7 @@ export async function createOverride(item: Partial): Promise): Promise if (newItem.substore) { const urlObj = new URL(`http://127.0.0.1:${subStorePort}${item.url}`) urlObj.searchParams.set('target', 'ClashMeta') @@ -158,7 +158,7 @@ export async function createProfile(item: Partial): Promise): Promise { const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` - const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' }) + const res = await chromeRequest.get(`${baseUrl}/api/subs`, { responseType: 'json' }) return res.data.data as ISubStoreSub[] } export async function subStoreCollections(): Promise { const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` - const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' }) + const res = await chromeRequest.get(`${baseUrl}/api/collections`, { responseType: 'json' }) return res.data.data as ISubStoreSub[] } diff --git a/src/main/resolve/autoUpdater.ts b/src/main/resolve/autoUpdater.ts index 7dd24f7..390c519 100644 --- a/src/main/resolve/autoUpdater.ts +++ b/src/main/resolve/autoUpdater.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import * as chromeRequest from '../utils/chromeRequest' import { parse } from '../utils/yaml' import { app, shell } from 'electron' import { getControledMihomoConfig } from '../config' @@ -14,7 +14,7 @@ import { checkAdminPrivileges } from '../core/manager' export async function checkUpdate(): Promise { const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() - const res = await axios.get( + const res = await chromeRequest.get( 'https://github.com/mihomo-party-org/mihomo-party/releases/latest/download/latest.yml', { headers: { 'Content-Type': 'application/octet-stream' }, @@ -83,7 +83,7 @@ export async function downloadAndInstallUpdate(version: string): Promise { } try { if (!existsSync(path.join(dataDir(), file))) { - const res = await axios.get(`${baseUrl}${file}`, { + const res = await chromeRequest.get(`${baseUrl}${file}`, { responseType: 'arraybuffer', proxy: { protocol: 'http', diff --git a/src/main/resolve/gistApi.ts b/src/main/resolve/gistApi.ts index 8a9b5e0..a803827 100644 --- a/src/main/resolve/gistApi.ts +++ b/src/main/resolve/gistApi.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import * as chromeRequest from '../utils/chromeRequest' import { getAppConfig, getControledMihomoConfig } from '../config' import { getRuntimeConfigStr } from '../core/factory' @@ -10,7 +10,7 @@ interface GistInfo { async function listGists(token: string): Promise { const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() - const res = await axios.get('https://api.github.com/gists', { + const res = await chromeRequest.get('https://api.github.com/gists', { headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -28,7 +28,7 @@ async function listGists(token: string): Promise { async function createGist(token: string, content: string): Promise { const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() - return await axios.post( + await chromeRequest.post( 'https://api.github.com/gists', { description: 'Auto Synced Clash Party Runtime Config', @@ -52,7 +52,7 @@ async function createGist(token: string, content: string): Promise { async function updateGist(token: string, id: string, content: string): Promise { const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() - return await axios.patch( + await chromeRequest.patch( `https://api.github.com/gists/${id}`, { description: 'Auto Synced Clash Party Runtime Config', diff --git a/src/main/resolve/server.ts b/src/main/resolve/server.ts index 3696d20..8946322 100644 --- a/src/main/resolve/server.ts +++ b/src/main/resolve/server.ts @@ -9,7 +9,7 @@ import net from 'net' import path from 'path' import { nativeImage } from 'electron' import express from 'express' -import axios from 'axios' +import * as chromeRequest from '../utils/chromeRequest' import AdmZip from 'adm-zip' import { systemLogger } from '../utils/logger' @@ -155,7 +155,7 @@ export async function downloadSubStore(): Promise { // 下载后端文件 const tempBackendPath = path.join(tempDir, 'sub-store.bundle.cjs') - const backendRes = await axios.get( + const backendRes = await chromeRequest.get( 'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js', { responseType: 'arraybuffer', @@ -167,9 +167,9 @@ export async function downloadSubStore(): Promise { } } ) - await writeFile(tempBackendPath, Buffer.from(backendRes.data)) + await writeFile(tempBackendPath, Buffer.from(backendRes.data as Buffer)) // 下载前端文件 - const frontendRes = await axios.get( + const frontendRes = await chromeRequest.get( 'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip', { responseType: 'arraybuffer', @@ -182,7 +182,7 @@ export async function downloadSubStore(): Promise { } ) // 先解压到临时目录 - const zip = new AdmZip(Buffer.from(frontendRes.data)) + const zip = new AdmZip(Buffer.from(frontendRes.data as Buffer)) zip.extractAllTo(tempDir, true) await cp(tempBackendPath, backendPath) if (existsSync(frontendDir)) { diff --git a/src/main/resolve/theme.ts b/src/main/resolve/theme.ts index c074908..3734eb2 100644 --- a/src/main/resolve/theme.ts +++ b/src/main/resolve/theme.ts @@ -1,7 +1,7 @@ import { copyFile, readdir, readFile, writeFile } from 'fs/promises' import { themesDir } from '../utils/dirs' import path from 'path' -import axios from 'axios' +import * as chromeRequest from '../utils/chromeRequest' import AdmZip from 'adm-zip' import { getControledMihomoConfig } from '../config' import { existsSync } from 'fs' @@ -36,7 +36,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]> export async function fetchThemes(): Promise { const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip' const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() - const zipData = await axios.get(zipUrl, { + const zipData = await chromeRequest.get(zipUrl, { responseType: 'arraybuffer', headers: { 'Content-Type': 'application/octet-stream' }, proxy: { @@ -45,7 +45,7 @@ export async function fetchThemes(): Promise { port: mixedPort } }) - const zip = new AdmZip(zipData.data as Buffer) + const zip = new AdmZip(Buffer.from(zipData.data as Buffer)) zip.extractAllTo(themesDir(), true) } diff --git a/src/main/utils/chromeRequest.ts b/src/main/utils/chromeRequest.ts new file mode 100644 index 0000000..b20140d --- /dev/null +++ b/src/main/utils/chromeRequest.ts @@ -0,0 +1,252 @@ +import { net, session } from 'electron' + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + headers?: Record + body?: string | Buffer + proxy?: { + protocol: 'http' | 'https' | 'socks5' + host: string + port: number + } | false + timeout?: number + responseType?: 'text' | 'json' | 'arraybuffer' + followRedirect?: boolean + maxRedirects?: number +} + +export interface Response { + data: T + status: number + statusText: string + headers: Record + url: string +} + +/** + * Make HTTP request using Chromium's network stack (via electron.net) + * This provides better compatibility, HTTP/2 support, and system certificate integration + */ +export async function request( + url: string, + options: RequestOptions = {} +): Promise> { + const { + method = 'GET', + headers = {}, + body, + proxy, + timeout = 30000, + responseType = 'text', + followRedirect = true, + maxRedirects = 20 + } = options + + return new Promise((resolve, reject) => { + let sessionToUse = session.defaultSession + let tempPartition: string | null = null + + // Set up proxy if specified + const setupProxy = async (): Promise => { + if (proxy) { + // Create temporary session partition to avoid affecting global proxy settings + tempPartition = `temp-request-${Date.now()}-${Math.random()}` + sessionToUse = session.fromPartition(tempPartition, { cache: false }) + const proxyUrl = `${proxy.protocol}://${proxy.host}:${proxy.port}` + await sessionToUse.setProxy({ proxyRules: proxyUrl }) + } + } + + const cleanup = (): void => { + // Cleanup temporary session if created + if (tempPartition) { + // Note: Electron doesn't provide session.destroy(), but temporary sessions + // will be garbage collected when no longer referenced + sessionToUse = null as any + } + } + + setupProxy() + .then(() => { + const req = net.request({ + method, + url, + session: sessionToUse, + redirect: followRedirect ? 'follow' : 'manual', + useSessionCookies: true + }) + + // Set request headers + Object.entries(headers).forEach(([key, value]) => { + req.setHeader(key, value) + }) + + // Timeout handling + let timeoutId: NodeJS.Timeout | undefined + if (timeout > 0) { + timeoutId = setTimeout(() => { + req.abort() + cleanup() + reject(new Error(`Request timeout after ${timeout}ms`)) + }, timeout) + } + + const chunks: Buffer[] = [] + let redirectCount = 0 + + req.on('redirect', () => { + redirectCount++ + if (redirectCount > maxRedirects) { + req.abort() + cleanup() + if (timeoutId) clearTimeout(timeoutId) + reject(new Error(`Too many redirects (>${maxRedirects})`)) + } + }) + + req.on('response', (res) => { + const { statusCode, statusMessage } = res + + // Extract response headers + const responseHeaders: Record = {} + const rawHeaders = res.rawHeaders || [] + for (let i = 0; i < rawHeaders.length; i += 2) { + responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1] + } + + res.on('data', (chunk: Buffer) => { + chunks.push(chunk) + }) + + res.on('end', () => { + cleanup() + if (timeoutId) clearTimeout(timeoutId) + + const buffer = Buffer.concat(chunks) + let data: any + + try { + switch (responseType) { + case 'json': + data = JSON.parse(buffer.toString('utf-8')) + break + case 'arraybuffer': + data = buffer + break + case 'text': + default: + data = buffer.toString('utf-8') + } + + resolve({ + data, + status: statusCode, + statusText: statusMessage, + headers: responseHeaders, + url: url + }) + } catch (error) { + reject(new Error(`Failed to parse response: ${error}`)) + } + }) + + res.on('error', (error) => { + cleanup() + if (timeoutId) clearTimeout(timeoutId) + reject(error) + }) + }) + + req.on('error', (error) => { + cleanup() + if (timeoutId) clearTimeout(timeoutId) + reject(error) + }) + + req.on('abort', () => { + cleanup() + if (timeoutId) clearTimeout(timeoutId) + reject(new Error('Request aborted')) + }) + + // Send request body + if (body) { + if (typeof body === 'string') { + req.write(body, 'utf-8') + } else { + req.write(body) + } + } + + req.end() + }) + .catch((error) => { + cleanup() + reject(new Error(`Failed to setup proxy: ${error}`)) + }) + }) +} + +/** + * Convenience method for GET requests + */ +export const get = ( + url: string, + options?: Omit +): Promise> => request(url, { ...options, method: 'GET' }) + +/** + * Convenience method for POST requests + */ +export const post = ( + url: string, + data: any, + options?: Omit +): Promise> => { + const body = typeof data === 'string' ? data : JSON.stringify(data) + const headers = options?.headers || {} + if (typeof data !== 'string' && !headers['content-type']) { + headers['content-type'] = 'application/json' + } + return request(url, { ...options, method: 'POST', body, headers }) +} + +/** + * Convenience method for PUT requests + */ +export const put = ( + url: string, + data: any, + options?: Omit +): Promise> => { + const body = typeof data === 'string' ? data : JSON.stringify(data) + const headers = options?.headers || {} + if (typeof data !== 'string' && !headers['content-type']) { + headers['content-type'] = 'application/json' + } + return request(url, { ...options, method: 'PUT', body, headers }) +} + +/** + * Convenience method for DELETE requests + */ +export const del = ( + url: string, + options?: Omit +): Promise> => request(url, { ...options, method: 'DELETE' }) + +/** + * Convenience method for PATCH requests + */ +export const patch = ( + url: string, + data: any, + options?: Omit +): Promise> => { + const body = typeof data === 'string' ? data : JSON.stringify(data) + const headers = options?.headers || {} + if (typeof data !== 'string' && !headers['content-type']) { + headers['content-type'] = 'application/json' + } + return request(url, { ...options, method: 'PATCH', body, headers }) +} diff --git a/src/main/utils/github.ts b/src/main/utils/github.ts index b565deb..b486910 100644 --- a/src/main/utils/github.ts +++ b/src/main/utils/github.ts @@ -1,5 +1,6 @@ -import axios from 'axios' +import * as chromeRequest from './chromeRequest' import { createWriteStream, createReadStream } from 'fs' +import { writeFile } from 'fs/promises' import { mihomoCoreDir } from './dirs' import AdmZip from 'adm-zip' import { execSync } from 'child_process' @@ -62,29 +63,30 @@ export async function getGitHubTags(owner: string, repo: string, forceRefresh = try { console.log(`[GitHub] Fetching tags for ${owner}/${repo}`) - const response = await axios.get( + const response = await chromeRequest.get( `${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': GITHUB_API_CONFIG.API_VERSION }, + responseType: 'json', timeout: 10000 } ) - + // 更新缓存 versionCache.set(cacheKey, { data: response.data, timestamp: Date.now() }) - + console.log(`[GitHub] Successfully fetched ${response.data.length} tags for ${owner}/${repo}`) return response.data } catch (error) { console.error(`[GitHub] Failed to fetch tags for ${owner}/${repo}:`, error) - if (axios.isAxiosError(error)) { - throw new Error(`GitHub API error: ${error.response?.status} - ${error.response?.statusText}`) + if (error instanceof Error) { + throw new Error(`GitHub API error: ${error.message}`) } throw new Error('Failed to fetch version list') } @@ -110,30 +112,17 @@ export function clearVersionCache(owner: string, repo: string): void { async function downloadGitHubAsset(url: string, outputPath: string): Promise { try { console.log(`[GitHub] Downloading asset from ${url}`) - const writer = createWriteStream(outputPath) - const response = await axios({ - url, - method: 'GET', - responseType: 'stream', + const response = await chromeRequest.get(url, { + responseType: 'arraybuffer', timeout: 30000 }) - response.data.pipe(writer) - - return new Promise((resolve, reject) => { - writer.on('finish', () => { - console.log(`[GitHub] Successfully downloaded asset to ${outputPath}`) - resolve() - }) - writer.on('error', (error) => { - console.error(`[GitHub] Failed to write asset to ${outputPath}:`, error) - reject(new Error(`Failed to download core file: ${error.message}`)) - }) - }) + await writeFile(outputPath, Buffer.from(response.data as Buffer)) + console.log(`[GitHub] Successfully downloaded asset to ${outputPath}`) } catch (error) { console.error(`[GitHub] Failed to download asset from ${url}:`, error) - if (axios.isAxiosError(error)) { - throw new Error(`Download error: ${error.response?.status} - ${error.response?.statusText}`) + if (error instanceof Error) { + throw new Error(`Download error: ${error.message}`) } throw new Error('Failed to download core file') } diff --git a/src/main/utils/image.ts b/src/main/utils/image.ts index 3bb1ffd..b518ee8 100644 --- a/src/main/utils/image.ts +++ b/src/main/utils/image.ts @@ -1,9 +1,9 @@ -import axios from 'axios' +import * as chromeRequest from './chromeRequest' import { getControledMihomoConfig } from '../config' export async function getImageDataURL(url: string): Promise { const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() - const res = await axios.get(url, { + const res = await chromeRequest.get(url, { responseType: 'arraybuffer', proxy: { protocol: 'http', @@ -12,6 +12,6 @@ export async function getImageDataURL(url: string): Promise { } }) const mimeType = res.headers['content-type'] - const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}` + const dataURL = `data:${mimeType};base64,${Buffer.from(res.data as Buffer).toString('base64')}` return dataURL } diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index f674523..f42c9af 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -291,7 +291,7 @@ export function registerIpcMainHandlers(): void { }) ipcMain.handle('showMainWindow', showMainWindow) ipcMain.handle('closeMainWindow', closeMainWindow) - ipcMain.handle('triggerMainWindow', triggerMainWindow) + ipcMain.handle('triggerMainWindow', (_e, force) => triggerMainWindow(force)) ipcMain.handle('showFloatingWindow', () => ipcErrorWrapper(showFloatingWindow)()) ipcMain.handle('closeFloatingWindow', () => ipcErrorWrapper(closeFloatingWindow)()) ipcMain.handle('showContextMenu', () => ipcErrorWrapper(showContextMenu)())