refactor: major package updates

This commit is contained in:
鲁树人
2025-10-16 01:00:38 +09:00
parent 2e52c67533
commit a11caaae92
34 changed files with 373 additions and 240 deletions
+49 -43
View File
@@ -2,83 +2,89 @@ import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-l
import { FaDownload } from 'react-icons/fa';
import { useAppSelector } from '~/hooks';
import { toast } from 'react-toastify';
import { SimpleQueue } from '~/util/SimpleQueue';
export function DownloadAll() {
const files = useAppSelector(selectFiles);
const onClickDownloadAll = async () => {
console.time('DownloadAll'); //开始计时
const fileCount = Object.keys(files).length;
const downloadAllAsync = async () => {
const fileList = Object.values(files);
const fileCount = fileList.length;
if (fileCount === 0) {
toast.warning('未添加文件');
return;
}
//判断所有文件是否处理完成
const allComplete = Object.values(files).every((file) => file.state !== ProcessState.PROCESSING);
// 判断所有文件是否处理完成
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) {
toast.warning('请等待所有文件解密完成');
return;
}
//过滤处理失败的文件
const completeFiles = Object.values(files).filter((file) => file.state === ProcessState.COMPLETE);
// 过滤处理失败的文件
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
//开始下载
let dir: FileSystemDirectoryHandle | undefined;
// 准备下载
let dir: FileSystemDirectoryHandle | null = null;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') {
return;
return; // user cancelled
}
console.error(e);
}
toast.warning('开始下载,请稍候');
const queue = new SimpleQueue(8);
const promises = Object.values(completeFiles).map(async (file) => {
console.log(`开始下载: ${file.fileName}`);
try {
if (dir) {
await DownloadNew(dir, file);
} else {
await DownloadOld(file);
}
console.log(`成功下载: ${file.fileName}`);
await queue.enter();
await downloadFile(file, dir);
} catch (e) {
console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e}`);
toast.error(`出现错误: ${e as Error}`);
throw e;
} finally {
queue.leave();
}
});
await Promise.allSettled(promises).then((f) => {
const success = f.filter((result) => result.status === 'fulfilled').length;
if (success === fileCount) {
toast.success(`成功下载: ${success}/${fileCount}首`);
} else {
toast.warning(`成功下载: ${success}/${fileCount}首`);
}
});
console.timeEnd('DownloadAll'); //停止计时
const promiseResults = await Promise.allSettled(promises);
const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
toast[level](`成功下载: ${success}/${fileCount}首`);
};
function onDownloadAll() {
downloadAllAsync().catch((e) => {
// this should not happen
console.error('下载全部出现错误', e);
});
}
return (
<button className="btn btn-primary" id="downloadAll" onClick={onClickDownloadAll} title="下载全部">
<button className="btn btn-primary" id="downloadAll" onClick={onDownloadAll} title="下载全部">
<FaDownload />
</button>
);
}
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const writable = await fileHandle.createWritable();
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable));
}
async function DownloadOld(file: DecryptedAudioFile) {
const a = document.createElement('a');
a.href = file.decrypted;
a.download = file.cleanName + '.' + file.ext;
document.body.append(a);
a.click();
a.remove();
async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
if (dir) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const fileStream = await fileHandle.createWritable();
try {
const res = await fetch(file.decrypted);
await res.body?.pipeTo(fileStream);
} catch {
await fileStream.abort();
}
} else {
const anchor = document.createElement('a');
anchor.href = file.decrypted;
anchor.download = file.cleanName + '.' + file.ext;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ export type DownloadBase64Props = {
filename: string;
mimetype?: string;
className?: string;
icon?: boolean | ReactNode;
icon?: ReactNode | true | false;
children?: ReactNode;
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { FiExternalLink } from 'react-icons/fi';
export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
icon?: boolean | ReactNode;
icon?: ReactNode | true | false;
};
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
+3 -4
View File
@@ -13,13 +13,12 @@ export interface ImportSecretModalProps {
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
const handleFileReceived = (files: File[]) => {
const promise = onImport(files[0]);
if (promise instanceof Promise) {
promise.catch((err) => {
const importResult = onImport(files[0]);
if (importResult instanceof Promise) {
importResult.catch((err) => {
console.error('could not import: ', err);
});
}
return promise;
};
const refModel = useRef<HTMLDialogElement>(null);
+1 -1
View File
@@ -11,7 +11,7 @@ export function SDKVersion() {
const refDialog = useRef<HTMLDialogElement>(null);
const [sdkVersion, setSdkVersion] = useState('...');
useEffect(() => {
getSDKVersion().then(setSdkVersion);
getSDKVersion().then(setSdkVersion, () => setSdkVersion('N/A'));
}, []);
return (
+4 -1
View File
@@ -27,7 +27,10 @@ export function SelectFile() {
fileName,
}),
);
dispatch(processFile({ fileId }));
dispatch(processFile({ fileId })).catch((err) => {
console.log(`failed to add file (id=${fileId}, name=${fileName}, err=${err as Error})`);
});
}
};
+3 -3
View File
@@ -19,18 +19,18 @@ export class KugouMusicDecipher implements DecipherInstance {
kgm.decrypt(block, offset);
}
return {
return Promise.resolve({
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
});
} finally {
kgmHdr?.free();
kgm?.free();
}
}
public static make() {
public static make(this: void) {
return new KugouMusicDecipher();
}
}
+3 -3
View File
@@ -18,18 +18,18 @@ export class KuwoMusicDecipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) {
kwm.decrypt(block, offset);
}
return {
return Promise.resolve({
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
});
} finally {
kwm?.free();
header?.free();
}
}
public static make() {
public static make(this: void) {
return new KuwoMusicDecipher();
}
}
+10 -7
View File
@@ -6,22 +6,25 @@ export class Migu3DKeylessDecipher implements DecipherInstance {
cipherName = 'Migu3D (Keyless)';
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
const audioBuffer = new Uint8Array(buffer);
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
for (const [block, i] of chunkBuffer(audioBuffer)) {
mg3d.decrypt(block, i);
try {
for (const [block, i] of chunkBuffer(audioBuffer)) {
mg3d.decrypt(block, i);
}
} finally {
mg3d.free();
}
mg3d.free();
return {
return Promise.resolve({
cipherName: this.cipherName,
status: Status.OK,
data: audioBuffer,
};
});
}
public static make() {
public static make(this: void) {
return new Migu3DKeylessDecipher();
}
}
@@ -26,17 +26,17 @@ export class NetEaseCloudMusicDecipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) {
ncm.decrypt(block, offset);
}
return {
return Promise.resolve({
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
});
} finally {
ncm.free();
}
}
public static make() {
public static make(this: void) {
return new NetEaseCloudMusicDecipher();
}
}
+14 -11
View File
@@ -19,14 +19,14 @@ export class QQMusicV1Decipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) {
decryptQMC1(block, offset);
}
return {
return Promise.resolve({
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
});
}
public static create() {
public static create(this: void) {
return new QQMusicV1Decipher();
}
}
@@ -62,25 +62,28 @@ export class QQMusicV2Decipher implements DecipherInstance {
throw new Error('EKey required');
}
const qmc2 = new QMC2(ekey);
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
for (const [block, offset] of chunkBuffer(audioBuffer)) {
qmc2.decrypt(block, offset);
const qmc2 = new QMC2(ekey);
try {
for (const [block, offset] of chunkBuffer(audioBuffer)) {
qmc2.decrypt(block, offset);
}
} finally {
qmc2.free();
}
qmc2.free();
return {
return Promise.resolve({
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
});
}
public static createWithUserKey() {
public static createWithUserKey(this: void) {
return new QQMusicV2Decipher(true);
}
public static createWithEmbeddedEKey() {
public static createWithEmbeddedEKey(this: void) {
return new QQMusicV2Decipher(false);
}
}
+10 -7
View File
@@ -18,20 +18,23 @@ export class QignTingFMDecipher implements DecipherInstance {
};
}
const qtfm = new QingTingFM(key, iv);
const audioBuffer = new Uint8Array(buffer);
for (const [block, i] of chunkBuffer(audioBuffer)) {
qtfm.decrypt(block, i);
const qtfm = new QingTingFM(key, iv);
try {
for (const [block, i] of chunkBuffer(audioBuffer)) {
qtfm.decrypt(block, i);
}
} finally {
qtfm.free();
}
return {
return Promise.resolve({
cipherName: this.cipherName,
status: Status.OK,
data: audioBuffer,
};
});
}
public static make() {
public static make(this: void) {
return new QignTingFMDecipher();
}
}
+3 -3
View File
@@ -4,15 +4,15 @@ export class TransparentDecipher implements DecipherInstance {
cipherName = 'none';
async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> {
return {
return Promise.resolve({
cipherName: 'None',
status: Status.OK,
data: buffer,
message: 'No decipher applied',
};
});
}
public static make() {
public static make(this: void) {
return new TransparentDecipher();
}
}
+11 -8
View File
@@ -6,23 +6,26 @@ export class XiamiDecipher implements DecipherInstance {
cipherName = 'Xiami (XM)';
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
const { copyPlainLength } = xm;
const audioBuffer = buffer.slice(0x10);
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
xm.decrypt(block);
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
try {
const { copyPlainLength } = xm;
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
xm.decrypt(block);
}
} finally {
xm.free();
}
xm.free();
return {
return Promise.resolve({
cipherName: this.cipherName,
status: Status.OK,
data: audioBuffer,
};
});
}
public static make() {
public static make(this: void) {
return new XiamiDecipher();
}
}
+26 -22
View File
@@ -23,18 +23,18 @@ export class XimalayaAndroidDecipher implements DecipherInstance {
}
const result = new Uint8Array(buffer);
result.set(slice, 0);
return {
return Promise.resolve({
cipherName: this.cipherName,
status: Status.OK,
data: result,
};
});
}
public static makeX2M() {
public static makeX2M(this: void) {
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
}
public static makeX3M() {
public static makeX3M(this: void) {
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
}
}
@@ -45,27 +45,31 @@ export class XimalayaPCDecipher implements DecipherInstance {
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
// Detect with first 0x400 bytes
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
const xm = new XmlyPC(buffer.subarray(0, headerSize));
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
xm.free();
const xmly = new XmlyPC(buffer.subarray(0, headerSize));
const result = new Uint8Array(audioSize);
result.set(audioHeader);
result.set(encryptedAudioPart, audioHeader.byteLength);
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
return {
status: Status.OK,
data: result,
cipherName: this.cipherName,
};
try {
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xmly;
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
const encryptedAudioPartLen = xmly.decrypt(encryptedAudioPart);
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
const result = new Uint8Array(audioSize);
result.set(audioHeader);
result.set(encryptedAudioPart, audioHeader.byteLength);
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
return Promise.resolve({
status: Status.OK,
data: result,
cipherName: this.cipherName,
});
} finally {
xmly.free();
}
}
public static make() {
public static make(this: void) {
return new XimalayaPCDecipher();
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb
const resp = cb(instance);
if (resp && isPromise(resp)) {
isAsync = true;
resp.finally(() => instance.free());
resp.finally(() => instance.free()).catch(() => {});
}
return resp;
} finally {
+1 -1
View File
@@ -11,5 +11,5 @@ export async function workerGetQtfmDeviceKey({
board,
}: GetQingTingFMDeviceKeyPayload) {
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
return hex(buffer);
return Promise.resolve(hex(buffer));
}
+1 -1
View File
@@ -1,5 +1,5 @@
// This is a dummy module for vite/rollup to resolve.
export function createRequire() {
import('radash'); // we need to import something, so vite don't complain on build
const _ = import('radash'); // we need to import something, so vite don't complain on build
throw new Error('this is a dummy module. Do not use');
}
+4 -8
View File
@@ -26,14 +26,10 @@ export function FileError({ error, code }: FileErrorProps) {
const copyError = () => {
if (error) {
navigator.clipboard
.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error }))
.then(() => {
toast.success('错误信息已复制到剪贴板');
})
.catch((e) => {
toast.error(`复制错误信息失败: ${e}`);
});
navigator.clipboard.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error })).then(
() => toast.success('错误信息已复制到剪贴板'),
(e) => toast.error(`复制错误信息失败: ${e as Error}`),
);
}
};
@@ -1,20 +1,11 @@
import { RiFileCopyLine } from 'react-icons/ri';
import { toast } from 'react-toastify';
import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
import { copyToClipboard } from '~/util/clipboard';
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
export function InstructionsPC() {
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
const copyDbPathToClipboard = () => {
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
const copyDbPathToClipboard = () => copyToClipboard(DB_PATH);
return (
<>
@@ -53,7 +53,7 @@ export function PanelQMCv2Key() {
toastImportResult(file.name, keys);
} catch (e) {
console.error('error during import: ', e);
alert(`导入数据库时发生错误:${e}`);
alert(`导入数据库时发生错误:${e as Error}`);
}
};
+8 -10
View File
@@ -9,6 +9,7 @@ import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
import { Ruby } from '~/components/Ruby';
import { HiWord } from '~/components/HelpText/HiWord';
import { toast } from 'react-toastify';
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
@@ -28,23 +29,20 @@ export function PanelQingTing() {
return;
}
const dataMap = Object.create(null);
const dataMap = Object.create(null) as GetQingTingFMDeviceKeyPayload;
for (const [, key, value] of plainText.matchAll(/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim)) {
dataMap[key.toLowerCase()] = value;
dataMap[key.toLowerCase() as keyof GetQingTingFMDeviceKeyPayload] = value;
}
const { product, device, manufacturer, brand, board, model } = dataMap;
if (product && device && manufacturer && brand && board && model) {
e.preventDefault();
workerClientBus
.request<string, GetQingTingFMDeviceKeyPayload>(
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY,
dataMap,
)
.then(setSecretKey)
.catch((err) => {
alert(`生成设备密钥时发生错误: ${err}`);
});
.request<
string,
GetQingTingFMDeviceKeyPayload
>(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, dataMap)
.then(setSecretKey, (err) => toast.error(`生成设备密钥时发生错误: ${err}`));
}
};
+1 -1
View File
@@ -55,7 +55,7 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
let lastSettings: unknown;
try {
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? '');
const loadedSettings = JSON.parse(localStorage.getItem(storageKey) ?? '') as ProductionSettings;
if (loadedSettings) {
const mergedSettings = mergeSettings(loadedSettings);
store.dispatch(setProductionChanges(mergedSettings));
+2 -1
View File
@@ -3,8 +3,9 @@ import '@testing-library/jest-dom';
// FIXME: Use something like jsdom-worker?
// see: https://github.com/developit/jsdom-worker
if (!global.Worker) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(global as any).Worker = class MockWorker {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
events: Record<string, (e: unknown) => void> = Object.create(null);
onmessage = undefined;
+28
View File
@@ -0,0 +1,28 @@
export class SimpleQueue {
private queue: (() => void)[] = [];
private running = 0;
constructor(private concurrency: number) {}
async enter() {
return new Promise<void>((resolve) => {
this.queue.push(resolve);
setTimeout(this.next);
});
}
leave() {
this.running--;
setTimeout(this.next);
}
private next = () => {
while (this.running < this.concurrency && this.queue.length > 0) {
const fn = this.queue.shift();
if (fn) {
this.running++;
setTimeout(fn);
}
}
};
}
+1 -1
View File
@@ -50,7 +50,7 @@ export class WorkerClientBus<T = string> {
async request<R, P>(actionName: T, payload: P): Promise<R> {
return new Promise((resolve, reject) => {
const id = `request://${actionName}/${nanoid()}`;
const id = `request://${actionName as string}/${nanoid()}`;
this.idPromiseMap.set(id, [resolve, reject]);
this.worker.postMessage({
id,
+1 -1
View File
@@ -2,7 +2,7 @@ import { ConcurrentQueue } from '../ConcurrentQueue';
import { nextTickAsync } from '../nextTick';
class SimpleQueue<T, R = void> extends ConcurrentQueue<T> {
handler(_item: T): Promise<R> {
handler(this: void, _item: T): Promise<R> {
throw new Error('Method not overridden');
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ test('should be able to forward request to worker client bus', async () => {
const bus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(null as never);
vi.spyOn(bus, 'request').mockImplementation(
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
return { actionName, payload };
return Promise.resolve({ actionName, payload });
},
);
+3 -1
View File
@@ -1,3 +1,5 @@
export function applyTemplate(tpl: string, values: Record<string, unknown>) {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : ''));
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) =>
Object.hasOwn(values, key) ? String(values[key]) : '',
);
}
+4 -8
View File
@@ -1,12 +1,8 @@
import { toast } from 'react-toastify';
export const copyToClipboard = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
navigator.clipboard.writeText(text).then(
() => toast.success('已复制到剪贴板'),
(err) => toast.error(`复制失败,请手动复制。\n错误: ${err as Error}`),
);
};
+1 -1
View File
@@ -1,3 +1,3 @@
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
return JSON.parse(JSON.stringify(obj)) as T;
}