diff --git a/package.json b/package.json index 7200583..a378995 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@electron-toolkit/utils": "^4.0.0", "@unblockneteasemusic/server": "^0.27.10", "cors": "^2.8.5", + "crypto-js": "^4.2.0", "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", @@ -42,9 +43,12 @@ "file-type": "^21.1.1", "flac-tagger": "^1.0.7", "font-list": "^1.6.0", + "form-data": "^4.0.5", "husky": "^9.1.7", + "jsencrypt": "^3.5.4", "music-metadata": "^11.10.3", "netease-cloud-music-api-alger": "^4.26.1", + "node-fetch": "^2.7.0", "node-id3": "^0.2.9", "node-machine-id": "^1.1.12", "pinia-plugin-persistedstate": "^4.7.1", @@ -59,6 +63,7 @@ "@rushstack/eslint-patch": "^1.15.0", "@types/howler": "^2.2.12", "@types/node": "^20.19.26", + "@types/node-fetch": "^2.6.13", "@types/tinycolor2": "^1.4.6", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", diff --git a/src/main/index.ts b/src/main/index.ts index bb4f280..0da2afa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { setupUpdateHandlers } from './modules/update'; import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window'; import { initWindowSizeManager } from './modules/window-size'; import { startMusicApi } from './server'; +import { initLxMusicHttp } from './modules/lxMusicHttp'; // 导入所有图标 const iconPath = join(__dirname, '../../resources'); @@ -57,6 +58,9 @@ function initialize(configStore: any) { // 启动音乐API startMusicApi(); + // 初始化落雪音乐 HTTP 请求处理 + initLxMusicHttp(); + // 加载歌词窗口 loadLyricWindow(ipcMain, mainWindow); diff --git a/src/main/modules/lxMusicHttp.ts b/src/main/modules/lxMusicHttp.ts new file mode 100644 index 0000000..2dd1842 --- /dev/null +++ b/src/main/modules/lxMusicHttp.ts @@ -0,0 +1,153 @@ +/** + * 落雪音乐 HTTP 请求处理(主进程) + * 绕过渲染进程的 CORS 限制 + */ + +import { ipcMain } from 'electron'; +import fetch, { type RequestInit } from 'node-fetch'; + +interface LxHttpRequest { + url: string; + options: { + method?: string; + headers?: Record; + body?: string; + form?: Record; + formData?: Record; + timeout?: number; + }; + requestId: string; +} + +interface LxHttpResponse { + statusCode: number; + headers: Record; + body: any; +} + +// 取消控制器映射 +const abortControllers = new Map(); + +/** + * 初始化 HTTP 请求处理 + */ +export const initLxMusicHttp = () => { + // 处理 HTTP 请求 + ipcMain.handle( + 'lx-music-http-request', + async (_, request: LxHttpRequest): Promise => { + const { url, options, requestId } = request; + const controller = new AbortController(); + + // 保存取消控制器 + abortControllers.set(requestId, controller); + + try { + console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`); + + const fetchOptions: RequestInit = { + method: options.method || 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ...(options.headers || {}) + }, + signal: controller.signal + }; + + // 处理请求体 + if (options.body) { + fetchOptions.body = options.body; + } else if (options.form) { + const formData = new URLSearchParams(options.form); + fetchOptions.body = formData.toString(); + fetchOptions.headers = { + ...fetchOptions.headers, + 'Content-Type': 'application/x-www-form-urlencoded' + }; + } else if (options.formData) { + // node-fetch 的 FormData 需要特殊处理 + const FormData = (await import('form-data')).default; + const formData = new FormData(); + for (const [key, value] of Object.entries(options.formData)) { + formData.append(key, value); + } + fetchOptions.body = formData as any; + // FormData 会自动设置 Content-Type + } + + // 设置超时 + const timeout = options.timeout || 30000; + const timeoutId = setTimeout(() => { + console.warn(`[LxMusicHttp] 请求超时: ${url}`); + controller.abort(); + }, timeout); + + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`); + + // 读取响应体 + const rawBody = await response.text(); + + // 尝试解析 JSON + let parsedBody: any = rawBody; + const contentType = response.headers.get('content-type') || ''; + if ( + contentType.includes('application/json') || + rawBody.startsWith('{') || + rawBody.startsWith('[') + ) { + try { + parsedBody = JSON.parse(rawBody); + } catch { + // 解析失败则使用原始字符串 + } + } + + // 转换 headers 为普通对象 + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + const result: LxHttpResponse = { + statusCode: response.status, + headers, + body: parsedBody + }; + + return result; + } catch (error: any) { + console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message); + throw error; + } finally { + // 清理取消控制器 + abortControllers.delete(requestId); + } + } + ); + + // 处理请求取消 + ipcMain.handle('lx-music-http-cancel', (_, requestId: string) => { + const controller = abortControllers.get(requestId); + if (controller) { + console.log(`[LxMusicHttp] 取消请求: ${requestId}`); + controller.abort(); + abortControllers.delete(requestId); + } + }); + + console.log('[LxMusicHttp] HTTP 请求处理已初始化'); +}; + +/** + * 清理所有正在进行的请求 + */ +export const cleanupLxMusicHttp = () => { + for (const [requestId, controller] of abortControllers.entries()) { + console.log(`[LxMusicHttp] 清理请求: ${requestId}`); + controller.abort(); + } + abortControllers.clear(); +}; diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 2dfcea9..95e0592 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -25,6 +25,12 @@ interface API { importLxMusicScript: () => Promise<{ name: string; content: string } | null>; invoke: (channel: string, ...args: any[]) => Promise; getSearchSuggestions: (keyword: string) => Promise; + lxMusicHttpRequest: (request: { + url: string; + options: any; + requestId: string; + }) => Promise; + lxMusicHttpCancel: (requestId: string) => Promise; } // 自定义IPC渲染进程通信接口 diff --git a/src/preload/index.ts b/src/preload/index.ts index 8691734..ef27906 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -58,7 +58,16 @@ const api = { return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`)); }, // 搜索建议 - getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword) + getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword), + + // 落雪音乐 HTTP 请求(绕过 CORS) + lxMusicHttpRequest: (request: { + url: string; + options: any; + requestId: string; + }) => ipcRenderer.invoke('lx-music-http-request', request), + + lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId) }; // 创建带类型的ipcRenderer对象,暴露给渲染进程 diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 811bdeb..58b7fbc 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -27,6 +27,7 @@ import { checkLoginStatus } from '@/utils/auth'; import { initAudioListeners, initMusicHook } from './hooks/MusicHook'; import { audioService } from './services/audioService'; +import { initLxMusicRunner } from './services/LxMusicSourceRunner'; import { isMobile } from './utils'; import { useAppShortcuts } from './utils/appShortcuts'; @@ -125,6 +126,21 @@ onMounted(async () => { // 初始化播放状态 await playerStore.initializePlayState(); + // 初始化落雪音源(如果有激活的音源) + const activeLxApiId = settingsStore.setData?.activeLxMusicApiId; + if (activeLxApiId) { + const lxMusicScripts = settingsStore.setData?.lxMusicScripts || []; + const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId); + if (activeScript && activeScript.script) { + try { + console.log('[App] 初始化激活的落雪音源:', activeScript.name); + await initLxMusicRunner(activeScript.script); + } catch (error) { + console.error('[App] 初始化落雪音源失败:', error); + } + } + } + // 如果有正在播放的音乐,则初始化音频监听器 if (playerStore.playMusic && playerStore.playMusic.id) { // 使用 nextTick 确保 DOM 更新后再初始化 diff --git a/src/renderer/api/lxMusicStrategy.ts b/src/renderer/api/lxMusicStrategy.ts index f663f4b..afed2e3 100644 --- a/src/renderer/api/lxMusicStrategy.ts +++ b/src/renderer/api/lxMusicStrategy.ts @@ -13,6 +13,74 @@ import type { SongResult } from '@/types/music'; import type { MusicParseResult } from './musicParser'; import { CacheManager } from './musicParser'; +/** + * 解析可能是 API 端点的 URL,获取真实音频 URL + * 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL + */ +const resolveAudioUrl = async (url: string): Promise => { + try { + // 检查是否看起来像 API 端点(包含 /api/ 且有查询参数) + const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url')); + + if (!isApiEndpoint) { + // 看起来像直接的音频 URL,直接返回 + return url; + } + + console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url); + + // 尝试获取真实 URL + const response = await fetch(url, { + method: 'HEAD', + redirect: 'manual' // 不自动跟随重定向 + }); + + // 检查是否是重定向 + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('Location'); + if (location) { + console.log('[LxMusicStrategy] API 返回重定向 URL:', location); + return location; + } + } + + // 如果 HEAD 请求没有重定向,尝试 GET 请求 + const getResponse = await fetch(url, { + redirect: 'follow' + }); + + // 检查 Content-Type + const contentType = getResponse.headers.get('Content-Type') || ''; + + // 如果是音频类型,返回最终 URL + if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) { + console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url); + return getResponse.url; + } + + // 如果是 JSON,尝试解析 + if (contentType.includes('application/json') || contentType.includes('text/json')) { + const json = await getResponse.json(); + console.log('[LxMusicStrategy] API 返回 JSON:', json); + + // 尝试从 JSON 中提取 URL(常见字段) + const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src; + if (audioUrl && typeof audioUrl === 'string') { + console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl); + return audioUrl; + } + } + + // 如果都不是,返回原始 URL(可能直接可用) + console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL'); + return url; + } catch (error) { + console.error('[LxMusicStrategy] URL 解析失败:', error); + // 解析失败时返回原始 URL + return url; + } +}; + /** * 将 SongResult 转换为 LxMusicInfo 格式 */ @@ -82,9 +150,17 @@ export class LxMusicStrategy { return false; } - // 检查是否导入了脚本 - const script = settingsStore?.setData?.lxMusicScript; - return Boolean(script); + // 检查是否有激活的音源 + const activeLxApiId = settingsStore?.setData?.activeLxMusicApiId; + if (!activeLxApiId) { + return false; + } + + // 检查音源列表中是否存在该 ID + const lxMusicScripts = settingsStore?.setData?.lxMusicScripts || []; + const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId); + + return Boolean(activeScript && activeScript.script); } /** @@ -103,18 +179,30 @@ export class LxMusicStrategy { try { const settingsStore = useSettingsStore(); - const script = settingsStore.setData?.lxMusicScript; - if (!script) { - console.log('[LxMusicStrategy] 未导入落雪音源脚本'); + // 获取激活的音源 ID + const activeLxApiId = settingsStore.setData?.activeLxMusicApiId; + if (!activeLxApiId) { + console.log('[LxMusicStrategy] 未选择激活的落雪音源'); return null; } + // 从音源列表中获取激活的脚本 + const lxMusicScripts = settingsStore.setData?.lxMusicScripts || []; + const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId); + + if (!activeScript || !activeScript.script) { + console.log('[LxMusicStrategy] 未找到激活的落雪音源脚本'); + return null; + } + + console.log(`[LxMusicStrategy] 使用激活的音源: ${activeScript.name} (ID: ${activeScript.id})`); + // 获取或初始化执行器 let runner = getLxMusicRunner(); if (!runner || !runner.isInitialized()) { console.log('[LxMusicStrategy] 初始化落雪音源执行器...'); - runner = await initLxMusicRunner(script); + runner = await initLxMusicRunner(activeScript.script); } // 获取可用音源 @@ -144,22 +232,33 @@ export class LxMusicStrategy { const lxQuality: LxQuality = QUALITY_TO_LX[quality || 'higher'] || '320k'; // 获取音乐 URL - const url = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality); + const rawUrl = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality); - if (!url) { + if (!rawUrl) { console.log('[LxMusicStrategy] 获取 URL 失败'); CacheManager.addFailedCache(id, this.name); return null; } - console.log('[LxMusicStrategy] 解析成功:', url.substring(0, 50) + '...'); + console.log('[LxMusicStrategy] 脚本返回 URL:', rawUrl.substring(0, 80) + '...'); + + // 解析可能是 API 端点的 URL + const resolvedUrl = await resolveAudioUrl(rawUrl); + + if (!resolvedUrl) { + console.log('[LxMusicStrategy] URL 解析失败'); + CacheManager.addFailedCache(id, this.name); + return null; + } + + console.log('[LxMusicStrategy] 最终音频 URL:', resolvedUrl.substring(0, 80) + '...'); return { data: { code: 200, message: 'success', data: { - url, + url: resolvedUrl, source: `lx-${bestSource}`, quality: lxQuality } diff --git a/src/renderer/components/settings/MusicSourceSettings.vue b/src/renderer/components/settings/MusicSourceSettings.vue index 1c4a8a6..d9d937d 100644 --- a/src/renderer/components/settings/MusicSourceSettings.vue +++ b/src/renderer/components/settings/MusicSourceSettings.vue @@ -55,7 +55,7 @@ class="source-card source-card--lxmusic" :class="{ 'source-card--selected': isSourceSelected('lxMusic'), - 'source-card--disabled': !settingsStore.setData.lxMusicScript + 'source-card--disabled': !activeLxApiId || lxMusicApis.length === 0 }" style="--source-color: #10b981" @click="toggleSource('lxMusic')" @@ -70,9 +70,9 @@

{{ - settingsStore.setData.lxMusicScript - ? lxMusicScriptInfo?.name || '已导入' - : '未导入 (请去落雪音源Tab配置)' + activeLxApiId && lxMusicScriptInfo + ? lxMusicScriptInfo.name + : '未配置 (请去落雪音源Tab配置)' }}

@@ -321,8 +321,19 @@ const activeLxApiId = computed({ // 落雪音源脚本信息(保持向后兼容) const lxMusicScriptInfo = computed(() => { const activeId = activeLxApiId.value; - if (!activeId) return null; + if (!activeId) { + console.log('[lxMusicScriptInfo] 没有激活的音源 ID'); + return null; + } + const activeApi = lxMusicApis.value.find((api) => api.id === activeId); + console.log('[lxMusicScriptInfo] 查找激活的音源:', { + activeId, + found: !!activeApi, + name: activeApi?.name, + infoName: activeApi?.info?.name + }); + return activeApi?.info || null; }); @@ -351,10 +362,16 @@ const toggleSource = (sourceKey: string) => { return; } - // 检查是否是落雪音源且未导入 - if (sourceKey === 'lxMusic' && !settingsStore.setData.lxMusicScript) { - message.warning('请先导入落雪音源脚本'); - return; + // 检查是否是落雪音源且未配置 + if (sourceKey === 'lxMusic') { + if (lxMusicApis.value.length === 0) { + message.warning('请先导入落雪音源脚本'); + return; + } + if (!activeLxApiId.value) { + message.warning('请先选择一个落雪音源'); + return; + } } const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform); @@ -411,6 +428,7 @@ const importLxMusicScript = async () => { const addLxMusicScript = async (scriptContent: string) => { // 解析脚本信息 const scriptInfo = parseScriptInfo(scriptContent); + console.log('[MusicSourceSettings] 解析到的脚本信息:', scriptInfo); // 尝试初始化执行器以验证脚本 try { @@ -418,6 +436,8 @@ const addLxMusicScript = async (scriptContent: string) => { const sources = runner.getSources(); const sourceKeys = Object.keys(sources) as LxSourceKey[]; + console.log('[MusicSourceSettings] 脚本支持的音源:', sourceKeys); + // 生成唯一 ID const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -432,6 +452,13 @@ const addLxMusicScript = async (scriptContent: string) => { createdAt: Date.now() }; + console.log('[MusicSourceSettings] 创建的音源配置:', { + id: newApiConfig.id, + name: newApiConfig.name, + infoName: newApiConfig.info.name, + sources: newApiConfig.sources + }); + // 添加到列表 const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig]; @@ -440,6 +467,12 @@ const addLxMusicScript = async (scriptContent: string) => { activeLxMusicApiId: id // 自动激活新添加的音源 }); + console.log('[MusicSourceSettings] 保存后的 store 数据:', { + scriptsCount: scripts.length, + activeId: id, + firstScript: scripts[0] ? { id: scripts[0].id, name: scripts[0].name } : null + }); + message.success(`音源脚本导入成功:${scriptInfo.name},支持 ${sourceKeys.length} 个音源`); // 导入成功后自动勾选 @@ -447,7 +480,7 @@ const addLxMusicScript = async (scriptContent: string) => { selectedSources.value.push('lxMusic'); } } catch (initError: any) { - console.error('落雪音源脚本初始化失败:', initError); + console.error('[MusicSourceSettings] 落雪音源脚本初始化失败:', initError); message.error(`脚本初始化失败:${initError.message}`); } }; @@ -463,8 +496,19 @@ const setActiveLxApi = async (apiId: string) => { } try { - // 初始化选中的脚本 - await initLxMusicRunner(api.script); + console.log('[MusicSourceSettings] 切换音源:', { + id: api.id, + name: api.name, + version: api.info?.version, + sources: api.sources + }); + + // 清除旧的 runner + setLxMusicRunner(null); + + // 初始化新选中的脚本 + const runner = await initLxMusicRunner(api.script); + console.log('[MusicSourceSettings] 音源初始化成功,支持的音源:', runner.getSources()); // 更新激活的音源 ID activeLxApiId.value = apiId; @@ -476,7 +520,7 @@ const setActiveLxApi = async (apiId: string) => { message.success(`已切换到音源: ${api.name}`); } catch (error: any) { - console.error('切换落雪音源失败:', error); + console.error('[MusicSourceSettings] 切换落雪音源失败:', error); message.error(`切换失败:${error.message}`); } }; @@ -641,6 +685,21 @@ watch( } ); +// 监听落雪音源列表变化 +watch( + () => [lxMusicApis.value.length, activeLxApiId.value], + ([apiCount, activeId]) => { + // 如果没有音源或没有激活的音源,自动从已选音源中移除 lxMusic + if (apiCount === 0 || !activeId) { + const index = selectedSources.value.indexOf('lxMusic'); + if (index > -1) { + selectedSources.value.splice(index, 1); + } + } + }, + { deep: true } +); + // 同步外部show属性变化 watch( () => props.show, diff --git a/src/renderer/services/LxMusicSourceRunner.ts b/src/renderer/services/LxMusicSourceRunner.ts index d868727..f1a5f10 100644 --- a/src/renderer/services/LxMusicSourceRunner.ts +++ b/src/renderer/services/LxMusicSourceRunner.ts @@ -17,6 +17,7 @@ import type { LxSourceConfig, LxSourceKey } from '@/types/lxMusic'; +import * as lxCrypto from '@/utils/lxCrypto'; /** * 解析脚本头部注释中的元信息 @@ -27,27 +28,46 @@ export const parseScriptInfo = (script: string): LxScriptInfo => { rawScript: script }; - // 匹配头部注释块 - const headerMatch = script.match(/^\/\*\*[\s\S]*?\*\//); - if (!headerMatch) return info; + // 尝试匹配不同格式的头部注释块 + // 支持 /** ... */ 和 /* ... */ 格式 + const headerMatch = script.match(/^\/\*+[\s\S]*?\*\//); + if (!headerMatch) { + console.warn('[parseScriptInfo] 未找到脚本头部注释块'); + return info; + } const header = headerMatch[0]; + console.log('[parseScriptInfo] 解析脚本头部:', header.substring(0, 200)); - // 解析各个字段 + // 解析各个字段(支持 * 前缀和无前缀两种格式) const nameMatch = header.match(/@name\s+(.+?)(?:\r?\n|\*\/)/); - if (nameMatch) info.name = nameMatch[1].trim(); + if (nameMatch) { + info.name = nameMatch[1].trim().replace(/^\*\s*/, ''); + console.log('[parseScriptInfo] 解析到名称:', info.name); + } else { + console.warn('[parseScriptInfo] 未找到 @name 标签'); + } const descMatch = header.match(/@description\s+(.+?)(?:\r?\n|\*\/)/); - if (descMatch) info.description = descMatch[1].trim(); + if (descMatch) { + info.description = descMatch[1].trim().replace(/^\*\s*/, ''); + } const versionMatch = header.match(/@version\s+(.+?)(?:\r?\n|\*\/)/); - if (versionMatch) info.version = versionMatch[1].trim(); + if (versionMatch) { + info.version = versionMatch[1].trim().replace(/^\*\s*/, ''); + console.log('[parseScriptInfo] 解析到版本:', info.version); + } const authorMatch = header.match(/@author\s+(.+?)(?:\r?\n|\*\/)/); - if (authorMatch) info.author = authorMatch[1].trim(); + if (authorMatch) { + info.author = authorMatch[1].trim().replace(/^\*\s*/, ''); + } const homepageMatch = header.match(/@homepage\s+(.+?)(?:\r?\n|\*\/)/); - if (homepageMatch) info.homepage = homepageMatch[1].trim(); + if (homepageMatch) { + info.homepage = homepageMatch[1].trim().replace(/^\*\s*/, ''); + } return info; }; @@ -209,26 +229,16 @@ export class LxMusicSourceRunner { } }, crypto: { - md5: (str: string) => { - // 简化的 MD5 实现,实际使用可能需要引入完整库 - console.warn('[LxMusicRunner] MD5 暂未完整实现'); - return str; - }, - randomBytes: (size: number) => { - const array = new Uint8Array(size); - crypto.getRandomValues(array); - return Array.from(array) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - }, - aesEncrypt: (buffer: any, _mode: string, _key: any, _iv: any) => { - console.warn('[LxMusicRunner] AES 加密暂未实现'); - return buffer; - }, - rsaEncrypt: (buffer: any, _key: string) => { - console.warn('[LxMusicRunner] RSA 加密暂未实现'); - return buffer; - } + md5: lxCrypto.md5, + sha1: lxCrypto.sha1, + sha256: lxCrypto.sha256, + randomBytes: lxCrypto.randomBytes, + aesEncrypt: lxCrypto.aesEncrypt, + aesDecrypt: lxCrypto.aesDecrypt, + rsaEncrypt: lxCrypto.rsaEncrypt, + rsaDecrypt: lxCrypto.rsaDecrypt, + base64Encode: lxCrypto.base64Encode, + base64Decode: lxCrypto.base64Decode }, zlib: { inflate: async (buffer: ArrayBuffer) => { @@ -337,96 +347,135 @@ export class LxMusicSourceRunner { } /** - * 处理 HTTP 请求 + * 处理 HTTP 请求(优先使用主进程,绕过 CORS 限制) */ private handleHttpRequest( url: string, options: any, callback: (err: Error | null, resp: any, body: any) => void ): () => void { - const controller = new AbortController(); - console.log(`[LxMusicRunner] HTTP 请求: ${options.method || 'GET'} ${url}`); - const fetchOptions: RequestInit = { - method: options.method || 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - ...options.headers - }, - signal: controller.signal, - // 使用 cors 模式尝试跨域请求 - mode: 'cors', - // 不发送凭据 - credentials: 'omit' - }; - - if (options.body) { - fetchOptions.body = options.body; - } else if (options.form) { - fetchOptions.body = new URLSearchParams(options.form); - fetchOptions.headers = { - ...fetchOptions.headers, - 'Content-Type': 'application/x-www-form-urlencoded' - }; - } else if (options.formData) { - const formData = new FormData(); - for (const [key, value] of Object.entries(options.formData)) { - formData.append(key, value as string); - } - fetchOptions.body = formData; - } - const timeout = options.timeout || 30000; - const timeoutId = setTimeout(() => { - console.warn(`[LxMusicRunner] HTTP 请求超时: ${url}`); - controller.abort(); - }, timeout); + const requestId = `lx_http_${Date.now()}_${Math.random().toString(36).substring(7)}`; - fetch(url, fetchOptions) - .then(async (response) => { - clearTimeout(timeoutId); - console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`); + // 尝试使用主进程 HTTP 请求(如果可用) + const hasMainProcessHttp = typeof window.api?.lxMusicHttpRequest === 'function'; - const rawBody = await response.text(); + if (hasMainProcessHttp) { + // 使用主进程 HTTP 请求(绕过 CORS) + console.log(`[LxMusicRunner] 使用主进程 HTTP 请求`); - // 尝试解析 JSON,如果是 JSON 则返回解析后的对象 - let parsedBody: any = rawBody; - const contentType = response.headers.get('content-type') || ''; - if ( - contentType.includes('application/json') || - rawBody.startsWith('{') || - rawBody.startsWith('[') - ) { - try { - parsedBody = JSON.parse(rawBody); - // 如果响应中包含 URL,缓存下来以备后用 - if (parsedBody && parsedBody.url && typeof parsedBody.url === 'string') { - this.lastMusicUrl = parsedBody.url; - } - } catch { - // 解析失败则使用原始字符串 - } - } - - callback( - null, - { - statusCode: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: parsedBody + window.api + .lxMusicHttpRequest({ + url, + options: { + ...options, + timeout }, - parsedBody - ); - }) - .catch((error) => { - clearTimeout(timeoutId); - console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message); - callback(error, null, null); - }); + requestId + }) + .then((response: any) => { + console.log(`[LxMusicRunner] HTTP 响应: ${response.statusCode} ${url}`); - // 返回取消函数 - return () => controller.abort(); + // 如果响应中包含 URL,缓存下来以备后用 + if (response.body && response.body.url && typeof response.body.url === 'string') { + this.lastMusicUrl = response.body.url; + } + + callback(null, response, response.body); + }) + .catch((error: Error) => { + console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message); + callback(error, null, null); + }); + + // 返回取消函数 + return () => { + void window.api?.lxMusicHttpCancel?.(requestId); + }; + } else { + // 回退到渲染进程 fetch(可能受 CORS 限制) + console.log(`[LxMusicRunner] 主进程 HTTP 不可用,使用渲染进程 fetch`); + + const controller = new AbortController(); + + const fetchOptions: RequestInit = { + method: options.method || 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ...options.headers + }, + signal: controller.signal, + mode: 'cors', + credentials: 'omit' + }; + + if (options.body) { + fetchOptions.body = options.body; + } else if (options.form) { + fetchOptions.body = new URLSearchParams(options.form); + fetchOptions.headers = { + ...fetchOptions.headers, + 'Content-Type': 'application/x-www-form-urlencoded' + }; + } else if (options.formData) { + const formData = new FormData(); + for (const [key, value] of Object.entries(options.formData)) { + formData.append(key, value as string); + } + fetchOptions.body = formData; + } + + const timeoutId = setTimeout(() => { + console.warn(`[LxMusicRunner] HTTP 请求超时: ${url}`); + controller.abort(); + }, timeout); + + fetch(url, fetchOptions) + .then(async (response) => { + clearTimeout(timeoutId); + console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`); + + const rawBody = await response.text(); + + // 尝试解析 JSON + let parsedBody: any = rawBody; + const contentType = response.headers.get('content-type') || ''; + if ( + contentType.includes('application/json') || + rawBody.startsWith('{') || + rawBody.startsWith('[') + ) { + try { + parsedBody = JSON.parse(rawBody); + if (parsedBody && parsedBody.url && typeof parsedBody.url === 'string') { + this.lastMusicUrl = parsedBody.url; + } + } catch { + // 解析失败则使用原始字符串 + } + } + + callback( + null, + { + statusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: parsedBody + }, + parsedBody + ); + }) + .catch((error) => { + clearTimeout(timeoutId); + console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message); + callback(error, null, null); + }); + + // 返回取消函数 + return () => controller.abort(); + } } /** diff --git a/src/renderer/utils/lxCrypto.ts b/src/renderer/utils/lxCrypto.ts new file mode 100644 index 0000000..750a31d --- /dev/null +++ b/src/renderer/utils/lxCrypto.ts @@ -0,0 +1,284 @@ +/** + * 落雪音乐加密工具 + * 实现 lx.utils.crypto API + * + * 提供 MD5、AES、RSA 等加密功能 + */ + +import CryptoJS from 'crypto-js'; +import { JSEncrypt } from 'jsencrypt'; + +/** + * MD5 哈希 + */ +export const md5 = (str: string): string => { + return CryptoJS.MD5(str).toString(); +}; + +/** + * 生成随机字节(返回16进制字符串) + */ +export const randomBytes = (size: number): string => { + const array = new Uint8Array(size); + crypto.getRandomValues(array); + return Array.from(array) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +}; + +/** + * AES 加密 + * + * @param buffer - 要加密的数据(字符串或 Buffer) + * @param mode - 加密模式(如 'cbc') + * @param key - 密钥(字符串或 WordArray) + * @param iv - 初始化向量(字符串或 WordArray) + * @returns 加密后的 Buffer(Uint8Array) + */ +export const aesEncrypt = ( + buffer: string | Uint8Array, + mode: string, + key: string | CryptoJS.lib.WordArray, + iv: string | CryptoJS.lib.WordArray +): Uint8Array => { + try { + // 将输入转换为 WordArray + let wordArray: CryptoJS.lib.WordArray; + if (typeof buffer === 'string') { + wordArray = CryptoJS.enc.Utf8.parse(buffer); + } else { + // Uint8Array 转 WordArray + const words: number[] = []; + for (let i = 0; i < buffer.length; i += 4) { + words.push( + ((buffer[i] || 0) << 24) | + ((buffer[i + 1] || 0) << 16) | + ((buffer[i + 2] || 0) << 8) | + (buffer[i + 3] || 0) + ); + } + wordArray = CryptoJS.lib.WordArray.create(words, buffer.length); + } + + // 处理密钥和 IV + const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key; + const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv; + + // 根据模式选择加密方式 + const modeObj = getModeFromString(mode); + + // 执行加密 + const encrypted = CryptoJS.AES.encrypt(wordArray, keyWordArray, { + iv: ivWordArray, + mode: modeObj, + padding: CryptoJS.pad.Pkcs7 + }); + + // 将结果转换为 Uint8Array + const ciphertext = encrypted.ciphertext; + const result = new Uint8Array(ciphertext.words.length * 4); + for (let i = 0; i < ciphertext.words.length; i++) { + const word = ciphertext.words[i]; + result[i * 4] = (word >>> 24) & 0xff; + result[i * 4 + 1] = (word >>> 16) & 0xff; + result[i * 4 + 2] = (word >>> 8) & 0xff; + result[i * 4 + 3] = word & 0xff; + } + + return result.slice(0, ciphertext.sigBytes); + } catch (error) { + console.error('[lxCrypto] AES 加密失败:', error); + throw error; + } +}; + +/** + * AES 解密 + */ +export const aesDecrypt = ( + buffer: Uint8Array, + mode: string, + key: string | CryptoJS.lib.WordArray, + iv: string | CryptoJS.lib.WordArray +): Uint8Array => { + try { + // Uint8Array 转 WordArray + const words: number[] = []; + for (let i = 0; i < buffer.length; i += 4) { + words.push( + ((buffer[i] || 0) << 24) | + ((buffer[i + 1] || 0) << 16) | + ((buffer[i + 2] || 0) << 8) | + (buffer[i + 3] || 0) + ); + } + const ciphertext = CryptoJS.lib.WordArray.create(words, buffer.length); + + // 处理密钥和 IV + const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key; + const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv; + + // 根据模式选择解密方式 + const modeObj = getModeFromString(mode); + + // 构造加密对象 + const cipherParams = CryptoJS.lib.CipherParams.create({ + ciphertext + }); + + // 执行解密 + const decrypted = CryptoJS.AES.decrypt(cipherParams, keyWordArray, { + iv: ivWordArray, + mode: modeObj, + padding: CryptoJS.pad.Pkcs7 + }); + + // 转换为 Uint8Array + const result = new Uint8Array(decrypted.words.length * 4); + for (let i = 0; i < decrypted.words.length; i++) { + const word = decrypted.words[i]; + result[i * 4] = (word >>> 24) & 0xff; + result[i * 4 + 1] = (word >>> 16) & 0xff; + result[i * 4 + 2] = (word >>> 8) & 0xff; + result[i * 4 + 3] = word & 0xff; + } + + return result.slice(0, decrypted.sigBytes); + } catch (error) { + console.error('[lxCrypto] AES 解密失败:', error); + throw error; + } +}; + +/** + * RSA 加密 + * + * @param buffer - 要加密的数据 + * @param publicKey - RSA 公钥(PEM 格式) + * @returns 加密后的数据(Uint8Array) + */ +export const rsaEncrypt = (buffer: string | Uint8Array, publicKey: string): Uint8Array => { + try { + const encrypt = new JSEncrypt(); + encrypt.setPublicKey(publicKey); + + // 转换输入为字符串 + let input: string; + if (typeof buffer === 'string') { + input = buffer; + } else { + // Uint8Array 转字符串 + input = new TextDecoder().decode(buffer); + } + + // 执行加密(返回 base64) + const encrypted = encrypt.encrypt(input); + if (!encrypted) { + throw new Error('RSA encryption failed'); + } + + // Base64 解码为 Uint8Array + const binaryString = atob(encrypted); + const result = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + result[i] = binaryString.charCodeAt(i); + } + + return result; + } catch (error) { + console.error('[lxCrypto] RSA 加密失败:', error); + throw error; + } +}; + +/** + * RSA 解密 + */ +export const rsaDecrypt = (buffer: Uint8Array, privateKey: string): Uint8Array => { + try { + const decrypt = new JSEncrypt(); + decrypt.setPrivateKey(privateKey); + + // Uint8Array 转 Base64 + let binaryString = ''; + for (let i = 0; i < buffer.length; i++) { + binaryString += String.fromCharCode(buffer[i]); + } + const base64 = btoa(binaryString); + + // 执行解密 + const decrypted = decrypt.decrypt(base64); + if (!decrypted) { + throw new Error('RSA decryption failed'); + } + + // 字符串转 Uint8Array + return new TextEncoder().encode(decrypted); + } catch (error) { + console.error('[lxCrypto] RSA 解密失败:', error); + throw error; + } +}; + +/** + * 从字符串获取加密模式 + */ +const getModeFromString = (mode: string): CryptoJS.lib.Mode => { + const modeStr = mode.toLowerCase(); + switch (modeStr) { + case 'cbc': + return CryptoJS.mode.CBC; + case 'cfb': + return CryptoJS.mode.CFB; + case 'ctr': + return CryptoJS.mode.CTR; + case 'ofb': + return CryptoJS.mode.OFB; + case 'ecb': + return CryptoJS.mode.ECB; + default: + console.warn(`[lxCrypto] 未知的加密模式: ${mode}, 使用 CBC`); + return CryptoJS.mode.CBC; + } +}; + +/** + * SHA1 哈希 + */ +export const sha1 = (str: string): string => { + return CryptoJS.SHA1(str).toString(); +}; + +/** + * SHA256 哈希 + */ +export const sha256 = (str: string): string => { + return CryptoJS.SHA256(str).toString(); +}; + +/** + * Base64 编码 + */ +export const base64Encode = (data: string | Uint8Array): string => { + if (typeof data === 'string') { + return btoa(data); + } else { + let binary = ''; + for (let i = 0; i < data.length; i++) { + binary += String.fromCharCode(data[i]); + } + return btoa(binary); + } +}; + +/** + * Base64 解码 + */ +export const base64Decode = (str: string): Uint8Array => { + const binaryString = atob(str); + const result = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + result[i] = binaryString.charCodeAt(i); + } + return result; +};