From b6e0fe0546d47c2b4c127b31a2956572535a8b6c Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 5 Jul 2026 17:51:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(lx):=20=E4=BF=AE=E5=A4=8D=E8=90=BD=E9=9B=AA?= =?UTF-8?q?=E9=9F=B3=E6=BA=90=E8=84=9A=E6=9C=AC=E5=AF=BC=E5=85=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8F=8A=20API=20=E7=AB=AF=E7=82=B9=E6=AD=BB=E9=93=BE?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E8=BF=9E=E7=8E=AF=E8=B7=B3=E6=AD=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/api/lxMusicStrategy.ts | 104 +++++++------- src/renderer/services/LxMusicSourceRunner.ts | 2 +- .../workers/lxScriptSandbox.worker.ts | 133 +++++++++++++++--- src/renderer/utils/lxCrypto.ts | 46 ++++-- 4 files changed, 204 insertions(+), 81 deletions(-) diff --git a/src/renderer/api/lxMusicStrategy.ts b/src/renderer/api/lxMusicStrategy.ts index b821ebb..8074a20 100644 --- a/src/renderer/api/lxMusicStrategy.ts +++ b/src/renderer/api/lxMusicStrategy.ts @@ -16,68 +16,72 @@ import { CacheManager } from './musicParser'; /** * 解析可能是 API 端点的 URL,获取真实音频 URL * 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL + * + * 通过主进程请求验证(绕过渲染进程 CORS)。端点确认失效时返回 null, + * 让解析策略正确失败并进入失败缓存,而不是把不可播放的 URL 当成功缓存 + * (否则会导致:坏 URL 进缓存 → audio 元素 Format error → 连续跳歌, + * 且后续策略如 unblockMusic 永远没有机会接手) */ -const resolveAudioUrl = async (url: string): Promise => { - try { - // 检查是否看起来像 API 端点(包含 /api/ 且有查询参数) - const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url')); +const resolveAudioUrl = async (url: string): Promise => { + // 检查是否看起来像 API 端点(包含 /api/ 且有查询参数) + const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url')); - if (!isApiEndpoint) { - // 看起来像直接的音频 URL,直接返回 + if (!isApiEndpoint) { + // 看起来像直接的音频 URL,直接返回 + return url; + } + + console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url); + + // 非 Electron 环境无法绕过 CORS 验证,保持乐观返回 + if (typeof window.api?.lxMusicHttpRequest !== 'function') { + return url; + } + + try { + const requestId = `lx_resolve_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const response = await window.api.lxMusicHttpRequest({ + url, + options: { + method: 'GET', + // 端点若直接返回音频流,用 Range 避免整段下载;返回 JSON 时 8KB 也足够 + headers: { Range: 'bytes=0-8191' }, + timeout: 15000 + }, + requestId + }); + + const status = response?.statusCode ?? 0; + const contentType = String(response?.headers?.['content-type'] || ''); + + if (status < 200 || status >= 400) { + console.warn(`[LxMusicStrategy] API 端点返回 ${status},判定解析失败`); + return null; + } + + // 端点直接返回音频流(或重定向到音频,主进程已自动跟随), + // audio 元素可以直接播放原始 URL + if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) { + console.log('[LxMusicStrategy] API 端点为音频流,直接使用原始 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; + // JSON 响应:尝试提取常见字段中的音频 URL + const body = response?.body; + if (body && typeof body === 'object') { + const audioUrl = body.url || body.data?.url || body.audio_url || body.link || body.src; if (audioUrl && typeof audioUrl === 'string') { console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl); return audioUrl; } } - // 如果都不是,返回原始 URL(可能直接可用) - console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL'); - return url; + // 2xx 但既不是音频也提取不到 URL(如 HTML 错误页),视为不可播放 + console.warn('[LxMusicStrategy] API 端点响应无法解析为音频,判定解析失败'); + return null; } catch (error) { - console.error('[LxMusicStrategy] URL 解析失败:', error); - // 解析失败时返回原始 URL - return url; + console.error('[LxMusicStrategy] URL 解析请求失败:', error); + return null; } }; diff --git a/src/renderer/services/LxMusicSourceRunner.ts b/src/renderer/services/LxMusicSourceRunner.ts index ddb9b12..49eea1f 100644 --- a/src/renderer/services/LxMusicSourceRunner.ts +++ b/src/renderer/services/LxMusicSourceRunner.ts @@ -366,7 +366,7 @@ export class LxMusicSourceRunner { this.initResolver = resolve; this.initRejecter = reject; this.initTimeoutId = window.setTimeout(() => { - this.rejectInitialization(new Error('脚本初始化超时')); + this.rejectInitialization(new Error('脚本初始化超时(10 秒内未调用 lx.send(inited))')); }, 10000); }); diff --git a/src/renderer/services/workers/lxScriptSandbox.worker.ts b/src/renderer/services/workers/lxScriptSandbox.worker.ts index 036f946..42b80c3 100644 --- a/src/renderer/services/workers/lxScriptSandbox.worker.ts +++ b/src/renderer/services/workers/lxScriptSandbox.worker.ts @@ -67,7 +67,6 @@ type HostWorkerMessage = | HostLogMessage; let requestHandler: ((data: any) => Promise) | null = null; -let initialized = false; let requestCounter = 0; const pendingHttpCallbacks = new Map< @@ -87,6 +86,90 @@ const postLog = (level: HostLogMessage['level'], ...args: any[]) => { }); }; +/** + * Node Buffer 语义的最小实现。 + * 落雪脚本按 lx-music-desktop 的 Node 环境编写: + * buffer.from/bufToString 依赖 encoding 参数(base64/hex 等), + * 且会对返回的 Buffer 调用 .toString(encoding) + */ +class LxBuffer extends Uint8Array { + toString(encoding = 'utf-8'): string { + return bytesToString(this, encoding); + } +} + +const toLxBuffer = (bytes: Uint8Array): LxBuffer => + new LxBuffer(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength); + +const hexToBytes = (hex: string): Uint8Array => { + const clean = hex.length % 2 ? `0${hex}` : hex; + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) || 0; + } + return out; +}; + +const toBytes = (data: any, encoding = 'utf-8'): Uint8Array => { + if (typeof data === 'string') { + switch (encoding.toLowerCase()) { + case 'base64': + return lxCrypto.base64Decode(data); + case 'hex': + return hexToBytes(data); + case 'binary': + case 'latin1': { + const out = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + out[i] = data.charCodeAt(i) & 0xff; + } + return out; + } + default: + return new TextEncoder().encode(data); + } + } + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (Array.isArray(data)) return Uint8Array.from(data); + return new Uint8Array(0); +}; + +const bytesToString = (data: any, encoding = 'utf-8'): string => { + const bytes = toBytes(data); + switch (encoding.toLowerCase()) { + case 'base64': + return lxCrypto.base64Encode(bytes); + case 'hex': { + let out = ''; + for (const byte of bytes) out += byte.toString(16).padStart(2, '0'); + return out; + } + case 'binary': + case 'latin1': { + let out = ''; + for (const byte of bytes) out += String.fromCharCode(byte); + return out; + } + default: + return new TextDecoder(encoding).decode(bytes); + } +}; + +/** + * 落雪脚本参照 lx-music-desktop 环境编写(隐藏 BrowserWindow,window 存在)。 + * 混淆脚本常用 `window -> (process/require ? global : this)` 探测全局对象, + * 而 module worker 中三者全为 undefined,随后访问 `.console` 即抛 + * "Cannot read properties of undefined (reading 'console')",这里补齐别名。 + */ +const exposeNodeLikeGlobals = () => { + const g = globalThis as any; + if (!g.window) g.window = g; + if (!g.global) g.global = g; +}; + const hardenGlobalScope = () => { const blockedKeys: Array = [ 'fetch', @@ -130,7 +213,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => { }, send: (eventName: string, data: any) => { if (eventName === 'inited') { - initialized = true; postToHost({ type: 'initialized', data: data as LxInitedData @@ -158,27 +240,37 @@ const createLxApi = (scriptInfo: LxScriptInfo) => { }, utils: { buffer: { - from: (data: any, _encoding?: string) => { - if (typeof data === 'string') { - return new TextEncoder().encode(data); - } - return new Uint8Array(data); - }, - bufToString: (buffer: Uint8Array, encoding?: string) => { - return new TextDecoder(encoding || 'utf-8').decode(buffer); - } + from: (data: any, encoding?: string) => toLxBuffer(toBytes(data, encoding)), + bufToString: (buffer: any, encoding?: string) => bytesToString(buffer, encoding) }, crypto: { md5: lxCrypto.md5, sha1: lxCrypto.sha1, sha256: lxCrypto.sha256, - randomBytes: lxCrypto.randomBytes, - aesEncrypt: lxCrypto.aesEncrypt, - aesDecrypt: lxCrypto.aesDecrypt, - rsaEncrypt: lxCrypto.rsaEncrypt, - rsaDecrypt: lxCrypto.rsaDecrypt, + // lx-music 中 randomBytes 返回 Node Buffer,脚本会对其调用 .toString(encoding) + randomBytes: (size: number) => { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return toLxBuffer(bytes); + }, + aesEncrypt: ( + buffer: string | Uint8Array, + mode: string, + key: string | Uint8Array, + iv: string | Uint8Array + ) => toLxBuffer(lxCrypto.aesEncrypt(buffer, mode, key as any, iv as any)), + aesDecrypt: ( + buffer: Uint8Array, + mode: string, + key: string | Uint8Array, + iv: string | Uint8Array + ) => toLxBuffer(lxCrypto.aesDecrypt(buffer, mode, key as any, iv as any)), + rsaEncrypt: (buffer: string | Uint8Array, key: string) => + toLxBuffer(lxCrypto.rsaEncrypt(buffer, key)), + rsaDecrypt: (buffer: Uint8Array, key: string) => + toLxBuffer(lxCrypto.rsaDecrypt(buffer, key)), base64Encode: lxCrypto.base64Encode, - base64Decode: lxCrypto.base64Decode + base64Decode: (str: string) => toLxBuffer(lxCrypto.base64Decode(str)) }, zlib: { inflate: async (buffer: ArrayBuffer) => { @@ -240,7 +332,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => { const resetWorkerState = () => { requestHandler = null; - initialized = false; pendingHttpCallbacks.clear(); requestCounter = 0; }; @@ -248,6 +339,7 @@ const resetWorkerState = () => { const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => { resetWorkerState(); hardenGlobalScope(); + exposeNodeLikeGlobals(); (globalThis as any).lx = createLxApi(scriptInfo); @@ -265,9 +357,8 @@ const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => { try { await import(/* @vite-ignore */ scriptUrl); - if (!initialized) { - throw new Error('脚本未调用 lx.send(EVENT_NAMES.inited, data)'); - } + // 不在此处判定 initialized:脚本可能异步初始化(如先请求远端配置, + // 回调里才调用 lx.send(inited)),由 Runner 侧的初始化超时兜底 } finally { URL.revokeObjectURL(scriptUrl); } diff --git a/src/renderer/utils/lxCrypto.ts b/src/renderer/utils/lxCrypto.ts index 750a31d..dd3360b 100644 --- a/src/renderer/utils/lxCrypto.ts +++ b/src/renderer/utils/lxCrypto.ts @@ -15,6 +15,32 @@ export const md5 = (str: string): string => { return CryptoJS.MD5(str).toString(); }; +/** + * Uint8Array 转 WordArray + */ +const u8ToWordArray = (u8: Uint8Array): CryptoJS.lib.WordArray => { + const words: number[] = []; + for (let i = 0; i < u8.length; i += 4) { + words.push( + ((u8[i] || 0) << 24) | ((u8[i + 1] || 0) << 16) | ((u8[i + 2] || 0) << 8) | (u8[i + 3] || 0) + ); + } + return CryptoJS.lib.WordArray.create(words, u8.length); +}; + +/** + * key/iv 归一化:脚本可能传字符串、Buffer(Uint8Array)或 WordArray, + * ECB 模式下 iv 还可能是 null/undefined + */ +const toKeyWordArray = ( + input: string | Uint8Array | CryptoJS.lib.WordArray | null | undefined +): CryptoJS.lib.WordArray | undefined => { + if (input == null) return undefined; + if (typeof input === 'string') return CryptoJS.enc.Utf8.parse(input); + if (input instanceof Uint8Array) return u8ToWordArray(input); + return input; +}; + /** * 生成随机字节(返回16进制字符串) */ @@ -38,8 +64,8 @@ export const randomBytes = (size: number): string => { export const aesEncrypt = ( buffer: string | Uint8Array, mode: string, - key: string | CryptoJS.lib.WordArray, - iv: string | CryptoJS.lib.WordArray + key: string | Uint8Array | CryptoJS.lib.WordArray, + iv?: string | Uint8Array | CryptoJS.lib.WordArray | null ): Uint8Array => { try { // 将输入转换为 WordArray @@ -61,8 +87,8 @@ export const aesEncrypt = ( } // 处理密钥和 IV - const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key; - const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv; + const keyWordArray = toKeyWordArray(key)!; + const ivWordArray = toKeyWordArray(iv); // 根据模式选择加密方式 const modeObj = getModeFromString(mode); @@ -98,8 +124,8 @@ export const aesEncrypt = ( export const aesDecrypt = ( buffer: Uint8Array, mode: string, - key: string | CryptoJS.lib.WordArray, - iv: string | CryptoJS.lib.WordArray + key: string | Uint8Array | CryptoJS.lib.WordArray, + iv?: string | Uint8Array | CryptoJS.lib.WordArray | null ): Uint8Array => { try { // Uint8Array 转 WordArray @@ -115,8 +141,8 @@ export const aesDecrypt = ( 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 keyWordArray = toKeyWordArray(key)!; + const ivWordArray = toKeyWordArray(iv); // 根据模式选择解密方式 const modeObj = getModeFromString(mode); @@ -222,9 +248,11 @@ export const rsaDecrypt = (buffer: Uint8Array, privateKey: string): Uint8Array = /** * 从字符串获取加密模式 + * 兼容 Node 风格的完整算法名(如 'aes-128-ecb'、'aes-128-cbc'), + * 落雪脚本传入的 mode 与 Node crypto.createCipheriv 一致 */ const getModeFromString = (mode: string): CryptoJS.lib.Mode => { - const modeStr = mode.toLowerCase(); + const modeStr = mode.toLowerCase().split('-').pop() || mode.toLowerCase(); switch (modeStr) { case 'cbc': return CryptoJS.mode.CBC;