diff --git a/electron/main/apis/netease/core/ncbl.ts b/electron/main/apis/netease/core/ncbl.ts index 703533ff..524ac74c 100644 --- a/electron/main/apis/netease/core/ncbl.ts +++ b/electron/main/apis/netease/core/ncbl.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID as cryptoRandomUUID } from "node:crypto"; import * as zlib from "node:zlib"; import { CLIENT_LOG3_DOMAIN } from "./config"; +import { fetchWithProxy } from "@main/utils/proxy"; interface NeteaseLogRecord { time: number; @@ -437,19 +438,22 @@ export const doUpload = async ( ): Promise => { const payload = encryptNCBL(metaJson, body); const multipart = buildMultipart(payload); - const resp = await fetch(`${CLIENT_LOG3_DOMAIN}/api/clientlog/encrypt/upload?multiupload=true`, { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${multipart.boundary}`, - Referer: "https://music.163.com/di", - "User-Agent": `Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/${ctx.app.version}`, - "Accept-Encoding": "gzip,deflate", - "Accept-Language": "zh-CN,zh;q=0.8", - Cookie: cookieStr, + const resp = await fetchWithProxy( + `${CLIENT_LOG3_DOMAIN}/api/clientlog/encrypt/upload?multiupload=true`, + { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${multipart.boundary}`, + Referer: "https://music.163.com/di", + "User-Agent": `Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/${ctx.app.version}`, + "Accept-Encoding": "gzip,deflate", + "Accept-Language": "zh-CN,zh;q=0.8", + Cookie: cookieStr, + }, + body: new Uint8Array(multipart.body), + signal: AbortSignal.timeout(15000), }, - body: new Uint8Array(multipart.body), - signal: AbortSignal.timeout(15000), - }); + ); const text = await resp.text(); let respBody: Record; diff --git a/electron/main/apis/netease/core/request.ts b/electron/main/apis/netease/core/request.ts index e23a1e75..28cbe036 100644 --- a/electron/main/apis/netease/core/request.ts +++ b/electron/main/apis/netease/core/request.ts @@ -2,7 +2,7 @@ * Netease API 请求层 * * 核心职责:根据加密方式(weapi / linuxapi / eapi / api / xeapi)构造 URL、headers、form body, - * 处理 cookie 合并、响应解密、状态码归一化。使用 Node 原生 fetch。 + * 处理 cookie 合并、响应解密、状态码归一化 */ import { randomBytes } from "node:crypto"; @@ -20,6 +20,7 @@ import { cookieObjToString, cookieToJson } from "./cookie"; import * as encrypt from "./crypto"; import { getAnonymousToken, getDeviceId } from "./device"; import { ensureXeapiKey, getXeapiSession, updateXeapiSession } from "./xeapi"; +import { fetchWithProxy } from "@main/utils/proxy"; /** 调用方传入的可选参数 */ export interface RequestOptions { @@ -266,14 +267,19 @@ export const createRequest = async ( let res: Response; try { - res = await fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(8000) }); + res = await fetchWithProxy(url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(8000), + }); } catch (err) { answer.status = 502; answer.body = { code: 502, msg: err instanceof Error ? err.message : String(err) }; throw new NeteaseRequestError(answer); } - // 收集 set-cookie(Node fetch 通过 headers.getSetCookie 暴露原始多值头) + // 收集 set-cookie const setCookie = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []); diff --git a/electron/main/apis/netease/core/xeapi.ts b/electron/main/apis/netease/core/xeapi.ts index cd1f8400..9dbd21d9 100644 --- a/electron/main/apis/netease/core/xeapi.ts +++ b/electron/main/apis/netease/core/xeapi.ts @@ -7,6 +7,7 @@ import { API_DOMAIN, UA_MAP } from "./config"; import { xeapiSign, xeapiDecryptPublicKey, type XeapiPublicKey } from "./crypto"; +import { fetchWithProxy } from "@main/utils/proxy"; let publicKeyState: XeapiPublicKey | null = null; let sessionId = ""; @@ -40,7 +41,7 @@ const fetchPublicKey = async ( uid: "", }; - const res = await fetch(`${API_DOMAIN}/api/gorilla/anti/crawler/security/key/get`, { + const res = await fetchWithProxy(`${API_DOMAIN}/api/gorilla/anti/crawler/security/key/get`, { method: "POST", headers: { "User-Agent": UA_MAP.api.android, diff --git a/electron/main/ipc/system.ts b/electron/main/ipc/system.ts index dc59877f..b14afa27 100644 --- a/electron/main/ipc/system.ts +++ b/electron/main/ipc/system.ts @@ -12,6 +12,7 @@ import { getMainWindow, focusMainWindow } from "@main/window"; import { fetchBytes } from "@main/utils/fetchBytes"; import { logsDir } from "@main/utils/paths"; import { consumePendingOrpheusUrl } from "@main/services/orpheus"; +import { testNetworkProxy } from "@main/utils/proxy"; /** * 注册系统相关的 IPC 事件 @@ -75,6 +76,9 @@ export const registerSystemIpc = (): void => { app.exit(0); }); + // 测试当前网络代理 + ipcMain.handle("system:testNetworkProxy", () => testNetworkProxy()); + // 把封面图 URL 拉成字节回渲染层 // 用于 canvas 取色等需要绕过跨域 tainted 的场景;限定 image/* 响应 ipcMain.handle("system:fetchRemoteBytes", async (_event, url: string) => { diff --git a/electron/main/utils/proxy.ts b/electron/main/utils/proxy.ts new file mode 100644 index 00000000..bade14a4 --- /dev/null +++ b/electron/main/utils/proxy.ts @@ -0,0 +1,55 @@ +import { store } from "@main/store"; +import { systemLog } from "@main/utils/logger"; +import { fetch as undiciFetch, ProxyAgent, Socks5ProxyAgent } from "undici"; +import type { Dispatcher } from "undici"; + +const PROXY_TEST_URL = "https://www.baidu.com"; + +let proxyAgent: Dispatcher | null = null; +let proxyAgentUrl = ""; + +const isManualProxyProtocol = (value: string): value is "http" | "https" | "socks5" => + value === "http" || value === "https" || value === "socks5"; + +/** 当前手动代理地址;off 或配置无效时返回 null,保持原生直连行为 */ +export const getNetworkProxyUrl = (): string | null => { + const config = store.get("system.networkProxy"); + if (!isManualProxyProtocol(config.protocol)) return null; + const host = config.host.trim(); + const port = Number(config.port); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return null; + return `${config.protocol}://${host}:${port}`; +}; + +const getProxyDispatcher = (): Dispatcher | undefined => { + const url = getNetworkProxyUrl(); + if (!url) return undefined; + if (!proxyAgent || proxyAgentUrl !== url) { + proxyAgent?.close().catch(() => {}); + proxyAgent = url.startsWith("socks5://") ? new Socks5ProxyAgent(url) : new ProxyAgent(url); + proxyAgentUrl = url; + systemLog.info(`[proxy] node fetch proxy=${url}`); + } + return proxyAgent; +}; + +/** Node fetch 包装:关闭代理时完全等价于原生 fetch,开启代理时才注入 dispatcher */ +export const fetchWithProxy = (input: string | URL, init?: RequestInit): Promise => { + const dispatcher = getProxyDispatcher(); + if (!dispatcher) return fetch(input, init); + return undiciFetch(input, { ...(init as RequestInit), dispatcher } as Parameters< + typeof undiciFetch + >[1]) as unknown as Promise; +}; + +/** 测试当前代理是否可用 */ +export const testNetworkProxy = async (): Promise => { + if (!getNetworkProxyUrl()) return false; + try { + const res = await fetchWithProxy(PROXY_TEST_URL, { signal: AbortSignal.timeout(8000) }); + return res.ok; + } catch (err) { + systemLog.warn("[proxy] test failed", err); + return false; + } +}; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 4bb40822..c1ef31bf 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -45,6 +45,7 @@ declare global { fileName: string, ) => Promise<{ success: boolean; path?: string; error?: string }>; relaunch: () => Promise; + testNetworkProxy: () => Promise; onProtocolUrl: (callback: (url: string) => void) => () => void; consumePendingProtocolUrl: () => Promise; }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c780c75a..1fcf4b2d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -134,6 +134,8 @@ const api = { ipcRenderer.invoke("system:saveFile", data, defaultName), // 重启应用 relaunch: () => ipcRenderer.invoke("system:relaunch"), + // 测试当前网络代理 + testNetworkProxy: () => ipcRenderer.invoke("system:testNetworkProxy"), // 订阅主进程下发的 orpheus 唤起 URL onProtocolUrl: (callback: (url: string) => void) => subscribe("protocol:orpheus", callback), diff --git a/package.json b/package.json index c66b80ca..5e91ed60 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "license": "AGPL-3.0", "license-file": "LICENSE", "engines": { - "node": ">=22", + "node": ">=22.19.0", "npm": ">=11", "pnpm": ">=10" }, @@ -57,6 +57,7 @@ "electron-updater": "^6.8.3", "font-list": "^2.0.2", "hono": "^4.12.25", + "undici": "^8.7.0", "ws": "^8.20.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52b9c8e2..72246ba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: hono: specifier: ^4.12.25 version: 4.12.25 + undici: + specifier: ^8.7.0 + version: 8.7.0 ws: specifier: ^8.20.1 version: 8.20.1 @@ -4180,6 +4183,10 @@ packages: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} + undici@8.7.0: + resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} + engines: {node: '>=22.19.0'} + unimport@5.7.0: resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} engines: {node: '>=18.12.0'} @@ -8549,6 +8556,8 @@ snapshots: undici@6.25.0: {} + undici@8.7.0: {} + unimport@5.7.0: dependencies: acorn: 8.16.0 diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index cdb5f85a..e65996ae 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -145,6 +145,11 @@ export const defaultSystemConfig: SystemConfig = { uiZoom: 100, onboardingCompleted: false, neteaseRealIp: false, + networkProxy: { + protocol: "off", + host: "127.0.0.1", + port: 7890, + }, neteaseScrobbleEnabled: false, neteaseScrobbleMode: "ncbl", registerOrpheusProtocol: false, diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 351e3d5b..a10fecb7 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -226,6 +226,19 @@ export interface ExternalApiSettings { port: number; } +/** 网络代理协议 */ +export type NetworkProxyProtocol = "off" | "http" | "https" | "socks5"; + +/** 网络代理配置 */ +export interface NetworkProxySettings { + /** 代理协议;off 时不改变原始请求路径 */ + protocol: NetworkProxyProtocol; + /** 代理服务器地址 */ + host: string; + /** 代理服务器端口 */ + port: number; +} + /** 外部 API 服务运行时状态 */ export interface ExternalApiStatus { /** 是否正在监听 */ @@ -397,6 +410,8 @@ export interface SystemConfig { onboardingCompleted: boolean; /** NCM请求注入国内 IP(X-Real-IP/X-Forwarded-For) */ neteaseRealIp: boolean; + /** 网络代理配置 */ + networkProxy: NetworkProxySettings; /** 听歌打卡开关 */ neteaseScrobbleEnabled: boolean; /** 听歌打卡上报方式 */ diff --git a/src/components/settings/SettingsItem.vue b/src/components/settings/SettingsItem.vue index e7bb6b7c..10c163c9 100644 --- a/src/components/settings/SettingsItem.vue +++ b/src/components/settings/SettingsItem.vue @@ -123,14 +123,26 @@ const descriptionText = computed(() => + (), { @@ -30,6 +32,7 @@ const props = withDefaults(defineProps(), { resize: "none", size: "medium", status: "default", + updateOn: "input", }); const isTextarea = computed(() => props.type === "textarea"); @@ -66,10 +69,57 @@ const emit = defineEmits<{ }>(); const isFocused = ref(false); -const showClear = computed(() => props.clearable && props.modelValue.length > 0 && !props.disabled); +const draftValue = ref(props.modelValue); +const displayValue = computed(() => + props.updateOn === "blur" ? draftValue.value : props.modelValue, +); +const showClear = computed( + () => props.clearable && displayValue.value.length > 0 && !props.disabled, +); + +watch( + () => props.modelValue, + (value) => { + if (props.updateOn === "input" || !isFocused.value) draftValue.value = value; + }, +); + +const commitValue = (): void => { + if (draftValue.value !== props.modelValue) emit("update:modelValue", draftValue.value); +}; + +const rollbackValue = (): void => { + draftValue.value = props.modelValue; +}; + +const handleInput = (value: string): void => { + if (props.updateOn === "input") { + emit("update:modelValue", value); + return; + } + draftValue.value = value; +}; const handleClear = () => { - emit("update:modelValue", ""); + handleInput(""); +}; + +const handleBlur = (): void => { + isFocused.value = false; + if (props.updateOn === "blur") commitValue(); + emit("blur"); +}; + +const handleEnter = (event: KeyboardEvent): void => { + if (props.updateOn !== "blur") return; + commitValue(); + (event.currentTarget as HTMLInputElement).blur(); +}; + +const handleEscape = (event: KeyboardEvent): void => { + if (props.updateOn !== "blur") return; + rollbackValue(); + (event.currentTarget as HTMLInputElement).blur(); }; @@ -93,22 +143,20 @@ const handleClear = () => {