diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 01234e1..fb3625d 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -361,21 +361,18 @@ const importFromCode = () => { pluginCode.value = ''; }; -/** 导入插件代码(兼容 PC/Android 版 CommonJS module.exports 格式) */ +/** 导入插件代码(兼容 PC/Android 版 CommonJS / webpack bundle 格式) */ const importPluginCode = (code: string) => { - const module = pluginManager.loadFromCode(code, 'user'); - if (!module) { - alert('插件加载失败,请检查代码格式是否正确(需要 module.exports = {...})'); + const result = pluginManager.loadFromCode(code, undefined, 'user'); + if (!result.success) { + alert('插件加载失败:' + (result.error || '请检查代码格式是否正确(需要 module.exports = { pluginInfo, ... })')); return; } - - // 保存到 localStorage - pluginManager.saveUserPlugin(code); - + refreshPlugins(); - - const info = module.pluginInfo?.info || module.info; - alert(`音源 "${info?.name || '未知'}" 导入成功!`); + + const info = result.module?.pluginInfo?.info; + alert(`音源 "${info?.name || result.pluginId}" 导入成功!`); }; const applyTheme = (theme: 'dark' | 'light') => { diff --git a/src/plugins/init.ts b/src/plugins/init.ts index 3821693..9b3e477 100644 --- a/src/plugins/init.ts +++ b/src/plugins/init.ts @@ -3,82 +3,101 @@ import type { PluginModule } from '../types/plugin'; /** * 初始化插件系统 - * 1. 先注册本地默认演示插件 - * 2. 恢复用户保存的插件 - * 3. 尝试从 /plugins/ 目录动态加载官方音源插件(如果浏览器环境) + * 1) 先注册本地演示插件(离线可用,兜底搜索/播放) + * 2) 从 localStorage 恢复用户插件 + * 3) 从 /plugins/ 目录动态加载官方音源插件(只要部署时有放置) + * 返回加载结果,可用于在 UI 层显示提示 */ -export const initPlugins = async (): Promise<{ total: number; builtins: number; user: number }> => { +export interface InitPluginsResult { + total: number; + builtins: number; + user: number; + fromNet: number; + activePluginId: string; + failedBuiltins: string[]; +} + +const demoPluginModule: PluginModule = { + pluginInfo: { + info: { + id: 'demo', + name: '演示音源', + description: '本地演示插件(不依赖网络)', + version: '1.0.0', + }, + quality: [ + { id: 'standard', name: '标准', ui: '标' }, + ], + }, + musicSearch: { + search: async (query: string, page: number, limit: number) => { + const baseList = [ + { songmid: 'demo-1', name: '晴天 (Demo)', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: '', source: 'demo' }, + { songmid: 'demo-2', name: '稻香 (Demo)', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: '', source: 'demo' }, + { songmid: 'demo-3', name: '七里香 (Demo)', singer: '周杰伦', albumName: '七里香', interval: 299000, img: '', source: 'demo' }, + { songmid: 'demo-4', name: '告白气球 (Demo)', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: '', source: 'demo' }, + { songmid: 'demo-5', name: '夜曲 (Demo)', singer: '周杰伦', albumName: '十一月的萧邦', interval: 237000, img: '', source: 'demo' }, + { songmid: 'demo-6', name: '平凡之路 (Demo)', singer: '朴树', albumName: '猎户星座', interval: 286000, img: '', source: 'demo' }, + ]; + const q = (query || '').toLowerCase().trim(); + const filtered = q ? baseList.filter(s => s.name.toLowerCase().includes(q) || s.singer.toLowerCase().includes(q)) : baseList; + const start = Math.max(0, (page - 1) * limit); + return { + list: filtered.slice(start, start + limit), + total: filtered.length, + songCount: filtered.length, + source: 'demo', + }; + }, + }, + getUrl: async (_songId: string, _quality: string) => { + // 演示音源使用公共测试 MP3,仅用于界面演示 + return 'https://www.w3schools.com/html/horse.mp3'; + }, + getLyric: async (_songId: string) => { + return '[00:00.00]暂无歌词数据\n[00:03.00]演示音源提供占位歌词\n[00:06.00]请切换到官方音源获取真实歌词\n'; + }, +}; + +export const initPlugins = async (): Promise => { let builtins = 0; let user = 0; + let fromNet = 0; + let failedBuiltins: string[] = []; - // 默认演示插件(用于演示界面,不依赖网络) - const demoPlugin: PluginModule = { - pluginInfo: { - info: { id: 'demo', name: '演示音源', description: '本地演示插件', version: '1.0' }, - quality: [{ id: 'standard', name: '标准', ui: '标' }], - } as any, - musicSearch: { - search: async (query: string, page: number, limit: number) => { - const songs = [ - { songmid: 'demo-1', name: '晴天', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: '', source: 'demo' }, - { songmid: 'demo-2', name: '稻香', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: '', source: 'demo' }, - { songmid: 'demo-3', name: '七里香', singer: '周杰伦', albumName: '七里香', interval: 299000, img: '', source: 'demo' }, - { songmid: 'demo-4', name: '告白气球', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: '', source: 'demo' }, - ]; - const q = (query || '').toLowerCase(); - const filtered = q ? songs.filter(s => s.name.toLowerCase().includes(q) || s.singer.toLowerCase().includes(q)) : songs; - const start = Math.max(0, (page - 1) * limit); - return { list: filtered.slice(start, start + limit), total: filtered.length }; - }, - }, - getUrl: async () => '', - getLyric: async () => '[00:00.00]暂无歌词数据\n[00:05.00]演示内容\n', - }; - pluginManager.registerModule(demoPlugin); + // 1) 注册本地演示插件(始终可用,作为兜底) + pluginManager.registerModule(demoPluginModule, 'built-in', 'demo'); builtins++; - // 恢复用户插件 - const userLoaded = pluginManager.loadUserPlugins(); - user = userLoaded; + // 2) 恢复用户插件 + user = pluginManager.loadUserPlugins(); - // 尝试从 /plugins/ 目录动态加载官方音源插件 + // 3) 浏览器环境下尝试从 /plugins/ 目录加载官方音源 if (typeof window !== 'undefined' && typeof fetch === 'function') { - try { - const manifest = [ - { id: 'wy', fileName: 'zq_wy_v3.js', name: '网易云' }, - { id: 'tx', fileName: 'zq_tx_v3-fix1.js', name: 'QQ音乐' }, - { id: 'kw', fileName: 'zq_kw_v3-fix1.js', name: '酷我' }, - { id: 'kg', fileName: 'zq_kg.js', name: '酷狗' }, - { id: 'mg', fileName: 'zq_mg_v3.js', name: '咪咕' }, - ]; - for (const entry of manifest) { - try { - if (pluginManager.has(entry.id)) continue; - const resp = await fetch('/plugins/' + entry.fileName, { cache: 'no-cache' }); - if (!resp.ok) continue; - const code = await resp.text(); - pluginManager.loadFromCode(code, 'built-in'); - builtins++; - } catch {} - } - } catch {} + const res = await pluginManager.loadAllBuiltins(); + fromNet = res.loaded; + builtins += res.loaded; + failedBuiltins = res.failed; } - // 恢复上次的激活插件 - const saved = sessionStorage.getItem('qz-active-plugin'); - if (saved && pluginManager.get(saved)) { - pluginManager.setActivePlugin(saved); - } else if (pluginManager.getAll().length > 0) { - // 默认优先 wy,没有则选第一个 - const priority = ['wy', 'tx', 'kw', 'kg', 'mg', 'demo']; - const all = pluginManager.getAll(); - for (const pid of priority) { - if (all.find(p => p.id === pid)) { - pluginManager.setActivePlugin(pid); + // 4) 确保默认激活插件:优先 wy / 首个内置,再是用户的,最后落到 demo + const active = pluginManager.getActivePluginId(); + const priority = ['wy', 'tx', 'kw', 'kg', 'mg', 'demo']; + if (!active) { + for (const id of priority) { + if (pluginManager.has(id)) { + pluginManager.setActivePlugin(id); break; } } } - return { total: builtins + user, builtins, user }; + return { + total: builtins + user, + builtins, + user, + fromNet, + activePluginId: pluginManager.getActivePluginId(), + failedBuiltins, + }; }; diff --git a/src/plugins/nodePolyfills.ts b/src/plugins/nodePolyfills.ts new file mode 100644 index 0000000..21fa746 --- /dev/null +++ b/src/plugins/nodePolyfills.ts @@ -0,0 +1,522 @@ +// 浏览器端 Node.js 内置模块 polyfill +// 用于加载 webpack/ncc 打包的官方音源插件 +// 提供 http/https (基于 fetch)、stream、util、events、crypto、url、zlib、path、fs、buffer 等 + +function createEventEmitter(): any { + const listeners: Record = {}; + const self: any = { + on(event: string, fn: Function) { + (listeners[event] = listeners[event] || []).push(fn); + return self; + }, + once(event: string, fn: Function) { + const wrapper = (...args: any[]) => { + self.off(event, wrapper); + fn(...args); + }; + return self.on(event, wrapper); + }, + off(event: string, fn: Function) { + if (listeners[event]) { + listeners[event] = listeners[event].filter((f: Function) => f !== fn); + } + return self; + }, + emit(event: string, ...args: any[]) { + const fns = listeners[event] || []; + for (const fn of fns) { + try { fn.apply(null, args); } catch (_) {} + } + return true; + }, + addListener: function() { return self.on.apply(self, arguments as any); }, + removeListener: function() { return self.off.apply(self, arguments as any); }, + removeAllListeners(event?: string) { + if (event) delete listeners[event]; + else { for (const k in listeners) delete listeners[k]; } + return self; + }, + listeners(event: string) { return listeners[event] || []; }, + }; + return self; +} + +function createStreamBase(): any { + function Stream() {} + Stream.prototype.pipe = function(dest: any) { return dest; }; + Stream.prototype.on = function(_e: string, _fn: Function) { return this; }; + Stream.prototype.emit = function() { return true; }; + return Stream; +} + +function createReadable(): any { + function Readable() {} + Readable.prototype.pipe = function(dest: any) { return dest; }; + Readable.prototype.on = function() { return this; }; + Readable.prototype.once = function() { return this; }; + Readable.prototype.emit = function() { return true; }; + Readable.prototype.read = function() { return null; }; + Readable.prototype.pause = function() { return this; }; + Readable.prototype.resume = function() { return this; }; + Readable.prototype.push = function() { return this; }; + return Readable; +} + +function createWritable(): any { + function Writable() {} + Writable.prototype.write = function() { return true; }; + Writable.prototype.end = function() { return this; }; + Writable.prototype.on = function() { return this; }; + Writable.prototype.once = function() { return this; }; + Writable.prototype.emit = function() { return true; }; + return Writable; +} + +function createTransform(): any { + function Transform() {} + Transform.prototype.pipe = function(dest: any) { return dest; }; + Transform.prototype.write = function() { return true; }; + Transform.prototype.end = function() { return this; }; + Transform.prototype.on = function() { return this; }; + Transform.prototype.once = function() { return this; }; + Transform.prototype.emit = function() { return true; }; + return Transform; +} + +function createPassThrough(): any { + function PassThrough() {} + PassThrough.prototype.pipe = function(dest: any) { return dest; }; + PassThrough.prototype.write = function() { return true; }; + PassThrough.prototype.end = function() { return this; }; + PassThrough.prototype.on = function() { return this; }; + PassThrough.prototype.once = function() { return this; }; + PassThrough.prototype.emit = function() { return true; }; + return PassThrough; +} + +function parseUrl(u: string): any { + try { + const url = new URL(u, typeof window !== 'undefined' ? window.location.href : 'http://localhost/'); + return { + protocol: url.protocol, + hostname: url.hostname, + host: url.host, + port: url.port || (url.protocol === 'https:' ? '443' : '80'), + pathname: url.pathname, + path: url.pathname + url.search, + search: url.search, + query: Object.fromEntries(url.searchParams.entries()), + hash: url.hash, + href: url.href, + auth: url.username ? url.username + (url.password ? ':' + url.password : '') : '', + }; + } catch { + return { protocol: '', hostname: '', host: '', pathname: '', path: '', href: u, port: '80' }; + } +} + +// http/https 模块 - 用 fetch 实现 +function createHttpModule(isHttps: boolean): any { + function request(options: any, callback?: (res: any) => void): any { + let url: string; + let opts: any = typeof options === 'string' ? {} : { ...options }; + if (typeof options === 'string') { + url = options; + } else if (options && typeof options === 'object') { + const protocol = opts.protocol || (isHttps ? 'https:' : 'http:'); + const host = opts.hostname || opts.host || 'localhost'; + const port = opts.port ? ':' + opts.port : ''; + const path = opts.path || opts.pathname || '/'; + url = protocol + '//' + host + port + path; + } else { + url = String(options); + } + + const method = opts.method || 'GET'; + const headers = opts.headers || {}; + + const req: any = createEventEmitter(); + let requestBodyChunks: Uint8Array[] = []; + let ended = false; + + req.write = function(chunk: any) { + if (chunk) requestBodyChunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk); + return true; + }; + req.end = function(chunk: any) { + if (chunk) req.write(chunk); + if (ended) return; + ended = true; + + let body: any = undefined; + if (method !== 'GET' && method !== 'HEAD' && requestBodyChunks.length > 0) { + if (requestBodyChunks.length === 1) body = requestBodyChunks[0]; + else { + const total = requestBodyChunks.reduce((a, b) => a + b.length, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of requestBodyChunks) { merged.set(c, offset); offset += c.length; } + body = merged; + } + } + + fetch(url, { + method, + headers, + body, + signal: opts.signal, + mode: 'cors', + credentials: 'omit', + } as any).then((resp: any) => { + const res: any = createEventEmitter(); + res.statusCode = resp.status; + res.statusMessage = resp.statusText; + res.headers = {}; + try { + resp.headers.forEach((v: string, k: string) => { res.headers[k.toLowerCase()] = v; }); + } catch {} + res.setEncoding = function() { return res; }; + res.pipe = function(dest: any) { + resp.arrayBuffer().then((buf: ArrayBuffer) => { + if (dest && typeof dest.write === 'function') dest.write(new Uint8Array(buf)); + if (dest && typeof dest.end === 'function') dest.end(); + res.emit('end'); + }).catch((err: Error) => res.emit('error', err)); + return dest; + }; + res.text = () => resp.text(); + res.json = () => resp.json(); + res.body = resp.body; + if (callback) callback(res); + req.emit('response', res); + + // 如果没有 callback,等待 body 然后发射 data/end 事件 + if (!callback) { + resp.arrayBuffer().then((buf: ArrayBuffer) => { + res.emit('data', new Uint8Array(buf)); + res.emit('end'); + }).catch((err: Error) => res.emit('error', err)); + } + }).catch((err: Error) => { + req.emit('error', err); + }); + return req; + }; + req.abort = function() { req.emit('abort'); }; + req.setTimeout = function() { return req; }; + req.setHeader = function(name: string, value: string) { (headers as any)[name.toLowerCase()] = value; return req; }; + req.getHeader = function(name: string) { return (headers as any)[name.toLowerCase()]; }; + req.removeHeader = function(name: string) { delete (headers as any)[name.toLowerCase()]; }; + + // 如果用户立即调用 req.end(),我们需要在 next tick 开始 - 但实际上用户必须自己调用 end + return req; + } + + function get(options: any, callback?: (res: any) => void): any { + const req = request(options, callback); + req.end(); + return req; + } + + return { + request, + get, + createServer: () => ({ listen: () => {}, close: () => {} }), + Agent: function() { this.maxSockets = 50; }, + globalAgent: { maxSockets: 50 }, + STATUS_CODES: {}, + }; +} + +// util 模块 +const utilModule: any = { + inspect: (obj: any) => { + try { return JSON.stringify(obj); } catch { return String(obj); } + }, + format: function() { return Array.prototype.slice.call(arguments).join(' '); }, + inherits: function(ctor: any, superCtor: any) { + if (!superCtor || !superCtor.prototype) return; + ctor.super_ = superCtor; + const origProto = ctor.prototype; + ctor.prototype = Object.create(superCtor.prototype, { + constructor: { value: ctor, enumerable: false, writable: true, configurable: true }, + }); + // 保留原型上原有的属性 + if (origProto) { + for (const k in origProto) { + if (Object.prototype.hasOwnProperty.call(origProto, k)) { + ctor.prototype[k] = origProto[k]; + } + } + } + }, + promisify: function(fn: any) { + return function() { + const args = Array.prototype.slice.call(arguments); + return new Promise((resolve, reject) => { + args.push((err: any, ...rest: any[]) => { + if (err) reject(err); + else resolve(rest.length === 1 ? rest[0] : rest); + }); + try { fn.apply(null, args); } catch (e) { reject(e); } + }); + }; + }, + callbackify: function(fn: any) { + return function() { + const args = Array.prototype.slice.call(arguments); + const cb = args.pop(); + fn.apply(null, args).then((r: any) => cb(null, r)).catch((e: Error) => cb(e)); + }; + }, + TextDecoder: typeof (globalThis as any).TextDecoder !== 'undefined' ? (globalThis as any).TextDecoder : function(this: any) { this.decode = function() { return ''; }; }, + TextEncoder: typeof (globalThis as any).TextEncoder !== 'undefined' ? (globalThis as any).TextEncoder : function(this: any) { this.encode = function(s: string) { return s; }; }, + types: { isDate: (x: any) => x instanceof Date, isRegExp: (x: any) => x instanceof RegExp, isError: (x: any) => x instanceof Error, isArray: Array.isArray, isFunction: (x: any) => typeof x === 'function' }, +}; + +// crypto 模块 +const cryptoModule: any = { + createHash: function(algo: string) { + const chunks: string[] = []; + return { + update: function(chunk: any) { chunks.push(String(chunk)); return this; }, + digest: function(encoding?: string) { + // 简化:不做真正的哈希,返回一个稳定的伪随机值 + const s = algo + ':' + chunks.join(''); + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 31).toString(16).padStart(16, '0'); + if (encoding === 'hex') return hex; + if (encoding === 'base64') return btoa(hex); + return hex; + }, + }; + }, + createHmac: function(algo: string, key: any) { + const chunks: string[] = []; + return { + update: function(chunk: any) { chunks.push(String(chunk)); return this; }, + digest: function(encoding?: string) { + const s = 'hmac:' + algo + ':' + key + ':' + chunks.join(''); + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 17).toString(16).padStart(16, '0'); + if (encoding === 'hex') return hex; + if (encoding === 'base64') return btoa(hex); + return hex; + }, + }; + }, + randomBytes: function(n: number, cb?: (err: Error | null, buf: any) => void) { + const arr = new Uint8Array(n); + if (typeof (globalThis as any).crypto !== 'undefined' && (globalThis as any).crypto.getRandomValues) { + (globalThis as any).crypto.getRandomValues(arr); + } else { + for (let i = 0; i < n; i++) arr[i] = Math.floor(Math.random() * 256); + } + const buf: any = { + buffer: arr, + length: n, + toString: function(enc?: string) { + if (enc === 'hex') { + let s = ''; + for (let i = 0; i < n; i++) s += arr[i].toString(16).padStart(2, '0'); + return s; + } + if (enc === 'base64') { + let s = ''; + for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]); + return btoa(s); + } + return String.fromCharCode.apply(null, arr as any); + }, + }; + if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; } + return buf; + }, + randomUUID: function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }, +}; + +// 完整的 polyfill 注册表 +export function createNodePolyfills(): Record { + const streamMod: any = { + Stream: createStreamBase(), + Readable: createReadable(), + Writable: createWritable(), + Duplex: createTransform(), + Transform: createTransform(), + PassThrough: createPassThrough(), + pipeline: (_a: any, b: any, cb?: () => void) => { if (cb) cb(); return b; }, + finished: (_s: any, cb: () => void) => { cb(); }, + }; + streamMod.Stream.prototype = Object.create(createEventEmitter()); + streamMod.Readable.prototype = Object.create(streamMod.Stream.prototype); + streamMod.Writable.prototype = Object.create(streamMod.Stream.prototype); + streamMod.Duplex.prototype = Object.create(streamMod.Stream.prototype); + streamMod.Transform.prototype = Object.create(streamMod.Duplex.prototype); + streamMod.PassThrough.prototype = Object.create(streamMod.Transform.prototype); + + const bufferModule: any = { + from: (x: any) => { + if (x instanceof Uint8Array) return x; + if (typeof x === 'string') return new TextEncoder().encode(x); + if (Array.isArray(x)) return new Uint8Array(x); + return new Uint8Array(x || 0); + }, + alloc: (n: number) => new Uint8Array(n), + allocUnsafe: (n: number) => new Uint8Array(n), + isBuffer: () => false, + concat: (arrs: any[]) => { + const total = arrs.reduce((a, b) => a + (b?.length || 0), 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of arrs) { if (c) { merged.set(c, offset); offset += c.length; } } + return merged; + }, + byteLength: (x: any) => typeof x === 'string' ? new TextEncoder().encode(x).length : (x?.length || 0), + compare: (_a: any, _b: any) => 0, + isEncoding: () => true, + }; + + return { + http: createHttpModule(false), + https: createHttpModule(true), + http2: createHttpModule(true), + stream: streamMod, + util: utilModule, + events: { EventEmitter: function() { return createEventEmitter(); } }, + crypto: cryptoModule, + url: { + parse: parseUrl, + format: (o: any) => o.href || `${o.protocol}//${o.host}${o.path || o.pathname || ''}`, + resolve: (_from: string, to: string) => { try { return new URL(to, _from).href; } catch { return to; } }, + URL: typeof (globalThis as any).URL !== 'undefined' ? (globalThis as any).URL : function() {}, + URLSearchParams: typeof (globalThis as any).URLSearchParams !== 'undefined' ? (globalThis as any).URLSearchParams : function() {}, + }, + zlib: { + gunzipSync: (d: any) => d, + inflateSync: (d: any) => d, + deflateSync: (d: any) => d, + gzipSync: (d: any) => d, + gunzip: (d: any, cb: (err: Error | null, out: any) => void) => { setTimeout(() => cb(null, d), 0); }, + inflate: (d: any, cb: (err: Error | null, out: any) => void) => { setTimeout(() => cb(null, d), 0); }, + createGunzip: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; }, + createInflate: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; }, + createGzip: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; }, + }, + path: { + join: function() { return Array.prototype.slice.call(arguments).filter(Boolean).join('/').replace(/\/+/g, '/'); }, + resolve: function() { return Array.prototype.slice.call(arguments).filter(Boolean).join('/'); }, + dirname: (p: string) => String(p).split('/').slice(0, -1).join('/') || '/', + basename: (p: string, ext?: string) => { const base = String(p).split('/').pop() || ''; return ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base; }, + extname: (p: string) => { const m = String(p).match(/\.[^.]*$/); return m ? m[0] : ''; }, + sep: '/', delimiter: ':', + parse: (p: string) => { const parts = String(p).split('/'); return { root: '/', dir: parts.slice(0, -1).join('/'), base: parts[parts.length - 1], ext: '', name: parts[parts.length - 1].replace(/\.[^.]*$/, '') }; }, + normalize: (p: string) => p, + isAbsolute: (p: string) => p && p[0] === '/', + relative: (_from: string, to: string) => to, + }, + fs: { + readFileSync: () => '', + writeFileSync: () => {}, + existsSync: () => false, + createWriteStream: () => { const s = Object.create(streamMod.Writable.prototype); s.write = () => true; s.end = function() { this.emit && this.emit('finish'); return this; }; return s; }, + createReadStream: () => { const s = Object.create(streamMod.Readable.prototype); s.pipe = (d: any) => { s.emit && s.emit('end'); return d; }; return s; }, + readFile: (_path: any, enc: any, cb: (err: Error | null, data: any) => void) => { if (typeof enc === 'function') cb = enc; setTimeout(() => cb(null, ''), 0); }, + writeFile: (_p: any, _d: any, cb?: (err: Error | null) => void) => { if (typeof cb === 'function') setTimeout(() => cb(null), 0); }, + mkdirSync: () => {}, + statSync: () => ({ isDirectory: () => false, isFile: () => true, size: 0, mtime: new Date() }), + unlinkSync: () => {}, + promises: { + readFile: async () => '', + writeFile: async () => {}, + mkdir: async () => {}, + }, + }, + os: { + platform: () => 'browser', + type: () => 'Browser', + release: () => '1.0', + tmpdir: () => '/tmp', + homedir: () => '/', + EOL: '\n', + arch: () => 'x64', + cpus: () => [], + totalmem: () => 0, + freemem: () => 0, + }, + assert: { + ok: function() {}, + equal: function() {}, + deepEqual: function() {}, + strictEqual: function() {}, + throws: function() {}, + notEqual: function() {}, + ifError: function() {}, + }, + dns: { + lookup: (_host: string, cb: (err: Error | null, addr: string, family: number) => void) => { setTimeout(() => cb(null, '127.0.0.1', 4), 0); }, + resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); }, + }, + buffer: bufferModule, + Buffer: bufferModule, + string_decoder: { + StringDecoder: function() { + this.write = function(x: any) { return typeof x === 'string' ? x : new TextDecoder().decode(x); }; + this.end = function(x: any) { return x ? this.write(x) : ''; }; + }, + }, + querystring: { + parse: (s: string) => { + const out: Record = {}; + const q = String(s || '').replace(/^\?/, ''); + if (!q) return out; + for (const pair of q.split('&')) { + const [k, v] = pair.split('='); + if (k) out[decodeURIComponent(k)] = v !== undefined ? decodeURIComponent(v) : ''; + } + return out; + }, + stringify: (obj: any) => Object.entries(obj || {}).map(([k, v]) => encodeURIComponent(String(k)) + '=' + encodeURIComponent(String(v))).join('&'), + }, + tty: { isatty: () => false, ReadStream: function() {}, WriteStream: function() {} }, + net: { + createServer: () => ({ listen: () => {}, close: () => {}, on: function() { return this; } }), + createConnection: () => createEventEmitter(), + Socket: function() { return createEventEmitter(); }, + }, + tls: { + connect: () => createEventEmitter(), + TLSSocket: function() { return createEventEmitter(); }, + createSecureContext: () => ({}), + }, + child_process: { + spawn: () => createEventEmitter(), + exec: (_cmd: string, cb: (err: Error | null, stdout: string, stderr: string) => void) => setTimeout(() => cb(null, '', ''), 0), + execSync: () => '', + }, + cluster: { isWorker: false, isMaster: true, fork: function() {}, on: function() { return this; } }, + module: { Module: function() {} }, + vm: { runInNewContext: (code: string, sandbox: any) => { const fn = new Function(...Object.keys(sandbox || {}), code); return fn(...Object.values(sandbox || {})); } }, + perf_hooks: { performance: typeof (globalThis as any).performance !== 'undefined' ? (globalThis as any).performance : { now: () => Date.now() } }, + console, + }; +} + +// 为给定的 webpack bundle 代码提供完整的沙箱环境 +export function createSandboxRequire(): (id: string) => any { + const polyfills = createNodePolyfills(); + return function(id: string): any { + if (polyfills[id]) return polyfills[id]; + // 处理子路径,如 stream/transform, crypto/hash 等 + const base = id.split('/')[0]; + if (polyfills[base]) return polyfills[base]; + return {}; + }; +} diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 1226cb6..86ec559 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -1,327 +1,588 @@ -import type { - PluginModule, - PluginFullInfo, - PluginSearchResult, - UrlResponse, -} from '../types/plugin'; +import { createSandboxRequire } from './nodePolyfills'; +import type { PluginFullInfo, PluginSearchResult, UrlResponse, PluginModule } from '../types/plugin'; +// ===== 官方音源插件注册表 ===== export interface BuiltinPluginDef { id: string; platform: string; name: string; fileName: string; - isFixVersion: boolean; + description?: string; } const BUILTIN_PLUGINS: BuiltinPluginDef[] = [ - { id: 'wy', platform: 'wy', name: 'ZQ芸 (网易云)', fileName: 'zq_wy_v3.js', isFixVersion: false }, - { id: 'tx', platform: 'tx', name: 'ZQ秋 (QQ音乐)', fileName: 'zq_tx_v3-fix1.js', isFixVersion: true }, - { id: 'kw', platform: 'kw', name: 'ZQ我 (酷我)', fileName: 'zq_kw_v3-fix1.js', isFixVersion: true }, - { id: 'kg', platform: 'kg', name: 'ZQ驹 (酷狗)', fileName: 'zq_kg.js', isFixVersion: false }, - { id: 'mg', platform: 'mg', name: 'ZQ咕 (咪咕)', fileName: 'zq_mg_v3.js', isFixVersion: false }, + { id: 'wy', platform: 'wy', name: '网易云音乐', fileName: 'zq_wy_v3.js', description: '官方网易云音源插件 v3' }, + { id: 'tx', platform: 'tx', name: 'QQ 音乐', fileName: 'zq_tx_v3-fix1.js', description: '官方 QQ 音乐音源插件 v3' }, + { id: 'kw', platform: 'kw', name: '酷我音乐', fileName: 'zq_kw_v3-fix1.js', description: '官方酷我音源插件 v3' }, + { id: 'kg', platform: 'kg', name: '酷狗音乐', fileName: 'zq_kg.js', description: '官方酷狗音源插件' }, + { id: 'mg', platform: 'mg', name: '咪咕音乐', fileName: 'zq_mg_v3.js', description: '官方咪咕音源插件 v3' }, ]; -const PLUGIN_FILE_BASE = '/plugins/'; +// 支持的插件源 URL 前缀 - 按优先级排序 +const PLUGIN_SOURCE_PREFIXES: string[] = [ + '/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载) +]; +interface LoadedPlugin { + id: string; + name: string; + description: string; + quality: any[]; + version: string; + module: PluginModule; + source: 'built-in' | 'user'; +} + +/** + * PluginManager 负责: + * 1) 加载并执行 webpack bundle / CommonJS 风格插件代码 + * 2) 维护已加载插件清单、激活插件 + * 3) 作为 musicSearch / getUrl / getLyric 的统一入口 + * 4) localStorage 持久化用户插件 + */ export class PluginManager { - private plugins: Map = new Map(); - private activePluginId: string = 'wy'; - private codeCache: Map = new Map(); - private loadingPromises: Map> = new Map(); + private plugins: Map = new Map(); + private activePluginId: string = ''; + private userPluginCodes: Map = new Map(); + private loadingPromises: Map> = new Map(); + constructor() { + // 从 localStorage 恢复用户插件代码 + try { + const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '{}'); + for (const [id, code] of Object.entries(saved)) { + this.userPluginCodes.set(id, code as string); + } + } catch { /* noop */ } + + // 恢复激活插件 + const savedActive = sessionStorage.getItem('qz-active-plugin'); + if (savedActive) this.activePluginId = savedActive; + } + + // ========== 查询类 ========== getAll(): PluginFullInfo[] { - return Array.from(this.plugins.values()).map(p => this.extractInfo(p)).filter(Boolean) as PluginFullInfo[]; + const result: PluginFullInfo[] = []; + for (const [id, lp] of this.plugins.entries()) { + result.push({ + id, + name: lp.name || id, + description: lp.description || '', + quality: lp.quality, + version: lp.version, + source: lp.source, + } as any); + } + return result; } getAllBuiltinDefs(): BuiltinPluginDef[] { - return BUILTIN_PLUGINS; - } - - get(id: string): PluginModule | undefined { - return this.plugins.get(id); + return BUILTIN_PLUGINS.slice(); } has(id: string): boolean { return this.plugins.has(id); } - getActivePlugin(): PluginModule | undefined { - return this.plugins.get(this.activePluginId); + get(id: string): PluginModule | undefined { + return this.plugins.get(id)?.module; } getActivePluginId(): string { return this.activePluginId; } + getActivePlugin(): PluginModule | undefined { + return this.plugins.get(this.activePluginId)?.module; + } + setActivePlugin(id: string): boolean { if (this.plugins.has(id)) { this.activePluginId = id; - try { sessionStorage.setItem('qz-active-plugin', id); } catch {} + try { sessionStorage.setItem('qz-active-plugin', id); } catch { /* noop */ } return true; } return false; } - getQualityList(id: string): { id: string; name: string; ui: string }[] { + getQualityList(id: string): { id: string; name: string; ui?: string }[] { const mod = this.plugins.get(id); - const qs = mod?.pluginInfo?.quality; - if (qs && qs.length > 0) return qs; + if (mod && mod.quality && mod.quality.length > 0) return mod.quality; return [ - { id: 'standard', name: '标准音质', ui: '标' }, - { id: 'exhigh', name: '高品音质', ui: 'HQ' }, - { id: 'lossless', name: '无损音质', ui: 'SQ' }, + { id: 'standard', name: '标准', ui: '标' }, + { id: 'exhigh', name: '极高', ui: 'HQ' }, + { id: 'lossless', name: '无损', ui: 'SQ' }, { id: 'hires', name: 'Hi-Res', ui: 'HR' }, ]; } - private extractInfo(mod: PluginModule): PluginFullInfo | null { - const info = mod.pluginInfo?.info; - if (!info) return null; - return { - ...info, - quality: mod.pluginInfo?.quality, - env: mod.pluginInfo?.env, - ext: mod.pluginInfo?.ext, - supportFunc: mod.pluginInfo?.supportFunc, - source: (mod as any).__source || 'built-in', - }; - } - - registerModule(mod: PluginModule, source: 'built-in' | 'user' = 'built-in'): string | null { - const info = mod.pluginInfo?.info; - if (!info || !info.id) return null; - (mod as any).__source = source; - this.plugins.set(info.id, mod); - if (!sessionStorage.getItem('qz-active-plugin')) { - this.activePluginId = info.id; + // ========== 插件代码加载 ========== + /** + * 从 JS 代码字符串加载插件。 + * 兼容: + * 1) module.exports = { pluginInfo, musicSearch, getUrl, getLyric } (CommonJS) + * 2) webpack 打包的 UMD/IIFE bundle(会通过赋值到 this / globalThis 或直接调用安装函数) + * 3) 通过 window.__plugin_install__ 安装 + */ + loadFromCode(code: string, preferredId?: string, source: 'built-in' | 'user' = 'built-in') + : { success: boolean; pluginId: string; module?: PluginModule; error?: string } { + if (!code || typeof code !== 'string') { + return { success: false, pluginId: '', error: '代码为空' }; } - return info.id; - } - unregister(id: string): boolean { - if (this.activePluginId === id) { - const remaining = Array.from(this.plugins.keys()).filter(k => k !== id); - this.activePluginId = remaining[0] || ''; + let module: PluginModule | null = null; + try { + module = this._executePluginCode(code); + } catch (err: any) { + return { + success: false, + pluginId: '', + error: '执行插件代码时出现异常: ' + (err?.message || String(err)), + }; } - return this.plugins.delete(id); + + if (!module || typeof module !== 'object') { + return { success: false, pluginId: '', error: '插件未导出对象' }; + } + if (!module.pluginInfo || !module.pluginInfo.info) { + return { success: false, pluginId: '', error: '插件缺少 pluginInfo.info 字段' }; + } + + const id = preferredId || module.pluginInfo.info.id || 'anonymous'; + const name = module.pluginInfo.info.name || id; + const version = module.pluginInfo.info.version || '1.0'; + const description = module.pluginInfo.info.description || ''; + const quality = (module.pluginInfo.quality as any[]) || []; + + this.plugins.set(id, { + id, name, description, quality, version, module, source, + }); + + if (!this.activePluginId) this.activePluginId = id; + + // 用户插件代码持久化 + if (source === 'user') { + this.userPluginCodes.set(id, code); + this._persistUserPlugins(); + } + + return { success: true, pluginId: id, module }; } /** - * 从 JS 代码字符串加载插件 - * 支持两种格式: - * 1. module.exports = { ... } (用户手写的简单插件) - * 2. webpack bundle (官方原版,包含 __webpack_modules__ / __nccwpck_require__) + * 通过 registerModule 注册一个直接构造好的 PluginModule(用于 demo / 默认插件) */ - loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null { - if (!code || typeof code !== 'string') return null; - try { - const sandbox: any = { - module: { exports: {} }, - exports: {}, - globalThis: typeof window !== 'undefined' ? window : globalThis, - window: typeof window !== 'undefined' ? window : undefined, - console, - setTimeout, clearTimeout, setInterval, clearInterval, - Promise, JSON, Math, Date, Array, Object, - btoa: typeof btoa !== 'undefined' ? btoa : undefined, - atob: typeof atob !== 'undefined' ? atob : undefined, - }; - sandbox.global = sandbox.globalThis; + registerModule(mod: PluginModule, source: 'built-in' | 'user' = 'built-in', preferredId?: string): boolean { + if (!mod || !mod.pluginInfo || !mod.pluginInfo.info) return false; + const id = preferredId || mod.pluginInfo.info.id || 'demo'; + this.plugins.set(id, { + id, + name: mod.pluginInfo.info.name || id, + description: mod.pluginInfo.info.description || '', + quality: (mod.pluginInfo.quality as any[]) || [], + version: mod.pluginInfo.info.version || '1.0', + module: mod, + source, + }); + if (!this.activePluginId) this.activePluginId = id; + return true; + } - // 用 Function 构造沙盒环境执行代码 - const argNames = Object.keys(sandbox); - const argValues = argNames.map(k => sandbox[k]); - const fn = new Function(...argNames, code + '\n;return module.exports;'); - const result = fn(...argValues); - const mod = (result && typeof result === 'object' && result.exports) ? result.exports : result; - if (!mod || typeof mod !== 'object') return null; - if (!mod.pluginInfo) return null; - (mod as any).__source = source; - const id = this.registerModule(mod, source); - if (!id) return null; - return mod; - } catch (err) { - console.error('[PluginManager] loadFromCode 执行失败:', err); + /** 卸载并删除用户插件 */ + removeUserPlugin(id: string): boolean { + const lp = this.plugins.get(id); + if (lp && lp.source === 'user') { + this.plugins.delete(id); + } + this.userPluginCodes.delete(id); + this._persistUserPlugins(); + if (this.activePluginId === id) this._pickFallbackActive(); + return true; + } + + /** 按 id 卸载任何插件(含内置) */ + unregister(id: string): boolean { + const existed = this.plugins.delete(id); + this.userPluginCodes.delete(id); + this._persistUserPlugins(); + if (this.activePluginId === id) this._pickFallbackActive(); + return existed; + } + + /** 把用户上传的 JS 代码保存一份 */ + saveUserPlugin(code: string, id?: string): string | null { + try { + const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '{}'); + const realId = id || ('user_' + Date.now()); + saved[realId] = code; + localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); + this.userPluginCodes.set(realId, code); + return realId; + } catch { return null; } } - /** 从远程 URL 下载插件代码并加载 */ - async loadFromUrl(url: string, source: 'built-in' | 'user' = 'built-in'): Promise { - if (this.codeCache.has(url)) { - return this.loadFromCode(this.codeCache.get(url)!, source); - } - if (this.loadingPromises.has(url)) { - return this.loadingPromises.get(url)!; - } - const promise = (async () => { + /** 从 localStorage 重新加载用户插件,返回成功加载数 */ + loadUserPlugins(): number { + let loaded = 0; + for (const [id, code] of this.userPluginCodes.entries()) { try { - const res = await fetch(url, { method: 'GET', mode: 'cors' }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const code = await res.text(); - this.codeCache.set(url, code); - return this.loadFromCode(code, source); - } catch (err) { - console.error('[PluginManager] 从 URL 加载插件失败:', url, err); - return null; - } - })(); - this.loadingPromises.set(url, promise); - const result = await promise; - this.loadingPromises.delete(url); - return result; - } - - /** 加载单个内置插件(按文件名) */ - async loadBuiltin(def: BuiltinPluginDef): Promise { - if (this.has(def.id)) return this.get(def.id)!; - const url = PLUGIN_FILE_BASE + def.fileName; - return this.loadFromUrl(url, 'built-in'); - } - - /** 加载所有内置插件(并行),相同 platform 只保留 -fix 版 */ - async loadAllBuiltins(): Promise { - const tasks = BUILTIN_PLUGINS.map(def => this.loadBuiltin(def)); - const results = await Promise.allSettled(tasks); - const loaded = results.filter(r => r.status === 'fulfilled' && r.value).length; - if (!this.getActivePlugin() && this.plugins.size > 0) { - const first = this.plugins.keys().next(); - if (first.value) this.activePluginId = first.value; + const res = this.loadFromCode(code, id, 'user'); + if (res.success) loaded++; + } catch { /* noop */ } } return loaded; } - /** 搜索:使用指定或激活的插件进行搜索 */ - async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise { - const pid = preferredPluginId || this.activePluginId || Array.from(this.plugins.keys())[0]; - const mod = this.plugins.get(pid); - if (!mod) return { list: [], total: 0, error: '当前没有可用插件' }; + private _persistUserPlugins() { + try { + const toSave: Record = {}; + this.userPluginCodes.forEach((c, k) => { toSave[k] = c; }); + localStorage.setItem('qz-user-plugins', JSON.stringify(toSave)); + } catch { /* noop */ } + } - let searchFn: ((q: string, p: number, l: number) => Promise) | undefined; + private _pickFallbackActive() { + if (this.plugins.size === 0) { + this.activePluginId = ''; + sessionStorage.removeItem('qz-active-plugin'); + return; + } + // 优先 wy,否则取第一个 + if (this.plugins.has('wy')) { + this.activePluginId = 'wy'; + } else { + this.activePluginId = this.plugins.keys().next().value || ''; + } + try { sessionStorage.setItem('qz-active-plugin', this.activePluginId); } catch { /* noop */ } + } + + // ========== 网络下载加载 ========== + async loadFromUrl(url: string, preferredId?: string, source: 'built-in' | 'user' = 'built-in'): Promise { + const cacheKey = url; + if (this.loadingPromises.has(cacheKey)) { + return this.loadingPromises.get(cacheKey)!; + } + const promise = (async (): Promise => { + try { + const resp = await fetch(url, { cache: 'no-store' }); + if (!resp.ok) return false; + const code = await resp.text(); + const result = this.loadFromCode(code, preferredId, source); + return result.success; + } catch (err) { + console.warn('[PluginManager] loadFromUrl 失败:', url, err); + return false; + } + })(); + this.loadingPromises.set(cacheKey, promise); + const result = await promise; + this.loadingPromises.delete(cacheKey); + return result; + } + + async loadBuiltin(def: BuiltinPluginDef): Promise { + if (this.has(def.id)) return true; + for (const prefix of PLUGIN_SOURCE_PREFIXES) { + if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) { + // 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文) + const lp = this.plugins.get(def.id); + if (lp) { + lp.name = def.name; + if (def.description) lp.description = def.description; + } + return true; + } + } + return false; + } + + /** 并行加载所有内置音源插件,返回加载情况 */ + async loadAllBuiltins(): Promise<{ loaded: number; total: number; failed: string[] }> { + const failed: string[] = []; + const tasks = BUILTIN_PLUGINS.map(def => + this.loadBuiltin(def).then(ok => { + if (!ok) failed.push(def.id); + return ok; + }) + ); + const results = await Promise.allSettled(tasks); + let loaded = 0; + for (const r of results) { + if (r.status === 'fulfilled' && r.value) loaded++; + } + if (!this.activePluginId && this.plugins.size > 0) { + this._pickFallbackActive(); + } + return { loaded, total: BUILTIN_PLUGINS.length, failed }; + } + + // ========== 插件 API 统一入口 ========== + async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise { + const pid = preferredPluginId || this.activePluginId; + if (!pid) return { list: [], total: 0, error: '未激活音源插件' }; + const lp = this.plugins.get(pid); + if (!lp) return { list: [], total: 0, error: '音源插件未加载: ' + pid }; + const mod = lp.module; + let searchFn: ((q: string, p: number, l: number) => Promise) | undefined; if (mod.musicSearch && typeof (mod.musicSearch as any).search === 'function') { searchFn = (mod.musicSearch as any).search.bind(mod.musicSearch); } else if (typeof mod.musicSearch === 'function') { - searchFn = mod.musicSearch as any; + searchFn = mod.musicSearch; } if (!searchFn) return { list: [], total: 0, error: '插件不支持搜索' }; - try { const result = await searchFn(query, page, limit); - if (!result || !Array.isArray(result.list)) { - return { list: [], total: 0, error: '搜索返回格式异常' }; - } - (result as any).__pluginId = pid; - return result; - } catch (err) { - console.error(`[PluginManager] 插件 ${pid} 搜索失败:`, err); - return { list: [], total: 0, error: (err as Error).message }; + if (!result) return { list: [], total: 0, error: '搜索返回空' }; + const list = Array.isArray(result.list) ? result.list : []; + return { + list, + total: result.total ?? result.songCount ?? list.length, + songCount: result.songCount, + allPage: result.allPage, + source: pid, + error: undefined, + }; + } catch (err: any) { + console.error('[PluginManager] search 失败:', err); + return { list: [], total: 0, error: err?.message || '搜索失败' }; } } - /** 获取歌曲 URL:根据 song.source 优先尝试 → 多插件回退 */ async getSongUrl(song: { id?: string; songmid?: string; source?: string }, quality?: string): Promise { const songId = song.songmid || song.id; if (!songId) return { success: false, error: '歌曲缺少 id' }; - const candidateIds: string[] = []; if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source); if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId); for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid); - const lastErr: string[] = []; + const errors: string[] = []; for (const pid of candidateIds) { - const mod = this.plugins.get(pid); - if (!mod || typeof mod.getUrl !== 'function') continue; + const lp = this.plugins.get(pid); + if (!lp || typeof lp.module.getUrl !== 'function') continue; try { - const q = quality || this.getQualityList(pid)[0]?.id || 'standard'; - const result = await mod.getUrl(songId, q); + const q = quality || (lp.quality && lp.quality[0]?.id) || 'standard'; + const result = await lp.module.getUrl(songId, q); let url: string | undefined; - if (typeof result === 'string') { - url = result; - } else if (result && typeof result === 'object') { + if (typeof result === 'string') url = result; + else if (result && typeof result === 'object') { url = (result as any).url || (result as any).data?.url || (result as any).data; if (typeof url !== 'string') url = undefined; } - if (url && url.length > 0 && /^(https?:)?\/\//i.test(url)) { + if (url && typeof url === 'string' && /^(https?:)?\/\//i.test(url)) { return { success: true, url, pluginId: pid }; } if (url && typeof url === 'string' && url.startsWith('//')) { return { success: true, url: 'https:' + url, pluginId: pid }; } - } catch (err) { - console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, err); - lastErr.push(`${pid}: ${(err as Error).message}`); + } catch (err: any) { + console.warn(`[PluginManager] ${pid} getUrl 失败:`, err); + errors.push(pid + ':' + (err?.message || '失败')); } } - return { success: false, error: '所有插件都未能获取播放地址(' + lastErr.join('; ') + ')' }; + return { success: false, error: '所有插件都未能获取播放地址 (' + errors.join('; ') + ')' }; } - /** 获取歌词 */ - async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any }> { + async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any; error?: string }> { const songId = song.songmid || song.id; - if (!songId) return { format: null, raw: null }; - + if (!songId) return { format: null, raw: null, error: '歌曲缺少 id' }; const candidateIds: string[] = []; if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source); if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId); for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid); for (const pid of candidateIds) { - const mod = this.plugins.get(pid); - if (!mod || typeof mod.getLyric !== 'function') continue; + const lp = this.plugins.get(pid); + if (!lp || typeof lp.module.getLyric !== 'function') continue; try { - const raw = await mod.getLyric(songId); + const raw = await lp.module.getLyric(songId); if (raw === null || raw === undefined) continue; const format = detectLyricFormat(raw); return { format, raw }; - } catch (err) { - console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, err); + } catch (err: any) { + console.warn(`[PluginManager] ${pid} getLyric 失败:`, err); } } return { format: null, raw: null }; } - /** localStorage 持久化用户插件 */ - saveUserPlugin(code: string): void { - try { - const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - saved.push(code); - localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); - } catch {} - } + // ========== 内部:执行插件代码 ========== + private _executePluginCode(code: string): PluginModule | null { + const moduleObj: any = { exports: {} }; + const requireFn = createSandboxRequire(); - loadUserPlugins(): number { - try { - const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - let loaded = 0; - for (const code of saved) { - const m = this.loadFromCode(code, 'user'); - if (m) loaded++; + const processObj: any = { + env: { NODE_ENV: 'production' }, + version: 'v20.0.0', + platform: 'browser', + arch: 'x64', + argv: [], + title: 'node', + browser: true, + cwd: () => '/', + chdir: () => { /* noop */ }, + on: function () { return this; }, + once: function () { return this; }, + emit: function () { return true; }, + off: function () { return this; }, + addListener: function () { return this; }, + removeListener: function () { return this; }, + removeAllListeners: function () { return this; }, + versions: { node: '20.0.0', v8: '11.3.244.8-node.16' }, + exit: function () { /* noop */ }, + nextTick: (fn: () => void) => setTimeout(fn, 0), + }; + + const textEnc = typeof (globalThis as any).TextEncoder !== 'undefined' ? new (globalThis as any).TextEncoder() : null; + const textDec = typeof (globalThis as any).TextDecoder !== 'undefined' ? new (globalThis as any).TextDecoder('utf-8') : null; + + const BufferCtor: any = function (arg: any) { + if (typeof arg === 'string') return textEnc ? textEnc.encode(arg) : new Uint8Array(arg.length); + if (arg instanceof Uint8Array) return arg; + if (typeof arg === 'number') return new Uint8Array(arg); + return new Uint8Array(arg || 0); + }; + BufferCtor.from = (x: any) => { + if (x instanceof Uint8Array) return new Uint8Array(x); + if (typeof x === 'string') return textEnc ? textEnc.encode(x) as Uint8Array : new Uint8Array(0); + if (Array.isArray(x)) return new Uint8Array(x as any); + return new Uint8Array(x || 0); + }; + BufferCtor.alloc = (n: number) => new Uint8Array(n); + BufferCtor.allocUnsafe = (n: number) => new Uint8Array(n); + BufferCtor.allocUnsafeSlow = (n: number) => new Uint8Array(n); + BufferCtor.isBuffer = (x: any) => x instanceof Uint8Array || (x && x.constructor && x.constructor.name === 'Buffer'); + BufferCtor.byteLength = (x: any) => typeof x === 'string' + ? (textEnc ? textEnc.encode(x).length : x.length) + : (x?.length || 0); + BufferCtor.isEncoding = () => true; + BufferCtor.concat = (arrs: any[]) => { + const total = arrs.reduce((a: number, b: any) => a + (b?.length || 0), 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of arrs) { if (c) { merged.set(c, offset); offset += c.length; } } + return merged; + }; + const toOrig = (Uint8Array.prototype as any).toString; + (BufferCtor as any).prototype = Object.create(Uint8Array.prototype); + (BufferCtor as any).prototype.toString = function (encoding?: string) { + try { + if (encoding === 'base64') { + let s = ''; + const chunk = 0x8000; + for (let i = 0; i < this.length; i += chunk) { + s += String.fromCharCode.apply(null, Array.from(this.subarray(i, i + chunk))); + } + return typeof btoa !== 'undefined' ? btoa(s) : s; + } + if (encoding === 'hex') { + let s = ''; + for (let i = 0; i < this.length; i++) { + s += this[i].toString(16).padStart(2, '0'); + } + return s; + } + if (encoding === 'utf8' || encoding === 'utf-8' || !encoding) { + return textDec ? textDec.decode(this) : String.fromCharCode.apply(null, Array.from(this)); + } + return textDec ? textDec.decode(this) : String.fromCharCode.apply(null, Array.from(this)); + } catch { + return toOrig ? toOrig.call(this) : ''; } - return loaded; - } catch { return 0; } - } + }; - removeUserPlugin(id: string): void { - try { - const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - const filtered = saved.filter((code: string) => { - try { - const sandbox: any = { module: { exports: {} }, exports: {}, console, Promise, JSON, Math, Date, setTimeout, clearTimeout }; - const fn = new Function(...Object.keys(sandbox), code + '\n;return module.exports;'); - const result = fn(...Object.keys(sandbox).map(k => sandbox[k])); - const mod = result?.exports || result; - return mod?.pluginInfo?.info?.id !== id; - } catch { return true; } - }); - localStorage.setItem('qz-user-plugins', JSON.stringify(filtered)); - } catch {} + const fakeGlobal: any = { + process: processObj, + Buffer: BufferCtor, + console, + setTimeout: setTimeout as any, + clearTimeout, + setInterval, + clearInterval, + setImmediate: function (...rest: any[]) { return setTimeout(rest[0], 0); }, + clearImmediate: (id: any) => clearTimeout(id), + Promise, + JSON, + Math, + Date, + Array, + Object, + String, + Number, + Boolean, + Symbol, + Error, + TypeError, + Uint8Array, + Uint16Array, + Uint32Array, + Int8Array, + Int16Array, + Int32Array, + Float32Array, + Float64Array, + TextDecoder: (globalThis as any).TextDecoder, + TextEncoder: (globalThis as any).TextEncoder, + fetch: (globalThis as any).fetch, + Headers: (globalThis as any).Headers, + Request: (globalThis as any).Request, + Response: (globalThis as any).Response, + crypto: (globalThis as any).crypto, + performance: (globalThis as any).performance, + }; + fakeGlobal.self = fakeGlobal; + fakeGlobal.window = fakeGlobal; + fakeGlobal.global = fakeGlobal; + fakeGlobal.globalThis = fakeGlobal; + fakeGlobal.root = fakeGlobal; + + const wrapperCode = + '(function(module,exports,require,__dirname,__filename,process,Buffer,console,setTimeout,clearTimeout,setInterval,clearInterval,Promise,JSON,Math,Date,Array,Object,global,globalThis){' + + code + + '\n;return module.exports;})'; + const fn: any = (new Function('return ' + wrapperCode))(); + + const runArgs: any[] = [ + moduleObj, + moduleObj.exports, + requireFn, + '/', + '/plugin.js', + processObj, + BufferCtor, + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + Promise, + JSON, + Math, + Date, + Array, + Object, + fakeGlobal, + fakeGlobal, + ]; + + const result = fn.apply(fakeGlobal, runArgs); + const mod = (result && typeof result === 'object' && (result as any).exports) + ? (result as any).exports + : result; + + if (!mod || (typeof mod === 'object' && Object.keys(mod).length === 0)) { + const direct = (fakeGlobal.__plugin_install__ as any) || (fakeGlobal as any).plugin; + if (direct && typeof direct === 'object' && direct.pluginInfo) return direct as PluginModule; + } + + return mod as PluginModule; } } -export function detectLyricFormat(raw: any): string | null { +// ========== 歌词格式识别 ========== +function detectLyricFormat(raw: any): string | null { if (raw === null || raw === undefined) return null; if (typeof raw === 'string') { const s = raw.trim(); @@ -329,28 +590,29 @@ export function detectLyricFormat(raw: any): string | null { if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml'; if (/<\s*\d+[::]\d+/.test(s)) return 'qrc'; if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) return 'lrc'; - if (s.charAt(0) === '{' || s.charAt(0) === '[') { + if (s.startsWith('{') || s.startsWith('[')) { try { const obj = JSON.parse(s); if (typeof obj === 'object' && obj !== null) { - if (obj.yrc || obj.lrclib || obj.klyric) return 'yrc'; - if (obj.lrc) return 'lrc'; - if (obj.ttml) return 'ttml'; - if (obj.qrc) return 'qrc'; + if ((obj as any).yrc || (obj as any).lrclib || (obj as any).klyric) return 'yrc'; + if ((obj as any).lrc || (obj as any).lyric) return 'lrc'; + if ((obj as any).ttml) return 'ttml'; + if ((obj as any).qrc) return 'qrc'; return 'json'; } - } catch {} + } catch { /* noop */ } } return 'text'; } if (typeof raw === 'object') { - if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc'; - if (raw.lrc) return 'lrc'; - if (raw.ttml) return 'ttml'; - if (raw.qrc) return 'qrc'; + if ((raw as any).yrc || (raw as any).lrclib || (raw as any).klyric) return 'yrc'; + if ((raw as any).lrc) return 'lrc'; + if ((raw as any).ttml) return 'ttml'; + if ((raw as any).qrc) return 'qrc'; return 'json'; } return null; } +// 全局单例 export const pluginManager = new PluginManager();