6 Commits
v0.6.0 ... main

Author SHA1 Message Date
鲁树人
a11caaae92 refactor: major package updates 2025-10-16 01:00:38 +09:00
鲁树人
2e52c67533 chore: minor package updates 2025-10-15 23:15:00 +09:00
鲁树人
56bb94b90c docs: update highlight styling 2025-10-15 23:12:47 +09:00
鲁树人
c1a5c6bde6 chore: reformat project 2025-10-15 23:10:19 +09:00
鲁树人
009804cbbd docs: update notes regarding supported formats 2025-10-15 23:10:12 +09:00
鲁树人
ff6fc467ae ci: only include artifact file once. 2025-10-15 22:52:59 +09:00
41 changed files with 1214 additions and 1075 deletions

View File

@@ -40,7 +40,6 @@ jobs:
draft: true draft: true
files: | files: |
release/um-react-*.zip release/um-react-*.zip
release/um-react-win64-*.zip
body: | body: |
上个版本:[v0.0.0](https://git.um-react.app/um/um-react/releases/tag/v0.0.0) 上个版本:[v0.0.0](https://git.um-react.app/um/um-react/releases/tag/v0.0.0)

View File

@@ -10,7 +10,7 @@
- CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。 - CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。
- [常见问题参考](./docs/faq_zh-hans.md) - [常见问题参考](./docs/faq_zh-hans.md)
> **WARNING** > [!WARNING]
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。 > 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
[授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE [授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE
@@ -19,7 +19,8 @@
[`@unlock_music_chat`]: https://t.me/unlock_music_chat [`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-actions]: https://git.um-react.app/um/um-react/actions?workflow=build.yaml [um-react-actions]: https://git.um-react.app/um/um-react/actions?workflow=build.yaml
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。 > [!TIP]
> 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
## 支持的格式 ## 支持的格式
@@ -31,27 +32,32 @@
- Mac 客户端 (`.mflach` 等) [^qm-key-mac] - Mac 客户端 (`.mflach` 等) [^qm-key-mac]
- [x] 网易云音乐 (`.ncm`) - [x] 网易云音乐 (`.ncm`)
- [x] 虾米音乐 (`.xm`) - [x] 虾米音乐 (`.xm`)
- [x] 酷我音乐 (`.kwm`) - [x] 酷我音乐 (`.kwm` / `.mflac`) [^kuwo-key-android]
- [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`) - [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`)
- PC / 安卓客户端的 `kgg` 文件需要提供密钥数据库。 - PC / 安卓客户端[^kgg-android]的 `kgg` 文件需要提供密钥数据库。
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`) - [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
- [x] 咪咕音乐格式 (`.mg3d`) - [x] 咪咕音乐格式 (`.mg3d`)
- [x] 蜻蜓 FM (`.qta`) - [x] 蜻蜓 FM (`.qta`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~ - 需要提取设备 ID。
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。 [^qm-key-pc]: QQ 音乐的 PC 客户端仅支持 v19.51 或更低版本。
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库并导入后使用。 [^qm-key-android]: QQ 音乐的安卓客户端支持需要获取超级管理员权限后提取密钥数据库并导入后使用。
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库并导入后使用。 [^qm-key-ios]: QQ 音乐 iOS 客户端支持需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库并导入后使用。
[^qm-key-mac]: 需要导入密钥数据库 [^qm-key-mac]: QQ 音乐 Mac 客户端支持需要导入密钥数据库,目前支持 v8.8.0 及 v10.7.1
[^kgg-android]: 酷狗音乐的安卓客户端支持需要获取超级管理员权限后提取密钥数据库并导入后使用。
[^kuwo-key-android]: 酷我音乐的安卓客户端下载的 AI 升频文件需要获取超级管理员权限后提取密钥数据库并导入后使用。
## 错误报告 ## 错误报告
有不支持的格式?请提交样本(加密文件)与客户端信息版本信息(如系统版本、下载渠道),或一并上传其安装包到[仓库的问题追踪区][project-issues]。 有不支持的格式?请提交样本(加密文件)与客户端信息版本信息(如系统版本、下载渠道),或一并上传其安装包到[仓库的问题追踪区][project-issues]。
⚠️ 如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。 > [!NOTE]
> 如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。 遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
@@ -59,7 +65,7 @@
[project-issues]: https://git.um-react.app/um/um-react/issues/new [project-issues]: https://git.um-react.app/um/um-react/issues/new
## 使用 Docker 构建、部署 (Linux) ## 使用 Docker 构建、部署
首先克隆仓库并进入目录: 首先克隆仓库并进入目录:
@@ -86,13 +92,17 @@ docker run -d -p 8080:80 --name um-react um-react
然后访问 `http://localhost:8080` 即可。 然后访问 `http://localhost:8080` 即可。
> [!NOTE]
> 项目不支持运行在子目录下。
## 开发相关 ## 开发相关
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。 从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
### 解密库开发 ### 解密库开发
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。 > [!TIP]
> 如果只是进行前端方面的更改,你可以跳过该节。
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。 请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。

View File

@@ -1,15 +1,16 @@
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import reactRefresh from 'eslint-plugin-react-refresh'; import reactRefresh from 'eslint-plugin-react-refresh';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
import eslintConfigPrettier from 'eslint-config-prettier/flat'; import eslintConfigPrettier from 'eslint-config-prettier/flat';
import globals from 'globals'; import globals from 'globals';
export default tseslint.config( export default defineConfig(
eslint.configs.recommended, eslint.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommendedTypeChecked,
reactRefresh.configs.recommended, reactRefresh.configs.recommended,
reactHooks.configs['recommended-latest'], reactHooks.configs.flat.recommended,
eslintConfigPrettier, eslintConfigPrettier,
{ {
@@ -40,4 +41,14 @@ export default tseslint.config(
}, },
}, },
}, },
{
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: ['*.mjs', 'src/*.mjs', 'scripts/*.mjs'],
},
},
},
},
); );

View File

@@ -20,57 +20,57 @@
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@unlock-music/crypto": "^0.1.12", "@unlock-music/crypto": "^0.1.12",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.6",
"radash": "^12.1.1", "radash": "^12.1.1",
"react": "^19.1.1", "react": "^19.2.0",
"react-dom": "^19.1.1", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router": "^7.8.2", "react-router": "^7.9.4",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"sql.js": "^1.13.0" "sql.js": "^1.13.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.37.0",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.14",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.1", "@types/node": "^24.7.2",
"@types/react": "^19.1.12", "@types/react": "^19.2.2",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.2",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/sql.js": "^1.4.9", "@types/sql.js": "^1.4.9",
"@types/tar-stream": "^3.1.4", "@types/tar-stream": "^3.1.4",
"@types/wicg-file-system-access": "^2023.10.6", "@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.42.0", "@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.42.0", "@typescript-eslint/parser": "^8.46.1",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.4",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"daisyui": "^5.1.8", "daisyui": "^5.3.2",
"eslint": "^9.35.0", "eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.3.0", "globals": "^16.4.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^26.1.0", "jsdom": "^27.0.0",
"lint-staged": "^16.1.6", "lint-staged": "^16.2.4",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup": "^4.50.1", "rollup": "^4.52.4",
"sass": "^1.92.1", "sass": "^1.93.2",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.14",
"tar-stream": "^3.1.7", "tar-stream": "^3.1.7",
"terser": "^5.44.0", "terser": "^5.44.0",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.42.0", "typescript-eslint": "^8.46.1",
"vite": "^7.1.5", "vite": "^7.1.10",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.1.0",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",

1785
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,83 +2,89 @@ import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-l
import { FaDownload } from 'react-icons/fa'; import { FaDownload } from 'react-icons/fa';
import { useAppSelector } from '~/hooks'; import { useAppSelector } from '~/hooks';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { SimpleQueue } from '~/util/SimpleQueue';
export function DownloadAll() { export function DownloadAll() {
const files = useAppSelector(selectFiles); const files = useAppSelector(selectFiles);
const onClickDownloadAll = async () => { const downloadAllAsync = async () => {
console.time('DownloadAll'); //开始计时 const fileList = Object.values(files);
const fileCount = Object.keys(files).length; const fileCount = fileList.length;
if (fileCount === 0) { if (fileCount === 0) {
toast.warning('未添加文件'); toast.warning('未添加文件');
return; return;
} }
// 判断所有文件是否处理完成 // 判断所有文件是否处理完成
const allComplete = Object.values(files).every((file) => file.state !== ProcessState.PROCESSING); const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) { if (!allComplete) {
toast.warning('请等待所有文件解密完成'); toast.warning('请等待所有文件解密完成');
return; 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 { try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' }); dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) { } catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
return; return; // user cancelled
} }
console.error(e);
} }
toast.warning('开始下载,请稍候'); toast.warning('开始下载,请稍候');
const queue = new SimpleQueue(8);
const promises = Object.values(completeFiles).map(async (file) => { const promises = Object.values(completeFiles).map(async (file) => {
console.log(`开始下载: ${file.fileName}`);
try { try {
if (dir) { await queue.enter();
await DownloadNew(dir, file); await downloadFile(file, dir);
} else {
await DownloadOld(file);
}
console.log(`成功下载: ${file.fileName}`);
} catch (e) { } catch (e) {
console.error(`下载失败: ${file.fileName}`, e); console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e}`); toast.error(`出现错误: ${e as Error}`);
throw e; throw e;
} finally {
queue.leave();
} }
}); });
await Promise.allSettled(promises).then((f) => {
const success = f.filter((result) => result.status === 'fulfilled').length; const promiseResults = await Promise.allSettled(promises);
if (success === fileCount) { const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
toast.success(`成功下载: ${success}/${fileCount}`); const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
} else { toast[level](`成功下载: ${success}/${fileCount}`);
toast.warning(`成功下载: ${success}/${fileCount}`);
}
});
console.timeEnd('DownloadAll'); //停止计时
}; };
function onDownloadAll() {
downloadAllAsync().catch((e) => {
// this should not happen
console.error('下载全部出现错误', e);
});
}
return ( return (
<button className="btn btn-primary" id="downloadAll" onClick={onClickDownloadAll} title="下载全部"> <button className="btn btn-primary" id="downloadAll" onClick={onDownloadAll} title="下载全部">
<FaDownload /> <FaDownload />
</button> </button>
); );
} }
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) { async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
if (dir) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true }); const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const writable = await fileHandle.createWritable(); const fileStream = await fileHandle.createWritable();
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable)); 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();
} }
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();
} }

View File

@@ -7,7 +7,7 @@ export type DownloadBase64Props = {
filename: string; filename: string;
mimetype?: string; mimetype?: string;
className?: string; className?: string;
icon?: boolean | ReactNode; icon?: ReactNode | true | false;
children?: ReactNode; children?: ReactNode;
}; };

View File

@@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { FiExternalLink } from 'react-icons/fi'; import { FiExternalLink } from 'react-icons/fi';
export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & { export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
icon?: boolean | ReactNode; icon?: ReactNode | true | false;
}; };
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) { export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {

View File

@@ -13,13 +13,12 @@ export interface ImportSecretModalProps {
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) { export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
const handleFileReceived = (files: File[]) => { const handleFileReceived = (files: File[]) => {
const promise = onImport(files[0]); const importResult = onImport(files[0]);
if (promise instanceof Promise) { if (importResult instanceof Promise) {
promise.catch((err) => { importResult.catch((err) => {
console.error('could not import: ', err); console.error('could not import: ', err);
}); });
} }
return promise;
}; };
const refModel = useRef<HTMLDialogElement>(null); const refModel = useRef<HTMLDialogElement>(null);

View File

@@ -11,7 +11,7 @@ export function SDKVersion() {
const refDialog = useRef<HTMLDialogElement>(null); const refDialog = useRef<HTMLDialogElement>(null);
const [sdkVersion, setSdkVersion] = useState('...'); const [sdkVersion, setSdkVersion] = useState('...');
useEffect(() => { useEffect(() => {
getSDKVersion().then(setSdkVersion); getSDKVersion().then(setSdkVersion, () => setSdkVersion('N/A'));
}, []); }, []);
return ( return (

View File

@@ -27,7 +27,10 @@ export function SelectFile() {
fileName, fileName,
}), }),
); );
dispatch(processFile({ fileId }));
dispatch(processFile({ fileId })).catch((err) => {
console.log(`failed to add file (id=${fileId}, name=${fileName}, err=${err as Error})`);
});
} }
}; };

View File

@@ -19,18 +19,18 @@ export class KugouMusicDecipher implements DecipherInstance {
kgm.decrypt(block, offset); kgm.decrypt(block, offset);
} }
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
cipherName: this.cipherName, cipherName: this.cipherName,
data: audioBuffer, data: audioBuffer,
}; });
} finally { } finally {
kgmHdr?.free(); kgmHdr?.free();
kgm?.free(); kgm?.free();
} }
} }
public static make() { public static make(this: void) {
return new KugouMusicDecipher(); return new KugouMusicDecipher();
} }
} }

View File

@@ -18,18 +18,18 @@ export class KuwoMusicDecipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) { for (const [block, offset] of chunkBuffer(audioBuffer)) {
kwm.decrypt(block, offset); kwm.decrypt(block, offset);
} }
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
cipherName: this.cipherName, cipherName: this.cipherName,
data: audioBuffer, data: audioBuffer,
}; });
} finally { } finally {
kwm?.free(); kwm?.free();
header?.free(); header?.free();
} }
} }
public static make() { public static make(this: void) {
return new KuwoMusicDecipher(); return new KuwoMusicDecipher();
} }
} }

View File

@@ -6,22 +6,25 @@ export class Migu3DKeylessDecipher implements DecipherInstance {
cipherName = 'Migu3D (Keyless)'; cipherName = 'Migu3D (Keyless)';
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> { async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
const audioBuffer = new Uint8Array(buffer); const audioBuffer = new Uint8Array(buffer);
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
try {
for (const [block, i] of chunkBuffer(audioBuffer)) { for (const [block, i] of chunkBuffer(audioBuffer)) {
mg3d.decrypt(block, i); mg3d.decrypt(block, i);
} }
} finally {
mg3d.free(); mg3d.free();
}
return { return Promise.resolve({
cipherName: this.cipherName, cipherName: this.cipherName,
status: Status.OK, status: Status.OK,
data: audioBuffer, data: audioBuffer,
}; });
} }
public static make() { public static make(this: void) {
return new Migu3DKeylessDecipher(); return new Migu3DKeylessDecipher();
} }
} }

View File

@@ -26,17 +26,17 @@ export class NetEaseCloudMusicDecipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) { for (const [block, offset] of chunkBuffer(audioBuffer)) {
ncm.decrypt(block, offset); ncm.decrypt(block, offset);
} }
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
cipherName: this.cipherName, cipherName: this.cipherName,
data: audioBuffer, data: audioBuffer,
}; });
} finally { } finally {
ncm.free(); ncm.free();
} }
} }
public static make() { public static make(this: void) {
return new NetEaseCloudMusicDecipher(); return new NetEaseCloudMusicDecipher();
} }
} }

View File

@@ -19,14 +19,14 @@ export class QQMusicV1Decipher implements DecipherInstance {
for (const [block, offset] of chunkBuffer(audioBuffer)) { for (const [block, offset] of chunkBuffer(audioBuffer)) {
decryptQMC1(block, offset); decryptQMC1(block, offset);
} }
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
cipherName: this.cipherName, cipherName: this.cipherName,
data: audioBuffer, data: audioBuffer,
}; });
} }
public static create() { public static create(this: void) {
return new QQMusicV1Decipher(); return new QQMusicV1Decipher();
} }
} }
@@ -62,25 +62,28 @@ export class QQMusicV2Decipher implements DecipherInstance {
throw new Error('EKey required'); throw new Error('EKey required');
} }
const qmc2 = new QMC2(ekey);
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size); const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
const qmc2 = new QMC2(ekey);
try {
for (const [block, offset] of chunkBuffer(audioBuffer)) { for (const [block, offset] of chunkBuffer(audioBuffer)) {
qmc2.decrypt(block, offset); qmc2.decrypt(block, offset);
} }
} finally {
qmc2.free(); qmc2.free();
}
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
cipherName: this.cipherName, cipherName: this.cipherName,
data: audioBuffer, data: audioBuffer,
}; });
} }
public static createWithUserKey() { public static createWithUserKey(this: void) {
return new QQMusicV2Decipher(true); return new QQMusicV2Decipher(true);
} }
public static createWithEmbeddedEKey() { public static createWithEmbeddedEKey(this: void) {
return new QQMusicV2Decipher(false); return new QQMusicV2Decipher(false);
} }
} }

View File

@@ -18,20 +18,23 @@ export class QignTingFMDecipher implements DecipherInstance {
}; };
} }
const qtfm = new QingTingFM(key, iv);
const audioBuffer = new Uint8Array(buffer); const audioBuffer = new Uint8Array(buffer);
const qtfm = new QingTingFM(key, iv);
try {
for (const [block, i] of chunkBuffer(audioBuffer)) { for (const [block, i] of chunkBuffer(audioBuffer)) {
qtfm.decrypt(block, i); qtfm.decrypt(block, i);
} }
} finally {
return { qtfm.free();
}
return Promise.resolve({
cipherName: this.cipherName, cipherName: this.cipherName,
status: Status.OK, status: Status.OK,
data: audioBuffer, data: audioBuffer,
}; });
} }
public static make() { public static make(this: void) {
return new QignTingFMDecipher(); return new QignTingFMDecipher();
} }
} }

View File

@@ -4,15 +4,15 @@ export class TransparentDecipher implements DecipherInstance {
cipherName = 'none'; cipherName = 'none';
async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> { async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> {
return { return Promise.resolve({
cipherName: 'None', cipherName: 'None',
status: Status.OK, status: Status.OK,
data: buffer, data: buffer,
message: 'No decipher applied', message: 'No decipher applied',
}; });
} }
public static make() { public static make(this: void) {
return new TransparentDecipher(); return new TransparentDecipher();
} }
} }

View File

@@ -6,23 +6,26 @@ export class XiamiDecipher implements DecipherInstance {
cipherName = 'Xiami (XM)'; cipherName = 'Xiami (XM)';
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> { async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
const { copyPlainLength } = xm;
const audioBuffer = buffer.slice(0x10); const audioBuffer = buffer.slice(0x10);
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
try {
const { copyPlainLength } = xm;
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) { for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
xm.decrypt(block); xm.decrypt(block);
} }
} finally {
xm.free(); xm.free();
}
return { return Promise.resolve({
cipherName: this.cipherName, cipherName: this.cipherName,
status: Status.OK, status: Status.OK,
data: audioBuffer, data: audioBuffer,
}; });
} }
public static make() { public static make(this: void) {
return new XiamiDecipher(); return new XiamiDecipher();
} }
} }

View File

@@ -23,18 +23,18 @@ export class XimalayaAndroidDecipher implements DecipherInstance {
} }
const result = new Uint8Array(buffer); const result = new Uint8Array(buffer);
result.set(slice, 0); result.set(slice, 0);
return { return Promise.resolve({
cipherName: this.cipherName, cipherName: this.cipherName,
status: Status.OK, status: Status.OK,
data: result, data: result,
}; });
} }
public static makeX2M() { public static makeX2M(this: void) {
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M'); return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
} }
public static makeX3M() { public static makeX3M(this: void) {
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M'); return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
} }
} }
@@ -45,27 +45,31 @@ export class XimalayaPCDecipher implements DecipherInstance {
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> { async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
// Detect with first 0x400 bytes // Detect with first 0x400 bytes
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024)); const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
const xm = new XmlyPC(buffer.subarray(0, headerSize)); const xmly = new XmlyPC(buffer.subarray(0, headerSize));
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
try {
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xmly;
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize; const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset; const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset); const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart); const encryptedAudioPartLen = xmly.decrypt(encryptedAudioPart);
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength; const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
xm.free();
const result = new Uint8Array(audioSize); const result = new Uint8Array(audioSize);
result.set(audioHeader); result.set(audioHeader);
result.set(encryptedAudioPart, audioHeader.byteLength); result.set(encryptedAudioPart, audioHeader.byteLength);
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen); result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
return { return Promise.resolve({
status: Status.OK, status: Status.OK,
data: result, data: result,
cipherName: this.cipherName, cipherName: this.cipherName,
}; });
} finally {
xmly.free();
}
} }
public static make() { public static make(this: void) {
return new XimalayaPCDecipher(); return new XimalayaPCDecipher();
} }
} }

View File

@@ -6,7 +6,7 @@ export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb
const resp = cb(instance); const resp = cb(instance);
if (resp && isPromise(resp)) { if (resp && isPromise(resp)) {
isAsync = true; isAsync = true;
resp.finally(() => instance.free()); resp.finally(() => instance.free()).catch(() => {});
} }
return resp; return resp;
} finally { } finally {

View File

@@ -1,10 +1,9 @@
import { import { ParseKugouHeaderPayload, ParseKugouHeaderResponse } from '~/decrypt-worker/types.ts';
ParseKugouHeaderPayload, ParseKugouHeaderResponse,
} from '~/decrypt-worker/types.ts';
import { KuGouHeader } from '@unlock-music/crypto'; import { KuGouHeader } from '@unlock-music/crypto';
export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayload): Promise<ParseKugouHeaderResponse> => { export const workerParseKugouHeader = async ({
blobURI,
}: ParseKugouHeaderPayload): Promise<ParseKugouHeaderResponse> => {
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob()); const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400)); const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400));
@@ -20,4 +19,4 @@ export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayloa
} finally { } finally {
kwm?.free(); kwm?.free();
} }
} };

View File

@@ -11,5 +11,5 @@ export async function workerGetQtfmDeviceKey({
board, board,
}: GetQingTingFMDeviceKeyPayload) { }: GetQingTingFMDeviceKeyPayload) {
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board); const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
return hex(buffer); return Promise.resolve(hex(buffer));
} }

View File

@@ -1,5 +1,5 @@
// This is a dummy module for vite/rollup to resolve. // This is a dummy module for vite/rollup to resolve.
export function createRequire() { 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'); throw new Error('this is a dummy module. Do not use');
} }

View File

@@ -26,14 +26,10 @@ export function FileError({ error, code }: FileErrorProps) {
const copyError = () => { const copyError = () => {
if (error) { if (error) {
navigator.clipboard navigator.clipboard.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error })).then(
.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error })) () => toast.success('错误信息已复制到剪贴板'),
.then(() => { (e) => toast.error(`复制错误信息失败: ${e as Error}`),
toast.success('错误信息已复制到剪贴板'); );
})
.catch((e) => {
toast.error(`复制错误信息失败: ${e}`);
});
} }
}; };

View File

@@ -1,20 +1,11 @@
import { RiFileCopyLine } from 'react-icons/ri'; import { RiFileCopyLine } from 'react-icons/ri';
import { toast } from 'react-toastify';
import { ExtLink } from '~/components/ExtLink'; import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock.tsx'; import { FilePathBlock } from '~/components/FilePathBlock.tsx';
import { copyToClipboard } from '~/util/clipboard';
export function InstructionsPC() {
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db'; const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
const copyDbPathToClipboard = () => { export function InstructionsPC() {
navigator.clipboard const copyDbPathToClipboard = () => copyToClipboard(DB_PATH);
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>

View File

@@ -53,7 +53,7 @@ export function PanelQMCv2Key() {
toastImportResult(file.name, keys); toastImportResult(file.name, keys);
} catch (e) { } catch (e) {
console.error('error during import: ', e); console.error('error during import: ', e);
alert(`导入数据库时发生错误:${e}`); alert(`导入数据库时发生错误:${e as Error}`);
} }
}; };

View File

@@ -9,6 +9,7 @@ import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts'; import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
import { Ruby } from '~/components/Ruby'; import { Ruby } from '~/components/Ruby';
import { HiWord } from '~/components/HelpText/HiWord'; 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'; const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
@@ -28,23 +29,20 @@ export function PanelQingTing() {
return; 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)) { 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; const { product, device, manufacturer, brand, board, model } = dataMap;
if (product && device && manufacturer && brand && board && model) { if (product && device && manufacturer && brand && board && model) {
e.preventDefault(); e.preventDefault();
workerClientBus workerClientBus
.request<string, GetQingTingFMDeviceKeyPayload>( .request<
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, string,
dataMap, GetQingTingFMDeviceKeyPayload
) >(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, dataMap)
.then(setSecretKey) .then(setSecretKey, (err) => toast.error(`生成设备密钥时发生错误: ${err}`));
.catch((err) => {
alert(`生成设备密钥时发生错误: ${err}`);
});
} }
}; };

View File

@@ -55,7 +55,7 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
let lastSettings: unknown; let lastSettings: unknown;
try { try {
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? ''); const loadedSettings = JSON.parse(localStorage.getItem(storageKey) ?? '') as ProductionSettings;
if (loadedSettings) { if (loadedSettings) {
const mergedSettings = mergeSettings(loadedSettings); const mergedSettings = mergeSettings(loadedSettings);
store.dispatch(setProductionChanges(mergedSettings)); store.dispatch(setProductionChanges(mergedSettings));

View File

@@ -3,8 +3,9 @@ import '@testing-library/jest-dom';
// FIXME: Use something like jsdom-worker? // FIXME: Use something like jsdom-worker?
// see: https://github.com/developit/jsdom-worker // see: https://github.com/developit/jsdom-worker
if (!global.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 { (global as any).Worker = class MockWorker {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
events: Record<string, (e: unknown) => void> = Object.create(null); events: Record<string, (e: unknown) => void> = Object.create(null);
onmessage = undefined; onmessage = undefined;

View File

@@ -5,7 +5,10 @@ import { ConcurrentQueue } from './ConcurrentQueue';
import { WorkerClientBus } from './WorkerEventBus'; import { WorkerClientBus } from './WorkerEventBus';
export class DecryptionQueue extends ConcurrentQueue<DecryptCommandPayload, DecryptionResult> { export class DecryptionQueue extends ConcurrentQueue<DecryptCommandPayload, DecryptionResult> {
constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) { constructor(
private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>,
maxQueue?: number,
) {
super(maxQueue); super(maxQueue);
} }

28
src/util/SimpleQueue.ts Normal file
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);
}
}
};
}

View File

@@ -50,7 +50,7 @@ export class WorkerClientBus<T = string> {
async request<R, P>(actionName: T, payload: P): Promise<R> { async request<R, P>(actionName: T, payload: P): Promise<R> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = `request://${actionName}/${nanoid()}`; const id = `request://${actionName as string}/${nanoid()}`;
this.idPromiseMap.set(id, [resolve, reject]); this.idPromiseMap.set(id, [resolve, reject]);
this.worker.postMessage({ this.worker.postMessage({
id, id,

View File

@@ -2,7 +2,7 @@ import { ConcurrentQueue } from '../ConcurrentQueue';
import { nextTickAsync } from '../nextTick'; import { nextTickAsync } from '../nextTick';
class SimpleQueue<T, R = void> extends ConcurrentQueue<T> { 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'); throw new Error('Method not overridden');
} }
} }

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); const bus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(null as never);
vi.spyOn(bus, 'request').mockImplementation( vi.spyOn(bus, 'request').mockImplementation(
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => { async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
return { actionName, payload }; return Promise.resolve({ actionName, payload });
}, },
); );

View File

@@ -1,3 +1,5 @@
export function applyTemplate(tpl: string, values: Record<string, unknown>) { 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]) : '',
);
} }

View File

@@ -1,12 +1,8 @@
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export const copyToClipboard = (text: string) => { export const copyToClipboard = (text: string) => {
navigator.clipboard navigator.clipboard.writeText(text).then(
.writeText(text) () => toast.success('已复制到剪贴板'),
.then(() => { (err) => toast.error(`复制失败,请手动复制。\n错误: ${err as Error}`),
toast.success('已复制到剪贴板'); );
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
}; };

View File

@@ -1,3 +1,3 @@
export function deepClone<T>(obj: T): T { export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj)) as T;
} }