修改底部版权区域,使用 Wrap 组件来实现文字间的间隔,把结束年份动态获取当前年份 #13
35
.drone.yml
@ -1,35 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: test & build
|
||||
image: node:20.10.0-bookworm
|
||||
commands:
|
||||
# - git config --global --add safe.directory "/drone/src"
|
||||
- corepack enable
|
||||
- corepack prepare pnpm@latest --activate
|
||||
- pnpm i --frozen-lockfile
|
||||
- pnpm build
|
||||
environment:
|
||||
# 让 npm 使用淘宝源
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
|
||||
- name: publish
|
||||
image: node:20.10.0-bookworm
|
||||
environment:
|
||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
||||
GITEA_API_KEY:
|
||||
from_secret: GITEA_API_KEY
|
||||
NETLIFY_SITE_ID:
|
||||
from_secret: NETLIFY_SITE_ID
|
||||
NETLIFY_API_KEY:
|
||||
from_secret: NETLIFY_API_KEY
|
||||
commands:
|
||||
- |
|
||||
python3 -m zipfile -c um-react.zip dist/.
|
||||
cp um-react.zip dist/"release-${DRONE_COMMIT_SHA}.zip"
|
||||
python3 -m zipfile -c um-react-site.zip dist/.
|
||||
- ./scripts/publish.sh
|
||||
- ./scripts/deploy.sh
|
@ -11,5 +11,5 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{{c,m,}js{x,on,},ts{x,}}]
|
||||
[*.{js{x,on,},ts{x,}}]
|
||||
indent_size = 2
|
||||
|
4
.env
@ -1,4 +0,0 @@
|
||||
# Example environment file for vite to use.
|
||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||
|
||||
VITE_ENABLE_PERF_LOG=0
|
@ -1,3 +0,0 @@
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
@ -14,14 +14,5 @@ module.exports = {
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
varsIgnorePattern: '^_',
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
*.mmkv binary
|
10
.gitignore
vendored
@ -8,7 +8,6 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
coverage/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@ -23,12 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Files created when running "drone exec" locally
|
||||
/.pnpm-store/
|
||||
/*.zip
|
||||
|
||||
/um-react-wry-*
|
||||
/um-react*.exe
|
||||
|
||||
/win64/
|
||||
|
@ -1 +0,0 @@
|
||||
pnpm exec lint-staged
|
@ -1 +0,0 @@
|
||||
pnpm test
|
6
.npmrc
@ -1,5 +1,3 @@
|
||||
use-node-version=20.10.0
|
||||
node-version=20.10.0
|
||||
use-node-version=18.16.0
|
||||
node-version=18.16.0
|
||||
engine-strict=true
|
||||
@um:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||
|
@ -1,5 +1,2 @@
|
||||
dist/
|
||||
|
||||
# Package manager
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
@ -1,12 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="test" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="test" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
@ -1,12 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="vite dev" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="start" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
31
.swcrc
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2020",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"decorators": false,
|
||||
"dynamicImport": false
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"pragma": "React.createElement",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": false,
|
||||
"runtime": "automatic"
|
||||
},
|
||||
"hidden": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "commonjs",
|
||||
"strict": false,
|
||||
"strictMode": true,
|
||||
"lazy": false,
|
||||
"noInterop": false
|
||||
}
|
||||
}
|
11
.vscode/extensions.json
vendored
@ -1,11 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"christian-kohler.path-intellisense",
|
||||
"txava.region-marker",
|
||||
"foxundermoon.shell-format",
|
||||
"jock.svg"
|
||||
]
|
||||
}
|
132
README.MD
@ -1,78 +1,22 @@
|
||||
# Unlock Music 音乐解锁 (React)
|
||||
# Getting started
|
||||
|
||||
[![Build Status](https://ci.unlock-music.dev/api/badges/um/um-react/status.svg)](https://ci.unlock-music.dev/um/um-react)
|
||||
前提: [安装 pnpm][install-pnpm],推荐 `corepack` 方法。
|
||||
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||
- CI 自动构建已经部署,可以在 [Packages][um-react-packages] 下载。
|
||||
- [常见问题参考](./docs/faq_zh-hans.md)
|
||||
```sh
|
||||
pnpm i
|
||||
pnpm start
|
||||
```
|
||||
|
||||
> **WARNING**
|
||||
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||
[install-pnpm]: https://pnpm.io/zh/installation
|
||||
|
||||
[授权协议]: 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
|
||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
## 支持的格式
|
||||
|
||||
- [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
|
||||
- [x] QQ 音乐 QMCv2
|
||||
- PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
|
||||
- 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
|
||||
- iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
|
||||
- Mac 客户端 (`.mflach` 等) [^qm-key-mac]
|
||||
- [x] 网易云音乐 (`.ncm`)
|
||||
- [x] 虾米音乐 (`.xm`)
|
||||
- [x] 酷我音乐 (`.kwm`)
|
||||
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
|
||||
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
|
||||
- [x] 咪咕音乐格式 (`.mg3d`)
|
||||
- [x] 蜻蜓 FM (`.qta`)
|
||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
|
||||
|
||||
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
|
||||
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。
|
||||
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
||||
[^qm-key-mac]: 需要导入密钥数据库。
|
||||
|
||||
## 错误报告
|
||||
|
||||
有不支持的格式?请提交样本(加密文件)与客户端信息版本信息(如系统版本、下载渠道),或一并上传其安装包到[仓库的问题追踪区][project-issues]。
|
||||
|
||||
⚠️ 如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||
|
||||
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
|
||||
|
||||
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。
|
||||
|
||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
||||
|
||||
## 开发相关
|
||||
|
||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||
|
||||
### 解密库开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||
|
||||
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。
|
||||
|
||||
### 架构
|
||||
## 架构
|
||||
|
||||
- 浏览器主线程: 渲染界面,处理 UI 更新
|
||||
- Web Worker: 负责计算方面的内容,如内容解密。
|
||||
|
||||
数据传输: 生成 blob url (`URL.createObjectURL`) 然后透过 `postMessage` 传递给线程,线程利用 `fetch` API 来获取文件信息。
|
||||
|
||||
### 贡献代码
|
||||
## 贡献代码
|
||||
|
||||
欢迎贡献代码。请确保:
|
||||
|
||||
@ -82,16 +26,54 @@
|
||||
|
||||
满足上述条件后发起 Pull Request,仓库管理员审阅后将合并到主分支。
|
||||
|
||||
## 相关项目
|
||||
## 基于 libparakeet SDK 开发
|
||||
|
||||
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||
- [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 单文件 (
|
||||
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
||||
`libparakeet-js` 编译目前需要 Linux 环境,请参考[仓库说明][libparakeet-js-doc]。
|
||||
|
||||
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||
该文档将假设这两个项目被放置在同级的目录下:
|
||||
|
||||
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
|
||||
```text
|
||||
~/Projects/um-projects
|
||||
/um-react
|
||||
/libparakeet-js
|
||||
```
|
||||
|
||||
若为不同目录,你需要调整 `vite.config.ts` 的 `server.fs.allow` 区段并加入新的路径。
|
||||
|
||||
[libparakeet-js-doc]: https://github.com/parakeet-rs/libparakeet-js/blob/main/README.MD
|
||||
|
||||
### 初次构建
|
||||
|
||||
- 进入上层目录 `cd ..`
|
||||
- 克隆 `libparakeet-js` 仓库 (目前需要 Linux 环境, Windows 下推荐使用 WSL2)
|
||||
- `git clone --recurse-submodules https://github.com/parakeet-rs/libparakeet-js.git`
|
||||
- 进入目录 `cd libparakeet-js`
|
||||
- 如果需要更新 `submodule`:
|
||||
- `git submodule update --init --recursive`
|
||||
- 运行 `./build.sh -j 4` 进行编译
|
||||
- 编译 `js-sdk`:
|
||||
- 进入 `npm` 目录: `cd npm`
|
||||
- 安装依赖: `pnpm i --frozen-lockfile`
|
||||
- 构建: `pnpm build`
|
||||
|
||||
### 做出更改
|
||||
|
||||
做出更改后,参考上面的内容进行重新编译
|
||||
|
||||
### 应用 SDK 更改
|
||||
|
||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||
|
||||
```sh
|
||||
pnpm link ../libparakeet-js/npm
|
||||
```
|
||||
|
||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的更改已合并。
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] #6 文件拖放 (利用 `react-dropzone`?)
|
||||
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
|
||||
- [ ] #7 简易元数据编辑器
|
||||
- [ ] #8 添加单元测试
|
||||
- [ ] #2 解密内容探测 (解密过程)
|
||||
|
@ -1,9 +0,0 @@
|
||||
# 利用 ADB 访问安卓私有数据
|
||||
|
||||
```sh
|
||||
APP_ID="com.tencent.qqmusic" # QQ 音乐
|
||||
APP_ID="cn.kuwo.player" # 酷我
|
||||
|
||||
adb shell su -c "tar c '/data/data/${APP_ID}/' | base64" \
|
||||
| base64 -d | pv | tar -x --strip-components 2
|
||||
```
|
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 25 KiB |
@ -1,36 +0,0 @@
|
||||
# 面向 `@unlock-music/crypto` 开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||
|
||||
该文档将假设这两个项目被放置在同级的目录下:
|
||||
|
||||
```text
|
||||
~/Projects/um-projects
|
||||
/um-react
|
||||
/lib_um_crypto_rust
|
||||
```
|
||||
|
||||
若为不同目录,你需要调整 `LIB_UM_WASM_LOADER_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||
|
||||
## 初次构建
|
||||
|
||||
- 进入上层目录:`cd ..`
|
||||
- 克隆 `lib_um_crypto_rust` 仓库
|
||||
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git`
|
||||
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
||||
- 安装所有 Node 以来:`pnpm i`
|
||||
- 构建:`pnpm build`
|
||||
|
||||
## 做出更改
|
||||
|
||||
做出更改后,参考上面的内容进行重新编译。
|
||||
|
||||
## 应用 SDK 更改
|
||||
|
||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||
|
||||
```sh
|
||||
pnpm link ../lib_um_crypto_rust/um_wasm_loader/
|
||||
```
|
||||
|
||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
@ -1,105 +0,0 @@
|
||||
# 常见问题解答
|
||||
|
||||
## QQ 音乐
|
||||
|
||||
### 解锁失败
|
||||
|
||||
#### 1、请检查您的文件。
|
||||
|
||||
尝试用下载音乐的设备播放一次看看,如果 QQ 音乐都没法播放,那解锁肯定会受到影响哦。
|
||||
|
||||
#### 2、检查您的平台。
|
||||
|
||||
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||
|
||||
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
||||
|
||||
> 重复下载同一首的歌曲**不重复扣下载配额**,但是*同一首歌的两个版本会重复扣下载配额*,请仔细分辨。
|
||||
|
||||
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
|
||||
|
||||
> 如果仍无法理解,可参考文末的图片操作
|
||||
|
||||
## 酷我音乐
|
||||
|
||||
### 解锁失败
|
||||
|
||||
酷我音乐的新版加密需要导入密钥。
|
||||
|
||||
#### 1、请检查您的文件。
|
||||
|
||||
尝试用下载音乐的设备播放一次看看,如果酷我音乐都没法播放,那解锁肯定会受到影响哦。
|
||||
|
||||
#### 2、检查您的平台。
|
||||
|
||||
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密,手机平台的其他音质暂时不需要提取密钥,PC 平台暂未推出使用新版加密的音质。
|
||||
|
||||
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
|
||||
|
||||
> Android 用户提取密钥需要 root,或者注入文件提供器。
|
||||
|
||||
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
|
||||
|
||||
> 图片教程请参考 QQ 音乐(在文末),酷我音乐仅仅是需要切换一下密钥类型。
|
||||
|
||||
## 网易云音乐
|
||||
|
||||
### 解锁失败
|
||||
|
||||
您大概率正在使用 Windows 平台的网易云音乐 3.0 测试版。该版本对歌曲的信息新增了某些字段,导致旧版解锁识别错误。您可以找 1.10.5 版本的旧解锁网站,或者直接换[新版解锁网站](https://um-react.netlify.app/)。
|
||||
|
||||
> [旧解锁网站 Demo](https://demo.unlock-music.dev/)拥有者暂时联系不上,所以暂时无法更新。
|
||||
|
||||
## 其他问题
|
||||
|
||||
### 新版解锁网站解锁的歌曲没有封面
|
||||
|
||||
目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。
|
||||
|
||||
### 安卓 root 相关
|
||||
|
||||
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
||||
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
|
||||
|
||||
如果希望不破坏系统完整性,你可以考虑使用模拟器。
|
||||
|
||||
※ **注意**:根据应用厂商的风控策略,使用模拟器登录的账号**有可能会被封锁**;使用前请自行评估风险。
|
||||
|
||||
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11 开始支援的适用于 Android™ 的 Windows 子系统 (WSA)。
|
||||
|
||||
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
|
||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp)
|
||||
|
||||
### Via 等浏览器无法正常解密/下载
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
已知有问题的浏览器:
|
||||
|
||||
- Via 浏览器
|
||||
- 夸克浏览器
|
||||
- UC 浏览器
|
||||
|
||||
可能会遇到的问题包括:
|
||||
|
||||
- 网页白屏
|
||||
- 无法下载解密后内容
|
||||
- 下载的文件名错误
|
||||
|
||||
### 新版解锁网站没有批量下载
|
||||
|
||||
目前没有做。抱歉。
|
||||
|
||||
## 仍有问题?
|
||||
|
||||
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。
|
||||
|
||||
> QQ 音乐导入密钥的图片教程
|
||||
|
||||
1. 选择【设定】
|
||||
<br/>![选择【设定】](./assets/faq_1_home.webp)
|
||||
2. 点击下拉菜单,选择【从文件导入密钥…】
|
||||
<br/>![点击下拉菜单,选择【从文件导入密钥…】](./assets/faq_2_import.webp)
|
||||
3. 选择对应的客户端并查阅说明
|
||||
<br/>![选择对应的客户端并查阅说明](./assets/faq_3_instructions.webp)
|
@ -1,63 +0,0 @@
|
||||
# 新手上路
|
||||
|
||||
该文档描述了如何本地运行或编译生产版本的「Unlock Music 音乐解锁」。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
- 安装 Node v16.17 或更高,推荐当前最新的 Node LTS 版本。
|
||||
- 安装/激活 `pnpm` [^1]:`corepack prepare pnpm@latest --activate`
|
||||
- 安装软件依赖:`pnpm i --frozen-lockfile`
|
||||
|
||||
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
|
||||
|
||||
## 本地运行
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm start
|
||||
```
|
||||
|
||||
然后根据提示打开[项目运行页面][vite-dev-url]即可。
|
||||
|
||||
[vite-dev-url]: http://localhost:5173/
|
||||
|
||||
## 构建生产版本
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
|
||||
|
||||
[vite-preview-url]: http://localhost:4173/
|
||||
|
||||
## 打包 `.zip`
|
||||
|
||||
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
|
||||
|
||||
1. 确保上述的构建步骤已完成。
|
||||
2. 确保 `python3` 已安装。
|
||||
3. 执行下述代码
|
||||
```sh
|
||||
python3 -m zipfile -c um-react.zip dist/.
|
||||
```
|
||||
|
||||
## 打包 win64 单文件
|
||||
|
||||
利用 Windows 系统自带的 [Edge WebView2 组件](https://learn.microsoft.com/zh-cn/microsoft-edge/webview2/)
|
||||
和 [wry](https://github.com/tauri-apps/wry) 进行一个单文件的打包。
|
||||
|
||||
大部分 Windows 10 或以上版本的操作系统已经集成了 WebView2 运行时。若无法正常启动,请[下载并安装 Edge WebView2 运行时](https://go.microsoft.com/fwlink/p/?LinkId=2124703)。
|
||||
|
||||
其它系统兼容性未知。
|
||||
|
||||
1. 确保你现在在 `linux-amd64` 环境下。
|
||||
2. 确保上述的 `um-react.zip` 构建已完成。
|
||||
3. 执行下述代码
|
||||
```sh
|
||||
./scripts/make-win64.sh
|
||||
```
|
||||
4. 等待提示 `[Build OK]` 即可。
|
@ -1,14 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cmn-Hans-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>音乐解锁 - Unlock Music</title>
|
||||
|
||||
<meta name="description" content="音乐解锁 - Unlock Music" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
||||
<meta name="theme-color" content="#4DBA87" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
|
31
jest.config.cjs
Normal file
@ -0,0 +1,31 @@
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/mocks/**'],
|
||||
coveragePathIgnorePatterns: [],
|
||||
setupFilesAfterEnv: ['./src/test-utils/setup-jest.ts'],
|
||||
testEnvironment: 'jsdom',
|
||||
modulePaths: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\\.(ts|js|tsx|jsx)$': '@swc/jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$',
|
||||
'^.+\\.module\\.(css|sass|scss)$',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src'],
|
||||
moduleNameMapper: {
|
||||
'^react-native$': 'react-native-web',
|
||||
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
|
||||
},
|
||||
moduleFileExtensions: [
|
||||
// Place tsx and ts to beginning as suggestion from Jest team
|
||||
// https://jestjs.io/docs/configuration#modulefileextensions-arraystring
|
||||
'tsx',
|
||||
'ts',
|
||||
'js',
|
||||
'json',
|
||||
'jsx',
|
||||
],
|
||||
// watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
|
||||
resetMocks: true,
|
||||
};
|
114
package.json
@ -1,93 +1,59 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.3.2",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
|
||||
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.mjs",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier -w .",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
||||
"prepare": "husky install"
|
||||
"test": "NODE_ENV=test jest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.2.2",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@chakra-ui/icons": "^2.0.19",
|
||||
"@chakra-ui/react": "^2.6.1",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@unlock-music/crypto": "0.1.0",
|
||||
"framer-motion": "^11.5.6",
|
||||
"nanoid": "^5.0.7",
|
||||
"radash": "^12.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-icons": "^5.3.0",
|
||||
"@jixun/libparakeet": "0.0.0-exp.16",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"framer-motion": "^10.12.8",
|
||||
"nanoid": "^4.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sass": "^1.79.3",
|
||||
"sql.js": "^1.11.0"
|
||||
"react-redux": "^8.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.6.1",
|
||||
"@types/react": "^18.3.9",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^2.1.1",
|
||||
"@vitest/ui": "^2.1.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
"rollup": "^4.22.4",
|
||||
"terser": "^5.33.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.7",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^2.1.1",
|
||||
"workbox-window": "^7.1.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown",
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
"@swc/core": "^1.3.58",
|
||||
"@swc/jest": "^0.2.26",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^20.1.1",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.3.2",
|
||||
"vite-plugin-top-level-await": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.2.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
diff --git a/dist/cjs/index.js b/dist/cjs/index.js
|
||||
index 23639c70ccd5ebbc4a12c1ba277e5ac81cf30513..aba3318977523785d02f3b8aa6e667a1aa46bf5d 100644
|
||||
--- a/dist/cjs/index.js
|
||||
+++ b/dist/cjs/index.js
|
||||
@@ -227,5 +227,5 @@ function terser(input = {}) {
|
||||
runWorker();
|
||||
|
||||
exports.default = terser;
|
||||
-module.exports = Object.assign(exports.default, exports);
|
||||
+module.exports = Object.assign(exports.default, exports, { terser });
|
||||
//# sourceMappingURL=index.js.map
|
||||
diff --git a/dist/es/index.js b/dist/es/index.js
|
||||
index 7296e677e6b2b38df9522b196fee24feec996793..4ca9052dd439ed22ff92cc72a79824b85e229678 100644
|
||||
--- a/dist/es/index.js
|
||||
+++ b/dist/es/index.js
|
||||
@@ -222,5 +222,5 @@ function terser(input = {}) {
|
||||
|
||||
runWorker();
|
||||
|
||||
-export { terser as default };
|
||||
+export { terser as default, terser };
|
||||
//# sourceMappingURL=index.js.map
|
@ -1,11 +0,0 @@
|
||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644
|
||||
--- a/dist/sql-wasm.js
|
||||
+++ b/dist/sql-wasm.js
|
||||
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) {
|
||||
else if (typeof exports === 'object'){
|
||||
exports["Module"] = initSqlJs;
|
||||
}
|
||||
+
|
||||
+var module;
|
||||
+export default initSqlJs;
|
11588
pnpm-lock.yaml
1
public/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Keep this folder
|
Before Width: | Height: | Size: 641 B |
Before Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 32 KiB |
@ -1,110 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
__netlify_upload() {
|
||||
local branch="$BRANCH_NAME"
|
||||
local production="$DEPLOY_PRODUCTION"
|
||||
[[ "$BRANCH_NAME" = "main" ]] && production="true"
|
||||
[[ -z "$production" ]] && production="false"
|
||||
|
||||
curl -sL \
|
||||
-H "Content-Type: application/zip" \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
--data-binary "@${1}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys?branch=${branch}&production=${production}"
|
||||
}
|
||||
|
||||
__netlify_get_deploy() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
"https://api.netlify.com/api/v1/deploys/${deploy_id}"
|
||||
}
|
||||
|
||||
# Publish a deployment to main URL.
|
||||
__netlify_promote() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys/${deploy_id}/restore"
|
||||
}
|
||||
|
||||
__netlify_get_error() {
|
||||
local error_message
|
||||
error_message="$(json_get "$upload_resp" message)"
|
||||
[[ "$error_message" = "null" ]] && error_message="$(json_get "$upload_resp" error_message)"
|
||||
echo -n "$error_message"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local json_body="$1"
|
||||
shift
|
||||
|
||||
echo -n "$json_body" | "${SCRIPTS_DIR}/read_json.mjs" "$@"
|
||||
}
|
||||
|
||||
deploy_netlify() {
|
||||
local upload_resp
|
||||
upload_resp="$(__netlify_upload "$1")"
|
||||
|
||||
local error_message="$(__netlify_get_error "$upload_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local deploy_id="$(json_get "$upload_resp" id)"
|
||||
local deploy_resp=""
|
||||
local deploy_state=""
|
||||
local retry_count=10
|
||||
while [[ "$retry_count" -gt 0 ]]; do
|
||||
deploy_resp="$(__netlify_get_deploy "$deploy_id")"
|
||||
deploy_state="$(json_get "$deploy_resp" 'state')"
|
||||
case "$deploy_state" in
|
||||
ready)
|
||||
echo 'Deploy to netlify OK!'
|
||||
echo " * main url: $(json_get "$deploy_resp" 'ssl_url')"
|
||||
echo " * branch: $(json_get "$deploy_resp" 'deploy_ssl_url')"
|
||||
echo " * permalink: $(json_get "$deploy_resp" 'links' 'permalink')"
|
||||
break
|
||||
;;
|
||||
error)
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * $(json_get "$deploy_resp" 'error_message')"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
retry_count="$((retry_count - 1))"
|
||||
sleep 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$BRANCH_NAME" = "main" ]]; then
|
||||
echo "Promoting latest main build..."
|
||||
local promote_resp="$(__netlify_promote "$(json_get "$deploy_resp" 'id')")"
|
||||
error_message="$(__netlify_get_error "$promote_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Promote netlify deploy failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
else
|
||||
echo 'Deployed to main url.'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# For deployment, we care a bit less
|
||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||
echo "Deploy to netlify..."
|
||||
deploy_netlify um-react-site.zip
|
||||
else
|
||||
echo "skip netlify deployment."
|
||||
fi
|
@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# sudo apt install -y jq zip
|
||||
|
||||
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
||||
|
||||
WRY_VER="0.1.1"
|
||||
|
||||
mkdir -p win64/{deps,dist}
|
||||
dl_file() {
|
||||
local FILE="$1"
|
||||
if [[ ! -f "win64/deps/$FILE" ]]; then
|
||||
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"win64/deps/${FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
dl_file "um-react-wry-builder-${WRY_VER}-linux-amd64"
|
||||
dl_file "um-react-wry-stub-${WRY_VER}-win64.exe"
|
||||
chmod a+x win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64
|
||||
|
||||
APP_VERSION="$(jq -r '.version' <package.json)"
|
||||
EXE_NAME="um-react-win64-${APP_VERSION}.exe"
|
||||
ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
|
||||
"./win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64" \
|
||||
-t "win64/deps/um-react-wry-stub-${WRY_VER}-win64.exe" \
|
||||
-r um-react.zip \
|
||||
-o "win64/dist/${EXE_NAME}"
|
||||
|
||||
touch -d 1970-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
|
||||
zip -9oX "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
|
||||
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
|
||||
|
||||
popd
|
@ -1,19 +0,0 @@
|
||||
import { minify } from 'terser';
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
|
||||
for (const file of readdirSync('dist/assets')) {
|
||||
if (!/\.(mjs|js)$/.test(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`minifying ${file}...`);
|
||||
const isModule = /\.mjs$/.test(file);
|
||||
|
||||
const output = await minify(readFileSync(`dist/assets/${file}`, 'utf-8'), {
|
||||
compress: true,
|
||||
mangle: true,
|
||||
module: isModule,
|
||||
});
|
||||
|
||||
writeFileSync(`dist/assets/${file}`, output.code);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
|
||||
publish_gitea() {
|
||||
local ZIP_NAME="$1"
|
||||
local URL="${DRONE_GITEA_SERVER}/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}"
|
||||
sha256sum "${ZIP_NAME}"
|
||||
curl -sLifu "um-release-bot:${GITEA_API_KEY}" -T "${ZIP_NAME}" "${URL}"
|
||||
echo "Uploaded to: ${URL}"
|
||||
}
|
||||
|
||||
# Only publish main branch by default
|
||||
if [[ "${BRANCH_NAME}" = "main" && -z "$DRONE_PULL_REQUEST" ]]; then
|
||||
echo 'prepare to publish...'
|
||||
|
||||
if [[ -n "${GITEA_API_KEY}" ]]; then
|
||||
echo "Publish to gitea..."
|
||||
publish_gitea "um-react.zip"
|
||||
fi
|
||||
fi
|
@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(0, 'utf-8').trim());
|
||||
|
||||
let value = data;
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
value = value[process.argv[i]] ?? null;
|
||||
}
|
||||
process.stdout.write(String(value), 'utf-8');
|
@ -1,14 +0,0 @@
|
||||
/* eslint-env node */
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
||||
|
||||
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
|
||||
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash ?? 'unknown'}` + '\n';
|
||||
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');
|
25
src/App.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Box, Center, Container } from '@chakra-ui/react';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
import { FileListing } from './features/file-listing/FileListing';
|
||||
import { Footer } from './Footer';
|
||||
import { WasmTest } from './WasmTest';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Box height="full" width="full" pt="4">
|
||||
<Container maxW="container.large">
|
||||
<Center>
|
||||
<SelectFile />
|
||||
</Center>
|
||||
<Box mt="8">
|
||||
<FileListing />
|
||||
</Box>
|
||||
{localStorage.__dev_test === '1' && <WasmTest />}
|
||||
<Footer />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
42
src/Footer.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Center, Flex, Link, Text, Wrap, WrapItem } from '@chakra-ui/react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { SDKVersion } from './SDKVersion';
|
||||
|
||||
export function Footer() {
|
||||
const year = useMemo(() => new Date().getFullYear(), []);
|
||||
lsr marked this conversation as resolved
|
||||
return (
|
||||
<Center height="footer.container">
|
||||
<Center
|
||||
height="footer.content"
|
||||
fontSize="sm"
|
||||
textAlign="center"
|
||||
position="fixed"
|
||||
bottom="0"
|
||||
w="full"
|
||||
bg="gray.100"
|
||||
color="gray.800"
|
||||
left="0"
|
||||
flexDir="column"
|
||||
>
|
||||
<Flex as={Text}>
|
||||
音乐解锁 (__APP_VERSION_SHORT__
|
||||
<Suspense>
|
||||
<SDKVersion />
|
||||
</Suspense>
|
||||
) - 移除已购音乐的加密保护
|
||||
</Flex>
|
||||
<Wrap>
|
||||
lsr marked this conversation as resolved
lsr
commented
这个文字改 Wrap 组件控制空格的更改感觉没必要? 反而变得复杂了…? 这个文字改 Wrap 组件控制空格的更改感觉没必要?
反而变得复杂了…?
|
||||
<WrapItem>Copyright © 2019 - {year}</WrapItem>
|
||||
<WrapItem>
|
||||
<Link href="https://git.unlock-music.dev/um" isExternal>UnlockMusic 团队</Link>
|
||||
</WrapItem>
|
||||
<WrapItem>|</WrapItem>
|
||||
<WrapItem>音乐解锁授权基于</WrapItem>
|
||||
<WrapItem>
|
||||
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>MIT许可协议</Link>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</Center>
|
||||
</Center>
|
||||
);
|
||||
}
|
@ -1,19 +1,18 @@
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
|
||||
import { workerClientBus } from '~/decrypt-worker/client';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
||||
import { workerClientBus } from './decrypt-worker/client';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './decrypt-worker/constants';
|
||||
|
||||
import usePromise from 'react-promise-suspense';
|
||||
|
||||
const getSDKVersion = async (): Promise<string> => {
|
||||
const getSDKVersion = async () => {
|
||||
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
|
||||
};
|
||||
|
||||
export function SDKVersion() {
|
||||
const sdkVersion = usePromise(getSDKVersion, []);
|
||||
|
||||
return (
|
||||
<Flex as="span" pl="1" alignItems="center" data-testid="sdk-version">
|
||||
<Flex as="span" pl="1" alignItems="center">
|
||||
<Tooltip
|
||||
hasArrow
|
||||
placement="top"
|
69
src/SelectFile.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { useId } from 'react';
|
||||
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { UnlockIcon } from '@chakra-ui/icons';
|
||||
import { useAppDispatch } from './hooks';
|
||||
import { addNewFile, processFile } from './features/file-listing/fileListingSlice';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export function SelectFile() {
|
||||
const dispatch = useAppDispatch();
|
||||
const id = useId();
|
||||
|
||||
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
for (const file of e.target.files) {
|
||||
const blobURI = URL.createObjectURL(file);
|
||||
const fileName = file.name;
|
||||
const fileId = 'file://' + nanoid();
|
||||
// FIXME: this should be a single action/thunk that first adds the item, then updates it.
|
||||
dispatch(
|
||||
addNewFile({
|
||||
id: fileId,
|
||||
blobURI,
|
||||
fileName,
|
||||
})
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
}
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
htmlFor={id}
|
||||
w="100%"
|
||||
maxW={480}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
transitionDuration="0.5s"
|
||||
p="6"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
_hover={{
|
||||
borderColor: 'gray.400',
|
||||
bg: 'gray.50',
|
||||
}}
|
||||
>
|
||||
<Box pb={3}>
|
||||
<UnlockIcon boxSize={8} />
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
{/* 将文件拖到此处,或 */}
|
||||
<Text as="span" color="teal.400">
|
||||
点我选择
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<input id={id} type="file" hidden multiple onChange={handleFileSelection} />
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
仅在浏览器内对文件进行解锁,无需消耗流量
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
31
src/WasmTest.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { loadLibParakeet, BlobSink, createArrayBufferReader } from '@jixun/libparakeet';
|
||||
|
||||
function testWasm() {
|
||||
loadLibParakeet().then(async (mod) => {
|
||||
const data = new Uint8Array(0x2000);
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
data[i] = i & 0xff;
|
||||
}
|
||||
const src = createArrayBufferReader(data, mod);
|
||||
const sink = new BlobSink(mod);
|
||||
mod.rw_test(sink.getWriter(), src);
|
||||
const collected = sink.collectBlob();
|
||||
const copied = await collected.arrayBuffer();
|
||||
const copiedView = new Uint8Array(copied);
|
||||
for (let i = 0; i < copied.byteLength; i++) {
|
||||
if (copiedView[i] !== (i & 0xff)) {
|
||||
alert(`validate at pos ${i} failed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
alert('wasm validate ok!');
|
||||
});
|
||||
}
|
||||
|
||||
export function WasmTest() {
|
||||
return (
|
||||
<button onClick={testWasm} type="button">
|
||||
Test WASM
|
||||
</button>
|
||||
);
|
||||
}
|
6
src/__test__/hello.test.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
test('hello', () => {
|
||||
render(<div>hello</div>);
|
||||
expect(screen.getByText('hello') as any).toBeInTheDocument();
|
||||
});
|
@ -1,21 +0,0 @@
|
||||
import { renderWithProviders, screen, waitFor } from '~/test-utils/test-helper';
|
||||
import { AppRoot } from '~/components/AppRoot';
|
||||
|
||||
vi.mock('../decrypt-worker/client', () => {
|
||||
return {
|
||||
workerClientBus: {
|
||||
request: vi.fn().mockResolvedValue('dummy'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('should be able to render App', async () => {
|
||||
renderWithProviders(<AppRoot />);
|
||||
|
||||
// Should eventually load sdk version
|
||||
await waitFor(() => screen.getByTestId('sdk-version'));
|
||||
|
||||
// Quick sanity check of known strings.
|
||||
expect(screen.getByText(/在浏览器内对文件进行解锁/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/UnlockMusic 团队/i)).toBeInTheDocument();
|
||||
});
|
@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
|
||||
<rect fill="#ddd" width="160" height="160" />
|
||||
<text fill="#0007" font-family="sans-serif" font-size="24" font-weight="bold"
|
||||
text-anchor="middle" letter-spacing="6"
|
||||
dy="9.45" x="50%" y="50%"
|
||||
>
|
||||
暂无封面
|
||||
</text>
|
||||
</svg>
|
Before Width: | Height: | Size: 348 B |
@ -1,171 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Code,
|
||||
Heading,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Text,
|
||||
chakra,
|
||||
} from '@chakra-ui/react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
||||
|
||||
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||
import { ExtLink } from '../ExtLink';
|
||||
|
||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
||||
};
|
||||
|
||||
export interface AndroidADBPullInstructionProps {
|
||||
dir: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
||||
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file });
|
||||
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
你需要
|
||||
<ruby>
|
||||
超级管理员
|
||||
<rp> (</rp>
|
||||
<rt>
|
||||
<code>root</code>
|
||||
</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
访问权限来访问安卓应用的私有数据。
|
||||
</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,获取管理员权限通常意味着你的安卓设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
|
||||
<Accordion allowToggle mt="2">
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在安卓手机端操作
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
启动具有 <Code>root</Code> 特权的文件浏览器
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
访问 <Code>{dir}/</Code> 目录。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
将文件 <Code>{file}</Code> 复制到浏览器可访问的目录。
|
||||
<br />
|
||||
(例如下载目录)
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>提交该数据库文件。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在 PC 端操作(ADB / PowerShell)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <Code>adb</Code> 命令可用。
|
||||
</Text>
|
||||
<Text>
|
||||
💡 如果没有,可以
|
||||
<ExtLink href="https://scoop.sh/#/apps?q=adb">
|
||||
使用 Scoop 安装 <ExternalLinkIcon />
|
||||
</ExtLink>
|
||||
。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>启动终端并进入 PowerShell 7 环境。</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>将安卓设备连接到电脑,并允许调试。</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
|
||||
{psAdbDumpCommand}
|
||||
</SyntaxHighlighter>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在 Linux / Mac 系统下操作(ADB / Shell)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <Code>adb</Code> 命令可用。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>将安卓设备连接到电脑,并允许调试。</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
|
||||
{shAdbDumpCommand}
|
||||
</SyntaxHighlighter>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
try {
|
||||
$gz_b64 = adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" | Out-String
|
||||
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
|
||||
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
|
||||
$outFile = New-Object System.IO.FileStream("${PWD}\{{ file }}", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
|
||||
$decoded.CopyTo($outFile)
|
||||
} catch {
|
||||
Write-Host "遇到错误:"
|
||||
Write-Host $_
|
||||
} finally {
|
||||
if ($outFile -ne $null) { $outFile.Dispose() }
|
||||
if ($decoded -ne $null) { $decoded.Dispose() }
|
||||
if ($bStream -ne $null) { $bStream.Dispose() }
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" \
|
||||
| base64 -d | gzip -d >'{{ file }}'
|
@ -1,57 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MdSettings, MdHome, MdQuestionAnswer } from 'react-icons/md';
|
||||
import { ChakraProvider, Tabs, TabList, TabPanels, Tab, TabPanel, Icon, chakra } from '@chakra-ui/react';
|
||||
|
||||
import { MainTab } from '~/tabs/MainTab';
|
||||
import { SettingsTab } from '~/tabs/SettingsTab';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { theme } from '~/theme';
|
||||
import { persistSettings } from '~/features/settings/persistSettings';
|
||||
import { setupStore } from '~/store';
|
||||
import { Footer } from '~/components/Footer';
|
||||
import { FaqTab } from '~/tabs/FaqTab';
|
||||
|
||||
// Private to this file only.
|
||||
const store = setupStore();
|
||||
|
||||
export function AppRoot() {
|
||||
useEffect(() => persistSettings(store), []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<Provider store={store}>
|
||||
<Tabs flex={1} minH={0} display="flex" flexDir="column">
|
||||
<TabList justifyContent="center">
|
||||
<Tab>
|
||||
<Icon as={MdHome} mr="1" />
|
||||
<chakra.span>应用</chakra.span>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={MdSettings} mr="1" />
|
||||
<chakra.span>设置</chakra.span>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={MdQuestionAnswer} mr="1" />
|
||||
<chakra.span>答疑</chakra.span>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex">
|
||||
<TabPanel>
|
||||
<MainTab />
|
||||
</TabPanel>
|
||||
<TabPanel flex={1} display="flex">
|
||||
<SettingsTab />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<FaqTab />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Footer />
|
||||
</Provider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Update every half hour
|
||||
const TIMER_UPDATE_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
const getCurrentYear = () => new Date().getFullYear();
|
||||
|
||||
export function CurrentYear() {
|
||||
const [year, setYear] = useState(getCurrentYear);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => setYear(getCurrentYear);
|
||||
updateTime();
|
||||
|
||||
const timer = setInterval(updateTime, TIMER_UPDATE_INTERVAL);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{year}</>;
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import type { AnchorHTMLAttributes } from 'react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { Link } from '@chakra-ui/react';
|
||||
|
||||
export function ExtLink({ children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<Link isExternal {...props} rel="noreferrer noopener nofollow">
|
||||
{children}
|
||||
<ExternalLinkIcon />
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export interface FileInputProps {
|
||||
onReceiveFiles: (files: File[]) => void;
|
||||
multiple?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FileInput({ children, onReceiveFiles }: FileInputProps) {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
multiple: true,
|
||||
onDropAccepted: onReceiveFiles,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
w="100%"
|
||||
maxW={480}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
transitionDuration="0.5s"
|
||||
p="6"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
_hover={{
|
||||
borderColor: 'gray.400',
|
||||
bg: 'gray.50',
|
||||
}}
|
||||
{...(isDragActive && {
|
||||
bg: 'blue.50',
|
||||
borderColor: 'blue.700',
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { Code, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export function FilePathBlock({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-all">
|
||||
<Code>{children}</Code>
|
||||
</Text>
|
||||
);
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { Center, Flex, Link, Text } from '@chakra-ui/react';
|
||||
import { Suspense } from 'react';
|
||||
import { SDKVersion } from './SDKVersion';
|
||||
import { CurrentYear } from './CurrentYear';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<Center
|
||||
fontSize="sm"
|
||||
textAlign="center"
|
||||
bottom="0"
|
||||
w="full"
|
||||
pt="3"
|
||||
pb="3"
|
||||
borderTop="1px solid"
|
||||
borderColor="gray.300"
|
||||
bg="gray.100"
|
||||
color="gray.800"
|
||||
flexDir="column"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Flex as={Text}>
|
||||
<Link href="https://git.unlock-music.dev/um/um-react" isExternal>
|
||||
音乐解锁
|
||||
</Link>
|
||||
{' (__APP_VERSION_SHORT__'}
|
||||
<Suspense>
|
||||
<SDKVersion />
|
||||
</Suspense>
|
||||
{') - 移除已购音乐的加密保护。'}
|
||||
</Flex>
|
||||
<Text>
|
||||
{'© 2019 - '}
|
||||
<CurrentYear />{' '}
|
||||
<Link href="https://git.unlock-music.dev/um" isExternal>
|
||||
UnlockMusic 团队
|
||||
</Link>
|
||||
{' | '}
|
||||
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
|
||||
使用 MIT 授权协议
|
||||
</Link>
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { Heading } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Header3({ children, className, id }: HeaderProps) {
|
||||
return (
|
||||
<Heading
|
||||
as="h3"
|
||||
id={id}
|
||||
className={className}
|
||||
pt={3}
|
||||
pb={1}
|
||||
borderBottom={'1px solid'}
|
||||
borderColor="gray.300"
|
||||
color="gray.800"
|
||||
size="lg"
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header4({ children, className, id }: HeaderProps) {
|
||||
return (
|
||||
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header5({ children, className, id }: HeaderProps) {
|
||||
return (
|
||||
<Heading as="h5" id={id} className={className} pt={3} pb={1} color="gray.700" size="sm">
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { Mark } from '@chakra-ui/react';
|
||||
|
||||
export function HiWord({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Mark bg="orange.100" borderRadius={5} px={2} mx={1}>
|
||||
{children}
|
||||
</Mark>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { chakra, css } from '@chakra-ui/react';
|
||||
|
||||
const cssUnselectable = css({ pointerEvents: 'none', userSelect: 'none' });
|
||||
|
||||
export function VQuote({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<chakra.span css={cssUnselectable}>「</chakra.span>
|
||||
{children}
|
||||
<chakra.span css={cssUnselectable}>」</chakra.span>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import {
|
||||
Center,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
|
||||
export interface ImportSecretModalProps {
|
||||
clientName?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>从文件导入密钥</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
||||
<Center>
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Text as="div" mt={2}>
|
||||
选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:
|
||||
</Text>
|
||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsCommand } from 'react-icons/bs';
|
||||
|
||||
export function MacCommandKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsCommand} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>command</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsShift } from 'react-icons/bs';
|
||||
|
||||
export function ShiftKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsShift} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>shift</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Link } from '@chakra-ui/react';
|
||||
|
||||
export interface ProjectIssueProps {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProjectIssue({ id, title }: ProjectIssueProps) {
|
||||
return (
|
||||
<Link isExternal target="_blank" href={`https://git.unlock-music.dev/um/um-react/issues/${id}`}>
|
||||
{`#${id}`}
|
||||
{title && ` - ${title}`}
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { UnlockIcon } from '@chakra-ui/icons';
|
||||
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { FileInput } from './FileInput';
|
||||
|
||||
export function SelectFile() {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
console.debug(
|
||||
'react-dropzone/onDropAccepted(%o, %o)',
|
||||
files.length,
|
||||
files.map((x) => x.name)
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const blobURI = URL.createObjectURL(file);
|
||||
const fileName = file.name;
|
||||
const fileId = 'file://' + nanoid();
|
||||
|
||||
// FIXME: this should be a single action/thunk that first adds the item, then updates it.
|
||||
dispatch(
|
||||
addNewFile({
|
||||
id: fileId,
|
||||
blobURI,
|
||||
fileName,
|
||||
})
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
||||
<Box pb={3}>
|
||||
<UnlockIcon boxSize={8} />
|
||||
</Box>
|
||||
<Text as="div" textAlign="center">
|
||||
拖放或
|
||||
<Text as="span" color="teal.400">
|
||||
点我选择
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
在浏览器内对文件进行解锁,零上传
|
||||
</Text>
|
||||
</Text>
|
||||
</FileInput>
|
||||
);
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import { NetEaseCloudMusicDecipher } from '~/decrypt-worker/decipher/NetEaseCloudMusic.ts';
|
||||
import { TransparentDecipher } from './decipher/Transparent.ts';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { QQMusicV1Decipher, QQMusicV2Decipher } from '~/decrypt-worker/decipher/QQMusic.ts';
|
||||
import { KuwoMusicDecipher } from '~/decrypt-worker/decipher/KuwoMusic.ts';
|
||||
import { KugouMusicDecipher } from '~/decrypt-worker/decipher/KugouMusic.ts';
|
||||
import { XimalayaAndroidDecipher, XimalayaPCDecipher } from '~/decrypt-worker/decipher/Ximalaya.ts';
|
||||
import { XiamiDecipher } from '~/decrypt-worker/decipher/XiamiMusic.ts';
|
||||
import { QignTingFMDecipher } from '~/decrypt-worker/decipher/QingTingFM.ts';
|
||||
import { Migu3DKeylessDecipher } from '~/decrypt-worker/decipher/Migu3d.ts';
|
||||
|
||||
export enum Status {
|
||||
OK = 0,
|
||||
NOT_THIS_CIPHER = 1,
|
||||
FAILED = 2,
|
||||
}
|
||||
|
||||
export type DecipherResult = DecipherOK | DecipherNotOK;
|
||||
|
||||
export interface DecipherNotOK {
|
||||
status: Exclude<Status, Status.OK>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DecipherOK {
|
||||
status: Status.OK;
|
||||
message?: string;
|
||||
data: Uint8Array;
|
||||
overrideExtension?: string;
|
||||
cipherName: string;
|
||||
}
|
||||
|
||||
export interface DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK>;
|
||||
}
|
||||
|
||||
export type DecipherFactory = () => DecipherInstance;
|
||||
|
||||
export const allCryptoFactories: DecipherFactory[] = [
|
||||
/// File with fixed headers goes first.
|
||||
|
||||
// NCM (*.ncm)
|
||||
NetEaseCloudMusicDecipher.make,
|
||||
|
||||
// KGM (*.kgm, *.vpr)
|
||||
KugouMusicDecipher.make,
|
||||
|
||||
// KWMv1 (*.kwm)
|
||||
KuwoMusicDecipher.make,
|
||||
|
||||
// Ximalaya PC (*.xm)
|
||||
XimalayaPCDecipher.make,
|
||||
|
||||
// Xiami (*.xm)
|
||||
XiamiDecipher.make,
|
||||
|
||||
// QingTingFM Android (*.qta)
|
||||
QignTingFMDecipher.make,
|
||||
|
||||
/// File with a fixed footer goes second
|
||||
|
||||
// QMCv2 (*.mflac)
|
||||
QQMusicV2Decipher.createWithUserKey,
|
||||
QQMusicV2Decipher.createWithEmbeddedEKey,
|
||||
|
||||
/// File without an obvious header or footer goes last.
|
||||
|
||||
// Migu3D/Keyless (*.wav; *.m4a)
|
||||
Migu3DKeylessDecipher.make,
|
||||
|
||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||
// should be moved to the bottom of the list for performance reasons.
|
||||
|
||||
// QMCv1 (*.qmcflac)
|
||||
QQMusicV1Decipher.create,
|
||||
|
||||
// Ximalaya (Android)
|
||||
XimalayaAndroidDecipher.makeX2M,
|
||||
XimalayaAndroidDecipher.makeX3M,
|
||||
|
||||
// Transparent crypto (not encrypted)
|
||||
TransparentDecipher.make,
|
||||
];
|
@ -1,12 +1,22 @@
|
||||
import { ConcurrentQueue } from '~/util/ConcurrentQueue';
|
||||
import { WorkerClientBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { DecryptionQueue } from '~/util/DecryptionQueue';
|
||||
|
||||
// TODO: Worker pool?
|
||||
export const workerClient = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
|
||||
|
||||
// FIXME: report the error so is obvious to the user.
|
||||
workerClient.addEventListener('error', console.error);
|
||||
workerClient.onerror = (err) => console.error(err);
|
||||
|
||||
class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: string }> {
|
||||
constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) {
|
||||
super(maxQueue);
|
||||
}
|
||||
|
||||
async handler(item: { id: string; blobURI: string }): Promise<void> {
|
||||
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI);
|
||||
}
|
||||
}
|
||||
|
||||
export const workerClientBus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(workerClient);
|
||||
export const decryptionQueue = new DecryptionQueue(workerClientBus);
|
||||
|
@ -1,8 +1,5 @@
|
||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
||||
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
||||
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
||||
|
10
src/decrypt-worker/crypto/CryptoBase.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface CryptoBase {
|
||||
/**
|
||||
* When returning false, a successful decryption should be checked by its decrypted content instead.
|
||||
*/
|
||||
hasSignature(): boolean;
|
||||
isSupported(blob: Blob): Promise<boolean>;
|
||||
decrypt(blob: Blob): Promise<Blob>;
|
||||
}
|
||||
|
||||
export type CryptoFactory = () => CryptoBase;
|
16
src/decrypt-worker/crypto/qmc/qmc_v1.key.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export default new Uint8Array([
|
||||
0x77, 0x48, 0x32, 0x73, 0xde, 0xf2, 0xc0, 0xc8, 0x95, 0xec, 0x30, 0xb2, 0x51, 0xc3, 0xe1, 0xa0, 0x9e, 0xe6, 0x9d,
|
||||
0xcf, 0xfa, 0x7f, 0x14, 0xd1, 0xce, 0xb8, 0xdc, 0xc3, 0x4a, 0x67, 0x93, 0xd6, 0x28, 0xc2, 0x91, 0x70, 0xca, 0x8d,
|
||||
0xa2, 0xa4, 0xf0, 0x08, 0x61, 0x90, 0x7e, 0x6f, 0xa2, 0xe0, 0xeb, 0xae, 0x3e, 0xb6, 0x67, 0xc7, 0x92, 0xf4, 0x91,
|
||||
0xb5, 0xf6, 0x6c, 0x5e, 0x84, 0x40, 0xf7, 0xf3, 0x1b, 0x02, 0x7f, 0xd5, 0xab, 0x41, 0x89, 0x28, 0xf4, 0x25, 0xcc,
|
||||
0x52, 0x11, 0xad, 0x43, 0x68, 0xa6, 0x41, 0x8b, 0x84, 0xb5, 0xff, 0x2c, 0x92, 0x4a, 0x26, 0xd8, 0x47, 0x6a, 0x7c,
|
||||
0x95, 0x61, 0xcc, 0xe6, 0xcb, 0xbb, 0x3f, 0x47, 0x58, 0x89, 0x75, 0xc3, 0x75, 0xa1, 0xd9, 0xaf, 0xcc, 0x08, 0x73,
|
||||
0x17, 0xdc, 0xaa, 0x9a, 0xa2, 0x16, 0x41, 0xd8, 0xa2, 0x06, 0xc6, 0x8b, 0xfc, 0x66, 0x34, 0x9f, 0xcf, 0x18, 0x23,
|
||||
0xa0, 0x0a, 0x74, 0xe7, 0x2b, 0x27, 0x70, 0x92, 0xe9, 0xaf, 0x37, 0xe6, 0x8c, 0xa7, 0xbc, 0x62, 0x65, 0x9c, 0xc2,
|
||||
0x08, 0xc9, 0x88, 0xb3, 0xf3, 0x43, 0xac, 0x74, 0x2c, 0x0f, 0xd4, 0xaf, 0xa1, 0xc3, 0x01, 0x64, 0x95, 0x4e, 0x48,
|
||||
0x9f, 0xf4, 0x35, 0x78, 0x95, 0x7a, 0x39, 0xd6, 0x6a, 0xa0, 0x6d, 0x40, 0xe8, 0x4f, 0xa8, 0xef, 0x11, 0x1d, 0xf3,
|
||||
0x1b, 0x3f, 0x3f, 0x07, 0xdd, 0x6f, 0x5b, 0x19, 0x30, 0x19, 0xfb, 0xef, 0x0e, 0x37, 0xf0, 0x0e, 0xcd, 0x16, 0x49,
|
||||
0xfe, 0x53, 0x47, 0x13, 0x1a, 0xbd, 0xa4, 0xf1, 0x40, 0x19, 0x60, 0x0e, 0xed, 0x68, 0x09, 0x06, 0x5f, 0x4d, 0xcf,
|
||||
0x3d, 0x1a, 0xfe, 0x20, 0x77, 0xe4, 0xd9, 0xda, 0xf9, 0xa4, 0x2b, 0x76, 0x1c, 0x71, 0xdb, 0x00, 0xbc, 0xfd, 0x0c,
|
||||
0x6c, 0xa5, 0x47, 0xf7, 0xf6, 0x00, 0x79, 0x4a, 0x11,
|
||||
]);
|
17
src/decrypt-worker/crypto/qmc/qmc_v1.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import key from './qmc_v1.key.ts';
|
||||
|
||||
export class QMC1Crypto implements CryptoBase {
|
||||
hasSignature(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async decrypt(blob: Blob): Promise<Blob> {
|
||||
return transformBlob(blob, (p) => p.make.QMCv1(key));
|
||||
}
|
||||
}
|
3
src/decrypt-worker/crypto/qmc/qmc_v2.key.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const SEED = 106;
|
||||
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
|
||||
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';
|
17
src/decrypt-worker/crypto/qmc/qmc_v2.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
||||
|
||||
export class QMC2Crypto implements CryptoBase {
|
||||
hasSignature(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async decrypt(blob: Blob): Promise<Blob> {
|
||||
return transformBlob(blob, (p) => p.make.QMCv2(p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2)));
|
||||
}
|
||||
}
|
51
src/decrypt-worker/crypto/xiami/xiami.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Xiami file header
|
||||
// offset description
|
||||
// 0x00 "ifmt"
|
||||
// 0x04 Format name, e.g. "FLAC".
|
||||
// 0x08 0xfe, 0xfe, 0xfe, 0xfe
|
||||
// 0x0C (3 bytes) Little-endian, size of data to copy without modification.
|
||||
// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data.
|
||||
// 0x0F (1 byte) File key, applied to
|
||||
// 0x10 Plaintext data
|
||||
// ???? Encrypted data
|
||||
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
const XIAMI_FILE_MAGIC = new Uint8Array('ifmt'.split('').map((x) => x.charCodeAt(0)));
|
||||
const XIAMI_EXPECTED_PADDING = new Uint8Array([0xfe, 0xfe, 0xfe, 0xfe]);
|
||||
|
||||
const u8Sub = (a: number, b: number) => {
|
||||
if (a > b) {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
return a + 0x100 - b;
|
||||
};
|
||||
|
||||
export class XiamiCrypto implements CryptoBase {
|
||||
hasSignature(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async isSupported(blob: Blob): Promise<boolean> {
|
||||
const headerBuffer = await blob.slice(0, 0x10).arrayBuffer();
|
||||
const header = new Uint8Array(headerBuffer);
|
||||
|
||||
return (
|
||||
header.slice(0x00, 0x04).every((b, i) => b === XIAMI_FILE_MAGIC[i]) &&
|
||||
header.slice(0x08, 0x0c).every((b, i) => b === XIAMI_EXPECTED_PADDING[i])
|
||||
);
|
||||
}
|
||||
|
||||
async decrypt(blob: Blob): Promise<Blob> {
|
||||
const headerBuffer = await blob.slice(0, 0x10).arrayBuffer();
|
||||
const header = new Uint8Array(headerBuffer);
|
||||
const key = u8Sub(header[0x0f], 1);
|
||||
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
||||
const decrypted = new Uint8Array(await blob.slice(0x10).arrayBuffer());
|
||||
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
||||
decrypted[i] = u8Sub(key, decrypted[i]);
|
||||
}
|
||||
return new Blob([decrypted]);
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { KuGou } from '@unlock-music/crypto';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class KugouMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'Kugou';
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
let kgm: KuGou | undefined;
|
||||
|
||||
try {
|
||||
kgm = KuGou.from_header(buffer.subarray(0, 0x400));
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
kgm.decrypt(block, offset);
|
||||
}
|
||||
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
kgm?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KugouMusicDecipher();
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { KuwoHeader, KWMDecipher } from '@unlock-music/crypto';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class KuwoMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'Kuwo';
|
||||
|
||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
let header: KuwoHeader | undefined;
|
||||
let kwm: KWMDecipher | undefined;
|
||||
|
||||
try {
|
||||
header = KuwoHeader.parse(buffer.subarray(0, 0x400));
|
||||
kwm = new KWMDecipher(header, options.kwm2key);
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
kwm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
kwm?.free();
|
||||
header?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KuwoMusicDecipher();
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { Migu3D } from '@unlock-music/crypto';
|
||||
|
||||
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);
|
||||
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
mg3d.decrypt(block, i);
|
||||
}
|
||||
mg3d.free();
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new Migu3DKeylessDecipher();
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { NCMFile } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
|
||||
export class NetEaseCloudMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'NCM/PC';
|
||||
|
||||
tryInit(ncm: NCMFile, buffer: Uint8Array) {
|
||||
let neededLength = 1024;
|
||||
while (neededLength !== 0) {
|
||||
console.debug('NCM/open: read %d bytes', neededLength);
|
||||
neededLength = ncm.open(buffer.subarray(0, neededLength));
|
||||
if (neededLength === -1) {
|
||||
throw new UnsupportedSourceFile('file is not ncm');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const ncm = new NCMFile();
|
||||
try {
|
||||
this.tryInit(ncm, buffer);
|
||||
|
||||
const audioBuffer = buffer.slice(ncm.audioOffset);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
ncm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
ncm.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new NetEaseCloudMusicDecipher();
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { decryptQMC1, QMC2, QMCFooter } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||
|
||||
export class QQMusicV1Decipher implements DecipherInstance {
|
||||
cipherName = 'QQMusic/QMC1';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const header = buffer.slice(0, 0x20);
|
||||
decryptQMC1(header, 0);
|
||||
if (!isDataLooksLikeAudio(header)) {
|
||||
throw new UnsupportedSourceFile('does not look like QMC file');
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
decryptQMC1(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static create() {
|
||||
return new QQMusicV1Decipher();
|
||||
}
|
||||
}
|
||||
|
||||
export class QQMusicV2Decipher implements DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
constructor(private readonly useUserKey: boolean) {
|
||||
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
||||
}
|
||||
|
||||
parseFooter(buffer: Uint8Array): { size: number; ekey?: undefined | string } {
|
||||
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
||||
|
||||
if (footer) {
|
||||
const { size, ekey } = footer;
|
||||
footer.free();
|
||||
return { size, ekey };
|
||||
}
|
||||
|
||||
// No footer, and we don't accept user key:
|
||||
if (!this.useUserKey) {
|
||||
throw new UnsupportedSourceFile('Not QMC2 File');
|
||||
}
|
||||
|
||||
return { size: 0 };
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
const footer = this.parseFooter(buffer.subarray(buffer.byteLength - 1024));
|
||||
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
||||
if (!ekey) {
|
||||
throw new Error('EKey required');
|
||||
}
|
||||
|
||||
const qmc2 = new QMC2(ekey);
|
||||
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
qmc2.decrypt(block, offset);
|
||||
}
|
||||
qmc2.free();
|
||||
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static createWithUserKey() {
|
||||
return new QQMusicV2Decipher(true);
|
||||
}
|
||||
|
||||
public static createWithEmbeddedEKey() {
|
||||
return new QQMusicV2Decipher(false);
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { QingTingFM } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { unhex } from '~/util/hex.ts';
|
||||
|
||||
export class QignTingFMDecipher implements DecipherInstance {
|
||||
cipherName = 'QingTingFM (Android, qta)';
|
||||
|
||||
async decrypt(buffer: Uint8Array, opts: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
const key = unhex(opts.qingTingAndroidKey || '');
|
||||
const iv = QingTingFM.getFileIV(opts.fileName);
|
||||
|
||||
if (key.byteLength !== 16 || iv.byteLength !== 16) {
|
||||
return {
|
||||
status: Status.FAILED,
|
||||
message: 'device key or iv invalid',
|
||||
};
|
||||
}
|
||||
|
||||
const qtfm = new QingTingFM(key, iv);
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
qtfm.decrypt(block, i);
|
||||
}
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QignTingFMDecipher();
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
|
||||
export class TransparentDecipher implements DecipherInstance {
|
||||
cipherName = 'none';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
return {
|
||||
cipherName: 'None',
|
||||
status: Status.OK,
|
||||
data: buffer,
|
||||
message: 'No decipher applied',
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new TransparentDecipher();
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { Xiami } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class XiamiDecipher implements DecipherInstance {
|
||||
cipherName = 'Xiami (XM)';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
||||
const { copyPlainLength } = xm;
|
||||
const audioBuffer = buffer.slice(0x10);
|
||||
|
||||
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
|
||||
xm.decrypt(block);
|
||||
}
|
||||
xm.free();
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XiamiDecipher();
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { decryptX2MHeader, decryptX3MHeader, XmlyPC } from '@unlock-music/crypto';
|
||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
|
||||
export class XimalayaAndroidDecipher implements DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
constructor(
|
||||
private decipher: (buffer: Uint8Array) => void,
|
||||
private cipherType: string,
|
||||
) {
|
||||
this.cipherName = `Ximalaya (Android, ${cipherType})`;
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
// Detect with first 0x400 bytes
|
||||
const slice = buffer.slice(0, 0x400);
|
||||
this.decipher(slice);
|
||||
if (!isDataLooksLikeAudio(slice)) {
|
||||
throw new UnsupportedSourceFile(`Not a Xmly android file (${this.cipherType})`);
|
||||
}
|
||||
const result = new Uint8Array(buffer);
|
||||
result.set(slice, 0);
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
public static makeX2M() {
|
||||
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
|
||||
}
|
||||
|
||||
public static makeX3M() {
|
||||
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
|
||||
}
|
||||
}
|
||||
|
||||
export class XimalayaPCDecipher implements DecipherInstance {
|
||||
cipherName = 'Ximalaya (PC)';
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
// Detect with first 0x400 bytes
|
||||
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
|
||||
const xm = new XmlyPC(buffer.subarray(0, headerSize));
|
||||
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
|
||||
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
|
||||
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
|
||||
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
|
||||
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
|
||||
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
|
||||
xm.free();
|
||||
|
||||
const result = new Uint8Array(audioSize);
|
||||
result.set(audioHeader);
|
||||
result.set(encryptedAudioPart, audioHeader.byteLength);
|
||||
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
|
||||
return {
|
||||
status: Status.OK,
|
||||
data: result,
|
||||
cipherName: this.cipherName,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XimalayaPCDecipher();
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
export interface DecryptCommandOptions {
|
||||
fileName: string;
|
||||
qmc2Key?: string;
|
||||
kwm2key?: string;
|
||||
qingTingAndroidKey?: string;
|
||||
}
|
||||
|
||||
export interface DecryptCommandPayload {
|
||||
id: string;
|
||||
blobURI: string;
|
||||
options: DecryptCommandOptions;
|
||||
}
|
||||
|
||||
export interface FetchMusicExNamePayload {
|
||||
blobURI: string;
|
||||
}
|
||||
|
||||
export interface ParseKuwoHeaderPayload {
|
||||
blobURI: string;
|
||||
}
|
||||
|
||||
export type ParseKuwoHeaderResponse = null | {
|
||||
resourceId: number;
|
||||
qualityId: number;
|
||||
};
|
||||
|
||||
export interface GetQingTingFMDeviceKeyPayload {
|
||||
product: string;
|
||||
device: string;
|
||||
manufacturer: string;
|
||||
brand: string;
|
||||
board: string;
|
||||
model: string;
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
export enum DecryptErrorType {
|
||||
UNSUPPORTED_FILE = 'UNSUPPORTED_FILE',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export class DecryptError extends Error {
|
||||
code = DecryptErrorType.UNKNOWN;
|
||||
|
||||
toJSON() {
|
||||
const { name, message, stack, code } = this;
|
||||
return { name, message, stack, code };
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedSourceFile extends DecryptError {
|
||||
code = DecryptErrorType.UNSUPPORTED_FILE;
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { detectAudioType } from '@unlock-music/crypto';
|
||||
|
||||
export function detectAudioExtension(buffer: Uint8Array): string {
|
||||
let neededLength = 0x100;
|
||||
let extension = 'bin';
|
||||
while (neededLength !== 0) {
|
||||
console.debug('AudioDetect: read %d bytes', neededLength);
|
||||
const detectResult = detectAudioType(buffer.subarray(0, neededLength));
|
||||
extension = detectResult.audioType;
|
||||
neededLength = detectResult.needMore;
|
||||
detectResult.free();
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
|
||||
if (buffer.byteLength < 0x20) {
|
||||
return false;
|
||||
}
|
||||
const detectResult = detectAudioType(buffer.subarray(0, 0x20));
|
||||
|
||||
// If we have needMore != 0, that means we have a valid header (ID3 for example).
|
||||
const ok = detectResult.needMore !== 0 || detectResult.audioType !== 'bin';
|
||||
detectResult.free();
|
||||
return ok;
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
||||
|
||||
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
||||
const len = buffer.byteLength;
|
||||
for (let i = 0; i < len; i += blockLen) {
|
||||
const idxEnd = Math.min(i + blockLen, len);
|
||||
const slice = buffer.subarray(i, idxEnd);
|
||||
yield [slice, i];
|
||||
}
|
||||
}
|
31
src/decrypt-worker/util/transformBlob.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@jixun/libparakeet';
|
||||
|
||||
export async function transformBlob(
|
||||
blob: Blob,
|
||||
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
|
||||
parakeet?: Parakeet
|
||||
) {
|
||||
const cleanup: (() => void)[] = [];
|
||||
|
||||
try {
|
||||
const mod = parakeet ?? (await fetchParakeet());
|
||||
const transformer = await transformerFactory(mod);
|
||||
cleanup.push(() => transformer.delete());
|
||||
|
||||
const reader = mod.make.Reader(await blob.arrayBuffer());
|
||||
cleanup.push(() => reader.delete());
|
||||
|
||||
const sink = mod.make.WriterSink();
|
||||
const writer = sink.getWriter();
|
||||
cleanup.push(() => writer.delete());
|
||||
|
||||
const result = transformer.Transform(writer, reader);
|
||||
if (result !== TransformResult.OK) {
|
||||
throw new Error(`transform failed with error: ${TransformResult[result]} (${result})`);
|
||||
}
|
||||
|
||||
return sink.collectBlob();
|
||||
} finally {
|
||||
cleanup.forEach((clean) => clean());
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
export const utf8Decoder = new TextDecoder('utf-8');
|
||||
|
||||
export function stringToUTF8Bytes(str: string) {
|
||||
return utf8Encoder.encode(str);
|
||||
}
|
||||
|
||||
export function bytesToUTF8String(str: BufferSource) {
|
||||
return utf8Decoder.decode(str);
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import { isPromise } from 'radash';
|
||||
|
||||
export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb: (inst: T) => R): R {
|
||||
let isAsync = false;
|
||||
try {
|
||||
const resp = cb(instance);
|
||||
if (resp && isPromise(resp)) {
|
||||
isAsync = true;
|
||||
resp.finally(() => instance.free());
|
||||
}
|
||||
return resp;
|
||||
} finally {
|
||||
if (!isAsync) {
|
||||
instance.free();
|
||||
}
|
||||
}
|
||||
}
|
49
src/decrypt-worker/worker-handler/decrypt.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { fetchParakeet } from '@jixun/libparakeet';
|
||||
import { CryptoFactory } from '../crypto/CryptoBase';
|
||||
|
||||
import { XiamiCrypto } from '../crypto/xiami/xiami';
|
||||
import { QMC1Crypto } from '../crypto/qmc/qmc_v1';
|
||||
import { QMC2Crypto } from '../crypto/qmc/qmc_v2';
|
||||
|
||||
// Use first 4MiB of the file to perform check.
|
||||
const TEST_FILE_HEADER_LEN = 1024 * 1024 * 4;
|
||||
|
||||
const decryptorFactories: CryptoFactory[] = [
|
||||
// Xiami (*.xm)
|
||||
() => new XiamiCrypto(),
|
||||
|
||||
// QMCv1 (*.qmcflac)
|
||||
() => new QMC1Crypto(),
|
||||
|
||||
// QMCv2 (*.mflac)
|
||||
() => new QMC2Crypto(),
|
||||
];
|
||||
|
||||
export const workerDecryptHandler = async (blobURI: string) => {
|
||||
const blob = await fetch(blobURI).then((r) => r.blob());
|
||||
const parakeet = await fetchParakeet();
|
||||
|
||||
for (const factory of decryptorFactories) {
|
||||
const decryptor = factory();
|
||||
if (await decryptor.isSupported(blob)) {
|
||||
try {
|
||||
const decryptedBlob = await decryptor.decrypt(blob);
|
||||
|
||||
// Check if we had a successful decryption
|
||||
const header = await decryptedBlob.slice(0, TEST_FILE_HEADER_LEN).arrayBuffer();
|
||||
const audioExt = parakeet.detectAudioExtension(header);
|
||||
if (!decryptor.hasSignature() && audioExt === 'bin') {
|
||||
// skip this decryptor result
|
||||
continue;
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(decryptedBlob), ext: audioExt };
|
||||
} catch (error) {
|
||||
console.error('decrypt failed: ', error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('could not decrypt file: no working decryptor found');
|
||||
};
|
@ -1,17 +1,15 @@
|
||||
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { getUmcVersion } from '@unlock-music/crypto';
|
||||
|
||||
import { workerDecryptHandler } from './worker/decrypt.ts';
|
||||
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
||||
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
||||
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
||||
import { getSDKVersion } from '@jixun/libparakeet';
|
||||
|
||||
import { workerDecryptHandler } from './worker-handler/decrypt';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, async () => {
|
||||
return getSDKVersion();
|
||||
});
|
||||
|
@ -1,91 +0,0 @@
|
||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils.ts';
|
||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types.ts';
|
||||
import { allCryptoFactories } from '../Deciphers.ts';
|
||||
import { toBlob } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
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';
|
||||
|
||||
class DecryptCommandHandler {
|
||||
private readonly label: string;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private buffer: Uint8Array,
|
||||
private options: DecryptCommandOptions,
|
||||
) {
|
||||
this.label = `DecryptCommandHandler(${label})`;
|
||||
}
|
||||
|
||||
log<R>(label: string, fn: () => Promise<R>): Promise<R> {
|
||||
return timedLogger(`${this.label}: ${label}`, fn);
|
||||
}
|
||||
|
||||
async decrypt(decipherFactories: DecipherFactory[]) {
|
||||
const errors: string[] = [];
|
||||
for (const factory of decipherFactories) {
|
||||
const decipher = factory();
|
||||
|
||||
const [result, error] = await go(this.tryDecryptWith(decipher));
|
||||
if (!error) {
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
errors.push(`${decipher.cipherName}: no response`);
|
||||
continue; // not supported
|
||||
}
|
||||
|
||||
const errMsg = error.message;
|
||||
if (errMsg) {
|
||||
errors.push(`${decipher.cipherName}: ${errMsg}`);
|
||||
}
|
||||
if (error instanceof UnsupportedSourceFile) {
|
||||
console.debug('[%s] Not this decipher:', decipher.cipherName, error);
|
||||
} else {
|
||||
console.error('decrypt failed with unknown error: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedSourceFile(errors.join('\n'));
|
||||
}
|
||||
|
||||
async tryDecryptWith(decipher: DecipherInstance) {
|
||||
const result = await this.log(`try decrypt with ${decipher.cipherName}`, async () =>
|
||||
decipher.decrypt(this.buffer, this.options),
|
||||
);
|
||||
switch (result.status) {
|
||||
case Status.NOT_THIS_CIPHER:
|
||||
return null;
|
||||
case Status.FAILED:
|
||||
throw new Error(`failed: ${result.message}`);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we had a successful decryption
|
||||
let audioExt = result.overrideExtension || detectAudioExtension(result.data);
|
||||
if (!result.overrideExtension && audioExt === 'bin') {
|
||||
throw new UnsupportedSourceFile('unable to produce valid audio file');
|
||||
}
|
||||
|
||||
// Convert mp4 to m4a
|
||||
if (audioExt.toLowerCase() === 'mp4') {
|
||||
audioExt = 'm4a';
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt };
|
||||
}
|
||||
}
|
||||
|
||||
export const workerDecryptHandler = async ({ id: payloadId, blobURI, options }: DecryptCommandPayload) => {
|
||||
await umCryptoReady;
|
||||
const id = payloadId.replace('://', ':');
|
||||
const label = `decrypt(${id})`;
|
||||
return withTimeGroupedLogs(label, async () => {
|
||||
const buffer = await fetch(blobURI).then((r) => r.arrayBuffer());
|
||||
const handler = new DecryptCommandHandler(id, new Uint8Array(buffer), options);
|
||||
return handler.decrypt(allCryptoFactories);
|
||||
});
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
import { KuwoHeader } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
try {
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(0, 1024));
|
||||
const kwm = KuwoHeader.parse(buffer);
|
||||
const { qualityId, resourceId } = kwm;
|
||||
kwm.free();
|
||||
return { qualityId, resourceId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts';
|
||||
import { QMCFooter } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseMusicExMediaName = async ({ blobURI }: FetchMusicExNamePayload) => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
try {
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(-1024));
|
||||
const footer = QMCFooter.parse(buffer);
|
||||
return footer?.mediaName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
||||
import { QingTingFM } from '@unlock-music/crypto';
|
||||
import { hex } from '~/util/hex.ts';
|
||||
|
||||
export async function workerGetQtfmDeviceKey({
|
||||
device,
|
||||
brand,
|
||||
model,
|
||||
product,
|
||||
manufacturer,
|
||||
board,
|
||||
}: GetQingTingFMDeviceKeyPayload) {
|
||||
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
|
||||
return hex(buffer);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
// 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
|
||||
throw new Error('this is a dummy module. Do not use');
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
||||
import { Header4 } from '~/components/HelpText/Headers';
|
||||
import { VQuote } from '~/components/HelpText/VQuote';
|
||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||
import { HiWord } from '~/components/HelpText/HiWord';
|
||||
import { KWMv2AllInstructions } from '~/features/settings/panels/KWMv2/KWMv2AllInstructions';
|
||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||
|
||||
export function KuwoFAQ() {
|
||||
return (
|
||||
<>
|
||||
<Header4>解锁失败</Header4>
|
||||
<List spacing={2}>
|
||||
<ListItem>
|
||||
<SegmentTryOfficialPlayer />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
日前,仅<HiWord>手机客户端</HiWord>下载的
|
||||
<VQuote>
|
||||
<strong>至臻全景声</strong>
|
||||
</VQuote>
|
||||
及
|
||||
<VQuote>
|
||||
<strong>至臻母带</strong>
|
||||
</VQuote>
|
||||
{'音质的音乐文件采用新版加密。'}
|
||||
</Text>
|
||||
<Text>其他音质目前不需要提取密钥。</Text>
|
||||
<Text>PC平台暂未推出使用新版加密的音质。</Text>
|
||||
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||
<Text>
|
||||
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法提取密钥。
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>注意</strong>:项目组不提倡使用、也不提供第三方修改版。使用前请自行评估风险。
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<SegmentKeyImportInstructions tab="KWMv2 密钥" clientInstructions={<KWMv2AllInstructions />} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { Header4 } from '~/components/HelpText/Headers';
|
||||
import { VQuote } from '~/components/HelpText/VQuote';
|
||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
||||
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
||||
|
||||
export function OtherFAQ() {
|
||||
return (
|
||||
<>
|
||||
<Header4>解密后没有封面等信息</Header4>
|
||||
<Text>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</Text>
|
||||
<Text>请使用第三方工具进行编辑或管理元信息。</Text>
|
||||
|
||||
<Header4>批量下载</Header4>
|
||||
<Text>
|
||||
{'暂时没有实现,不过你可以在 '}
|
||||
<ProjectIssue id={34} title="[UI] 全部下载功能" />
|
||||
{' 以及 '}
|
||||
<ProjectIssue id={43} title="批量下载" />
|
||||
{' 追踪该问题。'}
|
||||
</Text>
|
||||
|
||||
<Header4>安卓: 浏览器支持说明</Header4>
|
||||
<Text>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</Text>
|
||||
<Text>已知有问题的浏览器:</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>Via 浏览器</ListItem>
|
||||
<ListItem>夸克浏览器</ListItem>
|
||||
<ListItem>UC 浏览器</ListItem>
|
||||
</UnorderedList>
|
||||
<Text>可能会遇到的问题包括:</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>网页白屏</ListItem>
|
||||
<ListItem>无法下载解密后内容</ListItem>
|
||||
<ListItem>下载的文件名错误</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>安卓: root 相关说明</Header4>
|
||||
<Text>
|
||||
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
||||
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
|
||||
</Text>
|
||||
<Text>如果希望不破坏系统完整性,你可以考虑使用模拟器。</Text>
|
||||
<Text>
|
||||
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11
|
||||
开始支援的
|
||||
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
|
||||
<ruby>
|
||||
适用于 Android™ 的 Windows 子系统 (WSA)
|
||||
<rp> (</rp>
|
||||
<rt>
|
||||
<code>Windows Subsystem for Android</code>
|
||||
</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
</ExtLink>
|
||||
。
|
||||
</Text>
|
||||
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>
|
||||
<strong>注意</strong>:根据应用的风控策略,使用模拟器登录的账号<strong>有可能会导致账号被封锁</strong>
|
||||
{';使用前请自行评估风险。'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
{'WSA 可以参考 '}
|
||||
<ExtLink href="https://github.com/LSPosed/MagiskOnWSALocal">MagiskOnWSALocal</ExtLink>
|
||||
{' 的说明操作。'}
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
雷电模拟器可以在<VQuote>模拟器设置</VQuote> → <VQuote>其他设置</VQuote>中启用 root 特权。
|
||||
</Text>
|
||||
<Img borderRadius={5} border="1px solid #ccc" src={LdPlayerSettingsScreen}></Img>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>相关项目</Header4>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://github.com/CarlGao4/um-react-electron">
|
||||
<strong>
|
||||
<Code>um-react-electron</Code>
|
||||
</strong>
|
||||
</ExtLink>
|
||||
:利用 Electron 框架打包的本地版,提供适用于 Windows、Linux 和 Mac 平台的可执行文件。
|
||||
</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub 下载</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://git.unlock-music.dev/um/um-react-wry">
|
||||
<strong>
|
||||
<Code>um-react-wry</Code>
|
||||
</strong>
|
||||
</ExtLink>
|
||||
: 使用 WRY 框架封装的 Win64 单文件(需要
|
||||
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703">安装 Edge WebView2 运行时</ExtLink>
|
||||
{',Win10+ 操作系统自带)'}
|
||||
</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest">仓库下载</ExtLink>
|
||||
{' | 寻找文件名为 '}
|
||||
<Code>um-react-win64-</Code> 开头的附件
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>有更多问题?</Header4>
|
||||
<Text>
|
||||
{'欢迎进入 '}
|
||||
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram “音乐解锁-交流” 交流群</ExtLink>
|
||||
{' 一起探讨。'}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,159 +0,0 @@
|
||||
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react';
|
||||
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
import { Header4 } from '~/components/HelpText/Headers';
|
||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
import { InstructionsIOS } from '~/features/settings/panels/QMCv2/InstructionsIOS';
|
||||
import { InstructionsMac } from '~/features/settings/panels/QMCv2/InstructionsMac';
|
||||
|
||||
export function QQMusicFAQ() {
|
||||
return (
|
||||
<>
|
||||
<Header4>解锁失败</Header4>
|
||||
<SegmentTryOfficialPlayer />
|
||||
<Text>重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。</Text>
|
||||
<Text>
|
||||
部分平台获取的加密文件未包含密钥。选择你<strong>下载文件时</strong>使用的客户端来查看说明。
|
||||
</Text>
|
||||
<Accordion allowToggle my={2}>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Windows
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<Text>
|
||||
目前 Windows 客户端 19.51 或更低版本下载的歌曲文件无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||
</Text>
|
||||
<Text>你可以通过下方的链接获取 QQ 音乐 Windows 客户端 v19.51 的安装程序:</Text>
|
||||
<UnorderedList pl={3}>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||
<code>qq.com</code> 官方下载地址(推荐)
|
||||
</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||
<code>Archive.org</code> 存档
|
||||
</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Mac
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>Mac 需要降级到 8.8.0 或以下版本。</Text>
|
||||
<Text>
|
||||
<ExtLink href="https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg">
|
||||
<code>Archive.org</code> 存档
|
||||
</ExtLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<SegmentKeyImportInstructions
|
||||
tab="QMCv2 密钥"
|
||||
keyInstructionText="查看密钥提取说明:"
|
||||
clientInstructions={
|
||||
<Box p={2}>
|
||||
<InstructionsMac />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
安卓 (Android)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>安卓提取密钥需要 root 特权,建议用电脑操作。</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<Text>QQ 音乐官方版本需要提取密钥才能解密。</Text>
|
||||
<Text>
|
||||
你也可以尝试使用【QQ 音乐简洁版】或 OEM 定制版(如小米、魅族定制版)。简洁、定制版本目前不需要提取密钥。
|
||||
</Text>
|
||||
|
||||
<SegmentKeyImportInstructions
|
||||
tab="QMCv2 密钥"
|
||||
keyInstructionText="查看密钥提取说明:"
|
||||
clientInstructions={
|
||||
<Box p={2}>
|
||||
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
iOS (iPhone, iPad)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>iOS 用户提取歌曲困难,建议换用电脑操作;</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<SegmentKeyImportInstructions
|
||||
tab="QMCv2 密钥"
|
||||
keyInstructionText="查看密钥提取说明:"
|
||||
clientInstructions={
|
||||
<Box p={2}>
|
||||
<InstructionsIOS />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
没有必要过度缓存,这句话跑得挺快的。缓存本身也有开销。
或考虑作为常量放到组件外,这样就不需要缓存了(反正渲染后不会变)
参考文章:
https://jancat.github.io/post/2019/translation-usememo-and-usecallback/