34 Commits
v0.5.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
鲁树人
d514a87198 0.6.0 2025-10-15 22:47:59 +09:00
鲁树人
5c537ab8d9 ci: simplify release 2025-10-15 22:47:59 +09:00
鲁树人
48f10e8e30 fix: layout issue with android instruction in mobile screen 2025-10-15 22:08:51 +09:00
鲁树人
dfac382cbd Merge pull request '添加 Mac QQ 音乐 v10 的说明' (#3) from feat/mac-qqmusic-v10 into main
Reviewed-on: https://git.um-react.app/um/um-react/pulls/3
2025-10-15 12:59:19 +00:00
鲁树人
7b2558c585 feat: update instructions and scripts for QQMusic Mac v10.7 and v8.8.0 2025-10-15 02:08:32 +09:00
鲁树人
fb52b0197c feat: add insturctions on how to dump keys for v10 2025-10-15 00:57:56 +09:00
鲁树人
f49f629917 build: allow vite to access 'index.html' 2025-10-14 07:54:21 +09:00
鲁树人
8093d30579 chore: remove npm package lock file 2025-10-14 07:53:54 +09:00
鲁树人
4fe6efec1f 0.5.2 2025-09-08 21:31:34 +09:00
鲁树人
c4fe9ce938 fix: update dependencies 2025-09-08 21:31:34 +09:00
鲁树人
045bea8084 chore: log which decipher was used 2025-09-08 20:47:13 +09:00
鲁树人
c68195eb9a feat: add instructions to block update for qqmusic mac 8.8.0 2025-09-05 22:15:15 +09:00
鲁树人
b986a2ef99 0.5.1 2025-09-03 21:32:27 +09:00
鲁树人
b48d9b0079 chore: update major versions 2025-09-03 21:32:16 +09:00
鲁树人
62e49804a5 chore: update deps 2025-09-03 21:27:38 +09:00
鲁树人
c41e5ae531 build: sort out deps 2025-09-03 21:21:01 +09:00
鲁树人
27c33a7d20 fix: replace link to new git repo 2025-09-03 21:19:37 +09:00
鲁树人
bbb557eafd Merge pull request 'fix(downloadAll): 一点点修改' (#92) from awalol/um-react:fix-downloadall into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/92
2025-07-16 10:05:52 +00:00
awalol
befe35e5bc ui(DownloadAll): button position 2025-07-15 18:33:37 +00:00
awalol
1fb6526cdb docs: update faq 2025-07-15 18:33:37 +00:00
awalol
d122eaecf5 refactor(DownloadAll): 调整按钮位置 2025-07-15 18:33:37 +00:00
awalol
2da766168c refactor(DownloadAll): 并行下载 2025-07-15 18:33:37 +00:00
awalol
17200150dd feat(DownloadAll): 并行下载 2025-07-15 18:33:37 +00:00
awalol
2c461df5fc fix(downloadAll): 防止未解密文件导致的下载失败 优化目录选择框的弹出 优化权限请求 2025-07-15 18:33:37 +00:00
鲁树人
b493391371 Merge pull request '为解密文件添加 MIME 类型' (#95) from awalol/um-react:fix-mime into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/95
2025-07-14 11:27:00 +00:00
awalol
13b67f40aa refactor(MIME): add getMimeTypeFromExt function 2025-07-14 01:51:28 +08:00
awalol
fbe8ef8ba1 fix: add MIME type for the decrypted file 2025-07-11 23:40:31 +08:00
鲁树人
54f784d778 ci: fix wry build 2025-07-05 06:45:43 +09:00
75 changed files with 2578 additions and 13765 deletions

View File

@@ -20,8 +20,13 @@ body:
目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。 目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。
* [web.archive.org 镜像](https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg)
* [通过 Telegram 下载](https://t.me/um_lsr_ch/21) * [通过 Telegram 下载](https://t.me/um_lsr_ch/21)
安装好客户端后可以加装更新屏蔽更新:
* [屏蔽更新](https://t.me/um_lsr_ch/29)
--- ---
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。 如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。

View File

@@ -26,6 +26,34 @@ jobs:
with: with:
name: site name: site
path: dist/ path: dist/
- name: Create Release Package
if: startsWith(github.ref, 'refs/tags/')
run: |
VERSION=${GITHUB_REF#refs/tags/v}
mkdir release/
python3 -m zipfile -c "release/um-react-${VERSION}.zip" dist/.
cp win64/dist/*.zip "release/um-react-win64-${VERSION}.zip"
- name: Create Draft Release
if: startsWith(github.ref, 'refs/tags/')
uses: akkuman/gitea-release-action@v1
with:
draft: true
files: |
release/um-react-*.zip
body: |
上个版本:[v0.0.0](https://git.um-react.app/um/um-react/releases/tag/v0.0.0)
## 🐛 修正
- 修正内容
## ✨ 新增
- 新增内容
## 🔧 维护
- 维护内容
- name: Prepare for deployment - name: Prepare for deployment
run: | run: |
cp um-react.zip dist/"release-${GITHUB_SHA}.zip" cp um-react.zip dist/"release-${GITHUB_SHA}.zip"

4
.npmrc
View File

@@ -1,3 +1,3 @@
use-node-version=22.12.0 use-node-version=24.7.0
engine-strict=true engine-strict=true
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/ @unlock-music:registry=https://git.um-react.app/api/packages/um/npm/

View File

@@ -1,6 +1,6 @@
# Unlock Music 音乐解锁 (React) # Unlock Music 音乐解锁 (React)
[![Build Status](https://git.unlock-music.dev/um/um-react/actions/workflows/build.yaml/badge.svg)][um-react-actions] [![Build Status](https://git.um-react.app/um/um-react/actions/workflows/build.yaml/badge.svg)][um-react-actions]
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue] - 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
@@ -10,16 +10,17 @@
- 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.unlock-music.dev/um/um-react/src/branch/main/LICENSE [授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web [um-vue]: https://git.um-react.app/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli [unlock-music/cli]: https://git.um-react.app/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat [`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-actions]: https://git.unlock-music.dev/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,40 +32,45 @@
- 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/) 等。
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。 遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。 待实现的算法支持可[追踪 `crypto` 标签](https://git.um-react.app/um/um-react/issues?labels=67)。
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new [project-issues]: https://git.um-react.app/um/um-react/issues/new
## 使用 Docker 构建、部署 (Linux) ## 使用 Docker 构建、部署
首先克隆仓库并进入目录: 首先克隆仓库并进入目录:
```sh ```sh
git clone https://git.unlock-music.dev/um/um-react.git git clone https://git.um-react.app/um/um-react.git
cd um-react cd um-react
``` ```
@@ -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)」。
@@ -115,15 +125,15 @@ docker run -d -p 8080:80 --name um-react um-react
## 相关项目 ## 相关项目
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目 - [Unlock Music (Web)](https://git.um-react.app/um/web) - 原始项目
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版 - [Unlock Music (Cli)](https://git.um-react.app/um/cli) - 命令行批量处理版
- [lib_um_crypto_rust](https://git.unlock-music.dev/um/lib_um_crypto_rust) - 项目引入的解密算法实现 - [lib_um_crypto_rust](https://git.um-react.app/um/lib_um_crypto_rust) - 项目引入的解密算法实现
- [NPM 包](https://git.unlock-music.dev/um/-/packages/npm/@unlock-music%2Fcrypto) - [NPM 包](https://git.um-react.app/um/-/packages/npm/@unlock-music%2Fcrypto)
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。 - [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron) - [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest)
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 ( - [um-react-wry](https://git.um-react.app/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带) 需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带)
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件 - [本地下载](https://git.um-react.app/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703 [webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703

View File

@@ -16,7 +16,7 @@
- 进入上层目录:`cd ..` - 进入上层目录:`cd ..`
- 克隆 `lib_um_crypto_rust` 仓库 - 克隆 `lib_um_crypto_rust` 仓库
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git` - `git clone https://git.um-react.app/um/lib_um_crypto_rust.git`
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader` - 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
- 安装所有 Node 以来:`pnpm i` - 安装所有 Node 以来:`pnpm i`
- 构建:`pnpm build` - 构建:`pnpm build`

View File

@@ -69,7 +69,7 @@
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。 - WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。 - 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp) ![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc@2x.webp)
### Via 等浏览器无法正常解密/下载 ### Via 等浏览器无法正常解密/下载
@@ -87,10 +87,6 @@
- 无法下载解密后内容 - 无法下载解密后内容
- 下载的文件名错误 - 下载的文件名错误
### 新版解锁网站没有批量下载
目前没有做。抱歉。
## 仍有问题? ## 仍有问题?
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。 欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。

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'],
},
},
},
},
); );

11870
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.5.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@@ -17,60 +17,62 @@
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.9.0",
"@unlock-music/crypto": "0.1.10", "@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.0", "react": "^19.2.0",
"react-dom": "^19.1.0", "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.6.3", "react-router": "^7.9.4",
"react-syntax-highlighter": "^15.6.1", "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.30.1", "@eslint/js": "^9.37.0",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.14",
"@testing-library/jest-dom": "^6.6.3", "@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.0.10", "@types/node": "^24.7.2",
"@types/react": "^19.1.8", "@types/react": "^19.2.2",
"@types/react-dom": "^19.1.6", "@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/wicg-file-system-access": "^2023.10.6", "@types/tar-stream": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.35.1", "@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/parser": "^8.35.1", "@typescript-eslint/eslint-plugin": "^8.46.1",
"@vitejs/plugin-react": "^4.6.0", "@typescript-eslint/parser": "^8.46.1",
"@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.0.43", "daisyui": "^5.3.2",
"eslint": "^9.30.1", "eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.5", "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.2", "lint-staged": "^16.2.4",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup": "^4.44.2", "rollup": "^4.52.4",
"sass": "^1.89.2", "sass": "^1.93.2",
"simple-git-hooks": "^2.13.0", "simple-git-hooks": "^2.13.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.14",
"terser": "^5.43.1", "tar-stream": "^3.1.7",
"typescript": "^5.8.3", "terser": "^5.44.0",
"typescript-eslint": "^8.35.1", "typescript": "^5.9.3",
"vite": "^6.3.5", "typescript-eslint": "^8.46.1",
"vite-plugin-pwa": "^1.0.1", "vite": "^7.1.10",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-pwa": "^1.1.0",
"vite-plugin-wasm": "^3.4.1", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"workbox-build": "^7.3.0", "workbox-build": "^7.3.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
@@ -90,13 +92,17 @@
}, },
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
"sql.js": "patches/sql.js.patch" "sql.js": "patches/sql.js.patch"
}, },
"overrides": { "overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15" "sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
} },
"onlyBuiltDependencies": [
"@swc/core",
"@tailwindcss/oxide",
"esbuild",
"simple-git-hooks"
]
}, },
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4" "packageManager": "pnpm@10.15.1"
} }

3138
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
pushd "$(dirname "${BASH_SOURCE[0]}")/../" pushd "$(dirname "${BASH_SOURCE[0]}")/../"
WRY_VER="0.1.1" WRY_VER="0.1.2"
mkdir -p win64/{deps,dist} mkdir -p win64/{deps,dist}
dl_file() { dl_file() {

View File

@@ -65,3 +65,12 @@ h6 {
opacity: 0.75; opacity: 0.75;
} }
} }
#downloadAll {
position: absolute;
right: 0.5em;
bottom: 72px;
width: 48px;
height: 48px;
margin: 10px;
}

View File

@@ -1,5 +1,4 @@
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; import { CodeHighlight } from '../CodeHighlight';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
import { ExtLink } from '../ExtLink'; import { ExtLink } from '../ExtLink';
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw'; import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw'; import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
@@ -45,9 +44,7 @@ export function AdbInstructionTemplate({ dir, file, platform }: AdbInstructionTe
<li></li> <li></li>
<li> <li>
<p> USB </p> <p> USB </p>
<SyntaxHighlighter language={language} style={hljsStyleGitHub}> <CodeHighlight language={language}>{command}</CodeHighlight>
{command}
</SyntaxHighlighter>
<br /> <br />
<ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw"> <ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw">
MuMu MuMu

View File

@@ -9,10 +9,10 @@ export function RootExplorerGuide() {
<div className="flex flex-col items-start gap-4 @md:flex-row"> <div className="flex flex-col items-start gap-4 @md:flex-row">
<div> <div>
<Header5 className="[&]:mt-0 [&]:pt-0">Amaze </Header5> <Header5 className="[&]:mt-0 [&]:pt-0">Amaze </Header5>
<ul className="ml-2 list-disc list-inside"> <ul className="ml-2 list-disc">
<li> <li>
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center flex-wrap">
<FiMenu /> <FiMenu className="m-1" />
</div> </div>
</li> </li>
<li> <li>
@@ -41,16 +41,16 @@ export function RootExplorerGuide() {
</div> </div>
<div> <div>
<Header5 className="[&]:mt-0 [&]:pt-0">MT </Header5> <Header5 className="[&]:mt-0 [&]:pt-0">MT </Header5>
<ul className="ml-2 list-disc list-inside"> <ul className="ml-2 list-disc">
<li> <li>
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center flex-wrap">
<FiMenu /> <FiMenu className="m-1" />
</div> </div>
</li> </li>
<li> <li>
<div className="inline-flex items-center"> <div className="inline-flex items-center flex-wrap">
<FiMoreVertical className="ml-1" /> <FiMoreVertical className="ml-1" />
<VQuote></VQuote> <VQuote></VQuote>
</div> </div>
</li> </li>
<li> <li>

View File

@@ -15,7 +15,6 @@ import { Bounce, ToastContainer } from 'react-toastify';
import { SettingsHome } from '~/features/settings/SettingsHome'; import { SettingsHome } from '~/features/settings/SettingsHome';
import { FAQ_PAGES } from '~/faq/FAQPages'; import { FAQ_PAGES } from '~/faq/FAQPages';
import { FaqHome } from '~/faq/FaqHome'; import { FaqHome } from '~/faq/FaqHome';
import { DownloadAll } from '~/components/DownloadAll.tsx';
// Private to this file only. // Private to this file only.
const store = setupStore(); const store = setupStore();
@@ -72,7 +71,6 @@ export function AppRoot() {
transition={Bounce} transition={Bounce}
/> />
<DownloadAll />
<Footer /> <Footer />
</Provider> </Provider>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,10 @@
import { Light as SyntaxHighlighter, type SyntaxHighlighterProps } from 'react-syntax-highlighter';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
export function CodeHighlight({ children, ...props }: SyntaxHighlighterProps) {
return (
<SyntaxHighlighter style={hljsStyleGitHub} {...props}>
{children}
</SyntaxHighlighter>
);
}

View File

@@ -1,65 +1,90 @@
import { DecryptedAudioFile, selectFiles } from '~/features/file-listing/fileListingSlice'; import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-listing/fileListingSlice';
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 filesLength = Object.keys(files).length; const downloadAllAsync = async () => {
const onClickDownloadAll = async () => { const fileList = Object.values(files);
let dir: FileSystemDirectoryHandle | undefined; const fileCount = fileList.length;
let success = 0; if (fileCount === 0) {
try { toast.warning('未添加文件');
dir = await window.showDirectoryPicker(); return;
} catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') {
return;
}
} }
for (const [_, file] of Object.entries(files)) {
// 判断所有文件是否处理完成
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) {
toast.warning('请等待所有文件解密完成');
return;
}
// 过滤处理失败的文件
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
// 准备下载
let dir: FileSystemDirectoryHandle | null = null;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
return; // user cancelled
}
console.error(e);
}
toast.warning('开始下载,请稍候');
const queue = new SimpleQueue(8);
const promises = Object.values(completeFiles).map(async (file) => {
try { try {
if (dir) { await queue.enter();
await DownloadNew(dir, file); await downloadFile(file, dir);
} else {
await DownloadOld(file);
}
success++;
} catch (e) { } catch (e) {
console.error(`下载失败: ${file.fileName}`, e); console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e}`); toast.error(`出现错误: ${e as Error}`);
throw e;
} finally {
queue.leave();
} }
} });
if (success === filesLength) {
toast.success(`成功下载: ${success}/${filesLength}`); const promiseResults = await Promise.allSettled(promises);
} else { const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
toast.error(`成功下载: ${success}/${filesLength}`); const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
} toast[level](`成功下载: ${success}/${fileCount}`);
}; };
function onDownloadAll() {
downloadAllAsync().catch((e) => {
// this should not happen
console.error('下载全部出现错误', e);
});
}
return ( return (
<button <button className="btn btn-primary" id="downloadAll" onClick={onDownloadAll} title="下载全部">
style={{ width: '48px', height: '48px', paddingInline: '0px', margin: '10px', marginLeft: 'auto' }}
className="btn btn-primary"
onClick={onClickDownloadAll}
title="下载全部"
>
<FaDownload /> <FaDownload />
</button> </button>
); );
} }
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) { async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true }); if (dir) {
const writable = await fileHandle.createWritable(); const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable)); const fileStream = await fileHandle.createWritable();
} try {
const res = await fetch(file.decrypted);
async function DownloadOld(file: DecryptedAudioFile) { await res.body?.pipeTo(fileStream);
const a = document.createElement('a'); } catch {
a.href = file.decrypted; await fileStream.abort();
a.download = file.cleanName + '.' + file.ext; }
document.body.append(a); } else {
a.click(); const anchor = document.createElement('a');
a.remove(); anchor.href = file.decrypted;
anchor.download = file.cleanName + '.' + file.ext;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
} }

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { ExtLink } from './ExtLink';
import { IoMdArchive } from 'react-icons/io';
export type DownloadBase64Props = {
data: string;
filename: string;
mimetype?: string;
className?: string;
icon?: ReactNode | true | false;
children?: ReactNode;
};
export function DownloadBase64({
className,
children,
data,
filename,
icon,
mimetype = 'application/octet-stream',
}: DownloadBase64Props) {
return (
<ExtLink
icon={icon ?? <IoMdArchive className="inline size-sm ml-1" />}
className={className ?? 'link-info mx-1'}
download={filename}
href={`data:${mimetype};base64,${data}`}
>
{children ?? <code>{filename}</code>}
</ExtLink>
);
}

View File

@@ -1,15 +1,15 @@
import type { AnchorHTMLAttributes } from 'react'; 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; icon?: ReactNode | true | false;
}; };
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) { export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
return ( return (
<a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}> <a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}>
{children} {children}
{icon && <FiExternalLink className="inline size-sm ml-1" />} {icon === true ? <FiExternalLink className="inline size-sm ml-1" /> : icon}
</a> </a>
); );
} }

View File

@@ -5,25 +5,25 @@ export function Footer() {
const appVersionShort = '__APP_VERSION_SHORT__'; const appVersionShort = '__APP_VERSION_SHORT__';
return ( return (
<footer className="flex flex-col text-center p-4 bg-base-200"> <footer className="flex flex-col text-center p-4 bg-base-200">
<p className="flex flex-row justify-center items-center h-[1em]"> <div className="flex flex-row justify-center items-center h-[1em]">
<a className="link link-info mr-1" href="https://git.unlock-music.dev/um/um-react"> <a className="link link-info mr-1" href="https://git.um-react.app/um/um-react">
</a> </a>
( (
<a <a
title="使用 MIT 授权协议" title="使用 MIT 授权协议"
className="link link-info" className="link link-info"
href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE"
> >
MIT MIT
</a> </a>
, v{appVersionShort} , v{appVersionShort}
<SDKVersion />) <SDKVersion />)
</p> </div>
<p> <p>
{'© 2019 - '} {'© 2019 - '}
<CurrentYear /> <CurrentYear />
<a className="ml-1 link link-info" href="https://git.unlock-music.dev/um"> <a className="ml-1 link link-info" href="https://git.um-react.app/um">
Unlock Music Unlock Music
</a> </a>
</p> </p>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { FileInput } from '~/components/FileInput'; import { FileInput } from '~/components/FileInput';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
export interface ImportSecretModalProps { export interface ImportSecretModalProps {
clientName?: React.ReactNode; clientName?: React.ReactNode;
@@ -12,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);
@@ -31,7 +31,7 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
}, [show]); }, [show]);
return ( return (
<dialog ref={refModel} className="modal"> <dialog ref={refModel} className="modal" onClose={onClose}>
<div className="modal-box"> <div className="modal-box">
<form method="dialog" onSubmit={() => onClose()}> <form method="dialog" onSubmit={() => onClose()}>
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
@@ -41,7 +41,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
<FileInput onReceiveFiles={handleFileReceived}></FileInput> <FileInput onReceiveFiles={handleFileReceived}></FileInput>
<div className="mt-2">{clientName && <>{clientName}</>}</div> <div className="mt-2">{clientName && <>{clientName}</>}</div>
<div>{children}</div> <InSecretImportModalContext.Provider value={true}>
<div>{children}</div>
</InSecretImportModalContext.Provider>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -7,7 +7,7 @@ export interface ProjectIssueProps {
export function ProjectIssue({ id, title }: ProjectIssueProps) { export function ProjectIssue({ id, title }: ProjectIssueProps) {
return ( return (
<ExtLink target="_blank" href={`https://git.unlock-music.dev/um/um-react/issues/${id}`}> <ExtLink target="_blank" href={`https://git.um-react.app/um/um-react/issues/${id}`}>
{`#${id}`} {`#${id}`}
{title && ` - ${title}`} {title && ` - ${title}`}
</ExtLink> </ExtLink>

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

@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const InSecretImportModalContext = createContext<boolean>(false);

View File

@@ -25,7 +25,7 @@ export interface DecipherNotOK {
export interface DecipherOK { export interface DecipherOK {
status: Status.OK; status: Status.OK;
message?: string; message?: string;
data: Uint8Array; data: Uint8Array<ArrayBuffer>;
overrideExtension?: string; overrideExtension?: string;
cipherName: string; cipherName: string;
} }

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));
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();
} }
} }

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);
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);
} }
} }

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();
} }
} }

View File

@@ -3,16 +3,16 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-
export class TransparentDecipher implements DecipherInstance { export class TransparentDecipher implements DecipherInstance {
cipherName = 'none'; cipherName = 'none';
async decrypt(buffer: Uint8Array): 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);
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();
} }
} }

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();
} }
} }

View File

@@ -24,3 +24,17 @@ export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
detectResult.free(); detectResult.free();
return ok; return ok;
} }
const AudioMimeType: Record<string, string> = {
mp3: 'audio/mpeg',
flac: 'audio/flac',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
wma: 'audio/x-ms-wma',
wav: 'audio/x-wav',
dff: 'audio/x-dff',
};
export function getMimeTypeFromExt(ext: string) {
return AudioMimeType[ext] || 'application/octet-stream';
}

View File

@@ -1,7 +1,6 @@
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) => export const toArrayBuffer = async (src: Blob | BlobPart) => (src instanceof Blob ? await src.arrayBuffer() : src);
src instanceof Blob ? await src.arrayBuffer() : src; export const toBlob = (src: Blob | BlobPart, mimeType?: string) =>
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) => src instanceof Blob ? src : new Blob([src], { type: mimeType ?? 'application/octet-stream' });
src instanceof Blob ? src : new Blob([src]);
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> { export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
const len = buffer.byteLength; const len = buffer.byteLength;

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

@@ -6,7 +6,7 @@ import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deci
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts'; import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
import { ready as umCryptoReady } from '@unlock-music/crypto'; import { ready as umCryptoReady } from '@unlock-music/crypto';
import { go } from '~/util/go.ts'; import { go } from '~/util/go.ts';
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts'; import { getMimeTypeFromExt, detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
class DecryptCommandHandler { class DecryptCommandHandler {
private readonly label: string; private readonly label: string;
@@ -31,6 +31,7 @@ class DecryptCommandHandler {
const [result, error] = await go(this.tryDecryptWith(decipher)); const [result, error] = await go(this.tryDecryptWith(decipher));
if (!error) { if (!error) {
if (result) { if (result) {
console.debug(`[${decipher.cipherName}] Decryption OK`);
return result; return result;
} }
errors.push(`${decipher.cipherName}: no response`); errors.push(`${decipher.cipherName}: no response`);
@@ -75,7 +76,7 @@ class DecryptCommandHandler {
audioExt = 'm4a'; audioExt = 'm4a';
} }
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt }; return { decrypted: URL.createObjectURL(toBlob(result.data, getMimeTypeFromExt(audioExt))), ext: audioExt };
} }
} }

View File

@@ -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();
} }
} };

View File

@@ -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> => {

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

@@ -9,31 +9,31 @@ export function FAQAboutProject() {
<Header3 id="failed">um-react </Header3> <Header3 id="failed">um-react </Header3>
<p> <p>
um-react um-react
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um"> <a className="mx-1 link link-info" href="https://git.um-react.app/um">
Unlock Music Unlock Music
</a> </a>
React 使 React 使
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE"> <a className="mx-1 link link-info" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE">
MIT MIT
</a> </a>
</p> </p>
<p> <p>
<FaRust className="inline" /> <FaRust className="inline" />
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um/lib_um_crypto_rust"> <a className="mx-1 link link-info" href="https://git.um-react.app/um/lib_um_crypto_rust">
<code>lib_um_crypto_rust</code> <code>lib_um_crypto_rust</code>
</a> </a>
使 使
<a <a
className="mx-1 link link-info" className="mx-1 link link-info"
href="https://git.unlock-music.dev/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT" href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT"
> >
MIT MIT
</a> </a>
+ +
<a <a
className="mx-1 link link-info" className="mx-1 link link-info"
href="https://git.unlock-music.dev/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE" href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE"
> >
Apache Apache
</a> </a>

View File

@@ -1,6 +1,5 @@
import { ExtLink } from '~/components/ExtLink'; import { ExtLink } from '~/components/ExtLink';
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers'; import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
import { ProjectIssue } from '~/components/ProjectIssue';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
@@ -12,21 +11,12 @@ export function OtherFAQ() {
<p></p> <p></p>
<p>使</p> <p>使</p>
<Header3 id="batch-dl"></Header3>
<p>
{'暂时没有实现,不过你可以在 '}
<ProjectIssue id={34} title="[UI] 全部下载功能" />
{' 以及 '}
<ProjectIssue id={43} title="批量下载" />
{' 追踪该问题。'}
</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">
<div> <div>
<Header4></Header4> <Header4></Header4>
<ul className="list-disc list-inside pl-2"> <ul className="list-disc pl-8">
<li>Via </li> <li>Via </li>
<li></li> <li></li>
<li>UC </li> <li>UC </li>
@@ -35,7 +25,7 @@ export function OtherFAQ() {
<div> <div>
<Header4></Header4> <Header4></Header4>
<ul className="list-disc list-inside pl-2"> <ul className="list-disc pl-8">
<li></li> <li></li>
<li></li> <li></li>
<li></li> <li></li>
@@ -77,7 +67,7 @@ export function OtherFAQ() {
</li> </li>
<li> <li>
<p> <p>
<ExtLink className="mr-2" href="https://git.unlock-music.dev/um/um-react-wry"> <ExtLink className="mr-2" href="https://git.um-react.app/um/um-react-wry">
<strong> <strong>
<code>um-react-wry</code> <code>um-react-wry</code>
</strong> </strong>
@@ -89,7 +79,7 @@ export function OtherFAQ() {
<ul className="list-disc pl-6"> <ul className="list-disc pl-6">
<li> <li>
<p> <p>
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest"></ExtLink> <ExtLink href="https://git.um-react.app/um/um-react/releases/latest"></ExtLink>
{' | 寻找文件名为 '} {' | 寻找文件名为 '}
<code>um-react-win64-</code> <code>um-react-win64-</code>
</p> </p>

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';
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 (
<> <>

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

@@ -1,79 +1,31 @@
import { RiFileCopyLine } from 'react-icons/ri'; import { useId } from 'react';
import { toast } from 'react-toastify'; import { InstructionsMacV8 } from './InstructionsMacV8';
import { ExtLink } from '~/components/ExtLink'; import { InstructionsMacV10 } from './InstructionsMacV10';
import { FilePathBlock } from '~/components/FilePathBlock';
import { VQuote } from '~/components/HelpText/VQuote';
import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey';
const MAC_CLIENT_URL =
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/21';
const DB_PATH =
'~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId';
export function InstructionsMac() { export function InstructionsMac() {
const copyDbPathToClipboard = () => { const macInstructionId = useId();
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>
<p>Mac 使 mmkv </p> <p>Mac 使 mmkv </p>
<p> v8.8.0 </p> <p>使 v8.8.0 v10.7 v8.8.0 </p>
<p className="mt-4"> QQ Mac 8.8.0:</p> <div className="join join-vertical bg-base-100 mt-2 max-w-full">
<ul className="list-disc pl-6"> <div className="collapse collapse-arrow join-item border-base-300 border">
<li> <input type="radio" name={macInstructionId} />
<ExtLink className="link-info" href={MAC_CLIENT_URL}> <div className="collapse-title font-semibold">使 QQ Mac v8.8.0</div>
<code>Archive.org</code> <div className="collapse-content text-sm min-w-0">
</ExtLink> <InstructionsMacV8 />
</li> </div>
<li> </div>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}> <div className="collapse collapse-arrow join-item border-base-300 border">
Telegram <input type="radio" name={macInstructionId} />
</ExtLink> <div className="collapse-title font-semibold">使 QQ Mac v10.7.1</div>
</li> <div className="collapse-content text-sm min-w-0">
</ul> <InstructionsMacV10 />
</div>
<p className="mt-4"></p> </div>
<FilePathBlock>{DB_PATH}</FilePathBlock> </div>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={copyDbPathToClipboard}>
<RiFileCopyLine className="text-xl" />
<span></span>
</button>
<code>MMKVStreamEncryptId</code>
</li>
<li>
<VQuote></VQuote><VQuote></VQuote>
</li>
<li>
<VQuote>
<ShiftKey className="mx-1" />
{'+'}
<MacCommandKey className="mx-1" />
{'+'}
<kbd className="kbd mx-1">G</kbd>
</VQuote>
<VQuote></VQuote>
</li>
<li>
<code>MMKVStreamEncryptId</code>
</li>
<li></li>
</ol>
</> </>
); );
} }

View File

@@ -0,0 +1,65 @@
import { ExtLink } from '~/components/ExtLink';
import {
commandName as DUMP_COMMAND_NAME,
tarName as DUMP_COMMAND_TARBALL_NAME,
tarball as DUMP_COMMAND_BASE64,
} from './assets/qqmusic_v10.7_dump.command?&name=QQ 音乐 Mac v10 密钥提取.command&mac-command';
import { DownloadBase64 } from '~/components/DownloadBase64';
import { VQuote } from '~/components/HelpText/VQuote';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
import { useContext } from 'react';
const MAC_CLIENT_URL =
'https://c.y.qq.com/cgi-bin/file_redirect.fcg?bid=dldir&file=ecosfile%2Fmusic_clntupate%2Fmac%2Fother%2FQQMusicMac10.7.1Build00.dmg&sign=1-0cb9ee4c40e7447e2113cfdee2dc11c88487b0e31fe37cfe1c59e12c20956dce-689e9373';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/30';
export function InstructionsMacV10() {
const inSecretImportModal = useContext(InSecretImportModalContext);
return (
<>
<p className="mt-4"> QQ Mac 10.7.1:</p>
<ul className="list-disc pl-6">
<li>
<ExtLink className="link-info" href={MAC_CLIENT_URL}>
QQ
</ExtLink>
</li>
<li>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
Telegram
</ExtLink>
</li>
</ul>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<DownloadBase64 data={DUMP_COMMAND_BASE64} filename={DUMP_COMMAND_TARBALL_NAME}></DownloadBase64>
<code>{DUMP_COMMAND_NAME}</code>
</li>
<li>
<p>
<code>{DUMP_COMMAND_NAME}</code>
</p>
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
</li>
<li>
<code>qqmusic-mac-*.mmkv</code> <code>*</code>
</li>
{inSecretImportModal ? (
<li>
<code>qqmusic-mac-*.mmkv</code> <VQuote></VQuote>
</li>
) : (
<li>
<code>qqmusic-mac-*.mmkv</code>
</li>
)}
</ol>
</>
);
}

View File

@@ -0,0 +1,89 @@
import { RiFileCopyLine } from 'react-icons/ri';
import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock';
import { VQuote } from '~/components/HelpText/VQuote';
import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey';
import { copyToClipboard } from '~/util/clipboard';
import {
commandName as BLOCK_UPDATE_COMAND,
tarName as BLOCK_UPDATE_TAR_NAME,
tarball as BLOCK_UPDATE_BASE64,
} from './assets/qqmusic_v8.8.0_patch_update.command?&name=QQ 音乐 Mac v8.8.0 屏蔽更新.command&mac-command';
import { DownloadBase64 } from '~/components/DownloadBase64';
import { useContext } from 'react';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
const MAC_CLIENT_URL =
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/21';
const DB_PATH =
'~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId';
export function InstructionsMacV8() {
const inSecretImportModal = useContext(InSecretImportModalContext);
return (
<>
<p className="mt-4"> QQ Mac 8.8.0:</p>
<ul className="list-disc pl-6">
<li>
<ExtLink className="link-info" href={MAC_CLIENT_URL}>
<code>Archive.org</code>
</ExtLink>
</li>
<li>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
Telegram
</ExtLink>
</li>
</ul>
<p className="mt-4">
<DownloadBase64 filename={BLOCK_UPDATE_TAR_NAME} data={BLOCK_UPDATE_BASE64}></DownloadBase64>
<code>{BLOCK_UPDATE_COMAND}</code>
<span> QQ </span>
</p>
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
<p className="mt-4"></p>
<FilePathBlock>{DB_PATH}</FilePathBlock>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={() => copyToClipboard(DB_PATH)}>
<RiFileCopyLine className="text-xl" />
<span></span>
</button>
<code>MMKVStreamEncryptId</code>
</li>
<li>
{inSecretImportModal ? (
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
) : (
<p></p>
)}
<p>
<VQuote>
<ShiftKey className="mx-1" />
{'+'}
<MacCommandKey className="mx-1" />
{'+'}
<kbd className="kbd mx-1">G</kbd>
</VQuote>
<VQuote></VQuote>
</p>
</li>
</ol>
</>
);
}

View File

@@ -0,0 +1,3 @@
com.tencent.QQMusicMac.plist
iData/
qqmusic-mac-*.mmkv

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# QQMusic Mac MMKV Decryptor by LSR@Unlock Music
import hashlib
import re
import sys
from argparse import ArgumentParser
from dataclasses import dataclass
from os import PathLike
from os.path import dirname
from pathlib import Path
from struct import pack, unpack
@dataclass
class MMKVDecryptionData:
udid: str
mmkv_path: Path
mmkv_key: str
data: bytes
@property
def mmkv_name(self) -> str:
return self.mmkv_path.name
def _aes_128_cfb_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
"""Decrypt using `Crypto.Cipher.AES` _or_ fallback to `OpenSSL` otherwise"""
try:
from Crypto.Cipher import AES # pyright: ignore[reportMissingImports]
aes = AES.new(key[:16], AES.MODE_CFB, iv=iv, segment_size=128)
return aes.decrypt(ciphertext)
except ImportError:
from subprocess import PIPE, Popen
process = Popen(
["openssl", "enc", "-aes-128-cfb", "-d", "-K", key.hex(), "-iv", iv.hex()],
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
text=False,
)
stdout, stderr = process.communicate(input=ciphertext)
if process.returncode != 0:
raise RuntimeError(
f"OpenSSL error (install PyCryptodome instead): {stderr.decode()}"
)
return stdout
def _caesar(text: str, shift: int) -> str:
"""A simple Caesar cipher implementation for alphanumeric characters"""
result = ""
for char in text:
if char.isalpha():
base = ord("A") if char.isupper() else ord("a")
result += chr((ord(char) - base + shift) % 26 + base)
elif char.isdigit():
result += chr((ord(char) - ord("0") + shift) % 10 + ord("0"))
else:
result += char
return result
__MMKV_TYPE_STREAM_KEY = 1
def _derive_mmkv_config(udid: str, mmkv_type: int):
"""Derive MMKV name and key from UDID, return (name, key)"""
str1 = _caesar(udid, mmkv_type + 3)
int1 = int(udid[5:7], 16)
int2 = 5 + (int1 + mmkv_type) % 4
mmkv_name = str1[0:int2]
int3 = mmkv_type + 0xA546
str3 = f"{udid}{int3:04x}"
mmkv_key = hashlib.md5(str3.encode()).hexdigest()
return mmkv_name, mmkv_key
def _decrypt_mmkv(path: PathLike, key: bytes):
"""Decrypt MMKV file using the given key, return decrypted data"""
with open(path, "rb") as mmkv, open(str(path) + ".crc", "rb") as crc:
crc.seek(12)
iv = crc.read(16)
(real_size,) = unpack("<I", crc.read(4))
(mmkv_payload_size,) = unpack("<I", mmkv.read(4))
if mmkv_payload_size != real_size:
raise ValueError("MMKV file size mismatch")
decrypted_data = pack("<I", real_size)
decrypted_data += _aes_128_cfb_decrypt(key, iv, mmkv.read(real_size))
return decrypted_data
def _dump_udid(plist_file: PathLike):
"""Extract UDIDs from the given plist file"""
with open(plist_file, "rb") as f:
plist = f.read()
for m in re.finditer(rb"_\x10\(([0-9a-f]{40})_", plist):
yield m.group(1).decode()
def _dump_mmkv(plist_file: PathLike, data_dir: PathLike):
"""Dump all MMKV files from the given plist file and iData directory"""
for udid in _dump_udid(plist_file):
mmkv_name, mmkv_key = _derive_mmkv_config(udid, __MMKV_TYPE_STREAM_KEY)
mmkv_path = Path(data_dir) / mmkv_name
if not mmkv_path.exists() or not mmkv_path.is_file():
print(f"MMKV file not found, skipping (path={mmkv_path})", file=sys.stderr)
continue
try:
decrypted_mmkv = _decrypt_mmkv(mmkv_path, mmkv_key.encode())
except Exception as e:
print(
"Error decrypting mmkv, skipping"
f" (path={mmkv_path}, key={mmkv_key}, error={e})",
file=sys.stderr,
)
continue
yield MMKVDecryptionData(
udid=udid,
mmkv_path=mmkv_path,
mmkv_key=mmkv_key,
data=decrypted_mmkv,
)
def main():
parser = ArgumentParser(
description="QQMusic Mac MMKV Decryptor by LSR@Unlock Music"
)
parser.add_argument(
"-p",
"--plist",
type=str,
nargs="+",
help="Path to com.tencent.QQMusicMac.plist file or files",
default=[],
)
parser.add_argument(
"-i",
"--idata",
type=str,
help="Path to iData directory",
default="",
)
parser.add_argument("-f", "--force", action="store_true", help="Force overwrite")
parser.add_argument(
"-o",
"--output",
type=str,
help="Output directory for decrypted MMKV files (default: script directory)",
default=dirname(__file__),
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
args = parser.parse_args()
home_dir = Path.home()
app_sandbox_dir = home_dir / "Library/Containers/com.tencent.QQMusicMac/Data"
idata_dir = app_sandbox_dir / "Library/Application Support/QQMusicMac/iData"
if args.idata:
idata_dir = Path(args.idata)
plists = []
if args.plist:
plists = [Path(p) for p in args.plist]
else:
for base_dir in (home_dir, app_sandbox_dir):
plists.append(base_dir / "Library/Preferences/com.tencent.QQMusicMac.plist")
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
force = args.force
verbose = args.verbose
for plist_file in plists:
if plist_file.exists() and plist_file.is_file():
for dump in _dump_mmkv(plist_file, idata_dir):
out_path = output_dir / f"qqmusic-mac-{dump.mmkv_path.name}.mmkv"
if out_path.exists() and not force:
print(f"output exists, skipping (name={out_path.name})")
continue
if verbose:
print("*** MMKV DUMP ENTRY START ***")
print(f"UDID: {dump.udid}")
print(f"MMKV Name: {dump.mmkv_path.name}")
print(f"MMKV Key: {dump.mmkv_key}")
print(f"Output: {out_path.name}")
print("**** MMKV DUMP ENTRY END ****")
else:
print(f"Dumping mmkv: {out_path.name}...")
try:
with open(out_path, "wb") as f:
f.write(dump.data)
except Exception as e:
print(f"Error writing decrypted mmkv: {e}", file=sys.stderr)
continue
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
#!/bin/sh
echo '补丁中…'
patch_count=0
patch_qqmusic() {
SUDO="$1"
APP="$2"
if [ ! -d "$APP" ]; then
echo "路径不存在,跳过 $APP..."
return
fi
echo "修补 $APP..."
$SUDO sed -i.bak 's#<string>8.8.0</string>#<string>88.8.0</string>#' \
"$APP/Contents/Info.plist"
$SUDO codesign --force --deep --sign - "$APP"
$SUDO xattr -d com.apple.quarantine "$APP"
patch_count=$((patch_count + 1))
}
patch_qqmusic sudo "/Applications/QQMusic.app"
patch_qqmusic "" "$HOME/Applications/QQMusic.app"
echo "完成,已修补 $patch_count 个 QQ 音乐安装"

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

@@ -1,5 +1,6 @@
import { RiErrorWarningLine } from 'react-icons/ri'; import { RiErrorWarningLine } from 'react-icons/ri';
import { SelectFile } from '../components/SelectFile'; import { SelectFile } from '../components/SelectFile';
import { DownloadAll } from '~/components/DownloadAll.tsx';
import { FileListing } from '~/features/file-listing/FileListing'; import { FileListing } from '~/features/file-listing/FileListing';
import { useAppDispatch, useAppSelector } from '~/hooks.ts'; import { useAppDispatch, useAppSelector } from '~/hooks.ts';
@@ -39,6 +40,7 @@ export function MainTab() {
<div className="w-full mt-4"> <div className="w-full mt-4">
<FileListing /> <FileListing />
</div> </div>
<DownloadAll />
</div> </div>
</div> </div>
); );

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);
} }

View File

@@ -5,7 +5,7 @@ export class MMKVParser {
private offset = 4; private offset = 4;
private length: number; private length: number;
constructor(private view: DataView) { constructor(private view: DataView<ArrayBuffer>) {
const payloadLength = view.getUint32(0, true); const payloadLength = view.getUint32(0, true);
this.length = 4 + payloadLength; this.length = 4 + payloadLength;

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]) : '',
);
} }

8
src/util/clipboard.ts Normal file
View File

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

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;
} }

View File

@@ -1,7 +1,7 @@
import type { StagingKugouKey } from '~/features/settings/keyFormats'; import type { StagingKugouKey } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidKugouMMKV(view: DataView): Omit<StagingKugouKey, 'id'>[] { export function parseAndroidKugouMMKV(view: DataView<ArrayBuffer>): Omit<StagingKugouKey, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKugouKey, 'id'>[] = []; const result: Omit<StagingKugouKey, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {

View File

@@ -1,7 +1,7 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats'; import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] { export function parseAndroidKuwoEKey(view: DataView<ArrayBuffer>): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = []; const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {
@@ -21,7 +21,7 @@ export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'
return result; return result;
} }
export function parseIosKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] { export function parseIosKuwoEKey(view: DataView<ArrayBuffer>): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = []; const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {

View File

@@ -1,6 +1,6 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidQmEKey(view: DataView): Map<string, string> { export function parseAndroidQmEKey(view: DataView<ArrayBuffer>): Map<string, string> {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result = new Map<string, string>(); const result = new Map<string, string>();
while (!mmkv.eof) { while (!mmkv.eof) {

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() {

6
src/vite-env.d.ts vendored
View File

@@ -11,3 +11,9 @@ declare module '*?base64' {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*&mac-command' {
export const tarball: string;
export const commandName: string;
export const tarName: string;
}

View File

@@ -0,0 +1,37 @@
import { basename } from 'node:path';
import { readFile } from 'node:fs/promises';
import { Plugin } from 'vite';
import tar from 'tar-stream';
export const macCommandLoader: Plugin = {
name: 'mac-command-loader',
async transform(_: unknown, id: string) {
const [path, query] = id.split('?');
if (!query || !query.includes('mac-command')) return null;
const params = new URLSearchParams(query);
// Mac .command packer.
// - Create a tarball with the given file (a+x)
// - Encode to base64
const tarball = tar.pack();
const name = params.get('name') || `${basename(path)}.command`;
const data = await readFile(path);
tarball.entry({ name, mode: 0o755 }, data);
tarball.finalize();
const chunks: Buffer[] = [];
for await (const chunk of tarball) {
chunks.push(chunk as Buffer);
}
const dataBuffer = Buffer.concat(chunks);
const base64 = dataBuffer.toString('base64');
return `
export const tarball = ${JSON.stringify(base64)};
export const commandName = ${JSON.stringify(name)};
export const tarName = ${JSON.stringify(`${name}.tar`)};
`;
},
};

View File

@@ -12,6 +12,7 @@ import tailwindcss from '@tailwindcss/vite';
import { tryCommand } from './support/command'; import { tryCommand } from './support/command';
import { base64Loader } from './support/b64-loader'; import { base64Loader } from './support/b64-loader';
import { macCommandLoader } from './support/mac-command-loader';
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'));
@@ -32,6 +33,7 @@ export default defineConfig({
// strict: false, // strict: false,
allow: [ allow: [
'index.html',
'src', 'src',
'node_modules', 'node_modules',
@@ -46,6 +48,7 @@ export default defineConfig({
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
base64Loader, base64Loader,
macCommandLoader,
replace({ replace({
preventAssignment: true, preventAssignment: true,
values: { values: {