fix: 重写crypto polyfill/插件环境变量/本地音乐扫描/字段映射/加载回退

This commit is contained in:
miao-moe
2026-06-20 11:04:53 +08:00
parent c4c3c6b5a2
commit b57b40e873
5 changed files with 944 additions and 52 deletions

View File

@@ -274,36 +274,354 @@ const utilModule: any = {
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 helpers using Web Crypto API
async function subtleDigest(algo: string, data: Uint8Array): Promise<Uint8Array> {
const alg = algo === 'md5' ? 'MD5' : algo.toUpperCase();
const name = alg === 'MD5' ? 'MD5' : alg === 'SHA1' ? 'SHA-1' : alg === 'SHA256' ? 'SHA-256' : alg === 'SHA512' ? 'SHA-512' : alg;
const sub = (globalThis as any).crypto?.subtle;
if (sub) {
try { return new Uint8Array(await sub.digest(name, data)); } catch { /* fallthrough */ }
}
return simpleHash(algo, data);
}
function simpleHash(algo: string, data: Uint8Array): Uint8Array {
let s = '';
for (let i = 0; i < data.length; i++) s += String.fromCharCode(data[i]);
const algoKey = algo.toLowerCase();
let h: number;
if (algoKey === 'md5') {
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
const msg = new Uint8Array(data.length + 1);
msg.set(data);
const bitLen = data.length * 8;
msg[data.length] = 0x80;
const paddedLen = (((data.length + 9 + 63) & ~63) + 64) * 4;
const padded = new Uint8Array(paddedLen);
padded.set(msg);
const view = new DataView(padded.buffer);
view.setUint32(paddedLen - 8, bitLen >>> 32, true);
view.setUint32(paddedLen - 4, bitLen & 0xffffffff, true);
for (let i = 0; i < paddedLen; i += 64) {
const w = new Uint32Array(80);
for (let j = 0; j < 16; j++) w[j] = view.getUint32(i + j * 4, true);
for (let j = 16; j < 80; j++) {
w[j] = (w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16]);
w[j] = (w[j] << 1) | (w[j] >>> 31);
}
let A = a, B = b, C = c, D = d;
for (let j = 0; j < 80; j++) {
let F: number, g: number;
if (j < 20) { F = (B & C) | (~B & D); g = 0x5a827999; }
else if (j < 40) { F = B ^ C ^ D; g = 0x6ed9eba1; }
else if (j < 60) { F = (B & C) | (B & D) | (C & D); g = 0x8f1bbcdc; }
else { F = B ^ C ^ D; g = 0xca62c1d6; }
const temp = ((A << 5) | (A >>> 27)) + F + w[j] + g;
D = C; C = B; B = (B << 30) | (B >>> 2); A = temp;
}
a += A; b += B; c += C; d += D;
}
const out = new Uint8Array(16);
const v = new DataView(out.buffer);
v.setUint32(0, a, true); v.setUint32(4, b, true);
v.setUint32(8, c, true); v.setUint32(12, d, true);
return out;
}
let hash = 0;
for (let i = 0; i < s.length; i++) hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
const hex = Math.abs(hash).toString(16).padStart(8, '0');
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) out[i] = parseInt(hex.substr(i*2, 2), 16);
return out;
}
function toBuffer(data: any): Uint8Array {
if (data instanceof Uint8Array) return data;
if (typeof data === 'string') return new TextEncoder().encode(data);
if (Array.isArray(data)) return new Uint8Array(data);
if (data?.buffer instanceof ArrayBuffer) return new Uint8Array(data.buffer);
return new Uint8Array(0);
}
function bufferToString(buf: Uint8Array, encoding?: string): string {
if (encoding === 'hex') {
let s = '';
for (let i = 0; i < buf.length; i++) s += buf[i].toString(16).padStart(2, '0');
return s;
}
if (encoding === 'base64') {
let s = '';
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
try { return btoa(s); } catch { return s; }
}
return new TextDecoder().decode(buf);
}
// Minimal AES-128-ECB implementation (pure JS, for environments where Web Crypto ECB is unavailable)
function aesEcbEncrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes');
const Nb = 4, Nk = 4, Nr = 10;
const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16];
const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
const w = new Uint8Array(Nb * (Nr + 1) * 4);
for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; }
for (let i = Nk; i < Nb * (Nr + 1); i++) {
let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]];
if (i % Nk === 0) {
const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t];
}
w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3];
}
function subWord(r: Uint8Array) { for (let i = 0; i < 4; i++) r[i] = sbox[r[i]]; }
function rotWord(r: Uint8Array) { const t = r[0]; r[0] = r[1]; r[1] = r[2]; r[2] = r[3]; r[3] = t; }
function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; }
function subBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = sbox[state[i]]; }
function shiftRows(state: Uint8Array) {
const t = state[1]; state[1] = state[5]; state[5] = state[9]; state[9] = state[13]; state[13] = t;
const t2 = state[2]; state[2] = state[10]; state[10] = t2;
const t3 = state[6]; state[6] = state[14]; state[14] = t3;
const t4 = state[15]; state[15] = state[11]; state[11] = state[7]; state[7] = state[3]; state[3] = t4;
}
function mixColumns(state: Uint8Array) {
for (let c = 0; c < 4; c++) {
const i = c * 4;
const a = [state[i], state[i+1], state[i+2], state[i+3]];
state[i] = gmul(2, a[0]) ^ gmul(3, a[1]) ^ a[2] ^ a[3];
state[i+1] = a[0] ^ gmul(2, a[1]) ^ gmul(3, a[2]) ^ a[3];
state[i+2] = a[0] ^ a[1] ^ gmul(2, a[2]) ^ gmul(3, a[3]);
state[i+3] = gmul(3, a[0]) ^ a[1] ^ a[2] ^ gmul(2, a[3]);
}
}
function gmul(a: number, b: number): number {
let p = 0;
for (let i = 0; i < 8; i++) {
if (b & 1) p ^= a;
const hi = a & 0x80;
a = (a << 1) & 0xff;
if (hi) a ^= 0x1b;
b >>= 1;
}
return p;
}
const padded = data.length % 16 === 0 ? new Uint8Array(data) : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })();
const blocks = padded.length / 16;
const out = new Uint8Array(padded.length);
for (let b = 0; b < blocks; b++) {
const state = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16));
addRoundKey(state, 0);
for (let round = 1; round <= Nr; round++) {
subBytes(state);
shiftRows(state);
if (round < Nr) mixColumns(state);
addRoundKey(state, round);
}
out.set(state, b * 16);
}
if (data.length % 16 !== 0) return out.subarray(0, data.length);
return out;
}
function aesEcbDecrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes');
const Nb = 4, Nk = 4, Nr = 10;
const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16];
const invSbox = new Uint8Array(256);
for (let i = 0; i < 256; i++) invSbox[sbox[i]] = i;
const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
const w = new Uint8Array(Nb * (Nr + 1) * 4);
for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; }
for (let i = Nk; i < Nb * (Nr + 1); i++) {
let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]];
if (i % Nk === 0) { const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t]; }
w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3];
}
function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; }
function invSubBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = invSbox[state[i]]; }
function invShiftRows(state: Uint8Array) {
const t = state[13]; state[13] = state[9]; state[9] = state[5]; state[5] = state[1]; state[1] = t;
const t2 = state[2]; state[2] = state[10]; state[10] = t2;
const t3 = state[14]; state[14] = state[6]; state[6] = t3;
const t4 = state[3]; state[3] = state[7]; state[7] = state[11]; state[11] = state[15]; state[15] = t4;
}
function invMixColumns(state: Uint8Array) {
for (let c = 0; c < 4; c++) {
const i = c * 4;
const a = [state[i], state[i+1], state[i+2], state[i+3]];
state[i] = gmul(14, a[0]) ^ gmul(11, a[1]) ^ gmul(13, a[2]) ^ gmul(9, a[3]);
state[i+1] = gmul(9, a[0]) ^ gmul(14, a[1]) ^ gmul(11, a[2]) ^ gmul(13, a[3]);
state[i+2] = gmul(13, a[0]) ^ gmul(9, a[1]) ^ gmul(14, a[2]) ^ gmul(11, a[3]);
state[i+3] = gmul(11, a[0]) ^ gmul(13, a[1]) ^ gmul(9, a[2]) ^ gmul(14, a[3]);
}
}
function gmul(a: number, b: number): number {
let p = 0;
for (let i = 0; i < 8; i++) {
if (b & 1) p ^= a;
const hi = a & 0x80;
a = (a << 1) & 0xff;
if (hi) a ^= 0x1b;
b >>= 1;
}
return p;
}
if (data.length % 16 !== 0) throw new Error('AES data must be multiple of 16 bytes');
const blocks = data.length / 16;
const out = new Uint8Array(data.length);
for (let b = 0; b < blocks; b++) {
const state = new Uint8Array(data.subarray(b * 16, (b + 1) * 16));
addRoundKey(state, Nr);
for (let round = Nr - 1; round >= 0; round--) {
invShiftRows(state);
invSubBytes(state);
addRoundKey(state, round);
if (round > 0) invMixColumns(state);
}
out.set(state, b * 16);
}
return out;
}
async function aesCbcEncrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const sub = (globalThis as any).crypto?.subtle;
if (sub) {
const alg = { name: 'AES-CBC', iv };
const k = await sub.importKey('raw', key, alg, false, ['encrypt']);
return new Uint8Array(await sub.encrypt(alg, k, data));
}
const padded = data.length % 16 === 0 ? data : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })();
const blocks = padded.length / 16;
const out = new Uint8Array(padded.length);
let prev = new Uint8Array(iv);
for (let b = 0; b < blocks; b++) {
const block = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16));
for (let i = 0; i < 16; i++) block[i] ^= prev[i];
const encrypted = aesEcbEncrypt(block, key);
out.set(encrypted, b * 16);
prev = encrypted;
}
return out;
}
async function aesCbcDecrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const sub = (globalThis as any).crypto?.subtle;
if (sub) {
const alg = { name: 'AES-CBC', iv };
const k = await sub.importKey('raw', key, alg, false, ['decrypt']);
return new Uint8Array(await sub.decrypt(alg, k, data));
}
const blocks = data.length / 16;
const out = new Uint8Array(data.length);
let prev = new Uint8Array(iv);
for (let b = 0; b < blocks; b++) {
const block = new Uint8Array(data.subarray(b * 16, (b + 1) * 16));
const decrypted = aesEcbDecrypt(block, key);
for (let i = 0; i < 16; i++) out[b * 16 + i] = decrypted[i] ^ prev[i];
prev = block;
}
const padLen = out[out.length - 1];
if (padLen > 0 && padLen <= 16) return out.subarray(0, out.length - padLen);
return out;
}
function rsaPublicEncrypt(data: Uint8Array, keyPem: string): Uint8Array {
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
let pemData = keyPem.substring(pemHeader.length);
pemData = pemData.substring(0, pemData.indexOf(pemFooter)).trim();
const der = Uint8Array.from(atob(pemData), c => c.charCodeAt(0));
// Parse RSA public key from DER (simple PKCS#1 / SubjectPublicKeyInfo)
let offset = 0;
function readLen(): number {
const b = der[offset++];
if (b < 0x80) return b;
const n = b & 0x7f;
let len = 0;
for (let i = 0; i < n; i++) len = (len << 8) | der[offset++];
return len;
}
function readTag(tag: number): Uint8Array {
if (der[offset++] !== tag) throw new Error('Invalid DER tag');
const len = readLen();
const buf = der.subarray(offset, offset + len);
offset += len;
return buf;
}
offset = 0;
if (der[offset++] !== 0x30) throw new Error('Invalid DER sequence');
readLen();
if (der[offset] === 0x30) {
readTag(0x30);
const algoOid = readTag(0x06);
offset++; readLen();
if (der[offset++] !== 0x03) throw new Error('Invalid DER bit string');
const bitLen = readLen();
offset++;
if (der[offset++] !== 0x00) throw new Error('Invalid DER padding');
if (der[offset++] !== 0x30) throw new Error('Invalid DER inner sequence');
readLen();
}
const nDer = readTag(0x02);
const eDer = readTag(0x02);
function bigFromBytes(bytes: Uint8Array): bigint {
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return BigInt('0x' + hex);
}
const n = bigFromBytes(nDer);
const e = bigFromBytes(eDer);
const dataInt = bigFromBytes(data);
const result = dataInt ** e % n;
let hex = result.toString(16);
if (hex.length % 2 === 1) hex = '0' + hex;
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
// crypto 模块
const cryptoModule: any = {
constants: {
RSA_PKCS1_PADDING: 1,
RSA_NO_PADDING: 3,
RSA_PKCS1_OAEP_PADDING: 4,
RSA_PSS_SALTLEN_DIGEST: -1,
RSA_PSS_SALTLEN_MAX_SIGN: -2,
RSA_PSS_SALTLEN_AUTO: -1,
POINT_CONVERSION_COMPRESSED: 2,
POINT_CONVERSION_UNCOMPRESSED: 4,
POINT_CONVERSION_HYBRID: 6,
defaultCoreCipherList: '',
defaultCipherList: '',
},
createHash: function(algo: string) {
const chunks: string[] = [];
const chunks: Uint8Array[] = [];
return {
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
update: function(chunk: any) { chunks.push(toBuffer(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;
const combined = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })();
return bufferToString(simpleHash(algo, combined), encoding);
},
};
},
createHmac: function(algo: string, key: any) {
const chunks: string[] = [];
const chunks: Uint8Array[] = [];
const keyBuf = toBuffer(key);
return {
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
update: function(chunk: any) { chunks.push(toBuffer(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;
const data = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })();
const blockSize = algo === 'sha256' || algo === 'sha512' ? 128 : 64;
const k = keyBuf.length > blockSize ? simpleHash(algo, keyBuf) : keyBuf;
const iPadded = new Uint8Array(blockSize);
const oPadded = new Uint8Array(blockSize);
for (let i = 0; i < k.length; i++) { iPadded[i] = k[i] ^ 0x36; oPadded[i] = k[i] ^ 0x5c; }
for (let i = k.length; i < blockSize; i++) { iPadded[i] = 0x36; oPadded[i] = 0x5c; }
const inner = new Uint8Array(iPadded.length + data.length);
inner.set(iPadded); inner.set(data, iPadded.length);
const innerHash = simpleHash(algo, inner);
const outer = new Uint8Array(oPadded.length + innerHash.length);
outer.set(oPadded); outer.set(innerHash, oPadded.length);
return bufferToString(simpleHash(algo, outer), encoding);
},
};
},
@@ -328,7 +646,7 @@ const cryptoModule: any = {
for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]);
return btoa(s);
}
return String.fromCharCode.apply(null, arr as any);
return new TextDecoder().decode(arr);
},
};
if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; }
@@ -341,6 +659,46 @@ const cryptoModule: any = {
return v.toString(16);
});
},
createCipheriv: function(algo: string, key: any, iv: any) {
const keyBuf = toBuffer(key);
const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0);
return {
update: function(data: any) {
const dataBuf = toBuffer(data);
if (algo === 'aes-128-ecb') return aesEcbEncrypt(dataBuf, keyBuf);
return dataBuf;
},
final: function() { return new Uint8Array(0); },
};
},
createDecipheriv: function(algo: string, key: any, iv: any) {
const keyBuf = toBuffer(key);
const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0);
return {
update: function(data: any) {
const dataBuf = toBuffer(data);
if (algo === 'aes-128-ecb') return aesEcbDecrypt(dataBuf, keyBuf);
return dataBuf;
},
final: function() { return new Uint8Array(0); },
};
},
publicEncrypt: function(options: any, data: any) {
const keyPem = typeof options === 'string' ? options : options.key;
const dataBuf = toBuffer(data);
return rsaPublicEncrypt(dataBuf, keyPem);
},
privateDecrypt: function() { return new Uint8Array(0); },
privateEncrypt: function() { return new Uint8Array(0); },
createSign: function() { return { update: function() { return this; }, sign: function() { return new Uint8Array(0); } }; },
createVerify: function() { return { update: function() { return this; }, verify: function() { return true; } }; },
getCiphers: () => ['aes-128-cbc', 'aes-128-ecb'],
getHashes: () => ['md5', 'sha1', 'sha256'],
pbkdf2: function() {},
pbkdf2Sync: function() { return new Uint8Array(0); },
createDiffieHellman: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPrime: () => new Uint8Array(0), getGenerator: () => new Uint8Array(0) }; },
createECDH: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPublicKey: () => new Uint8Array(0) }; },
timingSafeEqual: function(a: any, b: any) { const al = toBuffer(a); const bl = toBuffer(b); if (al.length !== bl.length) return false; let d = 0; for (let i = 0; i < al.length; i++) d |= al[i] ^ bl[i]; return d === 0; },
};
// 完整的 polyfill 注册表
@@ -499,6 +857,7 @@ export function createNodePolyfills(): Record<string, any> {
resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); },
},
buffer: bufferModule,
// 'Buffer' is already an alias for bufferModule, but some plugins require('buffer').Buffer
Buffer: bufferModule,
string_decoder: {
StringDecoder: function() {

View File

@@ -20,9 +20,21 @@ const BUILTIN_PLUGINS: BuiltinPluginDef[] = [
];
// 支持的插件源 URL 前缀 - 按优先级排序
const PLUGIN_SOURCE_PREFIXES: string[] = [
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载
];
function getPluginSourcePrefixes(): string[] {
// 优先从 localStorage 读取自定义源(用户可以配置
try {
const custom = JSON.parse(localStorage.getItem('qz-plugin-sources') || '[]');
if (Array.isArray(custom) && custom.length > 0) {
return custom.map((s: string) => s.endsWith('/') ? s : s + '/');
}
} catch { /* noop */ }
return [
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载)
'https://cdn.jsdelivr.net/gh/your-repo/plugins@latest/', // CDN 回退
];
}
const PLUGIN_SOURCE_PREFIXES = getPluginSourcePrefixes();
interface LoadedPlugin {
id: string;
@@ -117,6 +129,34 @@ export class PluginManager {
];
}
// ========== 插件环境变量管理 ==========
getEnvDefs(id: string): { key: string; name: string; description?: string }[] {
const lp = this.plugins.get(id);
if (lp && lp.module && lp.module.pluginInfo && lp.module.pluginInfo.env) {
return lp.module.pluginInfo.env as any[];
}
return [];
}
setEnvVar(key: string, value: string): void {
try {
const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
envData[key] = value;
localStorage.setItem('qz-plugin-env', JSON.stringify(envData));
} catch { /* noop */ }
}
getEnvVar(key: string): string {
try {
const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
return envData[key] || '';
} catch { return ''; }
}
getAllEnvVars(): Record<string, string> {
try { return JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); } catch { return {}; }
}
// ========== 插件代码加载 ==========
/**
* 从 JS 代码字符串加载插件。
@@ -293,7 +333,8 @@ export class PluginManager {
async loadBuiltin(def: BuiltinPluginDef): Promise<boolean> {
if (this.has(def.id)) return true;
for (const prefix of PLUGIN_SOURCE_PREFIXES) {
const prefixes = getPluginSourcePrefixes();
for (const prefix of prefixes) {
if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) {
// 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文)
const lp = this.plugins.get(def.id);
@@ -508,6 +549,12 @@ export class PluginManager {
}
};
const pluginEnv: Record<string, string> = {};
try {
const savedEnv = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
Object.assign(pluginEnv, savedEnv);
} catch { /* noop */ }
const fakeGlobal: any = {
process: processObj,
Buffer: BufferCtor,
@@ -516,11 +563,11 @@ export class PluginManager {
exports: moduleObj.exports,
__dirname: '/',
__filename: '/plugin.js',
// Node 全局alias 到浏览器真实全局,以保持 window.location.href 正常工作)
global: globalThis,
globalThis: globalThis,
self: globalThis,
root: globalThis,
env: pluginEnv,
console,
setTimeout: setTimeout as any,
clearTimeout,

177
src/utils/localMusic.ts Normal file
View File

@@ -0,0 +1,177 @@
import type { Song, SongType } from '../types/song';
export interface ScannedTrack {
id: string;
name: string;
artist: string;
album: string;
duration: number;
file: File;
fileHandle?: FileSystemFileHandle;
url?: string;
}
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.ogg', '.aac', '.m4a', '.wma', '.ape', '.opus']);
function isAudioFile(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(name.slice(dot).toLowerCase());
}
function parseFileName(name: string): { title: string; artist: string } {
const dot = name.lastIndexOf('.');
const base = dot !== -1 ? name.slice(0, dot) : name;
const separators = [' - ', ' ', ' — ', ' -', '-', ' / ', '/'];
for (const sep of separators) {
const idx = base.indexOf(sep);
if (idx > 0 && idx < base.length - sep.length) {
return { artist: base.slice(0, idx).trim(), title: base.slice(idx + sep.length).trim() };
}
}
return { title: base, artist: '' };
}
function genId(): string {
return 'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
async function getDuration(file: File): Promise<number> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
audio.preload = 'metadata';
const timer = setTimeout(() => { audio.src = ''; URL.revokeObjectURL(url); resolve(0); }, 5000);
audio.onloadedmetadata = () => {
clearTimeout(timer);
const dur = Math.round(audio.duration * 1000);
audio.src = '';
URL.revokeObjectURL(url);
resolve(dur);
};
audio.onerror = () => { clearTimeout(timer); audio.src = ''; URL.revokeObjectURL(url); resolve(0); };
audio.src = url;
audio.load();
});
}
export async function scanFile(file: File, fileHandle?: FileSystemFileHandle): Promise<ScannedTrack | null> {
if (!isAudioFile(file.name)) return null;
const parsed = parseFileName(file.name);
const duration = await getDuration(file);
return {
id: genId(),
name: parsed.title || file.name,
artist: parsed.artist || '未知歌手',
album: '',
duration,
file,
fileHandle,
};
}
export async function scanDirectory(dirHandle: FileSystemDirectoryHandle, onProgress?: (name: string) => void): Promise<ScannedTrack[]> {
const results: ScannedTrack[] = [];
const entries: [string, FileSystemHandle][] = [];
for await (const entry of (dirHandle as any).entries()) {
entries.push(entry);
}
for (const [name, handle] of entries) {
if (onProgress) onProgress(name);
if (handle.kind === 'file') {
const fileHandle = handle as FileSystemFileHandle;
const file = await fileHandle.getFile();
const track = await scanFile(file, fileHandle);
if (track) results.push(track);
} else if (handle.kind === 'directory') {
const sub = await scanDirectory(handle as FileSystemDirectoryHandle, onProgress);
results.push(...sub);
}
}
return results;
}
export function trackToSong(track: ScannedTrack): Song {
return {
id: track.id,
name: track.name,
artist: track.artist,
picUrl: '',
url: track.url || '',
duration: formatDuration(track.duration),
source: 'local',
type: 'Local' as SongType,
albumName: track.album || null,
quality: 'standard',
};
}
export function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '0:00';
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
const DB_NAME = 'qz-local-music';
const DB_VERSION = 1;
const STORE_NAME = 'tracks';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function saveTracks(tracks: ScannedTrack[]): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const t of tracks) {
store.put({ id: t.id, name: t.name, artist: t.artist, album: t.album, duration: t.duration });
}
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
});
}
export async function loadTracks(): Promise<ScannedTrack[]> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => { db.close(); resolve(req.result || []); };
req.onerror = () => { db.close(); reject(req.error); };
});
} catch {
return [];
}
}
export async function clearSavedTracks(): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
});
} catch { /* noop */ }
}
export function supportsDirectoryPicker(): boolean {
return typeof (window as any).showDirectoryPicker === 'function';
}

View File

@@ -9,10 +9,9 @@ export function formatDuration(ms: number): string {
}
/**
* 将 PC 原版插件搜索结果转换为 Web 版 Song 格式
* 兼容 PC 版 songUtils.ts 的 transformSearchSong
* 将 PC/Android 原版插件搜索结果转换为 Web 版 Song 格式
*
* PC 原版搜索结果字段:
* PC 原版 v2 字段:
* - songmid: 歌曲 ID
* - name: 歌曲名
* - singer: 歌手名
@@ -22,21 +21,41 @@ export function formatDuration(ms: number): string {
* - albumId: 专辑 ID
* - albumName: 专辑名
* - types: 音质映射
*
* v3 插件字段:
* - id: 歌曲 ID
* - name: 歌曲名
* - artists: 歌手名
* - pic: 封面图
* - interval: 时长(毫秒字符串)
* - source: 来源标识
* - albumName: 专辑名
* - albumId: 专辑 ID
* - qualities: 音质映射
*/
export function transformSearchSong(raw: any): Song {
const id = String(raw.songmid || raw.id || raw.songId || '');
const name = raw.name || '未知歌曲';
const artist = raw.singer || raw.artist || raw.artists || '未知歌手';
const picUrl = raw.img || raw.picUrl || raw.pic || raw.mPic || raw.sPic || raw.m_img || raw.s_img || '';
const interval = raw.interval ? Number(raw.interval) : raw.duration ? Number(raw.duration) : 0;
const albumName = raw.albumName || raw.album || null;
const albumId = raw.albumId ? String(raw.albumId) : null;
const types = raw.types || raw.qualities || undefined;
return {
id: String(raw.songmid || raw.id || ''),
name: raw.name || '未知歌曲',
artist: raw.singer || raw.artist || '未知歌手',
picUrl: raw.img || raw.m_img || raw.s_img || raw.picUrl || '',
id,
name,
artist,
picUrl,
url: '',
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
duration: formatDuration(interval),
source: raw.source || '',
albumId: raw.albumId ? String(raw.albumId) : null,
albumName: raw.albumName || null,
albumId,
albumName,
type: 'Remote',
quality: 'auto',
types: raw.types || undefined,
types,
};
}

View File

@@ -1,18 +1,174 @@
<template>
<div class="view-container local-view">
<h1 class="view-title">Local Files</h1>
<div class="empty-state">
<h1 class="view-title">本地音乐</h1>
<!-- Toolbar -->
<div class="toolbar">
<button class="action-btn" @click="selectFolder" :disabled="scanning" v-if="supportsFolder">
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:folder-open'" width="18" height="18" />
{{ scanning ? '扫描中...' : '选择文件夹' }}
</button>
<button class="action-btn" @click="selectFiles" :disabled="scanning">
<Icon icon="lucide:file-plus" width="18" height="18" />
选择文件
</button>
<button class="action-btn secondary" @click="clearAll" v-if="tracks.length > 0">
<Icon icon="lucide:trash-2" width="18" height="18" />
清空列表
</button>
<span class="track-count" v-if="tracks.length > 0">{{ tracks.length }} 首歌曲</span>
</div>
<!-- Progress -->
<div class="progress-bar" v-if="scanProgress > 0 && scanProgress < 100">
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
</div>
<p class="scan-status" v-if="scanMessage">{{ scanMessage }}</p>
<!-- Song list -->
<div class="track-list" v-if="tracks.length > 0">
<div
v-for="(track, idx) in tracks"
:key="track.id"
class="track-item"
:class="{ active: currentTrackId === track.id }"
@dblclick="playTrack(idx)"
>
<span class="track-idx">{{ idx + 1 }}</span>
<div class="track-info">
<span class="track-name">{{ track.name }}</span>
<span class="track-artist">{{ track.artist }}</span>
</div>
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
<button class="play-btn" @click="playTrack(idx)" title="播放">
<Icon icon="lucide:play" width="16" height="16" />
</button>
</div>
</div>
<!-- Empty state -->
<div class="empty-state" v-else-if="!scanning">
<div class="icon-box">
<Icon icon="lucide:music" width="48" height="48" />
</div>
<p>No local files scanned yet.</p>
<button class="action-btn">Scan Folder</button>
<p>{{ scanned ? '未找到音频文件' : '暂无本地音乐' }}</p>
<p class="hint">选择包含音乐文件的文件夹或直接选择文件</p>
</div>
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
multiple
accept="audio/*"
style="display:none"
@change="onFileSelected"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player';
import { scanFile, scanDirectory, loadTracks, saveTracks, clearSavedTracks, supportsDirectoryPicker, trackToSong, formatDuration } from '../utils/localMusic';
import type { ScannedTrack } from '../utils/localMusic';
const player = usePlayerStore();
const fileInput = ref<HTMLInputElement | null>(null);
const tracks = ref<ScannedTrack[]>([]);
const scanning = ref(false);
const scanProgress = ref(0);
const scanMessage = ref('');
const scanned = ref(false);
const supportsFolder = supportsDirectoryPicker();
const currentTrackId = ref('');
onMounted(async () => {
const saved = await loadTracks();
if (saved.length > 0) {
tracks.value = saved as ScannedTrack[];
scanned.value = true;
}
});
async function selectFolder() {
try {
const dirHandle = await (window as any).showDirectoryPicker();
scanning.value = true;
scanProgress.value = 5;
scanMessage.value = '正在扫描文件夹...';
const found = await scanDirectory(dirHandle, (name) => {
scanMessage.value = `扫描: ${name}`;
});
scanProgress.value = 90;
scanMessage.value = `找到 ${found.length} 首歌曲`;
if (found.length > 0) {
tracks.value = found;
await saveTracks(found);
scanned.value = true;
}
scanProgress.value = 100;
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 2000);
} catch (err: any) {
if (err?.name !== 'AbortError' && err?.name !== 'SecurityError') {
console.warn('选择文件夹失败:', err);
}
scanMessage.value = '';
} finally {
scanning.value = false;
}
}
function selectFiles() {
fileInput.value?.click();
}
async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
scanning.value = true;
scanProgress.value = 10;
scanMessage.value = `处理 ${input.files.length} 个文件...`;
const found: ScannedTrack[] = [];
let done = 0;
for (const file of Array.from(input.files)) {
const track = await scanFile(file);
if (track) found.push(track);
done++;
scanProgress.value = 10 + Math.round((done / input.files.length) * 80);
scanMessage.value = `处理: ${file.name}`;
}
if (found.length > 0) {
tracks.value = found;
await saveTracks(found);
scanned.value = true;
}
scanProgress.value = 100;
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 1000);
scanning.value = false;
input.value = '';
}
function playTrack(idx: number) {
const track = tracks.value[idx];
if (!track) return;
currentTrackId.value = track.id;
const song = trackToSong(track);
song.url = URL.createObjectURL(track.file);
const list = tracks.value.map(t => {
const s = trackToSong(t);
s.url = URL.createObjectURL(t.file);
return s;
});
player.playFromList(song, list);
}
async function clearAll() {
tracks.value = [];
scanned.value = false;
await clearSavedTracks();
}
</script>
<style scoped>
@@ -22,6 +178,148 @@ import { Icon } from '@iconify/vue';
margin-bottom: 24px;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: var(--radius-full);
font-weight: 600;
font-size: 0.9rem;
border: none;
cursor: pointer;
background: var(--color-accent);
color: var(--color-bg-primary);
transition: opacity 0.2s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn:hover:not(:disabled) {
opacity: 0.85;
}
.action-btn.secondary {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.track-count {
margin-left: auto;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.progress-bar {
height: 4px;
background: var(--color-bg-secondary);
border-radius: 2px;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.scan-status {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.track-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.track-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: default;
transition: background 0.15s;
gap: 12px;
}
.track-item:hover {
background: var(--color-bg-secondary);
}
.track-item.active {
background: var(--color-bg-secondary);
color: var(--color-accent);
}
.track-idx {
width: 24px;
text-align: right;
color: var(--color-text-muted);
font-size: 0.85rem;
flex-shrink: 0;
}
.track-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.track-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-artist {
font-size: 0.8rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-duration {
color: var(--color-text-muted);
font-size: 0.85rem;
flex-shrink: 0;
}
.play-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.play-btn:hover {
color: var(--color-accent);
background: var(--color-bg-tertiary, rgba(128,128,128,0.1));
}
.empty-state {
height: 400px;
display: flex;
@@ -38,17 +336,9 @@ import { Icon } from '@iconify/vue';
margin-bottom: 24px;
}
.action-btn {
margin-top: 24px;
background-color: var(--color-accent);
color: var(--color-bg-primary);
padding: 12px 24px;
border-radius: var(--radius-full);
font-weight: 600;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 0.9;
.hint {
font-size: 0.85rem;
margin-top: 8px;
opacity: 0.7;
}
</style>