mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 19:43:02 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11caaae92 | ||
|
|
2e52c67533 | ||
|
|
56bb94b90c | ||
|
|
c1a5c6bde6 | ||
|
|
009804cbbd | ||
|
|
ff6fc467ae | ||
|
|
d514a87198 | ||
|
|
5c537ab8d9 | ||
|
|
48f10e8e30 | ||
|
|
dfac382cbd | ||
|
|
7b2558c585 | ||
|
|
fb52b0197c | ||
|
|
f49f629917 | ||
|
|
8093d30579 | ||
|
|
4fe6efec1f | ||
|
|
c4fe9ce938 | ||
|
|
045bea8084 | ||
|
|
c68195eb9a | ||
|
|
b986a2ef99 | ||
|
|
b48d9b0079 | ||
|
|
62e49804a5 | ||
|
|
c41e5ae531 | ||
|
|
27c33a7d20 | ||
|
|
bbb557eafd | ||
|
|
befe35e5bc | ||
|
|
1fb6526cdb | ||
|
|
d122eaecf5 | ||
|
|
2da766168c | ||
|
|
17200150dd | ||
|
|
2c461df5fc | ||
|
|
b493391371 | ||
|
|
13b67f40aa | ||
|
|
fbe8ef8ba1 | ||
|
|
54f784d778 |
@@ -20,8 +20,13 @@ body:
|
||||
|
||||
目前 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)
|
||||
|
||||
安装好客户端后可以加装更新屏蔽更新:
|
||||
|
||||
* [屏蔽更新](https://t.me/um_lsr_ch/29)
|
||||
|
||||
---
|
||||
|
||||
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||
|
||||
@@ -26,6 +26,34 @@ jobs:
|
||||
with:
|
||||
name: site
|
||||
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
|
||||
run: |
|
||||
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
||||
use-node-version=22.12.0
|
||||
use-node-version=24.7.0
|
||||
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/
|
||||
|
||||
64
README.MD
64
README.MD
@@ -1,6 +1,6 @@
|
||||
# Unlock Music 音乐解锁 (React)
|
||||
|
||||
[][um-react-actions]
|
||||
[][um-react-actions]
|
||||
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||
@@ -10,16 +10,17 @@
|
||||
- CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。
|
||||
- [常见问题参考](./docs/faq_zh-hans.md)
|
||||
|
||||
> **WARNING**
|
||||
> [!WARNING]
|
||||
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||
|
||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
||||
[um-vue]: https://git.unlock-music.dev/um/web
|
||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||
[授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE
|
||||
[um-vue]: https://git.um-react.app/um/web
|
||||
[unlock-music/cli]: https://git.um-react.app/um/cli
|
||||
[`@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]
|
||||
- [x] 网易云音乐 (`.ncm`)
|
||||
- [x] 虾米音乐 (`.xm`)
|
||||
- [x] 酷我音乐 (`.kwm`)
|
||||
- [x] 酷我音乐 (`.kwm` / `.mflac`) [^kuwo-key-android]
|
||||
- [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`)
|
||||
- PC / 安卓客户端的 `kgg` 文件需要提供密钥数据库。
|
||||
- PC / 安卓客户端[^kgg-android]的 `kgg` 文件需要提供密钥数据库。
|
||||
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
|
||||
- [x] 咪咕音乐格式 (`.mg3d`)
|
||||
- [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]。
|
||||
|
||||
⚠️ 如果文件太大,请上传到不需要登入下载的网盘,如 [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
|
||||
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
|
||||
```
|
||||
|
||||
@@ -86,13 +92,17 @@ docker run -d -p 8080:80 --name um-react um-react
|
||||
|
||||
然后访问 `http://localhost:8080` 即可。
|
||||
|
||||
> [!NOTE]
|
||||
> 项目不支持运行在子目录下。
|
||||
|
||||
## 开发相关
|
||||
|
||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||
|
||||
### 解密库开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||
> [!TIP]
|
||||
> 如果只是进行前端方面的更改,你可以跳过该节。
|
||||
|
||||
请参考文档「[面向 `@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 (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||
- [lib_um_crypto_rust](https://git.unlock-music.dev/um/lib_um_crypto_rust) - 项目引入的解密算法实现
|
||||
- [NPM 包](https://git.unlock-music.dev/um/-/packages/npm/@unlock-music%2Fcrypto)
|
||||
- [Unlock Music (Web)](https://git.um-react.app/um/web) - 原始项目
|
||||
- [Unlock Music (Cli)](https://git.um-react.app/um/cli) - 命令行批量处理版
|
||||
- [lib_um_crypto_rust](https://git.um-react.app/um/lib_um_crypto_rust) - 项目引入的解密算法实现
|
||||
- [NPM 包](https://git.um-react.app/um/-/packages/npm/@unlock-music%2Fcrypto)
|
||||
- [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)
|
||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest)
|
||||
- [um-react-wry](https://git.um-react.app/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||
需要[安装 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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
- 进入上层目录:`cd ..`
|
||||
- 克隆 `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`
|
||||
- 安装所有 Node 以来:`pnpm i`
|
||||
- 构建:`pnpm build`
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
|
||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||

|
||||

|
||||
|
||||
### Via 等浏览器无法正常解密/下载
|
||||
|
||||
@@ -87,10 +87,6 @@
|
||||
- 无法下载解密后内容
|
||||
- 下载的文件名错误
|
||||
|
||||
### 新版解锁网站没有批量下载
|
||||
|
||||
目前没有做。抱歉。
|
||||
|
||||
## 仍有问题?
|
||||
|
||||
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import eslint from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier/flat';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactRefresh.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
eslintConfigPrettier,
|
||||
|
||||
{
|
||||
@@ -40,4 +41,14 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.mjs', 'src/*.mjs', 'scripts/*.mjs'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
11870
package-lock.json
generated
11870
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
@@ -17,60 +17,62 @@
|
||||
"prepare": "simple-git-hooks"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@unlock-music/crypto": "0.1.10",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@unlock-music/crypto": "^0.1.12",
|
||||
"classnames": "^2.5.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"nanoid": "^5.1.6",
|
||||
"radash": "^12.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-router": "^7.9.4",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-toastify": "^11.0.5",
|
||||
"sql.js": "^1.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@types/wicg-file-system-access": "^2023.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
||||
"@typescript-eslint/parser": "^8.35.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"daisyui": "^5.0.43",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"daisyui": "^5.3.2",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"jsdom": "^27.0.0",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.6.2",
|
||||
"rollup": "^4.44.2",
|
||||
"sass": "^1.89.2",
|
||||
"simple-git-hooks": "^2.13.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"rollup": "^4.52.4",
|
||||
"sass": "^1.93.2",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tar-stream": "^3.1.7",
|
||||
"terser": "^5.44.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"vite": "^7.1.10",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
@@ -90,13 +92,17 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
||||
"sql.js": "patches/sql.js.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4"
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core",
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
"simple-git-hooks"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.15.1"
|
||||
}
|
||||
|
||||
3138
pnpm-lock.yaml
generated
3138
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
|
||||
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
||||
|
||||
WRY_VER="0.1.1"
|
||||
WRY_VER="0.1.2"
|
||||
|
||||
mkdir -p win64/{deps,dist}
|
||||
dl_file() {
|
||||
|
||||
@@ -65,3 +65,12 @@ h6 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
#downloadAll {
|
||||
position: absolute;
|
||||
right: 0.5em;
|
||||
bottom: 72px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
||||
import { CodeHighlight } from '../CodeHighlight';
|
||||
import { ExtLink } from '../ExtLink';
|
||||
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||
@@ -45,9 +44,7 @@ export function AdbInstructionTemplate({ dir, file, platform }: AdbInstructionTe
|
||||
<li>将安卓设备连接到电脑。</li>
|
||||
<li>
|
||||
<p>粘贴执行下述代码执行。若设备提示「是否允许 USB 调试」或「超级用户请求」,选择允许:</p>
|
||||
<SyntaxHighlighter language={language} style={hljsStyleGitHub}>
|
||||
{command}
|
||||
</SyntaxHighlighter>
|
||||
<CodeHighlight language={language}>{command}</CodeHighlight>
|
||||
<br />※ 安卓模拟器可能需要额外操作,如
|
||||
<ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw">
|
||||
网易 MuMu 模拟器
|
||||
|
||||
@@ -9,10 +9,10 @@ export function RootExplorerGuide() {
|
||||
<div className="flex flex-col items-start gap-4 @md:flex-row">
|
||||
<div>
|
||||
<Header5 className="[&]:mt-0 [&]:pt-0">Amaze 文件浏览器</Header5>
|
||||
<ul className="ml-2 list-disc list-inside">
|
||||
<ul className="ml-2 list-disc">
|
||||
<li>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
点触主界面左上角的 <FiMenu /> 打开侧边栏
|
||||
<div className="inline-flex items-center flex-wrap">
|
||||
点触主界面左上角的 <FiMenu className="m-1" /> 打开侧边栏
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
@@ -41,16 +41,16 @@ export function RootExplorerGuide() {
|
||||
</div>
|
||||
<div>
|
||||
<Header5 className="[&]:mt-0 [&]:pt-0">MT 管理器</Header5>
|
||||
<ul className="ml-2 list-disc list-inside">
|
||||
<ul className="ml-2 list-disc">
|
||||
<li>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
点触主界面左上角的 <FiMenu /> 打开侧边栏
|
||||
<div className="inline-flex items-center flex-wrap">
|
||||
点触主界面左上角的 <FiMenu className="m-1" /> 打开侧边栏
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="inline-flex items-center">
|
||||
<div className="inline-flex items-center flex-wrap">
|
||||
点触侧边栏右上方的 <FiMoreVertical className="ml-1" />
|
||||
,点触<VQuote>设置</VQuote>
|
||||
弹出菜单,点触<VQuote>设置</VQuote>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Bounce, ToastContainer } from 'react-toastify';
|
||||
import { SettingsHome } from '~/features/settings/SettingsHome';
|
||||
import { FAQ_PAGES } from '~/faq/FAQPages';
|
||||
import { FaqHome } from '~/faq/FaqHome';
|
||||
import { DownloadAll } from '~/components/DownloadAll.tsx';
|
||||
|
||||
// Private to this file only.
|
||||
const store = setupStore();
|
||||
@@ -72,7 +71,6 @@ export function AppRoot() {
|
||||
transition={Bounce}
|
||||
/>
|
||||
|
||||
<DownloadAll />
|
||||
<Footer />
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
|
||||
10
src/components/CodeHighlight.tsx
Normal file
10
src/components/CodeHighlight.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { useAppSelector } from '~/hooks';
|
||||
import { toast } from 'react-toastify';
|
||||
import { SimpleQueue } from '~/util/SimpleQueue';
|
||||
|
||||
export function DownloadAll() {
|
||||
const files = useAppSelector(selectFiles);
|
||||
const filesLength = Object.keys(files).length;
|
||||
const onClickDownloadAll = async () => {
|
||||
let dir: FileSystemDirectoryHandle | undefined;
|
||||
let success = 0;
|
||||
try {
|
||||
dir = await window.showDirectoryPicker();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
const downloadAllAsync = async () => {
|
||||
const fileList = Object.values(files);
|
||||
const fileCount = fileList.length;
|
||||
if (fileCount === 0) {
|
||||
toast.warning('未添加文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断所有文件是否处理完成
|
||||
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
|
||||
if (!allComplete) {
|
||||
toast.warning('请等待所有文件解密完成');
|
||||
return;
|
||||
}
|
||||
for (const [_, file] of Object.entries(files)) {
|
||||
|
||||
// 过滤处理失败的文件
|
||||
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
|
||||
|
||||
// 准备下载
|
||||
let dir: FileSystemDirectoryHandle | null = null;
|
||||
try {
|
||||
if (dir) {
|
||||
await DownloadNew(dir, file);
|
||||
} else {
|
||||
await DownloadOld(file);
|
||||
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
return; // user cancelled
|
||||
}
|
||||
success++;
|
||||
console.error(e);
|
||||
}
|
||||
toast.warning('开始下载,请稍候');
|
||||
const queue = new SimpleQueue(8);
|
||||
const promises = Object.values(completeFiles).map(async (file) => {
|
||||
try {
|
||||
await queue.enter();
|
||||
await downloadFile(file, dir);
|
||||
} catch (e) {
|
||||
console.error(`下载失败: ${file.fileName}`, e);
|
||||
toast.error(`出现错误: ${e}`);
|
||||
}
|
||||
}
|
||||
if (success === filesLength) {
|
||||
toast.success(`成功下载: ${success}/${filesLength}首`);
|
||||
} else {
|
||||
toast.error(`成功下载: ${success}/${filesLength}首`);
|
||||
toast.error(`出现错误: ${e as Error}`);
|
||||
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}首`);
|
||||
};
|
||||
|
||||
function onDownloadAll() {
|
||||
downloadAllAsync().catch((e) => {
|
||||
// this should not happen
|
||||
console.error('下载全部出现错误', e);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
style={{ width: '48px', height: '48px', paddingInline: '0px', margin: '10px', marginLeft: 'auto' }}
|
||||
className="btn btn-primary"
|
||||
onClick={onClickDownloadAll}
|
||||
title="下载全部"
|
||||
>
|
||||
<button className="btn btn-primary" id="downloadAll" onClick={onDownloadAll} title="下载全部">
|
||||
<FaDownload />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) {
|
||||
async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
|
||||
if (dir) {
|
||||
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable));
|
||||
}
|
||||
|
||||
async function DownloadOld(file: DecryptedAudioFile) {
|
||||
const a = document.createElement('a');
|
||||
a.href = file.decrypted;
|
||||
a.download = file.cleanName + '.' + file.ext;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
const fileStream = await fileHandle.createWritable();
|
||||
try {
|
||||
const res = await fetch(file.decrypted);
|
||||
await res.body?.pipeTo(fileStream);
|
||||
} catch {
|
||||
await fileStream.abort();
|
||||
}
|
||||
} else {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = file.decrypted;
|
||||
anchor.download = file.cleanName + '.' + file.ext;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
}
|
||||
|
||||
32
src/components/DownloadBase64.tsx
Normal file
32
src/components/DownloadBase64.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { AnchorHTMLAttributes } from 'react';
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
||||
import { FiExternalLink } from 'react-icons/fi';
|
||||
|
||||
export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
icon?: boolean;
|
||||
icon?: ReactNode | true | false;
|
||||
};
|
||||
|
||||
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
|
||||
return (
|
||||
<a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}>
|
||||
{children}
|
||||
{icon && <FiExternalLink className="inline size-sm ml-1" />}
|
||||
{icon === true ? <FiExternalLink className="inline size-sm ml-1" /> : icon}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,25 +5,25 @@ export function Footer() {
|
||||
const appVersionShort = '__APP_VERSION_SHORT__';
|
||||
return (
|
||||
<footer className="flex flex-col text-center p-4 bg-base-200">
|
||||
<p 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">
|
||||
<div className="flex flex-row justify-center items-center h-[1em]">
|
||||
<a className="link link-info mr-1" href="https://git.um-react.app/um/um-react">
|
||||
音乐解锁
|
||||
</a>
|
||||
(
|
||||
<a
|
||||
title="使用 MIT 授权协议"
|
||||
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
|
||||
</a>
|
||||
, v{appVersionShort}
|
||||
<SDKVersion />)
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
{'© 2019 - '}
|
||||
<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
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
|
||||
|
||||
export interface ImportSecretModalProps {
|
||||
clientName?: React.ReactNode;
|
||||
@@ -12,13 +13,12 @@ export interface ImportSecretModalProps {
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
const promise = onImport(files[0]);
|
||||
if (promise instanceof Promise) {
|
||||
promise.catch((err) => {
|
||||
const importResult = onImport(files[0]);
|
||||
if (importResult instanceof Promise) {
|
||||
importResult.catch((err) => {
|
||||
console.error('could not import: ', err);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
const refModel = useRef<HTMLDialogElement>(null);
|
||||
@@ -31,7 +31,7 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
|
||||
}, [show]);
|
||||
|
||||
return (
|
||||
<dialog ref={refModel} className="modal">
|
||||
<dialog ref={refModel} className="modal" onClose={onClose}>
|
||||
<div className="modal-box">
|
||||
<form method="dialog" onSubmit={() => onClose()}>
|
||||
<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>
|
||||
|
||||
<div className="mt-2">选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</div>
|
||||
<InSecretImportModalContext.Provider value={true}>
|
||||
<div>{children}</div>
|
||||
</InSecretImportModalContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ProjectIssueProps {
|
||||
|
||||
export function ProjectIssue({ id, title }: ProjectIssueProps) {
|
||||
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}`}
|
||||
{title && ` - ${title}`}
|
||||
</ExtLink>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function SDKVersion() {
|
||||
const refDialog = useRef<HTMLDialogElement>(null);
|
||||
const [sdkVersion, setSdkVersion] = useState('...');
|
||||
useEffect(() => {
|
||||
getSDKVersion().then(setSdkVersion);
|
||||
getSDKVersion().then(setSdkVersion, () => setSdkVersion('N/A'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,10 @@ export function SelectFile() {
|
||||
fileName,
|
||||
}),
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
|
||||
dispatch(processFile({ fileId })).catch((err) => {
|
||||
console.log(`failed to add file (id=${fileId}, name=${fileName}, err=${err as Error})`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
3
src/context/InSecretImportModal.tsx
Normal file
3
src/context/InSecretImportModal.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const InSecretImportModalContext = createContext<boolean>(false);
|
||||
@@ -25,7 +25,7 @@ export interface DecipherNotOK {
|
||||
export interface DecipherOK {
|
||||
status: Status.OK;
|
||||
message?: string;
|
||||
data: Uint8Array;
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
overrideExtension?: string;
|
||||
cipherName: string;
|
||||
}
|
||||
|
||||
@@ -19,18 +19,18 @@ export class KugouMusicDecipher implements DecipherInstance {
|
||||
kgm.decrypt(block, offset);
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
kgmHdr?.free();
|
||||
kgm?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new KugouMusicDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,18 @@ export class KuwoMusicDecipher implements DecipherInstance {
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
kwm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
return Promise.resolve({
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
kwm?.free();
|
||||
header?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new KuwoMusicDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,25 @@ export class Migu3DKeylessDecipher implements DecipherInstance {
|
||||
cipherName = 'Migu3D (Keyless)';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
|
||||
|
||||
try {
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
mg3d.decrypt(block, i);
|
||||
}
|
||||
} finally {
|
||||
mg3d.free();
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new Migu3DKeylessDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,17 +26,17 @@ export class NetEaseCloudMusicDecipher implements DecipherInstance {
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
ncm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
return Promise.resolve({
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
ncm.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new NetEaseCloudMusicDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ export class QQMusicV1Decipher implements DecipherInstance {
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
decryptQMC1(block, offset);
|
||||
}
|
||||
return {
|
||||
return Promise.resolve({
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static create() {
|
||||
public static create(this: void) {
|
||||
return new QQMusicV1Decipher();
|
||||
}
|
||||
}
|
||||
@@ -62,25 +62,28 @@ export class QQMusicV2Decipher implements DecipherInstance {
|
||||
throw new Error('EKey required');
|
||||
}
|
||||
|
||||
const qmc2 = new QMC2(ekey);
|
||||
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||
const qmc2 = new QMC2(ekey);
|
||||
try {
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
qmc2.decrypt(block, offset);
|
||||
}
|
||||
} finally {
|
||||
qmc2.free();
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static createWithUserKey() {
|
||||
public static createWithUserKey(this: void) {
|
||||
return new QQMusicV2Decipher(true);
|
||||
}
|
||||
|
||||
public static createWithEmbeddedEKey() {
|
||||
public static createWithEmbeddedEKey(this: void) {
|
||||
return new QQMusicV2Decipher(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,20 +18,23 @@ export class QignTingFMDecipher implements DecipherInstance {
|
||||
};
|
||||
}
|
||||
|
||||
const qtfm = new QingTingFM(key, iv);
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
const qtfm = new QingTingFM(key, iv);
|
||||
try {
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
qtfm.decrypt(block, i);
|
||||
}
|
||||
|
||||
return {
|
||||
} finally {
|
||||
qtfm.free();
|
||||
}
|
||||
return Promise.resolve({
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new QignTingFMDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-
|
||||
export class TransparentDecipher implements DecipherInstance {
|
||||
cipherName = 'none';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
return {
|
||||
async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> {
|
||||
return Promise.resolve({
|
||||
cipherName: 'None',
|
||||
status: Status.OK,
|
||||
data: buffer,
|
||||
message: 'No decipher applied',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new TransparentDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,26 @@ export class XiamiDecipher implements DecipherInstance {
|
||||
cipherName = 'Xiami (XM)';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
||||
const { copyPlainLength } = xm;
|
||||
const audioBuffer = buffer.slice(0x10);
|
||||
|
||||
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
||||
try {
|
||||
const { copyPlainLength } = xm;
|
||||
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
|
||||
xm.decrypt(block);
|
||||
}
|
||||
} finally {
|
||||
xm.free();
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
public static make(this: void) {
|
||||
return new XiamiDecipher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,18 @@ export class XimalayaAndroidDecipher implements DecipherInstance {
|
||||
}
|
||||
const result = new Uint8Array(buffer);
|
||||
result.set(slice, 0);
|
||||
return {
|
||||
return Promise.resolve({
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: result,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static makeX2M() {
|
||||
public static makeX2M(this: void) {
|
||||
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
|
||||
}
|
||||
|
||||
public static makeX3M() {
|
||||
public static makeX3M(this: void) {
|
||||
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
|
||||
}
|
||||
}
|
||||
@@ -45,27 +45,31 @@ export class XimalayaPCDecipher implements DecipherInstance {
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
// Detect with first 0x400 bytes
|
||||
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
|
||||
const xm = new XmlyPC(buffer.subarray(0, headerSize));
|
||||
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
|
||||
const xmly = new XmlyPC(buffer.subarray(0, headerSize));
|
||||
|
||||
try {
|
||||
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xmly;
|
||||
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
|
||||
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
|
||||
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
|
||||
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
|
||||
const encryptedAudioPartLen = xmly.decrypt(encryptedAudioPart);
|
||||
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
|
||||
xm.free();
|
||||
|
||||
const result = new Uint8Array(audioSize);
|
||||
result.set(audioHeader);
|
||||
result.set(encryptedAudioPart, audioHeader.byteLength);
|
||||
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
|
||||
return {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,17 @@ export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
|
||||
detectResult.free();
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||
src instanceof Blob ? await src.arrayBuffer() : src;
|
||||
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||
src instanceof Blob ? src : new Blob([src]);
|
||||
export const toArrayBuffer = async (src: Blob | BlobPart) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||
export const toBlob = (src: Blob | BlobPart, mimeType?: string) =>
|
||||
src instanceof Blob ? src : new Blob([src], { type: mimeType ?? 'application/octet-stream' });
|
||||
|
||||
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
||||
const len = buffer.byteLength;
|
||||
|
||||
@@ -6,7 +6,7 @@ export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb
|
||||
const resp = cb(instance);
|
||||
if (resp && isPromise(resp)) {
|
||||
isAsync = true;
|
||||
resp.finally(() => instance.free());
|
||||
resp.finally(() => instance.free()).catch(() => {});
|
||||
}
|
||||
return resp;
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deci
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
||||
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 {
|
||||
private readonly label: string;
|
||||
@@ -31,6 +31,7 @@ class DecryptCommandHandler {
|
||||
const [result, error] = await go(this.tryDecryptWith(decipher));
|
||||
if (!error) {
|
||||
if (result) {
|
||||
console.debug(`[${decipher.cipherName}] Decryption OK`);
|
||||
return result;
|
||||
}
|
||||
errors.push(`${decipher.cipherName}: no response`);
|
||||
@@ -75,7 +76,7 @@ class DecryptCommandHandler {
|
||||
audioExt = 'm4a';
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt };
|
||||
return { decrypted: URL.createObjectURL(toBlob(result.data, getMimeTypeFromExt(audioExt))), ext: audioExt };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {
|
||||
ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
||||
|
||||
} from '~/decrypt-worker/types.ts';
|
||||
import { ParseKugouHeaderPayload, ParseKugouHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
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 arrayBuffer = await blob.arrayBuffer();
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400));
|
||||
|
||||
let kwm : KuGouHeader | undefined;
|
||||
let kwm: KuGouHeader | undefined;
|
||||
|
||||
try {
|
||||
kwm = new KuGouHeader(buffer);
|
||||
@@ -20,4 +19,4 @@ export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayloa
|
||||
} finally {
|
||||
kwm?.free();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,5 +11,5 @@ export async function workerGetQtfmDeviceKey({
|
||||
board,
|
||||
}: GetQingTingFMDeviceKeyPayload) {
|
||||
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
|
||||
return hex(buffer);
|
||||
return Promise.resolve(hex(buffer));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This is a dummy module for vite/rollup to resolve.
|
||||
export function createRequire() {
|
||||
import('radash'); // we need to import something, so vite don't complain on build
|
||||
const _ = import('radash'); // we need to import something, so vite don't complain on build
|
||||
throw new Error('this is a dummy module. Do not use');
|
||||
}
|
||||
|
||||
@@ -9,31 +9,31 @@ export function FAQAboutProject() {
|
||||
<Header3 id="failed">um-react 是什么</Header3>
|
||||
<p>
|
||||
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
|
||||
</a>
|
||||
基于 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
|
||||
</a>
|
||||
授权协议。
|
||||
</p>
|
||||
<p>
|
||||
它的解密核心由 <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>
|
||||
</a>
|
||||
驱动,使用
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
+
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
|
||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
||||
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
@@ -12,21 +11,12 @@ export function OtherFAQ() {
|
||||
<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>
|
||||
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
|
||||
<div className="flex flex-col md:flex-row gap-2 md:gap-8">
|
||||
<div>
|
||||
<Header4>已知有问题的浏览器</Header4>
|
||||
<ul className="list-disc list-inside pl-2">
|
||||
<ul className="list-disc pl-8">
|
||||
<li>Via 浏览器</li>
|
||||
<li>夸克浏览器</li>
|
||||
<li>UC 浏览器</li>
|
||||
@@ -35,7 +25,7 @@ export function OtherFAQ() {
|
||||
|
||||
<div>
|
||||
<Header4>可能会遇到的问题包括</Header4>
|
||||
<ul className="list-disc list-inside pl-2">
|
||||
<ul className="list-disc pl-8">
|
||||
<li>网页白屏</li>
|
||||
<li>无法下载解密后内容</li>
|
||||
<li>下载的文件名错误</li>
|
||||
@@ -77,7 +67,7 @@ export function OtherFAQ() {
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<code>um-react-wry</code>
|
||||
</strong>
|
||||
@@ -89,7 +79,7 @@ export function OtherFAQ() {
|
||||
<ul className="list-disc pl-6">
|
||||
<li>
|
||||
<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> 开头的附件
|
||||
</p>
|
||||
|
||||
@@ -26,14 +26,10 @@ export function FileError({ error, code }: FileErrorProps) {
|
||||
|
||||
const copyError = () => {
|
||||
if (error) {
|
||||
navigator.clipboard
|
||||
.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error }))
|
||||
.then(() => {
|
||||
toast.success('错误信息已复制到剪贴板');
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(`复制错误信息失败: ${e}`);
|
||||
});
|
||||
navigator.clipboard.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error })).then(
|
||||
() => toast.success('错误信息已复制到剪贴板'),
|
||||
(e) => toast.error(`复制错误信息失败: ${e as Error}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { RiFileCopyLine } from 'react-icons/ri';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
|
||||
import { copyToClipboard } from '~/util/clipboard';
|
||||
|
||||
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
|
||||
export function InstructionsPC() {
|
||||
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
|
||||
const copyDbPathToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(DB_PATH)
|
||||
.then(() => {
|
||||
toast.success('已复制到剪贴板');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`复制失败,请手动复制\n${err}`);
|
||||
});
|
||||
};
|
||||
const copyDbPathToClipboard = () => copyToClipboard(DB_PATH);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function PanelQMCv2Key() {
|
||||
toastImportResult(file.name, keys);
|
||||
} catch (e) {
|
||||
console.error('error during import: ', e);
|
||||
alert(`导入数据库时发生错误:${e}`);
|
||||
alert(`导入数据库时发生错误:${e as Error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
|
||||
import { Ruby } from '~/components/Ruby';
|
||||
import { HiWord } from '~/components/HelpText/HiWord';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
||||
|
||||
@@ -28,23 +29,20 @@ export function PanelQingTing() {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMap = Object.create(null);
|
||||
const dataMap = Object.create(null) as GetQingTingFMDeviceKeyPayload;
|
||||
for (const [, key, value] of plainText.matchAll(/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim)) {
|
||||
dataMap[key.toLowerCase()] = value;
|
||||
dataMap[key.toLowerCase() as keyof GetQingTingFMDeviceKeyPayload] = value;
|
||||
}
|
||||
const { product, device, manufacturer, brand, board, model } = dataMap;
|
||||
|
||||
if (product && device && manufacturer && brand && board && model) {
|
||||
e.preventDefault();
|
||||
workerClientBus
|
||||
.request<string, GetQingTingFMDeviceKeyPayload>(
|
||||
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY,
|
||||
dataMap,
|
||||
)
|
||||
.then(setSecretKey)
|
||||
.catch((err) => {
|
||||
alert(`生成设备密钥时发生错误: ${err}`);
|
||||
});
|
||||
.request<
|
||||
string,
|
||||
GetQingTingFMDeviceKeyPayload
|
||||
>(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, dataMap)
|
||||
.then(setSecretKey, (err) => toast.error(`生成设备密钥时发生错误: ${err}`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,79 +1,31 @@
|
||||
import { RiFileCopyLine } from 'react-icons/ri';
|
||||
import { toast } from 'react-toastify';
|
||||
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';
|
||||
|
||||
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';
|
||||
import { useId } from 'react';
|
||||
import { InstructionsMacV8 } from './InstructionsMacV8';
|
||||
import { InstructionsMacV10 } from './InstructionsMacV10';
|
||||
|
||||
export function InstructionsMac() {
|
||||
const copyDbPathToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(DB_PATH)
|
||||
.then(() => {
|
||||
toast.success('已复制到剪贴板');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`复制失败,请手动复制\n${err}`);
|
||||
});
|
||||
};
|
||||
const macInstructionId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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">密钥文件通常存储在下述路径:</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={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>
|
||||
<div className="join join-vertical bg-base-100 mt-2 max-w-full">
|
||||
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||
<input type="radio" name={macInstructionId} />
|
||||
<div className="collapse-title font-semibold">使用 QQ 音乐 Mac v8.8.0</div>
|
||||
<div className="collapse-content text-sm min-w-0">
|
||||
<InstructionsMacV8 />
|
||||
</div>
|
||||
</div>
|
||||
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||
<input type="radio" name={macInstructionId} />
|
||||
<div className="collapse-title font-semibold">使用 QQ 音乐 Mac v10.7.1</div>
|
||||
<div className="collapse-content text-sm min-w-0">
|
||||
<InstructionsMacV10 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/features/settings/panels/QMCv2/InstructionsMacV10.tsx
Normal file
65
src/features/settings/panels/QMCv2/InstructionsMacV10.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
src/features/settings/panels/QMCv2/InstructionsMacV8.tsx
Normal file
89
src/features/settings/panels/QMCv2/InstructionsMacV8.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/features/settings/panels/QMCv2/assets/.gitignore
vendored
Normal file
3
src/features/settings/panels/QMCv2/assets/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
com.tencent.QQMusicMac.plist
|
||||
iData/
|
||||
qqmusic-mac-*.mmkv
|
||||
213
src/features/settings/panels/QMCv2/assets/qqmusic_v10.7_dump.command
Executable file
213
src/features/settings/panels/QMCv2/assets/qqmusic_v10.7_dump.command
Executable 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()
|
||||
@@ -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 音乐安装"
|
||||
@@ -55,7 +55,7 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
|
||||
let lastSettings: unknown;
|
||||
|
||||
try {
|
||||
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? '');
|
||||
const loadedSettings = JSON.parse(localStorage.getItem(storageKey) ?? '') as ProductionSettings;
|
||||
if (loadedSettings) {
|
||||
const mergedSettings = mergeSettings(loadedSettings);
|
||||
store.dispatch(setProductionChanges(mergedSettings));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||
import { SelectFile } from '../components/SelectFile';
|
||||
import { DownloadAll } from '~/components/DownloadAll.tsx';
|
||||
|
||||
import { FileListing } from '~/features/file-listing/FileListing';
|
||||
import { useAppDispatch, useAppSelector } from '~/hooks.ts';
|
||||
@@ -39,6 +40,7 @@ export function MainTab() {
|
||||
<div className="w-full mt-4">
|
||||
<FileListing />
|
||||
</div>
|
||||
<DownloadAll />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,9 @@ import '@testing-library/jest-dom';
|
||||
// FIXME: Use something like jsdom-worker?
|
||||
// see: https://github.com/developit/jsdom-worker
|
||||
if (!global.Worker) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
(global as any).Worker = class MockWorker {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
events: Record<string, (e: unknown) => void> = Object.create(null);
|
||||
|
||||
onmessage = undefined;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { ConcurrentQueue } from './ConcurrentQueue';
|
||||
import { WorkerClientBus } from './WorkerEventBus';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export class MMKVParser {
|
||||
private offset = 4;
|
||||
private length: number;
|
||||
|
||||
constructor(private view: DataView) {
|
||||
constructor(private view: DataView<ArrayBuffer>) {
|
||||
const payloadLength = view.getUint32(0, true);
|
||||
this.length = 4 + payloadLength;
|
||||
|
||||
|
||||
28
src/util/SimpleQueue.ts
Normal file
28
src/util/SimpleQueue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export class WorkerClientBus<T = string> {
|
||||
|
||||
async request<R, P>(actionName: T, payload: P): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `request://${actionName}/${nanoid()}`;
|
||||
const id = `request://${actionName as string}/${nanoid()}`;
|
||||
this.idPromiseMap.set(id, [resolve, reject]);
|
||||
this.worker.postMessage({
|
||||
id,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ConcurrentQueue } from '../ConcurrentQueue';
|
||||
import { nextTickAsync } from '../nextTick';
|
||||
|
||||
class SimpleQueue<T, R = void> extends ConcurrentQueue<T> {
|
||||
handler(_item: T): Promise<R> {
|
||||
handler(this: void, _item: T): Promise<R> {
|
||||
throw new Error('Method not overridden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ test('should be able to forward request to worker client bus', async () => {
|
||||
const bus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(null as never);
|
||||
vi.spyOn(bus, 'request').mockImplementation(
|
||||
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
|
||||
return { actionName, payload };
|
||||
return Promise.resolve({ actionName, payload });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export function applyTemplate(tpl: string, values: Record<string, unknown>) {
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : ''));
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) =>
|
||||
Object.hasOwn(values, key) ? String(values[key]) : '',
|
||||
);
|
||||
}
|
||||
|
||||
8
src/util/clipboard.ts
Normal file
8
src/util/clipboard.ts
Normal 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}`),
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
return JSON.parse(JSON.stringify(obj)) as T;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StagingKugouKey } from '~/features/settings/keyFormats';
|
||||
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 result: Omit<StagingKugouKey, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
|
||||
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 result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
@@ -21,7 +21,7 @@ export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'
|
||||
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 result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 result = new Map<string, string>();
|
||||
while (!mmkv.eof) {
|
||||
|
||||
6
src/vite-env.d.ts
vendored
6
src/vite-env.d.ts
vendored
@@ -11,3 +11,9 @@ declare module '*?base64' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*&mac-command' {
|
||||
export const tarball: string;
|
||||
export const commandName: string;
|
||||
export const tarName: string;
|
||||
}
|
||||
|
||||
37
support/mac-command-loader.ts
Normal file
37
support/mac-command-loader.ts
Normal 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`)};
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
import { tryCommand } from './support/command';
|
||||
import { base64Loader } from './support/b64-loader';
|
||||
import { macCommandLoader } from './support/mac-command-loader';
|
||||
|
||||
const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
|
||||
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
|
||||
@@ -32,6 +33,7 @@ export default defineConfig({
|
||||
// strict: false,
|
||||
|
||||
allow: [
|
||||
'index.html',
|
||||
'src',
|
||||
'node_modules',
|
||||
|
||||
@@ -46,6 +48,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
base64Loader,
|
||||
macCommandLoader,
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
|
||||
Reference in New Issue
Block a user