Files
AlgerMusicPlayer/src/renderer/services/workers/lxScriptSandbox.worker.ts
T

428 lines
11 KiB
TypeScript
Raw Normal View History

import type { LxInitedData, LxScriptInfo } from '@/types/lxMusic';
import * as lxCrypto from '@/utils/lxCrypto';
type WorkerInitMessage = {
type: 'initialize';
script: string;
scriptInfo: LxScriptInfo;
};
type WorkerInvokeMessage = {
type: 'invoke-request';
callId: string;
payload: any;
};
type WorkerHttpResponseMessage = {
type: 'http-response';
requestId: string;
response: any;
body: any;
error?: string;
};
type WorkerHostMessage = WorkerInitMessage | WorkerInvokeMessage | WorkerHttpResponseMessage;
type HostInitializedMessage = {
type: 'initialized';
data: LxInitedData;
};
type HostScriptErrorMessage = {
type: 'script-error';
message: string;
};
type HostInvokeResultMessage = {
type: 'invoke-result';
callId: string;
result: any;
};
type HostInvokeErrorMessage = {
type: 'invoke-error';
callId: string;
message: string;
};
type HostHttpRequestMessage = {
type: 'http-request';
requestId: string;
url: string;
options: any;
};
type HostLogMessage = {
type: 'log';
level: 'log' | 'warn' | 'error' | 'info';
args: any[];
};
type HostWorkerMessage =
| HostInitializedMessage
| HostScriptErrorMessage
| HostInvokeResultMessage
| HostInvokeErrorMessage
| HostHttpRequestMessage
| HostLogMessage;
let requestHandler: ((data: any) => Promise<any>) | null = null;
let requestCounter = 0;
const pendingHttpCallbacks = new Map<
string,
(error: Error | null, response: any, body: any) => void
>();
const postToHost = (message: HostWorkerMessage) => {
self.postMessage(message);
};
const postLog = (level: HostLogMessage['level'], ...args: any[]) => {
postToHost({
type: 'log',
level,
args
});
};
/**
* Node Buffer 语义的最小实现。
* 落雪脚本按 lx-music-desktop 的 Node 环境编写:
* buffer.from/bufToString 依赖 encoding 参数(base64/hex 等),
* 且会对返回的 Buffer 调用 .toString(encoding)
*/
class LxBuffer extends Uint8Array {
toString(encoding = 'utf-8'): string {
return bytesToString(this, encoding);
}
}
const toLxBuffer = (bytes: Uint8Array): LxBuffer =>
new LxBuffer(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength);
const hexToBytes = (hex: string): Uint8Array => {
const clean = hex.length % 2 ? `0${hex}` : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) || 0;
}
return out;
};
const toBytes = (data: any, encoding = 'utf-8'): Uint8Array => {
if (typeof data === 'string') {
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Decode(data);
case 'hex':
return hexToBytes(data);
case 'binary':
case 'latin1': {
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
default:
return new TextEncoder().encode(data);
}
}
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (Array.isArray(data)) return Uint8Array.from(data);
return new Uint8Array(0);
};
const bytesToString = (data: any, encoding = 'utf-8'): string => {
const bytes = toBytes(data);
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Encode(bytes);
case 'hex': {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
case 'binary':
case 'latin1': {
let out = '';
for (const byte of bytes) out += String.fromCharCode(byte);
return out;
}
default:
return new TextDecoder(encoding).decode(bytes);
}
};
/**
* 落雪脚本参照 lx-music-desktop 环境编写(隐藏 BrowserWindowwindow 存在)。
* 混淆脚本常用 `window -> (process/require ? global : this)` 探测全局对象,
* 而 module worker 中三者全为 undefined,随后访问 `.console` 即抛
* "Cannot read properties of undefined (reading 'console')",这里补齐别名。
*/
const exposeNodeLikeGlobals = () => {
const g = globalThis as any;
if (!g.window) g.window = g;
if (!g.global) g.global = g;
};
const hardenGlobalScope = () => {
const blockedKeys: Array<keyof typeof globalThis> = [
'fetch',
'XMLHttpRequest',
'WebSocket',
'EventSource'
] as any;
blockedKeys.forEach((key) => {
try {
Object.defineProperty(globalThis, key, {
configurable: true,
writable: false,
value: undefined
});
} catch {
// ignore
}
});
};
const createLxApi = (scriptInfo: LxScriptInfo) => {
return {
version: '2.8.0',
env: 'desktop',
appInfo: {
version: '2.8.0',
versionNum: 208,
locale: 'zh-cn'
},
currentScriptInfo: scriptInfo,
EVENT_NAMES: {
inited: 'inited',
request: 'request',
updateAlert: 'updateAlert'
},
on: (eventName: string, handler: (data: any) => Promise<any>) => {
if (eventName === 'request') {
requestHandler = handler;
}
},
send: (eventName: string, data: any) => {
if (eventName === 'inited') {
postToHost({
type: 'initialized',
data: data as LxInitedData
});
} else if (eventName === 'updateAlert') {
postLog('info', '[LxScript][updateAlert]', data);
}
},
request: (
url: string,
options: any,
callback: (err: Error | null, resp: any, body: any) => void
) => {
const requestId = `wreq_${Date.now()}_${requestCounter++}`;
pendingHttpCallbacks.set(requestId, callback);
postToHost({
type: 'http-request',
requestId,
url,
options
});
return () => {
pendingHttpCallbacks.delete(requestId);
};
},
utils: {
buffer: {
from: (data: any, encoding?: string) => toLxBuffer(toBytes(data, encoding)),
bufToString: (buffer: any, encoding?: string) => bytesToString(buffer, encoding)
},
crypto: {
md5: lxCrypto.md5,
sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256,
// lx-music 中 randomBytes 返回 Node Buffer,脚本会对其调用 .toString(encoding)
randomBytes: (size: number) => {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return toLxBuffer(bytes);
},
aesEncrypt: (
buffer: string | Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesEncrypt(buffer, mode, key as any, iv as any)),
aesDecrypt: (
buffer: Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesDecrypt(buffer, mode, key as any, iv as any)),
rsaEncrypt: (buffer: string | Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaEncrypt(buffer, key)),
rsaDecrypt: (buffer: Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaDecrypt(buffer, key)),
base64Encode: lxCrypto.base64Encode,
base64Decode: (str: string) => toLxBuffer(lxCrypto.base64Decode(str))
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
try {
const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
},
deflate: async (buffer: ArrayBuffer) => {
try {
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
}
}
}
};
};
const resetWorkerState = () => {
requestHandler = null;
pendingHttpCallbacks.clear();
requestCounter = 0;
};
const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
resetWorkerState();
hardenGlobalScope();
exposeNodeLikeGlobals();
(globalThis as any).lx = createLxApi(scriptInfo);
const sandboxScript = `
const globalThisRef = globalThis;
const lx = globalThis.lx;
${script}
export {};
`;
const scriptUrl = URL.createObjectURL(
new Blob([sandboxScript], {
type: 'text/javascript'
})
);
try {
await import(/* @vite-ignore */ scriptUrl);
// 不在此处判定 initialized:脚本可能异步初始化(如先请求远端配置,
// 回调里才调用 lx.send(inited)),由 Runner 侧的初始化超时兜底
} finally {
URL.revokeObjectURL(scriptUrl);
}
};
const resolveInvocation = async (callId: string, payload: any) => {
if (!requestHandler) {
postToHost({
type: 'invoke-error',
callId,
message: '脚本未注册请求处理器'
});
return;
}
try {
const result = await requestHandler(payload);
postToHost({
type: 'invoke-result',
callId,
result
});
} catch (error) {
postToHost({
type: 'invoke-error',
callId,
message: error instanceof Error ? error.message : String(error)
});
}
};
self.onmessage = async (event: MessageEvent<WorkerHostMessage>) => {
const message = event.data;
switch (message.type) {
case 'initialize':
try {
await initializeScript(message.script, message.scriptInfo);
} catch (error) {
postToHost({
type: 'script-error',
message: error instanceof Error ? error.message : String(error)
});
}
break;
case 'invoke-request':
await resolveInvocation(message.callId, message.payload);
break;
case 'http-response': {
const callback = pendingHttpCallbacks.get(message.requestId);
if (!callback) return;
pendingHttpCallbacks.delete(message.requestId);
if (message.error) {
callback(new Error(message.error), null, null);
} else {
callback(null, message.response, message.body);
}
break;
}
default:
break;
}
};