16 Commits
Author SHA1 Message Date
鲁树人 10e857a903 0.6.2 2026-10-22 02:23:47 +09:00
鲁树人 537391ed03 docs: update README and FAQ for QQ Music client key requirements
- Revised README to clarify that QQ Music PC client versions 19.57 to 22.76 require importing a key file extracted using qmpc-mmkv-cli.
- Updated issue template to reflect the same changes regarding key file requirements for Windows clients.
- Modified FAQ to specify that only versions 19.51 or lower do not require a key, while versions 19.57 to 22.76 do.
- Enhanced instructions for obtaining the key file in the PC instructions panel.
2026-10-21 11:40:55 +09:00
鲁树人 881aa5e44c chore: remove unused environment variable from build workflow 2026-10-21 10:55:35 +09:00
鲁树人 757bbd2172 0.6.1 2026-10-21 10:53:12 +09:00
鲁树人 3f14a3acff chore: update Vite configuration to use vmThreads pool 2026-10-21 10:52:44 +09:00
鲁树人 b142c1ba34 chore: dep pkg upgrade
- Updated Node version in Dockerfile and .npmrc.
- Upgraded various dependencies in package.json, including react-dropzone, react-router, and eslint.
- Added pnpm-workspace.yaml for workspace management.
- Adjusted TypeScript configurations and test utilities for compatibility.
- Enhanced documentation for installation instructions and TypeScript toolchain.
2026-10-21 10:51:06 +09:00
鲁树人 4df197ac4d chore: minor pkg updates 2026-10-21 10:25:22 +09:00
鲁树人 07d160c547 feat(#10): add batch download settings and capabilities
- Introduced new batch download settings in the settings slice, allowing users to configure options for directory picking, streaming ZIP downloads, and blob ZIP downloads.
- Implemented a new general settings panel for managing batch download preferences.
- Added utility functions to handle directory selection and ZIP file creation.
- Updated the download logic to support multiple download modes based on user preferences.
- Enhanced documentation to reflect changes in batch download functionality.
2026-10-21 10:24:21 +09:00
鲁树人 3059f495ab Merge pull request 'feat: allow deployment on subpaths' (#11) from Charles/um-react:main into main
Reviewed-on: https://git.um-react.app/um/um-react/pulls/11
2026-07-15 10:30:44 +01:00
Charles 024847a45a feat: allow deployment on subpaths 2026-01-30 16:28:39 +08:00
鲁树人 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
71 changed files with 4527 additions and 4039 deletions
+3 -1
View File
@@ -7,7 +7,9 @@ body:
value: | value: |
### Windows 客户端 ### Windows 客户端
目前 Windows 客户端仅支持 19.51 或更低版本下载的歌曲文件。 目前 Windows 客户端支持 19.57 至 22.76(目前最新版)下载的歌曲文件,但需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`。
使用 19.51 或更低版本下载的歌曲文件无需密钥。
* [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe) * [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe)
* [通过 Telegram 下载](https://t.me/um_lsr_ch/24) * [通过 Telegram 下载](https://t.me/um_lsr_ch/24)
-3
View File
@@ -4,8 +4,6 @@ on: [push]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
npm_config_registry: https://registry.npmmirror.com
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -40,7 +38,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)
-2
View File
@@ -1,3 +1 @@
use-node-version=24.7.0
engine-strict=true
@unlock-music:registry=https://git.um-react.app/api/packages/um/npm/ @unlock-music:registry=https://git.um-react.app/api/packages/um/npm/
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-slim AS build FROM node:24.22.0-slim AS build
ENV PNPM_HOME="/p" ENV PNPM_HOME="/p"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
WORKDIR /app WORKDIR /app
@@ -9,7 +9,7 @@ RUN corepack enable pnpm \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml .npmrc ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm exec true RUN pnpm exec true
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
+22 -12
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.57 至 v22.76)需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`。
[^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)」。
+13 -1
View File
@@ -10,7 +10,7 @@
#### 2、检查您的平台。 #### 2、检查您的平台。
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。 日前,Windows 客户端 <mark>v19.57 至 v22.76</mark>(目前最新版)下载的歌曲需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`;<mark>v19.51 或更低版本</mark>下载的歌曲无需密钥。其余平台的官方正式版本均需要提取密钥。
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。 > iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
@@ -71,6 +71,18 @@
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。 - 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/[email protected]) ![雷电模拟器 其他设置](../src/faq/assets/[email protected])
### 批量下载约十首后失败 / 无法全部下载
Chromium 系浏览器会限制页面在短时间内通过程序触发的**多个**文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:
1. 选择输出目录,直接写入文件(推荐,Chrome / Edge)
2. 流式生成单个 ZIP 并保存
3. 在内存中生成 ZIP 后触发一次下载(兼容性最好)
若全部失败会弹出提示。大批量转换也可使用 [命令行版本](https://git.um-react.app/um/cli)。
### Via 等浏览器无法正常解密/下载 ### Via 等浏览器无法正常解密/下载
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。 ⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
+6 -2
View File
@@ -4,8 +4,8 @@
## 安装依赖 ## 安装依赖
- 安装 Node v16.17 或更高,推荐当前最新的 Node LTS 版本。 - 安装 Node 24.22.0;pnpm 也会根据 `package.json` 中的 `devEngines.runtime` 自动获取该版本。
- 安装/激活 `pnpm` [^1]:`corepack prepare pnpm@latest --activate` - 安装/激活项目指定的 `pnpm` [^1]:`corepack prepare pnpm@12.14.0 --activate`
- 安装软件依赖:`pnpm i --frozen-lockfile` - 安装软件依赖:`pnpm i --frozen-lockfile`
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。 [^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
@@ -34,6 +34,10 @@ pnpm build
[vite-preview-url]: http://localhost:4173/ [vite-preview-url]: http://localhost:4173/
### TypeScript 工具链
编译和 ESLint 统一使用 TypeScript 6.x,暂不升级到 TypeScript 7。
## 打包 `.zip` ## 打包 `.zip`
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。 建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
+14 -3
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'],
},
},
},
},
); );
+4
View File
@@ -9,6 +9,10 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" /> <link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
<meta name="theme-color" content="#4DBA87" /> <meta name="theme-color" content="#4DBA87" />
<script>
// 设置基础路径,如果希望部署在/music-unlock/,则改为 '/music-unlock/',默认部署在根路径
window.BASE_URL = '/';
</script>
</head> </head>
<body> <body>
+60 -66
View File
@@ -1,7 +1,7 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.6.0", "version": "0.6.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@@ -17,65 +17,66 @@
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.13.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", "fflate": "^0.8.3",
"idb-keyval": "^6.3.0",
"nanoid": "^6.0.2",
"radash": "^12.1.1", "radash": "^12.1.1",
"react": "^19.1.1", "react": "^19.3.1",
"react-dom": "^19.1.1", "react-dom": "^19.3.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^20.1.4",
"react-icons": "^5.5.0", "react-icons": "^5.7.0",
"react-redux": "^9.2.0", "react-redux": "^9.3.0",
"react-router": "^7.8.2", "react-router": "^8.5.0",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^16.1.2",
"react-toastify": "^11.0.5", "react-toastify": "^11.1.0",
"sql.js": "^1.13.0" "sql.js": "^1.15.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^10.0.1",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.3",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.8",
"@types/node": "^24.3.1", "@types/node": "^26.6.5",
"@types/react": "^19.1.12", "@types/react": "^19.3.0",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.3.0",
"@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.11",
"@types/tar-stream": "^3.1.4", "@types/tar-stream": "^3.1.5",
"@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.72.0",
"@typescript-eslint/parser": "^8.42.0", "@typescript-eslint/parser": "^8.72.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^6.1.2",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^5.0.5",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^5.0.5",
"daisyui": "^5.1.8", "daisyui": "^5.7.50",
"eslint": "^9.35.0", "eslint": "^10.13.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.1.1",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.5.7",
"globals": "^16.3.0", "globals": "^17.13.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^26.1.0", "jsdom": "^30.1.3",
"lint-staged": "^16.1.6", "lint-staged": "^17.6.0",
"prettier": "^3.6.2", "prettier": "^3.9.10",
"rollup": "^4.50.1", "rollup": "^4.64.12",
"sass": "^1.92.1", "sass": "^1.105.2",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.14.0",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.3.3",
"tar-stream": "^3.1.7", "tar-stream": "^3.2.2",
"terser": "^5.44.0", "terser": "^5.51.2",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"typescript-eslint": "^8.42.0", "typescript-eslint": "^8.72.0",
"vite": "^7.1.5", "vite": "^8.3.4",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^2.0.0",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.6.0",
"vite-plugin-wasm": "^3.5.0", "vitest": "^5.0.5",
"vitest": "^3.2.4", "workbox-build": "^7.4.1",
"workbox-build": "^7.3.0", "workbox-window": "^7.4.1"
"workbox-window": "^7.3.0"
}, },
"lint-staged": { "lint-staged": {
"*": "prettier --write --ignore-unknown", "*": "prettier --write --ignore-unknown",
@@ -90,19 +91,12 @@
"printWidth": 120, "printWidth": 120,
"tabWidth": 2 "tabWidth": 2
}, },
"pnpm": { "devEngines": {
"patchedDependencies": { "runtime": {
"sql.js": "patches/sql.js.patch" "name": "node",
}, "version": "24.22.0",
"overrides": { "onFail": "download"
"sourcemap-codec": "npm:@jridgewell/[email protected]" }
},
"onlyBuiltDependencies": [
"@swc/core",
"@tailwindcss/oxide",
"esbuild",
"simple-git-hooks"
]
}, },
"packageManager": "pnpm@10.15.1" "packageManager": "pnpm@12.14.0"
} }
+3505 -3728
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
engineStrict: true
patchedDependencies:
sql.js: patches/sql.js.patch
overrides:
sourcemap-codec: npm:@jridgewell/[email protected]
allowBuilds:
'@parcel/watcher': false
'@swc/core': true
'@tailwindcss/oxide': true
esbuild: true
simple-git-hooks: true
minimumReleaseAge: 0
+1 -1
View File
@@ -25,7 +25,7 @@ export function AppRoot() {
useEffect(() => persistSettings(store), []); useEffect(() => persistSettings(store), []);
return ( return (
<BrowserRouter> <BrowserRouter basename={window.BASE_URL || '/'}>
<Provider store={store}> <Provider store={store}>
<div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content"> <div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content">
<NavLink to="/" role="tab" className={tabClassNames}> <NavLink to="/" role="tab" className={tabClassNames}>
+163 -49
View File
@@ -2,83 +2,197 @@ 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';
import { selectFinalBatchDownloadSettings } from '~/features/settings/settingsSelector';
import type { BatchDownloadSettings } from '~/features/settings/settingsSlice';
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
import { resolveWritableDirectory } from '~/util/downloadDirectoryHandle';
import { downloadZipAsBlob, downloadZipViaSavePicker, ZipSourceFile } from '~/util/streamZip';
import { triggerAnchorDownload } from '~/util/triggerDownload';
export function DownloadAll() { export function DownloadAll() {
const files = useAppSelector(selectFiles); const files = useAppSelector(selectFiles);
const onClickDownloadAll = async () => { const batchSettings = useAppSelector(selectFinalBatchDownloadSettings);
console.time('DownloadAll'); //开始计时
const fileCount = Object.keys(files).length; const downloadAllAsync = async () => {
const fileList = Object.values(files);
const fileCount = fileList.length;
if (fileCount === 0) { if (fileCount === 0) {
toast.warning('未添加文件'); toast.warning('未添加文件');
return; return;
} }
//判断所有文件是否处理完成 const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
const allComplete = Object.values(files).every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) { if (!allComplete) {
toast.warning('请等待所有文件解密完成'); toast.warning('请等待所有文件解密完成');
return; return;
} }
//过滤处理失败的文件 const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
const completeFiles = Object.values(files).filter((file) => file.state === ProcessState.COMPLETE); if (completeFiles.length === 0) {
toast.warning('没有可下载的已解密文件');
//开始下载 return;
let dir: FileSystemDirectoryHandle | undefined;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') {
return;
}
} }
// Single file: direct blob download, no ZIP / directory picker.
if (completeFiles.length === 1) {
downloadSingleBlob(completeFiles[0]);
toast.success('已开始下载');
return;
}
const modes = buildEnabledModes(batchSettings);
if (modes.length === 0) {
toast.error('批量下载失败:未启用任何可用的下载方式,请在「设定 → 通用设置」中勾选。');
return;
}
toast.warning('开始下载,请稍候'); toast.warning('开始下载,请稍候');
const promises = Object.values(completeFiles).map(async (file) => { for (const mode of modes) {
console.log(`开始下载: ${file.fileName}`);
try { try {
if (dir) { const result = await runMode(mode, completeFiles, batchSettings.rememberDirectory);
await DownloadNew(dir, file); if (result === 'ok') {
} else { return;
await DownloadOld(file);
} }
console.log(`成功下载: ${file.fileName}`); // cancelled / skipped → try next mode
} catch (e) { } catch (e) {
console.error(`下载失败: ${file.fileName}`, e); console.error(`批量下载模式 ${mode} 失败`, e);
toast.error(`出现错误: ${e}`); toast.error(`下载方式失败,尝试下一种: ${e instanceof Error ? e.message : String(e)}`);
throw e;
} }
}); }
await Promise.allSettled(promises).then((f) => {
const success = f.filter((result) => result.status === 'fulfilled').length; toast.error('批量下载失败:当前浏览器不支持或您取消了所有可用方式。可在「设定 → 通用设置」调整选项。');
if (success === fileCount) {
toast.success(`成功下载: ${success}/${fileCount}首`);
} else {
toast.warning(`成功下载: ${success}/${fileCount}首`);
}
});
console.timeEnd('DownloadAll'); //停止计时
}; };
function onDownloadAll() {
downloadAllAsync().catch((e) => {
console.error('下载全部出现错误', e);
toast.error(`批量下载出现错误: ${e instanceof Error ? e.message : String(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) { type BatchMode = 'directory' | 'streamingZip' | 'blobZip';
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const writable = await fileHandle.createWritable(); function buildEnabledModes(
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable)); settings: Pick<BatchDownloadSettings, 'directoryPicker' | 'streamingZip' | 'blobZip'>,
): BatchMode[] {
const modes: BatchMode[] = [];
if (settings.directoryPicker && supportsDirectoryPicker()) {
modes.push('directory');
}
if (settings.streamingZip && supportsStreamingZipDownload()) {
modes.push('streamingZip');
}
if (settings.blobZip) {
modes.push('blobZip');
}
return modes;
} }
async function DownloadOld(file: DecryptedAudioFile) { async function runMode(
const a = document.createElement('a'); mode: BatchMode,
a.href = file.decrypted; completeFiles: DecryptedAudioFile[],
a.download = file.cleanName + '.' + file.ext; rememberDirectory: boolean,
document.body.append(a); ): Promise<'ok' | 'cancelled'> {
a.click(); switch (mode) {
a.remove(); case 'directory':
return downloadToDirectory(completeFiles, rememberDirectory);
case 'streamingZip':
return downloadStreamingZip(completeFiles);
case 'blobZip':
await downloadBlobZip(completeFiles);
return 'ok';
}
}
async function downloadToDirectory(
completeFiles: DecryptedAudioFile[],
remember: boolean,
): Promise<'ok' | 'cancelled'> {
const dir = await resolveWritableDirectory(remember);
if (!dir) {
return 'cancelled';
}
const fileCount = completeFiles.length;
const queue = new SimpleQueue(8);
const promises = completeFiles.map(async (file) => {
try {
await queue.enter();
await writeFileToDirectory(file, dir);
} catch (e) {
console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e instanceof Error ? e.message : String(e)}`);
throw e;
} finally {
queue.leave();
}
});
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}首`);
if (success === 0) {
throw new Error('未能写入任何文件到所选目录');
}
return 'ok';
}
function decryptedDownloadName(file: DecryptedAudioFile): string {
return `${file.cleanName}.${file.ext}`;
}
async function writeFileToDirectory(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle) {
const fileHandle = await dir.getFileHandle(decryptedDownloadName(file), { create: true });
const fileStream = await fileHandle.createWritable();
try {
const res = await fetch(file.decrypted);
if (!res.ok) {
throw new Error(`无法读取文件: ${decryptedDownloadName(file)} (${res.status})`);
}
if (!res.body) {
await fileStream.write(await res.arrayBuffer());
await fileStream.close();
return;
}
await res.body.pipeTo(fileStream);
} catch (e) {
await fileStream.abort();
throw e;
}
}
function toZipSources(files: DecryptedAudioFile[]): ZipSourceFile[] {
return files.map((file) => ({
name: decryptedDownloadName(file),
url: file.decrypted,
}));
}
function downloadSingleBlob(file: DecryptedAudioFile) {
triggerAnchorDownload(file.decrypted, decryptedDownloadName(file));
}
async function downloadStreamingZip(completeFiles: DecryptedAudioFile[]): Promise<'ok' | 'cancelled'> {
const sources = toZipSources(completeFiles);
const result = await downloadZipViaSavePicker(sources);
if (result === 'ok') {
toast.success(`已导出 ZIP(${completeFiles.length} 首)`);
}
return result;
}
async function downloadBlobZip(completeFiles: DecryptedAudioFile[]): Promise<void> {
await downloadZipAsBlob(toZipSources(completeFiles));
toast.success(`已开始下载 ZIP(${completeFiles.length} 首)`);
} }
+1 -1
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;
}; };
+1 -1
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) {
+3 -4
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);
+1 -1
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 (
+4 -1
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})`);
});
} }
}; };
@@ -0,0 +1,33 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FileInput } from '../FileInput';
test('receives multiple files from the file picker', async () => {
const onReceiveFiles = vi.fn();
const files = [new File(['first'], 'first.qmc3'), new File(['second'], 'second.mflac')];
const { container } = render(<FileInput onReceiveFiles={onReceiveFiles}>Choose files</FileInput>);
const input = container.querySelector('input');
if (!input) throw new Error('File input is missing');
await userEvent.upload(input, files);
await waitFor(() => expect(onReceiveFiles).toHaveBeenCalledOnce());
expect(onReceiveFiles.mock.calls[0][0]).toEqual(files);
});
test('receives multiple dropped files', async () => {
const onReceiveFiles = vi.fn();
const files = [new File(['first'], 'first.kgm'), new File(['second'], 'second.ncm')];
render(<FileInput onReceiveFiles={onReceiveFiles}>Drop files</FileInput>);
fireEvent.drop(screen.getByText('Drop files'), {
dataTransfer: {
files,
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file })),
types: ['Files'],
},
});
await waitFor(() => expect(onReceiveFiles).toHaveBeenCalledOnce());
expect(onReceiveFiles.mock.calls[0][0]).toEqual(files);
});
+3 -3
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();
} }
} }
+3 -3
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();
} }
} }
+10 -7
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));
for (const [block, i] of chunkBuffer(audioBuffer)) { try {
mg3d.decrypt(block, i); for (const [block, i] of chunkBuffer(audioBuffer)) {
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();
} }
} }
@@ -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();
} }
} }
+14 -11
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);
for (const [block, offset] of chunkBuffer(audioBuffer)) { const qmc2 = new QMC2(ekey);
qmc2.decrypt(block, offset); try {
for (const [block, offset] of chunkBuffer(audioBuffer)) {
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);
} }
} }
+10 -7
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);
for (const [block, i] of chunkBuffer(audioBuffer)) { const qtfm = new QingTingFM(key, iv);
qtfm.decrypt(block, i); try {
for (const [block, i] of chunkBuffer(audioBuffer)) {
qtfm.decrypt(block, i);
}
} finally {
qtfm.free();
} }
return Promise.resolve({
return {
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();
} }
} }
+3 -3
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();
} }
} }
+11 -8
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);
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) { const xm = Xiami.from_header(buffer.subarray(0, 0x10));
xm.decrypt(block); 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, 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();
} }
} }
+26 -22
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;
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 result = new Uint8Array(audioSize); try {
result.set(audioHeader); const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xmly;
result.set(encryptedAudioPart, audioHeader.byteLength); const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen); const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
return { const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
status: Status.OK, const encryptedAudioPartLen = xmly.decrypt(encryptedAudioPart);
data: result, const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
cipherName: this.cipherName,
}; 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(); 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); 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 {
@@ -1,15 +1,14 @@
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));
let kwm : KuGouHeader | undefined; let kwm: KuGouHeader | undefined;
try { try {
kwm = new KuGouHeader(buffer); kwm = new KuGouHeader(buffer);
@@ -20,4 +19,4 @@ export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayloa
} finally { } finally {
kwm?.free(); kwm?.free();
} }
} };
@@ -1,4 +1,4 @@
import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts'; import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
import { KuwoHeader } from '@unlock-music/crypto'; import { KuwoHeader } from '@unlock-music/crypto';
export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise<ParseKuwoHeaderResponse> => { export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise<ParseKuwoHeaderResponse> => {
+1 -1
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));
} }
+1 -1
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');
} }
+16
View File
@@ -11,6 +11,22 @@ export function OtherFAQ() {
<p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p> <p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p>
<p>请使用第三方工具进行编辑或管理元信息。</p> <p>请使用第三方工具进行编辑或管理元信息。</p>
<Header3 id="batch-download">批量下载约十首后失败 / 无法全部下载</Header3>
<p>
Chromium 系浏览器会限制页面在短时间内通过程序触发的<strong>多个</strong>
文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
</p>
<p className="my-2">本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:</p>
<ol className="list-decimal pl-8 my-2">
<li>选择输出目录,直接写入文件(推荐,Chrome / Edge)</li>
<li>流式生成单个 ZIP 并保存</li>
<li>在内存中生成 ZIP 后触发一次下载(兼容性最好)</li>
</ol>
<p>
若全部失败会弹出提示。大批量转换也可使用
<ExtLink href="https://git.um-react.app/um/cli">命令行版本</ExtLink>。
</p>
<Header3 id="android-browsers">安卓: 浏览器支持说明</Header3> <Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p> <p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
<div className="flex flex-col md:flex-row gap-2 md:gap-8"> <div className="flex flex-col md:flex-row gap-2 md:gap-8">
+4 -8
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}`);
});
} }
}; };
@@ -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';
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
export function InstructionsPC() { export function InstructionsPC() {
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db'; const copyDbPathToClipboard = () => copyToClipboard(DB_PATH);
const copyDbPathToClipboard = () => {
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react';
import { useAppDispatch, useAppSelector } from '~/hooks';
import { selectStagingBatchDownloadSettings } from '../settingsSelector';
import { BatchDownloadSettings, updateBatchDownloadSetting } from '../settingsSlice';
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
import { clearRememberedDirectoryHandle, hasRememberedDirectoryHandle } from '~/util/downloadDirectoryHandle';
import { toast } from 'react-toastify';
export function PanelGeneral() {
const dispatch = useAppDispatch();
const batch = useAppSelector(selectStagingBatchDownloadSettings);
const canDirectoryPicker = supportsDirectoryPicker();
const canStreamingZip = supportsStreamingZipDownload();
const [hasRememberedDir, setHasRememberedDir] = useState(false);
const refreshRemembered = useCallback(() => {
hasRememberedDirectoryHandle()
.then(setHasRememberedDir)
.catch(() => setHasRememberedDir(false));
}, []);
useEffect(() => {
refreshRemembered();
}, [refreshRemembered]);
const setBatch = (key: keyof BatchDownloadSettings, value: boolean) => {
dispatch(updateBatchDownloadSetting({ key, value }));
};
const handleClearDirectory = async () => {
await clearRememberedDirectoryHandle();
refreshRemembered();
toast.info('已清除记住的输出目录');
};
const idDir = useId();
const idRemember = useId();
const idStreamZip = useId();
const idBlobZip = useId();
return (
<div className="min-h-0 flex-col grow px-1">
<h2 className="text-2xl font-bold mb-4">通用设置</h2>
<h3 className="text-xl font-semibold mb-2">批量下载</h3>
<p className="text-sm text-base-content/80 mb-4">
点击「下载全部」时,将按下方勾选项的顺序依次尝试。若选中的模式均不可用或全部失败,会弹出提示。
</p>
<div className="flex flex-col gap-3">
<BatchOption
id={idDir}
checked={batch.directoryPicker && canDirectoryPicker}
disabled={!canDirectoryPicker}
dimmed={!canDirectoryPicker}
title="使用选择目录批量下载"
description={
canDirectoryPicker
? '通过 showDirectoryPicker 将解密文件直接写入所选目录(推荐,Chrome / Edge)。'
: '当前浏览器不支持 showDirectoryPicker。'
}
onChange={(checked) => setBatch('directoryPicker', checked)}
/>
<div className={`ml-6 flex flex-col gap-2 ${batch.directoryPicker && canDirectoryPicker ? '' : 'opacity-50'}`}>
<BatchOption
id={idRemember}
checked={batch.rememberDirectory}
disabled={!batch.directoryPicker || !canDirectoryPicker}
title="记住输出目录"
description="将目录句柄保存在本机;下次下载时可能仍需确认写入权限。"
onChange={(checked) => setBatch('rememberDirectory', checked)}
/>
<div className="flex items-center gap-2 text-sm">
<span className="text-base-content/70">{hasRememberedDir ? '已记住一个输出目录' : '尚未记住输出目录'}</span>
<button
type="button"
className="btn btn-xs btn-ghost"
disabled={!hasRememberedDir}
onClick={() => void handleClearDirectory()}
>
清除
</button>
</div>
</div>
<BatchOption
id={idStreamZip}
checked={batch.streamingZip && canStreamingZip}
disabled={!canStreamingZip}
dimmed={!canStreamingZip}
title="流式生成 ZIP"
description={
canStreamingZip
? '边打包边写入单个 ZIP 文件(showSaveFilePicker),内存占用较低。'
: '当前浏览器不支持 showSaveFilePicker,无法流式写出 ZIP。'
}
onChange={(checked) => setBatch('streamingZip', checked)}
/>
<BatchOption
id={idBlobZip}
checked={batch.blobZip}
title="生成单个 ZIP 并触发下载"
description={
<>
在内存中生成 ZIP 后触发一次浏览器下载。兼容性最好;ZIP 需整包驻留内存,建议合计不超过约
500MB~1GB(视设备可用内存而定,过大可能失败或卡顿)。
</>
}
onChange={(checked) => setBatch('blobZip', checked)}
/>
</div>
</div>
);
}
type BatchOptionProps = {
id: string;
checked: boolean;
disabled?: boolean;
dimmed?: boolean;
title: string;
description: ReactNode;
onChange: (checked: boolean) => void;
};
function BatchOption({ id, checked, disabled, dimmed, title, description, onChange }: BatchOptionProps) {
return (
<label className={`flex items-start gap-2 ${dimmed ? 'opacity-50' : ''}`}>
<input
id={id}
type="checkbox"
className="checkbox checkbox-sm mt-0.5"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span>
<span className="font-medium">{title}</span>
<span className="block text-sm text-base-content/70">{description}</span>
</span>
</label>
);
}
@@ -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}`);
} }
}; };
+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 { 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}`);
});
} }
}; };
@@ -3,6 +3,7 @@ import { HiWord } from '~/components/HelpText/HiWord';
import NoopExecutable from './assets/noop.exe?base64'; import NoopExecutable from './assets/noop.exe?base64';
import NoopExecutableSource from './assets/noop.asm.txt?base64'; import NoopExecutableSource from './assets/noop.asm.txt?base64';
const QMPC_MMKV_RELEASES_URL = 'https://git.um-react.app/um/qmpc-mmkv/releases';
const PC_CLIENT_URL = 'https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe'; const PC_CLIENT_URL = 'https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe';
const PC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/24'; const PC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/24';
@@ -10,14 +11,20 @@ export function InstructionsPC() {
return ( return (
<> <>
<p className="mt-2"> <p className="mt-2">
使用 <span className="text-error">19.57 或更高版本</span>下载的歌曲文件 使用 <span className="text-primary">19.57 至 22.76</span>下载的歌曲文件
<HiWord>需要导入密钥</HiWord>。 <HiWord>需要导入密钥</HiWord>。
<br />
目前未公开密钥获取方式,因此不支持。
</p> </p>
<p className="mt-4"> <p className="mt-4">
使用 <span className="text-primary">19.51 或更低版本</span>下载的歌曲文件 请从{' '}
<ExtLink className="link-info" href={QMPC_MMKV_RELEASES_URL}>
qmpc-mmkv-cli
</ExtLink>{' '}
获取工具,提取密钥文件 <code>qmpc-mmkv-v1.mmkv</code> 后在本页导入。
</p>
<p className="mt-4">
使用 <span className="text-base-content/70">19.51 或更低版本</span>下载的歌曲文件
<HiWord>无需密钥</HiWord>。 <HiWord>无需密钥</HiWord>。
</p> </p>
+24 -2
View File
@@ -1,7 +1,13 @@
import { debounce } from 'radash'; import { debounce } from 'radash';
import type { AppStore } from '~/store'; import type { AppStore } from '~/store';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice'; import {
settingsSlice,
setProductionChanges,
ProductionSettings,
DEFAULT_BATCH_DOWNLOAD_SETTINGS,
BatchDownloadSettings,
} from './settingsSlice';
import { enumObject } from '~/util/objects'; import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils'; import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats'; import { parseKwm2ProductionKey } from './keyFormats';
@@ -9,8 +15,24 @@ import { deepClone } from '~/util/deepClone';
const DEFAULT_STORAGE_KEY = 'um-react-settings'; const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeBatchDownload(source: Partial<BatchDownloadSettings> | undefined): BatchDownloadSettings {
const result = { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS };
if (!source) {
return result;
}
for (const key of Object.keys(DEFAULT_BATCH_DOWNLOAD_SETTINGS) as (keyof BatchDownloadSettings)[]) {
if (typeof source[key] === 'boolean') {
result[key] = source[key];
}
}
return result;
}
function mergeSettings(settings: ProductionSettings): ProductionSettings { function mergeSettings(settings: ProductionSettings): ProductionSettings {
const draft = deepClone(settingsSlice.getInitialState().production); const draft = deepClone(settingsSlice.getInitialState().production);
draft.general.batchDownload = mergeBatchDownload(settings?.general?.batchDownload);
if (settings?.qmc2) { if (settings?.qmc2) {
const { allowFuzzyNameSearch, keys } = settings.qmc2; const { allowFuzzyNameSearch, keys } = settings.qmc2;
for (const [k, v] of enumObject(keys)) { for (const [k, v] of enumObject(keys)) {
@@ -55,7 +77,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));
@@ -66,3 +66,6 @@ export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse):
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android; export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android; export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
export const selectStagingBatchDownloadSettings = (state: RootState) => state.settings.staging.general.batchDownload;
export const selectFinalBatchDownloadSettings = (state: RootState) => state.settings.production.general.batchDownload;
+36
View File
@@ -21,7 +21,17 @@ import {
StagingKugouKey, StagingKugouKey,
} from './keyFormats'; } from './keyFormats';
export interface BatchDownloadSettings {
directoryPicker: boolean;
rememberDirectory: boolean;
streamingZip: boolean;
blobZip: boolean;
}
export interface StagingSettings { export interface StagingSettings {
general: {
batchDownload: BatchDownloadSettings;
};
qmc2: { qmc2: {
keys: StagingQMCv2Key[]; keys: StagingQMCv2Key[];
allowFuzzyNameSearch: boolean; allowFuzzyNameSearch: boolean;
@@ -38,6 +48,9 @@ export interface StagingSettings {
} }
export interface ProductionSettings { export interface ProductionSettings {
general: {
batchDownload: BatchDownloadSettings;
};
qmc2: { qmc2: {
keys: ProductionQMCv2Keys; // { [fileName]: ekey } keys: ProductionQMCv2Keys; // { [fileName]: ekey }
allowFuzzyNameSearch: boolean; allowFuzzyNameSearch: boolean;
@@ -53,6 +66,13 @@ export interface ProductionSettings {
}; };
} }
export const DEFAULT_BATCH_DOWNLOAD_SETTINGS: BatchDownloadSettings = {
directoryPicker: true,
rememberDirectory: true,
streamingZip: true,
blobZip: true,
};
export interface SettingsState { export interface SettingsState {
dirty: boolean; dirty: boolean;
staging: StagingSettings; staging: StagingSettings;
@@ -62,12 +82,14 @@ export interface SettingsState {
const initialState: SettingsState = { const initialState: SettingsState = {
dirty: false, dirty: false,
staging: { staging: {
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
qmc2: { allowFuzzyNameSearch: true, keys: [] }, qmc2: { allowFuzzyNameSearch: true, keys: [] },
kwm2: { keys: [] }, kwm2: { keys: [] },
qtfm: { android: '' }, qtfm: { android: '' },
kugou: { keys: [] }, kugou: { keys: [] },
}, },
production: { production: {
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
qmc2: { allowFuzzyNameSearch: true, keys: {} }, qmc2: { allowFuzzyNameSearch: true, keys: {} },
kwm2: { keys: {} }, kwm2: { keys: {} },
kugou: { keys: {} }, kugou: { keys: {} },
@@ -76,6 +98,9 @@ const initialState: SettingsState = {
}; };
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
general: {
batchDownload: { ...staging.general.batchDownload },
},
qmc2: { qmc2: {
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue), keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch, allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
@@ -90,6 +115,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
}); });
const productionToStaging = (production: ProductionSettings): StagingSettings => ({ const productionToStaging = (production: ProductionSettings): StagingSettings => ({
general: {
batchDownload: { ...production.general.batchDownload },
},
qmc2: { qmc2: {
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging), keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch, allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
@@ -208,6 +236,13 @@ export const settingsSlice = createSlice({
state.staging.qtfm.android = deviceKey; state.staging.qtfm.android = deviceKey;
state.dirty = true; state.dirty = true;
}, },
updateBatchDownloadSetting(
state,
{ payload: { key, value } }: PayloadAction<{ key: keyof BatchDownloadSettings; value: boolean }>,
) {
state.staging.general.batchDownload[key] = value;
state.dirty = true;
},
// //
discardStagingChanges: (state) => { discardStagingChanges: (state) => {
state.dirty = false; state.dirty = false;
@@ -252,6 +287,7 @@ export const {
kugouImportKeys, kugouImportKeys,
qtfmAndroidUpdateKey, qtfmAndroidUpdateKey,
updateBatchDownloadSetting,
commitStagingChange, commitStagingChange,
discardStagingChanges, discardStagingChanges,
+2 -1
View File
@@ -1,13 +1,14 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { PanelGeneral } from '~/features/settings/panels/PanelGeneral.tsx';
import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx'; import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx';
import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx'; import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx';
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx'; import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx'; import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx';
export const SETTINGS_TABS: Record<string, { name: string; Tab: FC }> = { export const SETTINGS_TABS: Record<string, { name: string; Tab: FC }> = {
general: { name: '通用设置', Tab: PanelGeneral },
qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key }, qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key }, kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
kgg: { name: 'KGG 密钥', Tab: PanelKGGKey }, kgg: { name: 'KGG 密钥', Tab: PanelKGGKey },
qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing }, qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing },
// misc: { name: '其它/待定', Tab: () => <p>这里空空如也~</p> },
} as const; } as const;
+3 -2
View File
@@ -1,10 +1,11 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom/vitest';
// 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;
+1 -1
View File
@@ -17,7 +17,7 @@ export function renderWithProviders(
ui: React.ReactElement, ui: React.ReactElement,
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {}, { preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
) { ) {
function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element { function Wrapper({ children }: PropsWithChildren<unknown>): React.JSX.Element {
return <Provider store={store}>{children}</Provider>; return <Provider store={store}>{children}</Provider>;
} }
+4 -1
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
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> { 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,
+1 -1
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');
} }
} }
+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); 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 });
}, },
); );
+1 -1
View File
@@ -1,6 +1,6 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('throw error on broken file', () => { test('throw error on broken file', () => {
+3 -1
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]) : '',
);
} }
+11
View File
@@ -0,0 +1,11 @@
export function supportsDirectoryPicker(): boolean {
return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
}
export function supportsSaveFilePicker(): boolean {
return typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function';
}
export function supportsStreamingZipDownload(): boolean {
return supportsSaveFilePicker();
}
+4 -8
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}`);
});
}; };
+1 -1
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;
} }
+65
View File
@@ -0,0 +1,65 @@
import { get, set, del } from 'idb-keyval';
import { isAbortError } from '~/util/fsPicker';
const DIRECTORY_HANDLE_KEY = 'um-react-batch-download-dir';
export async function getRememberedDirectoryHandle(): Promise<FileSystemDirectoryHandle | null> {
try {
const handle = await get<FileSystemDirectoryHandle>(DIRECTORY_HANDLE_KEY);
return handle ?? null;
} catch {
return null;
}
}
export async function setRememberedDirectoryHandle(handle: FileSystemDirectoryHandle): Promise<void> {
await set(DIRECTORY_HANDLE_KEY, handle);
}
export async function clearRememberedDirectoryHandle(): Promise<void> {
await del(DIRECTORY_HANDLE_KEY);
}
export async function hasRememberedDirectoryHandle(): Promise<boolean> {
return (await getRememberedDirectoryHandle()) !== null;
}
export async function verifyDirectoryPermission(
handle: FileSystemDirectoryHandle,
mode: FileSystemPermissionMode = 'readwrite',
): Promise<boolean> {
const opts: FileSystemHandlePermissionDescriptor = { mode };
if ((await handle.queryPermission(opts)) === 'granted') {
return true;
}
if ((await handle.requestPermission(opts)) === 'granted') {
return true;
}
return false;
}
/**
* Resolve a writable directory: remembered handle (if allowed) → picker.
* Returns null when the user cancels the picker.
*/
export async function resolveWritableDirectory(remember: boolean): Promise<FileSystemDirectoryHandle | null> {
if (remember) {
const remembered = await getRememberedDirectoryHandle();
if (remembered && (await verifyDirectoryPermission(remembered))) {
return remembered;
}
}
try {
const dir = await window.showDirectoryPicker({ mode: 'readwrite' });
if (remember) {
await setRememberedDirectoryHandle(dir);
}
return dir;
} catch (e) {
if (isAbortError(e)) {
return null;
}
throw e;
}
}
+4
View File
@@ -0,0 +1,4 @@
/** True when the user cancelled a File System Access picker (or similar AbortError). */
export function isAbortError(e: unknown): boolean {
return e instanceof DOMException && e.name === 'AbortError';
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo'; import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => { test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { parseAndroidQmEKey } from '../qm'; import { parseAndroidQmEKey } from '../qm';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse qm mmkv file', () => { test('parse qm mmkv file', () => {
+2 -2
View File
@@ -5,8 +5,8 @@ const nextTickFn =
typeof setImmediate !== 'undefined' typeof setImmediate !== 'undefined'
? (setImmediate as NextTickFn) ? (setImmediate as NextTickFn)
: typeof requestAnimationFrame !== 'undefined' : typeof requestAnimationFrame !== 'undefined'
? (requestAnimationFrame as NextTickFn) ? (requestAnimationFrame as NextTickFn)
: (setTimeout as NextTickFn); : (setTimeout as NextTickFn);
/* c8 ignore stop */ /* c8 ignore stop */
export async function nextTickAsync() { export async function nextTickAsync() {
+160
View File
@@ -0,0 +1,160 @@
import { Zip, ZipPassThrough } from 'fflate';
import { isAbortError } from '~/util/fsPicker';
import { triggerAnchorDownload } from '~/util/triggerDownload';
export interface ZipSourceFile {
/** Path inside the ZIP (UTF-8; non-ASCII gets Language Encoding Flag). */
name: string;
/** Blob URL or other fetchable URL of file contents. */
url: string;
}
/**
* Build a streaming ZIP (STORE / no recompression) from fetchable URLs.
* Filenames with non-ASCII characters get ZIP UTF-8 (LEF) via fflate.
*/
export function createZipReadableStream(files: ZipSourceFile[]): ReadableStream<Uint8Array> {
const zip = new Zip();
return new ReadableStream<Uint8Array>({
start(controller) {
zip.ondata = (err, data, final) => {
if (err) {
controller.error(err);
return;
}
controller.enqueue(data);
if (final) {
controller.close();
}
};
void (async () => {
try {
const usedNames = new Set<string>();
for (const file of files) {
const name = uniqueZipName(file.name, usedNames);
const entry = new ZipPassThrough(name);
zip.add(entry);
const res = await fetch(file.url);
if (!res.ok) {
throw new Error(`无法读取文件: ${file.name} (${res.status})`);
}
if (!res.body) {
const buf = new Uint8Array(await res.arrayBuffer());
entry.push(buf, true);
continue;
}
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
entry.push(new Uint8Array(0), true);
break;
}
entry.push(value, false);
}
}
zip.end();
} catch (e) {
zip.terminate();
controller.error(e);
}
})();
},
cancel() {
zip.terminate();
},
});
}
/** Split `name.ext` → base + ext (ext includes the leading dot). */
function splitBaseExt(name: string): { base: string; ext: string } {
const dot = name.lastIndexOf('.');
if (dot > 0) {
return { base: name.slice(0, dot), ext: name.slice(dot) };
}
return { base: name, ext: '' };
}
function uniqueZipName(name: string, used: Set<string>): string {
if (!used.has(name)) {
used.add(name);
return name;
}
const { base, ext } = splitBaseExt(name);
let i = 2;
let candidate: string;
do {
candidate = `${base} (${i})${ext}`;
i += 1;
} while (used.has(candidate));
used.add(candidate);
return candidate;
}
export function defaultZipFileName(files: ZipSourceFile[]): string {
const now = new Date();
const date = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
const first = files[0]?.name ?? '已解密的音乐';
const stem = splitBaseExt(first).base || '已解密的音乐';
return `${date} - ${stem} 等.zip`;
}
export async function downloadZipViaSavePicker(
files: ZipSourceFile[],
suggestedName = defaultZipFileName(files),
): Promise<'ok' | 'cancelled'> {
let handle: FileSystemFileHandle;
try {
handle = await window.showSaveFilePicker({
suggestedName,
types: [
{
description: 'ZIP archive',
accept: { 'application/zip': ['.zip'] },
},
],
});
} catch (e) {
if (isAbortError(e)) {
return 'cancelled';
}
throw e;
}
const writable = await handle.createWritable();
try {
await createZipReadableStream(files).pipeTo(writable);
} catch (e) {
try {
await writable.abort();
} catch {
// ignore
}
throw e;
}
return 'ok';
}
export async function downloadZipAsBlob(files: ZipSourceFile[], filename = defaultZipFileName(files)): Promise<void> {
const blob = await new Response(createZipReadableStream(files), {
headers: { 'Content-Type': 'application/zip' },
}).blob();
const objectUrl = URL.createObjectURL(blob);
try {
triggerAnchorDownload(objectUrl, filename);
} finally {
// Revoke after a short delay so the download can start.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
}
+9
View File
@@ -0,0 +1,9 @@
/** Programmatically trigger a browser file download via a temporary anchor. */
export function triggerAnchorDownload(href: string, filename: string): void {
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
+4
View File
@@ -17,3 +17,7 @@ declare module '*&mac-command' {
export const commandName: string; export const commandName: string;
export const tarName: string; export const tarName: string;
} }
declare interface Window {
BASE_URL: string;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Plugin } from 'vite';
export const base64Loader: Plugin = { export const base64Loader: Plugin = {
name: 'base64-loader', name: 'base64-loader',
async transform(_: unknown, id: string) { async load(id: string) {
const [path, query] = id.split('?'); const [path, query] = id.split('?');
if (query != 'base64') return null; if (query != 'base64') return null;
+3 -2
View File
@@ -5,7 +5,7 @@ import tar from 'tar-stream';
export const macCommandLoader: Plugin = { export const macCommandLoader: Plugin = {
name: 'mac-command-loader', name: 'mac-command-loader',
async transform(_: unknown, id: string) { async load(id: string) {
const [path, query] = id.split('?'); const [path, query] = id.split('?');
if (!query || !query.includes('mac-command')) return null; if (!query || !query.includes('mac-command')) return null;
@@ -23,7 +23,8 @@ export const macCommandLoader: Plugin = {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of tarball) { for await (const chunk of tarball) {
chunks.push(chunk as Buffer); if (!Buffer.isBuffer(chunk)) throw new TypeError('Expected a tarball Buffer chunk');
chunks.push(chunk);
} }
const dataBuffer = Buffer.concat(chunks); const dataBuffer = Buffer.concat(chunks);
const base64 = dataBuffer.toString('base64'); const base64 = dataBuffer.toString('base64');
+1 -2
View File
@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@types/wicg-file-system-access", "vitest/globals", "@testing-library/jest-dom"], "types": ["node", "@types/wicg-file-system-access", "vitest/globals", "@testing-library/jest-dom/vitest"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@@ -17,7 +17,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"esModuleInterop": true, "esModuleInterop": true,
"paths": { "paths": {
"~/*": ["./src/*"], "~/*": ["./src/*"],
+1
View File
@@ -4,6 +4,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts", "support/**/*.ts"] "include": ["vite.config.ts", "support/**/*.ts"]
+1 -1
View File
@@ -6,4 +6,4 @@
"./src/test-utils/**", "./src/test-utils/**",
"./src/**/*.test.{js,jsx,ts,tsx}" "./src/**/*.test.{js,jsx,ts,tsx}"
] ]
} }
+17 -17
View File
@@ -6,23 +6,23 @@ import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm'; import wasm from 'vite-plugin-wasm';
import replace from '@rollup/plugin-replace'; import replace from '@rollup/plugin-replace';
import topLevelAwait from 'vite-plugin-top-level-await';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { tryCommand } from './support/command'; import { tryCommand } from './support/command.ts';
import { base64Loader } from './support/b64-loader'; import { base64Loader } from './support/b64-loader.ts';
import { macCommandLoader } from './support/mac-command-loader'; import { macCommandLoader } from './support/mac-command-loader.ts';
const projectRoot = url.fileURLToPath(new URL('.', import.meta.url)); const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8')); const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8')) as { version: string };
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always'; const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown'); const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, projectRoot, 'unknown');
const version = `${pkg.version} (${shortCommit})`; const version = `${pkg.version} (${shortCommit})`;
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './',
worker: { worker: {
format: 'es', format: 'es',
}, },
@@ -58,7 +58,6 @@ export default defineConfig({
}), }),
react(), react(),
wasm(), wasm(),
topLevelAwait(),
VitePWA({ VitePWA({
registerType: 'prompt', registerType: 'prompt',
workbox: { workbox: {
@@ -90,22 +89,22 @@ export default defineConfig({
], ],
resolve: { resolve: {
alias: { alias: {
'~': path.resolve(__dirname, 'src'), '~': path.resolve(projectRoot, 'src'),
'@sql-wasm': path.resolve(__dirname, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'), '@sql-wasm': path.resolve(projectRoot, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
// workaround for vite, workbox (PWA) // workaround for vite, workbox (PWA)
module: path.resolve(__dirname, 'src', 'dummy.mjs'), module: path.resolve(projectRoot, 'src', 'dummy.mjs'),
}, },
}, },
build: { build: {
minify: true, minify: true,
rollupOptions: { rolldownOptions: {
output: { output: {
manualChunks: { manualChunks(id) {
core: ['react', 'react-dom'], if (/\/node_modules\/(react|react-dom)\//.test(id)) return 'core';
router: ['react-router'], if (/\/node_modules\/react-router\//.test(id)) return 'router';
store: ['react-redux', '@reduxjs/toolkit'], if (/\/node_modules\/(react-redux|@reduxjs\/toolkit)\//.test(id)) return 'store';
extras: ['react-dropzone', 'react-toastify'], if (/\/node_modules\/(react-dropzone|react-toastify)\//.test(id)) return 'extras';
}, },
}, },
}, },
@@ -114,11 +113,12 @@ export default defineConfig({
globals: true, globals: true,
mockReset: true, mockReset: true,
environment: 'jsdom', environment: 'jsdom',
pool: 'vmThreads',
setupFiles: ['src/test-utils/setup-jest.ts'], setupFiles: ['src/test-utils/setup-jest.ts'],
// workaround: sql.js is not ESModule friendly, yet... // workaround: sql.js is not ESModule friendly, yet...
deps: { deps: {
optimizer: { optimizer: {
web: { client: {
include: ['sql.js'], include: ['sql.js'],
}, },
}, },