mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 11:33:02 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe6efec1f | ||
|
|
c4fe9ce938 | ||
|
|
045bea8084 | ||
|
|
c68195eb9a | ||
|
|
b986a2ef99 | ||
|
|
b48d9b0079 | ||
|
|
62e49804a5 | ||
|
|
c41e5ae531 | ||
|
|
27c33a7d20 | ||
|
|
bbb557eafd | ||
|
|
befe35e5bc | ||
|
|
1fb6526cdb | ||
|
|
d122eaecf5 | ||
|
|
2da766168c | ||
|
|
17200150dd | ||
|
|
2c461df5fc | ||
|
|
b493391371 | ||
|
|
13b67f40aa | ||
|
|
fbe8ef8ba1 | ||
|
|
54f784d778 | ||
|
|
665524ee34 | ||
|
|
7c319fa4d9 | ||
|
|
2598b977ab | ||
|
|
d81963ddcd | ||
|
|
99bce5c4ef | ||
|
|
3fcbe054b0 | ||
|
|
67fbe77157 | ||
|
|
d57cdcdb49 | ||
|
|
ff79b4ce27 | ||
|
|
cbb6347251 | ||
|
|
a5d0ec29a1 | ||
|
|
fa7292f65b | ||
|
|
519ced5e88 | ||
|
|
b33ffa6ca7 | ||
|
|
09c1bc474e | ||
|
|
e0b3bd60c2 | ||
|
|
6371c58cd5 | ||
|
|
98f1be9ac7 | ||
|
|
3541af7a96 | ||
|
|
3ab73d8369 | ||
|
|
6cb1f9f87f | ||
|
|
9518b813bd | ||
|
|
2e4e57be45 | ||
|
|
75b43e1e84 | ||
|
|
246ba48135 | ||
|
|
13c669b4ea | ||
|
|
089d66cbf4 | ||
|
|
33a5f277fa | ||
|
|
8a77cb0dc8 | ||
|
|
0a820b620b | ||
|
|
721d947fdb | ||
|
|
1880220aaa | ||
|
|
d91e2fffe4 | ||
|
|
88cfbcd337 |
@@ -1,3 +0,0 @@
|
|||||||
dist/
|
|
||||||
node_modules/
|
|
||||||
coverage/
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'prettier',
|
|
||||||
],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': 'warn',
|
|
||||||
'@typescript-eslint/no-unused-vars': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
varsIgnorePattern: '^_',
|
|
||||||
argsIgnorePattern: '^_',
|
|
||||||
destructuredArrayIgnorePattern: '^_',
|
|
||||||
ignoreRestSiblings: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
46
.gitea/ISSUE_TEMPLATE/50-qqmusic-android.yaml
Normal file
46
.gitea/ISSUE_TEMPLATE/50-qqmusic-android.yaml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: QQ 音乐 (安卓)
|
||||||
|
about: 解密使用「QQ 音乐」的「安卓」客户端下载的文件失败时选择该项。
|
||||||
|
title: '[QQ音乐]: 请填写标题'
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### 安卓客户端
|
||||||
|
|
||||||
|
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
|
||||||
|
|
||||||
|
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
|
||||||
|
|
||||||
|
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: 错误描述
|
||||||
|
description: |
|
||||||
|
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 相关日志
|
||||||
|
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: 检查清单
|
||||||
|
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||||
|
options:
|
||||||
|
- label: 我有填写一个简单易懂的标题
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读上方的说明
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读 um-react 的答疑部分
|
||||||
|
required: true
|
||||||
|
- label: 我的安卓设备已获得 root 权限
|
||||||
|
required: true
|
||||||
68
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
68
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: QQ 音乐 (Windows/Mac)
|
||||||
|
about: 解密使用「QQ 音乐」的「Windows」或「Mac」客户端下载的文件失败时选择该项。
|
||||||
|
title: '[QQ音乐]: 请填写标题'
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Windows 客户端
|
||||||
|
|
||||||
|
目前 Windows 客户端仅支持 19.51 或更低版本下载的歌曲文件。
|
||||||
|
|
||||||
|
* [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe)
|
||||||
|
* [通过 Telegram 下载](https://t.me/um_lsr_ch/24)
|
||||||
|
|
||||||
|
安装好客户端后可以加装更新屏蔽更新:
|
||||||
|
|
||||||
|
* [通过 Telegram 下载](https://t.me/um_lsr_ch/6)
|
||||||
|
|
||||||
|
### Mac 客户端
|
||||||
|
|
||||||
|
目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。
|
||||||
|
|
||||||
|
* [web.archive.org 镜像](https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg)
|
||||||
|
* [通过 Telegram 下载](https://t.me/um_lsr_ch/21)
|
||||||
|
|
||||||
|
安装好客户端后可以加装更新屏蔽更新:
|
||||||
|
|
||||||
|
* [屏蔽更新](https://t.me/um_lsr_ch/29)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: 平台
|
||||||
|
description: 你使用的客户端平台是…
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- Mac
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: 错误描述
|
||||||
|
description: |
|
||||||
|
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 相关日志
|
||||||
|
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: 检查清单
|
||||||
|
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||||
|
options:
|
||||||
|
- label: 我有填写一个简单易懂的标题
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读上方的说明
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读 um-react 的答疑部分
|
||||||
|
required: true
|
||||||
51
.gitea/ISSUE_TEMPLATE/53-kuwo-android.yaml
Normal file
51
.gitea/ISSUE_TEMPLATE/53-kuwo-android.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: 酷我音乐 (安卓)
|
||||||
|
about: 解密使用「酷我音乐」的「安卓」客户端下载的文件失败时选择该项。
|
||||||
|
title: '[酷我音乐]: 请填写标题'
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### 酷我音乐 - 安卓客户端
|
||||||
|
|
||||||
|
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
|
||||||
|
|
||||||
|
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
|
||||||
|
|
||||||
|
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
|
||||||
|
|
||||||
|
※ 如果你使用酷我音乐的是所谓“破解版”,你的问题会被忽略。
|
||||||
|
已知部分“第三方魔改”会破坏密钥写出过程,请从官方渠道安装酷我音乐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: 错误描述
|
||||||
|
description: |
|
||||||
|
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 相关日志
|
||||||
|
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: 检查清单
|
||||||
|
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||||
|
options:
|
||||||
|
- label: 我有填写一个简单易懂的标题
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读上方的说明
|
||||||
|
required: true
|
||||||
|
- label: 我有阅读 um-react 的答疑部分
|
||||||
|
required: true
|
||||||
|
- label: 我的安卓设备已获得 root 权限
|
||||||
|
required: true
|
||||||
|
- label: 我能使用官方渠道下载的酷我音乐客户端复现该问题
|
||||||
|
required: true
|
||||||
4
.gitea/ISSUE_TEMPLATE/99-default.md
Normal file
4
.gitea/ISSUE_TEMPLATE/99-default.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
name: '其它'
|
||||||
|
about: '如果你遇到的问题不符合上述模板的描述,请选择此项。'
|
||||||
|
---
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -17,16 +17,20 @@ jobs:
|
|||||||
- args: [--frozen-lockfile, --strict-peer-dependencies]
|
- args: [--frozen-lockfile, --strict-peer-dependencies]
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
- name: Prepare for deployment
|
- name: Pack Win64
|
||||||
run: |
|
run: |
|
||||||
python3 -m zipfile -c um-react.zip dist/.
|
python3 -m zipfile -c um-react.zip dist/.
|
||||||
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
|
./scripts/make-win64.sh
|
||||||
python3 -m zipfile -c um-react-site.zip dist/.
|
|
||||||
- name: Publish Artifact
|
- name: Publish Artifact
|
||||||
uses: christopherhx/gitea-upload-artifact@v4
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: site
|
name: site
|
||||||
path: dist/
|
path: dist/
|
||||||
|
- name: Prepare for deployment
|
||||||
|
run: |
|
||||||
|
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
|
||||||
|
cp win64/dist/*.zip dist/"release-${GITHUB_SHA}-win64.zip"
|
||||||
|
python3 -m zipfile -c um-react-site.zip dist/.
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
env:
|
env:
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,3 +32,7 @@ dist-ssr
|
|||||||
/um-react*.exe
|
/um-react*.exe
|
||||||
|
|
||||||
/win64/
|
/win64/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
*.py[cod]
|
||||||
|
__pycache__
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
|||||||
use-node-version=22.12.0
|
use-node-version=24.7.0
|
||||||
engine-strict=true
|
engine-strict=true
|
||||||
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
@unlock-music:registry=https://git.um-react.app/api/packages/um/npm/
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"editorconfig.editorconfig",
|
"bradlc.vscode-tailwindcss",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"foxundermoon.shell-format"
|
"foxundermoon.shell-format"
|
||||||
]
|
]
|
||||||
|
|||||||
28
README.MD
28
README.MD
@@ -1,6 +1,6 @@
|
|||||||
# Unlock Music 音乐解锁 (React)
|
# Unlock Music 音乐解锁 (React)
|
||||||
|
|
||||||
[](https://ci.unlock-music.dev/um/um-react)
|
[][um-react-actions]
|
||||||
|
|
||||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||||
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
> **WARNING**
|
> **WARNING**
|
||||||
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||||
|
|
||||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
[授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE
|
||||||
[um-vue]: https://git.unlock-music.dev/um/web
|
[um-vue]: https://git.um-react.app/um/web
|
||||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
[unlock-music/cli]: https://git.um-react.app/um/cli
|
||||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||||
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions
|
[um-react-actions]: https://git.um-react.app/um/um-react/actions?workflow=build.yaml
|
||||||
|
|
||||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||||
|
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
|
|
||||||
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
|
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
|
||||||
|
|
||||||
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。
|
待实现的算法支持可[追踪 `crypto` 标签](https://git.um-react.app/um/um-react/issues?labels=67)。
|
||||||
|
|
||||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
[project-issues]: https://git.um-react.app/um/um-react/issues/new
|
||||||
|
|
||||||
## 使用 Docker 构建、部署 (Linux)
|
## 使用 Docker 构建、部署 (Linux)
|
||||||
|
|
||||||
首先克隆仓库并进入目录:
|
首先克隆仓库并进入目录:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.unlock-music.dev/um/um-react.git
|
git clone https://git.um-react.app/um/um-react.git
|
||||||
cd um-react
|
cd um-react
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,13 +115,15 @@ docker run -d -p 8080:80 --name um-react um-react
|
|||||||
|
|
||||||
## 相关项目
|
## 相关项目
|
||||||
|
|
||||||
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
- [Unlock Music (Web)](https://git.um-react.app/um/web) - 原始项目
|
||||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
- [Unlock Music (Cli)](https://git.um-react.app/um/cli) - 命令行批量处理版
|
||||||
|
- [lib_um_crypto_rust](https://git.um-react.app/um/lib_um_crypto_rust) - 项目引入的解密算法实现
|
||||||
|
- [NPM 包](https://git.um-react.app/um/-/packages/npm/@unlock-music%2Fcrypto)
|
||||||
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
||||||
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest)
|
||||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
- [um-react-wry](https://git.um-react.app/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||||
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||||
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
- [本地下载](https://git.um-react.app/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
||||||
|
|
||||||
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- 进入上层目录:`cd ..`
|
- 进入上层目录:`cd ..`
|
||||||
- 克隆 `lib_um_crypto_rust` 仓库
|
- 克隆 `lib_um_crypto_rust` 仓库
|
||||||
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git`
|
- `git clone https://git.um-react.app/um/lib_um_crypto_rust.git`
|
||||||
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
||||||
- 安装所有 Node 以来:`pnpm i`
|
- 安装所有 Node 以来:`pnpm i`
|
||||||
- 构建:`pnpm build`
|
- 构建:`pnpm build`
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
|
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
|
||||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||||

|

|
||||||
|
|
||||||
### Via 等浏览器无法正常解密/下载
|
### Via 等浏览器无法正常解密/下载
|
||||||
|
|
||||||
@@ -87,10 +87,6 @@
|
|||||||
- 无法下载解密后内容
|
- 无法下载解密后内容
|
||||||
- 下载的文件名错误
|
- 下载的文件名错误
|
||||||
|
|
||||||
### 新版解锁网站没有批量下载
|
|
||||||
|
|
||||||
目前没有做。抱歉。
|
|
||||||
|
|
||||||
## 仍有问题?
|
## 仍有问题?
|
||||||
|
|
||||||
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。
|
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。
|
||||||
|
|||||||
43
eslint.config.mjs
Normal file
43
eslint.config.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier/flat';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactRefresh.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
eslintConfigPrettier,
|
||||||
|
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'warn',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
destructuredArrayIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
ignores: ['**/dist/', '**/node_modules/', '**/coverage/'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['scripts/*.mjs'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh-cmn-Hans-CN">
|
<html lang="zh-cmn-Hans-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>音乐解锁 - Unlock Music</title>
|
<title>音乐解锁 - Unlock Music</title>
|
||||||
|
|
||||||
<meta name="description" content="音乐解锁 - Unlock Music" />
|
<meta name="description" content="音乐解锁 - Unlock Music" />
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
||||||
<meta name="theme-color" content="#4DBA87" />
|
<meta name="theme-color" content="#4DBA87" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main id="root"></main>
|
<main id="root"></main>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|||||||
11870
package-lock.json
generated
Normal file
11870
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
112
package.json
112
package.json
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.2",
|
"version": "0.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
|
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
|
||||||
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.mjs",
|
"build:finalize": "node scripts/write-version.mjs",
|
||||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -17,58 +17,62 @@
|
|||||||
"prepare": "simple-git-hooks"
|
"prepare": "simple-git-hooks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/anatomy": "^2.3.4",
|
"@reduxjs/toolkit": "^2.9.0",
|
||||||
"@chakra-ui/icons": "^2.2.4",
|
"@unlock-music/crypto": "^0.1.12",
|
||||||
"@chakra-ui/react": "^2.10.4",
|
"classnames": "^2.5.1",
|
||||||
"@emotion/react": "^11.14.0",
|
"nanoid": "^5.1.5",
|
||||||
"@emotion/styled": "^11.14.0",
|
"radash": "^12.1.1",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"react": "^19.1.1",
|
||||||
"@unlock-music/crypto": "0.1.6",
|
"react-dom": "^19.1.1",
|
||||||
"framer-motion": "^11.14.4",
|
"react-dropzone": "^14.3.8",
|
||||||
"nanoid": "^5.0.9",
|
"react-icons": "^5.5.0",
|
||||||
"radash": "^12.1.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-dropzone": "^14.3.5",
|
|
||||||
"react-icons": "^5.4.0",
|
|
||||||
"react-promise-suspense": "^0.3.4",
|
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-router": "^7.8.2",
|
||||||
"sass": "^1.83.0",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"sql.js": "^1.12.0"
|
"react-toastify": "^11.0.5",
|
||||||
|
"sql.js": "^1.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.1",
|
"@eslint/js": "^9.35.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@rollup/plugin-replace": "^6.0.2",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@types/node": "^22.10.2",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/react": "^18.3.16",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/react": "^19.1.12",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@types/wicg-file-system-access": "^2023.10.6",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@typescript-eslint/parser": "^8.42.0",
|
||||||
"@vitest/coverage-v8": "^2.1.8",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"@vitest/ui": "^2.1.8",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^8.57.1",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"daisyui": "^5.1.8",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint": "^9.35.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^26.1.0",
|
||||||
"lint-staged": "^15.2.11",
|
"lint-staged": "^16.1.6",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.6.2",
|
||||||
"rollup": "^4.28.1",
|
"rollup": "^4.50.1",
|
||||||
"simple-git-hooks": "^2.11.1",
|
"sass": "^1.92.1",
|
||||||
"terser": "^5.37.0",
|
"simple-git-hooks": "^2.13.1",
|
||||||
"typescript": "^5.7.2",
|
"tailwindcss": "^4.1.13",
|
||||||
"vite": "^5.4.11",
|
"terser": "^5.44.0",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"typescript": "^5.9.2",
|
||||||
"vite-plugin-top-level-await": "^1.4.4",
|
"typescript-eslint": "^8.42.0",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite": "^7.1.5",
|
||||||
"vitest": "^2.1.8",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"workbox-build": "^7.3.0",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -86,13 +90,17 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
|
||||||
"sql.js": "patches/sql.js.patch"
|
"sql.js": "patches/sql.js.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
|
||||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
|
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4"
|
"onlyBuiltDependencies": [
|
||||||
|
"@swc/core",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"esbuild",
|
||||||
|
"simple-git-hooks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.15.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||||
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644
|
index 6ef6c1f54a368431b22dea4da123f1341b4a1780..8dd8a53cefa7779f0556eb1038be35ad547beabb 100644
|
||||||
--- a/dist/sql-wasm.js
|
--- a/dist/sql-wasm.js
|
||||||
+++ b/dist/sql-wasm.js
|
+++ b/dist/sql-wasm.js
|
||||||
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) {
|
@@ -173,16 +173,5 @@ if(0<K)Sa=Yc;else{if(f.preRun)for("function"==typeof f.preRun&&(f.preRun=[f.preR
|
||||||
else if (typeof exports === 'object'){
|
return initSqlJsPromise;
|
||||||
exports["Module"] = initSqlJs;
|
} // The end of our initSqlJs function
|
||||||
}
|
|
||||||
+
|
-// This bit below is copied almost exactly from what you get when you use the MODULARIZE=1 flag with emcc
|
||||||
|
-// However, we don't want to use the emcc modularization. See shell-pre.js
|
||||||
|
-if (typeof exports === 'object' && typeof module === 'object'){
|
||||||
|
- module.exports = initSqlJs;
|
||||||
|
- // This will allow the module to be used in ES6 or CommonJS
|
||||||
|
- module.exports.default = initSqlJs;
|
||||||
|
-}
|
||||||
|
-else if (typeof define === 'function' && define['amd']) {
|
||||||
|
- define([], function() { return initSqlJs; });
|
||||||
|
-}
|
||||||
|
-else if (typeof exports === 'object'){
|
||||||
|
- exports["Module"] = initSqlJs;
|
||||||
|
-}
|
||||||
+var module;
|
+var module;
|
||||||
+export default initSqlJs;
|
+export default initSqlJs;
|
||||||
|
|||||||
6014
pnpm-lock.yaml
generated
6014
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
public/_redirects
Normal file
2
public/_redirects
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Support SPA routing in Netlify
|
||||||
|
/* /index.html 200
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
||||||
|
|
||||||
WRY_VER="0.1.1"
|
WRY_VER="0.1.2"
|
||||||
|
|
||||||
mkdir -p win64/{deps,dist}
|
mkdir -p win64/{deps,dist}
|
||||||
dl_file() {
|
dl_file() {
|
||||||
@@ -26,8 +26,8 @@ ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
|
|||||||
-r um-react.zip \
|
-r um-react.zip \
|
||||||
-o "win64/dist/${EXE_NAME}"
|
-o "win64/dist/${EXE_NAME}"
|
||||||
|
|
||||||
touch -d 1970-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
|
touch -d 1980-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
|
||||||
zip -9oX "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
|
zip -9oXj "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
|
||||||
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
|
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
|
||||||
|
|
||||||
popd
|
popd
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const __dirname = dirname(__filename);
|
|||||||
|
|
||||||
let commitHash = process.env.GIT_COMMIT || 'unknown';
|
let commitHash = process.env.GIT_COMMIT || 'unknown';
|
||||||
try {
|
try {
|
||||||
execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to get commit hash:', e);
|
console.error('Failed to get commit hash:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/App.css
Normal file
76
src/App.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: dracula --prefersdark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* theme: https://daisyui.com/theme-generator/#theme=eJx1kuFupCAUhV-FmGyym1TCBQHt2zB67Zg6MAFNu2323ZdhGkdH_Annu9dzjnwX1lyweC1OZjS2xa54KcrSm26YQxlwxHZyPsqMSo-XtdgPOHZZ5eQ-t_dh-MLtMr7VHrsewsn5Dm80XD_TRYfX6Xw7p5N1Q8CfU-tG58vQnjFFGYe385Sgu3AyAUtgLErufWzPv5v6F2GE_Xlm-JpRkaGMAeG1okLLHS3WNL_TinBVUSlgR7fOTminZYKznQeL8-TNuCBCHyG7bZlEg-3dolfJHzRN9CepEvWaDHPbYggLLO-wrAjIGEo1a_jDeDvYtwVWqSfQDZE1FbBZjN6n__2z9haH8koSrqng8tnsPlQaYFARLiuqWJUxfTTEgYBSFEBkzOfri0OcNJJyrXchDr0JAjHMJvXVDxfj_z4aYqkhqAnUFdVPMTBu7jI4j0-JA9V8E8DE0CsXElKl0Tco2giZsXGUNbYKNYv7s24Op-IDYozWOuNqPyPSjOC3jjSkVjvszTxGZvIzvhRXjz36ED_5Xrz2Zgz47z_whE0z */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: 'balanced';
|
||||||
|
default: true;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: 'light';
|
||||||
|
--color-base-100: oklch(98% 0 0);
|
||||||
|
--color-base-200: oklch(96% 0.001 286.375);
|
||||||
|
--color-base-300: oklch(92% 0.006 264.531);
|
||||||
|
--color-base-content: oklch(20% 0 0);
|
||||||
|
--color-primary: oklch(60% 0.118 184.704);
|
||||||
|
--color-primary-content: oklch(98% 0.014 180.72);
|
||||||
|
--color-secondary: oklch(60% 0.126 221.723);
|
||||||
|
--color-secondary-content: oklch(98% 0.019 200.873);
|
||||||
|
--color-accent: oklch(51% 0.222 16.935);
|
||||||
|
--color-accent-content: oklch(93% 0.032 17.717);
|
||||||
|
--color-neutral: oklch(37% 0 0);
|
||||||
|
--color-neutral-content: oklch(98% 0 0);
|
||||||
|
--color-info: oklch(42% 0.199 265.638);
|
||||||
|
--color-info-content: oklch(97% 0.014 254.604);
|
||||||
|
--color-success: oklch(52% 0.154 150.069);
|
||||||
|
--color-success-content: oklch(97% 0.021 166.113);
|
||||||
|
--color-warning: oklch(66% 0.179 58.318);
|
||||||
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
|
--color-error: oklch(57% 0.245 27.325);
|
||||||
|
--color-error-content: oklch(97% 0.013 17.38);
|
||||||
|
--radius-selector: 0.5rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 1;
|
||||||
|
}
|
||||||
|
@theme {
|
||||||
|
--font-display:
|
||||||
|
system-ui, Sarasa UI SC, Source Han Sans CN, Noto Sans CJK SC, sans-serif, Apple Color Emoji, Segoe UI Emoji;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, Consolas, Sarasa Mono CJK SC, Sarasa UI SC, Source Han Sans CN, Noto Sans CJK SC, Microsoft YaHei UI,
|
||||||
|
monospace, Apple Color Emoji, Segoe UI Emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
&:hover > a[data-anchor] {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadAll {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5em;
|
||||||
|
bottom: 72px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ test('should be able to render App', async () => {
|
|||||||
await waitFor(() => screen.getByTestId('sdk-version'));
|
await waitFor(() => screen.getByTestId('sdk-version'));
|
||||||
|
|
||||||
// Quick sanity check of known strings.
|
// Quick sanity check of known strings.
|
||||||
expect(screen.getByText(/在浏览器内对文件进行解锁/i)).toBeInTheDocument();
|
expect(screen.getByText(/音乐解锁/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/UnlockMusic 团队/i)).toBeInTheDocument();
|
expect(screen.getByText(/Unlock Music/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/MIT/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,49 @@
|
|||||||
import {
|
import type { RefObject } from 'react';
|
||||||
Button,
|
import { MdAdd, MdDeleteForever, MdFileUpload } from 'react-icons/md';
|
||||||
ButtonGroup,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
|
||||||
|
|
||||||
export interface AddKeyProps {
|
export interface AddKeyProps {
|
||||||
addKey: () => void;
|
addKey: () => void;
|
||||||
importKeyFromFile?: () => void;
|
importKeyFromFile?: () => void;
|
||||||
clearKeys?: () => void;
|
clearKeys?: () => void;
|
||||||
|
refContainer?: RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) {
|
export function AddKey({ addKey, refContainer, importKeyFromFile, clearKeys }: AddKeyProps) {
|
||||||
|
const scrollToLastKey = () => {
|
||||||
|
const container = refContainer?.current;
|
||||||
|
if (container) {
|
||||||
|
const inputs = container.querySelectorAll('input[data-name="key-input--name"]');
|
||||||
|
const lastInput = inputs[inputs.length - 1] as HTMLInputElement | null;
|
||||||
|
if (lastInput) {
|
||||||
|
lastInput.focus({ preventScroll: true });
|
||||||
|
lastInput.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddKey = () => {
|
||||||
|
addKey();
|
||||||
|
setTimeout(scrollToLastKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack pb={2} pt={2}>
|
<div className="flex flex-row justify-between items-center">
|
||||||
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
<div className="join">
|
||||||
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
<button type="button" className="join-item btn flex items-center gap-2" onClick={handleAddKey}>
|
||||||
添加一条密钥
|
<MdAdd className="text-lg" /> 添加一条
|
||||||
</Button>
|
</button>
|
||||||
<Menu>
|
<button type="button" className="join-item btn flex items-center gap-2" onClick={importKeyFromFile}>
|
||||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
<MdFileUpload className="text-lg" />
|
||||||
<MenuList>
|
导入数据库…
|
||||||
{importKeyFromFile && (
|
</button>
|
||||||
<MenuItem onClick={importKeyFromFile} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
<button type="button" className="join-item btn flex items-center gap-2 btn-error" onClick={clearKeys}>
|
||||||
从文件导入密钥…
|
<MdDeleteForever className="text-lg" />
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{importKeyFromFile && clearKeys && <MenuDivider />}
|
|
||||||
{clearKeys && (
|
|
||||||
<MenuItem color="red" onClick={clearKeys} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
|
||||||
清空密钥
|
清空密钥
|
||||||
</MenuItem>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</MenuList>
|
</div>
|
||||||
</Menu>
|
|
||||||
</ButtonGroup>
|
|
||||||
</HStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { CodeHighlight } from '../CodeHighlight';
|
||||||
|
import { ExtLink } from '../ExtLink';
|
||||||
|
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||||
|
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||||
|
import { applyTemplate } from '~/util/applyTemplate';
|
||||||
|
|
||||||
|
export interface AdbInstructionTemplateProps {
|
||||||
|
dir: string;
|
||||||
|
file: string;
|
||||||
|
platform: 'win32' | 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_USB_DEBUGGING = 'https://developer.android.com/studio/debug/dev-options?hl=zh-cn#Enable-debugging';
|
||||||
|
|
||||||
|
const LANGUAGE_MAP = {
|
||||||
|
win32: { language: 'ps1', template: PowerShellAdbDumpCommandTemplate },
|
||||||
|
linux: { language: 'sh', template: ShellAdbDumpCommandTemplate },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdbInstructionTemplate({ dir, file, platform }: AdbInstructionTemplateProps) {
|
||||||
|
const { language, template } = LANGUAGE_MAP[platform];
|
||||||
|
const command = applyTemplate(template, { dir, file });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal pl-4">
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
确保 <code>adb</code> 命令可用。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{platform === 'win32' && (
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
💡 如果没有,可以
|
||||||
|
<ExtLink href="https://scoop.sh/#/apps?q=adb">使用 Scoop 安装</ExtLink>。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>启动终端,进入 PowerShell 环境。</li>
|
||||||
|
<li>
|
||||||
|
在手机<ExtLink href={URL_USB_DEBUGGING}>启用 USB 调试</ExtLink>
|
||||||
|
</li>
|
||||||
|
<li>将安卓设备连接到电脑。</li>
|
||||||
|
<li>
|
||||||
|
<p>粘贴执行下述代码执行。若设备提示「是否允许 USB 调试」或「超级用户请求」,选择允许:</p>
|
||||||
|
<CodeHighlight language={language}>{command}</CodeHighlight>
|
||||||
|
<br />※ 安卓模拟器可能需要额外操作,如
|
||||||
|
<ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw">
|
||||||
|
网易 MuMu 模拟器
|
||||||
|
</ExtLink>
|
||||||
|
需要提前使用 <code>adb connect ...</code> 指令连接模拟器。详细请参考官方说明文档并调整上述脚本。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
提交当前目录下的 <code>{file}</code> 文件。
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,171 +1,73 @@
|
|||||||
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';
|
import { ExtLink } from '../ExtLink';
|
||||||
|
import { Ruby } from '../Ruby';
|
||||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
import { useId } from 'react';
|
||||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
import { RootExplorerGuide } from './RootExplorerGuide';
|
||||||
};
|
import { AdbInstructionTemplate } from './AdbInstructionTemplate';
|
||||||
|
import { HiWord } from '../HelpText/HiWord';
|
||||||
|
|
||||||
export interface AndroidADBPullInstructionProps {
|
export interface AndroidADBPullInstructionProps {
|
||||||
dir: string;
|
dir: string;
|
||||||
file: string;
|
file: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL_AMAZE = 'https://github.com/TeamAmaze/AmazeFileManager/releases/latest';
|
||||||
|
const URL_MT2 = 'https://mt2.cn/download/';
|
||||||
|
|
||||||
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
||||||
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file });
|
const androidInstructionId = useId();
|
||||||
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<p>
|
||||||
你需要
|
你需要<Ruby caption="root">超级管理员</Ruby>访问权限来访问安卓应用的私有数据。
|
||||||
<ruby>
|
</p>
|
||||||
超级管理员
|
<p>
|
||||||
<rp> (</rp>
|
|
||||||
<rt>
|
|
||||||
<code>root</code>
|
|
||||||
</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
访问权限来访问安卓应用的私有数据。
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
⚠️ 请注意,获取管理员权限通常意味着你的安卓设备
|
⚠️ 请注意,获取管理员权限通常意味着你的安卓设备
|
||||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
<HiWord>将失去保修资格</HiWord>。
|
||||||
</Text>
|
</p>
|
||||||
|
|
||||||
<Accordion allowToggle mt="2">
|
<div className="join join-vertical bg-base-100 mt-2 max-w-full">
|
||||||
<AccordionItem>
|
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||||
<Heading as="h3" size="md">
|
<input type="radio" name={androidInstructionId} />
|
||||||
<AccordionButton>
|
<div className="collapse-title font-semibold">在安卓手机端操作</div>
|
||||||
<Box as="span" flex="1" textAlign="left">
|
<div className="collapse-content text-sm min-w-0">
|
||||||
在安卓手机端操作
|
<ol className="list-decimal pl-4">
|
||||||
</Box>
|
<li>
|
||||||
<AccordionIcon />
|
启动支持 <code>root</code> 特权的文件浏览器,如 <ExtLink href={URL_AMAZE}>Amaze 文件浏览器</ExtLink>、
|
||||||
</AccordionButton>
|
<ExtLink href={URL_MT2}>MT 管理器</ExtLink> 等。
|
||||||
</Heading>
|
</li>
|
||||||
<AccordionPanel pb={4}>
|
<li>
|
||||||
<OrderedList>
|
※ 记得启用 root 特权!
|
||||||
<ListItem>
|
<RootExplorerGuide />
|
||||||
<Text>
|
</li>
|
||||||
启动具有 <Code>root</Code> 特权的文件浏览器
|
<li>
|
||||||
</Text>
|
<p>
|
||||||
</ListItem>
|
访问 <code>{dir}/</code> 目录。
|
||||||
<ListItem>
|
</p>
|
||||||
<Text>
|
<p>※ 从侧边栏选择根目录开始。</p>
|
||||||
访问 <Code>{dir}/</Code> 目录。
|
</li>
|
||||||
</Text>
|
<li>
|
||||||
</ListItem>
|
将文件 <code>{file}</code> 复制到浏览器可访问的目录(例如下载目录)。
|
||||||
<ListItem>
|
</li>
|
||||||
<Text>
|
<li>提交该数据库文件。</li>
|
||||||
将文件 <Code>{file}</Code> 复制到浏览器可访问的目录。
|
</ol>
|
||||||
<br />
|
</div>
|
||||||
(例如下载目录)
|
</div>
|
||||||
</Text>
|
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||||
</ListItem>
|
<input type="radio" name={androidInstructionId} />
|
||||||
<ListItem>
|
<div className="collapse-title font-semibold">在 PC 端操作(使用 ADB / PowerShell)</div>
|
||||||
<Text>提交该数据库文件。</Text>
|
<div className="collapse-content text-sm min-w-0">
|
||||||
</ListItem>
|
<AdbInstructionTemplate dir={dir} file={file} platform="win32" />
|
||||||
</OrderedList>
|
</div>
|
||||||
</AccordionPanel>
|
</div>
|
||||||
</AccordionItem>
|
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||||
|
<input type="radio" name={androidInstructionId} />
|
||||||
<AccordionItem>
|
<div className="collapse-title font-semibold">在 Linux / Mac 系统下操作(使用 ADB / Shell)</div>
|
||||||
<Heading as="h3" size="md">
|
<div className="collapse-content text-sm min-w-0">
|
||||||
<AccordionButton>
|
<AdbInstructionTemplate dir={dir} file={file} platform="linux" />
|
||||||
<Box as="span" flex="1" textAlign="left">
|
</div>
|
||||||
在 PC 端操作(ADB / PowerShell)
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { FiMenu, FiMoreVertical } from 'react-icons/fi';
|
||||||
|
import { Header5 } from '../HelpText/Headers';
|
||||||
|
import { Ruby } from '../Ruby';
|
||||||
|
import { VQuote } from '../HelpText/VQuote';
|
||||||
|
|
||||||
|
export function RootExplorerGuide() {
|
||||||
|
return (
|
||||||
|
<div className="@container inline-flex flex-col items-start w-full pl-4">
|
||||||
|
<div className="flex flex-col items-start gap-4 @md:flex-row">
|
||||||
|
<div>
|
||||||
|
<Header5 className="[&]:mt-0 [&]:pt-0">Amaze 文件浏览器</Header5>
|
||||||
|
<ul className="ml-2 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
点触主界面左上角的 <FiMenu /> 打开侧边栏
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
滑动到最底端,点触
|
||||||
|
<VQuote>
|
||||||
|
<Ruby caption="Settings">设置</Ruby>
|
||||||
|
</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
点触
|
||||||
|
<VQuote>
|
||||||
|
<Ruby caption="Behaviour">行为</Ruby>
|
||||||
|
</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
找到
|
||||||
|
<VQuote>
|
||||||
|
<Ruby caption="Advanced">高级</Ruby>
|
||||||
|
</VQuote>
|
||||||
|
,勾选
|
||||||
|
<VQuote>
|
||||||
|
<Ruby caption="Root Explorer">根目录浏览器</Ruby>
|
||||||
|
</VQuote>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Header5 className="[&]:mt-0 [&]:pt-0">MT 管理器</Header5>
|
||||||
|
<ul className="ml-2 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
点触主界面左上角的 <FiMenu /> 打开侧边栏
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
点触侧边栏右上方的 <FiMoreVertical className="ml-1" />
|
||||||
|
,点触<VQuote>设置</VQuote>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
勾选<VQuote>请求 Root 权限</VQuote>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,57 +1,78 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, NavLink, Route, Routes } from 'react-router';
|
||||||
import { MdSettings, MdHome, MdQuestionAnswer } from 'react-icons/md';
|
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 { MainTab } from '~/tabs/MainTab';
|
||||||
import { SettingsTab } from '~/tabs/SettingsTab';
|
import { SettingsTab } from '~/tabs/SettingsTab';
|
||||||
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { theme } from '~/theme';
|
|
||||||
import { persistSettings } from '~/features/settings/persistSettings';
|
import { persistSettings } from '~/features/settings/persistSettings';
|
||||||
import { setupStore } from '~/store';
|
import { setupStore } from '~/store';
|
||||||
import { Footer } from '~/components/Footer';
|
import { Footer } from '~/components/Footer';
|
||||||
import { FaqTab } from '~/tabs/FaqTab';
|
import { FaqTab } from '~/tabs/FaqTab';
|
||||||
|
import { SETTINGS_TABS } from '~/features/settings/settingsTabs';
|
||||||
|
import { Bounce, ToastContainer } from 'react-toastify';
|
||||||
|
import { SettingsHome } from '~/features/settings/SettingsHome';
|
||||||
|
import { FAQ_PAGES } from '~/faq/FAQPages';
|
||||||
|
import { FaqHome } from '~/faq/FaqHome';
|
||||||
|
|
||||||
// Private to this file only.
|
// Private to this file only.
|
||||||
const store = setupStore();
|
const store = setupStore();
|
||||||
|
|
||||||
|
const tabClassNames = ({ isActive }: { isActive: boolean }) => `mb-[-2px] tab ${isActive ? 'tab-active' : ''}`;
|
||||||
|
|
||||||
export function AppRoot() {
|
export function AppRoot() {
|
||||||
useEffect(() => persistSettings(store), []);
|
useEffect(() => persistSettings(store), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChakraProvider theme={theme}>
|
<BrowserRouter>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Tabs flex={1} minH={0} display="flex" flexDir="column">
|
<div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content">
|
||||||
<TabList justifyContent="center">
|
<NavLink to="/" role="tab" className={tabClassNames}>
|
||||||
<Tab>
|
<MdHome />
|
||||||
<Icon as={MdHome} mr="1" />
|
应用
|
||||||
<chakra.span>应用</chakra.span>
|
</NavLink>
|
||||||
</Tab>
|
<NavLink to="/settings" role="tab" className={tabClassNames}>
|
||||||
<Tab>
|
<MdSettings />
|
||||||
<Icon as={MdSettings} mr="1" />
|
设置
|
||||||
<chakra.span>设置</chakra.span>
|
</NavLink>
|
||||||
</Tab>
|
<NavLink to="/questions" role="tab" className={tabClassNames}>
|
||||||
<Tab>
|
<MdQuestionAnswer />
|
||||||
<Icon as={MdQuestionAnswer} mr="1" />
|
答疑
|
||||||
<chakra.span>答疑</chakra.span>
|
</NavLink>
|
||||||
</Tab>
|
</div>
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex">
|
<main className="flex-1 flex justify-center min-h-0 overflow-auto">
|
||||||
<TabPanel>
|
<Routes>
|
||||||
<MainTab />
|
<Route path="/" Component={MainTab} />
|
||||||
</TabPanel>
|
<Route path="/settings" Component={SettingsTab}>
|
||||||
<TabPanel flex={1} display="flex">
|
<Route index Component={SettingsHome} />
|
||||||
<SettingsTab />
|
{Object.entries(SETTINGS_TABS).map(([key, { Tab }]) => (
|
||||||
</TabPanel>
|
<Route key={key} path={key} Component={Tab} />
|
||||||
<TabPanel>
|
))}
|
||||||
<FaqTab />
|
</Route>
|
||||||
</TabPanel>
|
<Route path="/questions" Component={FaqTab}>
|
||||||
</TabPanels>
|
<Route index Component={FaqHome} />
|
||||||
</Tabs>
|
{FAQ_PAGES.map(({ id, Component }) => (
|
||||||
|
<Route key={id} path={id} Component={Component} />
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ToastContainer
|
||||||
|
position="bottom-center"
|
||||||
|
autoClose={5000}
|
||||||
|
newestOnTop
|
||||||
|
closeOnClick={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
theme="colored"
|
||||||
|
transition={Bounce}
|
||||||
|
/>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Provider>
|
</Provider>
|
||||||
</ChakraProvider>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/components/CodeHighlight.tsx
Normal file
10
src/components/CodeHighlight.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Light as SyntaxHighlighter, type SyntaxHighlighterProps } from 'react-syntax-highlighter';
|
||||||
|
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
||||||
|
|
||||||
|
export function CodeHighlight({ children, ...props }: SyntaxHighlighterProps) {
|
||||||
|
return (
|
||||||
|
<SyntaxHighlighter style={hljsStyleGitHub} {...props}>
|
||||||
|
{children}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/Dialog.tsx
Normal file
40
src/components/Dialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
closeButton?: boolean;
|
||||||
|
backdropClose?: boolean;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ closeButton, backdropClose, title, children, show, onClose }: DialogProps) {
|
||||||
|
const refModel = useRef<HTMLDialogElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
refModel.current?.showModal();
|
||||||
|
} else {
|
||||||
|
refModel.current?.close();
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog ref={refModel} className="modal">
|
||||||
|
<div className="modal-box">
|
||||||
|
{closeButton && (
|
||||||
|
<form method="dialog" onSubmit={onClose}>
|
||||||
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<h3 className="font-bold text-lg pb-3">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{backdropClose && (
|
||||||
|
<form method="dialog" className="modal-backdrop" onSubmit={onClose}>
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/DownloadAll.tsx
Normal file
84
src/components/DownloadAll.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-listing/fileListingSlice';
|
||||||
|
import { FaDownload } from 'react-icons/fa';
|
||||||
|
import { useAppSelector } from '~/hooks';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export function DownloadAll() {
|
||||||
|
const files = useAppSelector(selectFiles);
|
||||||
|
const onClickDownloadAll = async () => {
|
||||||
|
console.time('DownloadAll'); //开始计时
|
||||||
|
const fileCount = Object.keys(files).length;
|
||||||
|
if (fileCount === 0) {
|
||||||
|
toast.warning('未添加文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//判断所有文件是否处理完成
|
||||||
|
const allComplete = Object.values(files).every((file) => file.state !== ProcessState.PROCESSING);
|
||||||
|
if (!allComplete) {
|
||||||
|
toast.warning('请等待所有文件解密完成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//过滤处理失败的文件
|
||||||
|
const completeFiles = Object.values(files).filter((file) => file.state === ProcessState.COMPLETE);
|
||||||
|
|
||||||
|
//开始下载
|
||||||
|
let dir: FileSystemDirectoryHandle | undefined;
|
||||||
|
try {
|
||||||
|
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.warning('开始下载,请稍候');
|
||||||
|
|
||||||
|
const promises = Object.values(completeFiles).map(async (file) => {
|
||||||
|
console.log(`开始下载: ${file.fileName}`);
|
||||||
|
try {
|
||||||
|
if (dir) {
|
||||||
|
await DownloadNew(dir, file);
|
||||||
|
} else {
|
||||||
|
await DownloadOld(file);
|
||||||
|
}
|
||||||
|
console.log(`成功下载: ${file.fileName}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`下载失败: ${file.fileName}`, e);
|
||||||
|
toast.error(`出现错误: ${e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.allSettled(promises).then((f) => {
|
||||||
|
const success = f.filter((result) => result.status === 'fulfilled').length;
|
||||||
|
if (success === fileCount) {
|
||||||
|
toast.success(`成功下载: ${success}/${fileCount}首`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`成功下载: ${success}/${fileCount}首`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.timeEnd('DownloadAll'); //停止计时
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="btn btn-primary" id="downloadAll" onClick={onClickDownloadAll} title="下载全部">
|
||||||
|
<FaDownload />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) {
|
||||||
|
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function DownloadOld(file: DecryptedAudioFile) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = file.decrypted;
|
||||||
|
a.download = file.cleanName + '.' + file.ext;
|
||||||
|
document.body.append(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { AnchorHTMLAttributes } from 'react';
|
import type { AnchorHTMLAttributes } from 'react';
|
||||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
import { FiExternalLink } from 'react-icons/fi';
|
||||||
import { Link } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
export function ExtLink({ children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
icon?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
|
||||||
return (
|
return (
|
||||||
<Link isExternal {...props} rel="noreferrer noopener nofollow">
|
<a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}>
|
||||||
{children}
|
{children}
|
||||||
<ExternalLinkIcon />
|
{icon && <FiExternalLink className="inline size-sm ml-1" />}
|
||||||
</Link>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { Box } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
export interface FileInputProps {
|
export interface FileInputProps {
|
||||||
onReceiveFiles: (files: File[]) => void;
|
onReceiveFiles: (files: File[]) => void;
|
||||||
@@ -14,30 +14,19 @@ export function FileInput({ children, onReceiveFiles }: FileInputProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
w="100%"
|
className={classnames(
|
||||||
maxW={480}
|
'w-full max-w-xl border rounded-lg transition duration-500 p-6 border-base-300 mx-auto',
|
||||||
borderWidth="1px"
|
'cursor-pointer flex flex-col items-center bg-base-200 hover:border-gray-400 hover:bg-gray-50 hover:dark:bg-gray-800',
|
||||||
borderRadius="lg"
|
{
|
||||||
transitionDuration="0.5s"
|
'bg-blue-50 dark:bg-blue-900 border-blue-700': isDragActive,
|
||||||
p="6"
|
},
|
||||||
cursor="pointer"
|
)}
|
||||||
display="flex"
|
tabIndex={0}
|
||||||
flexDir="column"
|
|
||||||
alignItems="center"
|
|
||||||
_hover={{
|
|
||||||
borderColor: 'gray.400',
|
|
||||||
bg: 'gray.50',
|
|
||||||
}}
|
|
||||||
{...(isDragActive && {
|
|
||||||
bg: 'blue.50',
|
|
||||||
borderColor: 'blue.700',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Code, Text } from '@chakra-ui/react';
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function FilePathBlock({ children }: { children: React.ReactNode }) {
|
export function FilePathBlock({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-all">
|
<pre className="whitespace-pre-wrap break-all">
|
||||||
<Code>{children}</Code>
|
<code>{children}</code>
|
||||||
</Text>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,32 @@
|
|||||||
import { Center, Flex, Link, Text } from '@chakra-ui/react';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import { SDKVersion } from './SDKVersion';
|
import { SDKVersion } from './SDKVersion';
|
||||||
import { CurrentYear } from './CurrentYear';
|
import { CurrentYear } from './CurrentYear';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const appVersionShort = '__APP_VERSION_SHORT__';
|
||||||
return (
|
return (
|
||||||
<Center
|
<footer className="flex flex-col text-center p-4 bg-base-200">
|
||||||
fontSize="sm"
|
<p className="flex flex-row justify-center items-center h-[1em]">
|
||||||
textAlign="center"
|
<a className="link link-info mr-1" href="https://git.um-react.app/um/um-react">
|
||||||
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>
|
</a>
|
||||||
{' (__APP_VERSION_SHORT__'}
|
(
|
||||||
<Suspense>
|
<a
|
||||||
<SDKVersion />
|
title="使用 MIT 授权协议"
|
||||||
</Suspense>
|
className="link link-info"
|
||||||
{') - 移除已购音乐的加密保护。'}
|
href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE"
|
||||||
</Flex>
|
>
|
||||||
<Text>
|
MIT
|
||||||
|
</a>
|
||||||
|
, v{appVersionShort}
|
||||||
|
<SDKVersion />)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
{'© 2019 - '}
|
{'© 2019 - '}
|
||||||
<CurrentYear />{' '}
|
<CurrentYear />
|
||||||
<Link href="https://git.unlock-music.dev/um" isExternal>
|
<a className="ml-1 link link-info" href="https://git.um-react.app/um">
|
||||||
UnlockMusic 团队
|
Unlock Music
|
||||||
</Link>
|
</a>
|
||||||
{' | '}
|
</p>
|
||||||
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
|
</footer>
|
||||||
使用 MIT 授权协议
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/components/HelpText/HeaderAnchor.tsx
Normal file
9
src/components/HelpText/HeaderAnchor.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { RiLink } from 'react-icons/ri';
|
||||||
|
|
||||||
|
export function HeaderAnchor({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<a href={`#${id}`} data-anchor={id} className="absolute -left-6 opacity-10 transition-opacity duration-200">
|
||||||
|
<RiLink className="max-h-[.75em]" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Heading } from '@chakra-ui/react';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { HeaderAnchor } from './HeaderAnchor';
|
||||||
|
|
||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -7,36 +7,39 @@ export interface HeaderProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commonHeaderClasses = 'relative flex items-center pt-3 pb-1 font-bold';
|
||||||
|
|
||||||
|
export function Header2({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h2 id={id} className={`${commonHeaderClasses} text-3xl border-b border-base-300 ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
export function Header3({ children, className, id }: HeaderProps) {
|
export function Header3({ children, className, id }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Heading
|
<h3 id={id} className={`${commonHeaderClasses} text-2xl border-b border-base-300 ${className}`}>
|
||||||
as="h3"
|
{id && <HeaderAnchor id={id} />}
|
||||||
id={id}
|
|
||||||
className={className}
|
|
||||||
pt={3}
|
|
||||||
pb={1}
|
|
||||||
borderBottom={'1px solid'}
|
|
||||||
borderColor="gray.300"
|
|
||||||
color="gray.800"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Heading>
|
</h3>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header4({ children, className, id }: HeaderProps) {
|
export function Header4({ children, className, id }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
<h4 id={id} className={`${commonHeaderClasses} text-xl ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
{children}
|
{children}
|
||||||
</Heading>
|
</h4>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header5({ children, className, id }: HeaderProps) {
|
export function Header5({ children, className, id }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Heading as="h5" id={id} className={className} pt={3} pb={1} color="gray.700" size="sm">
|
<h5 id={id} className={`${commonHeaderClasses} text-lg ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
{children}
|
{children}
|
||||||
</Heading>
|
</h5>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { Mark } from '@chakra-ui/react';
|
export function HiWord({ className = '', children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
return <mark className={`bg-orange-100 rounded-md px-2 mx-1 ${className}`}>{children}</mark>;
|
||||||
export function HiWord({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Mark bg="orange.100" borderRadius={5} px={2} mx={1}>
|
|
||||||
{children}
|
|
||||||
</Mark>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { chakra, css } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
const cssUnselectable = css({ pointerEvents: 'none', userSelect: 'none' });
|
|
||||||
|
|
||||||
export function VQuote({ children }: { children: React.ReactNode }) {
|
export function VQuote({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<chakra.span css={cssUnselectable}>「</chakra.span>
|
<span className="select-none">「</span>
|
||||||
{children}
|
{children}
|
||||||
<chakra.span css={cssUnselectable}>」</chakra.span>
|
<span className="select-none">」</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/components/ImageFigure.tsx
Normal file
43
src/components/ImageFigure.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
export interface ImageFigureProps {
|
||||||
|
srcSet: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
loading?: 'lazy' | 'eager';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
export function ImageFigure({ alt, srcSet, children, className, loading }: ImageFigureProps) {
|
||||||
|
const refDialog = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className={classNames(className, 'inline-flex flex-col items-center')}>
|
||||||
|
<img
|
||||||
|
className={`rounded-md cursor-pointer border border-base-300 max-h-48`}
|
||||||
|
loading={loading}
|
||||||
|
srcSet={srcSet}
|
||||||
|
alt={alt}
|
||||||
|
onClick={() => refDialog?.current?.showModal()}
|
||||||
|
/>
|
||||||
|
{children && <figcaption className="text-sm text-base-content/70">{children}</figcaption>}
|
||||||
|
|
||||||
|
<dialog ref={refDialog} className="modal text-left">
|
||||||
|
<div className="modal-box max-w-[50vw]">
|
||||||
|
<form method="dialog">
|
||||||
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<h3 className="font-bold text-lg">查看图片</h3>
|
||||||
|
|
||||||
|
<figure className="flex flex-col justify-center text-center">
|
||||||
|
<img srcSet={srcSet} alt={alt} />
|
||||||
|
{children && <figcaption className="text-sm text-base-content/70">{children}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,4 @@
|
|||||||
import {
|
import { useEffect, useRef } from 'react';
|
||||||
Center,
|
|
||||||
Flex,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { FileInput } from '~/components/FileInput';
|
import { FileInput } from '~/components/FileInput';
|
||||||
|
|
||||||
@@ -25,32 +14,36 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
|
|||||||
const handleFileReceived = (files: File[]) => {
|
const handleFileReceived = (files: File[]) => {
|
||||||
const promise = onImport(files[0]);
|
const promise = onImport(files[0]);
|
||||||
if (promise instanceof Promise) {
|
if (promise instanceof Promise) {
|
||||||
promise.catch(err => {
|
promise.catch((err) => {
|
||||||
console.error('could not import: ', err);
|
console.error('could not import: ', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return promise;
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const refModel = useRef<HTMLDialogElement>(null);
|
||||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
useEffect(() => {
|
||||||
<ModalOverlay />
|
if (show) {
|
||||||
<ModalContent>
|
refModel.current?.showModal();
|
||||||
<ModalHeader>从文件导入密钥</ModalHeader>
|
} else {
|
||||||
<ModalCloseButton />
|
refModel.current?.close();
|
||||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
}
|
||||||
<Center>
|
}, [show]);
|
||||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Text as="div" mt={2}>
|
return (
|
||||||
选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:
|
<dialog ref={refModel} className="modal">
|
||||||
</Text>
|
<div className="modal-box">
|
||||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
<form method="dialog" onSubmit={() => onClose()}>
|
||||||
{children}
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
</Flex>
|
</form>
|
||||||
</Flex>
|
<h3 className="font-bold text-lg">从文件导入密钥</h3>
|
||||||
</ModalContent>
|
<div className="py-4 flex flex-col gap-2 flex-1">
|
||||||
</Modal>
|
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||||
|
|
||||||
|
<div className="mt-2">选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/components/InfoModal.tsx
Normal file
25
src/components/InfoModal.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Dialog } from '~/components/Dialog.tsx';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface InfoModalProps {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoModal(props: InfoModalProps) {
|
||||||
|
const { title, description, children } = props;
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button className="btn btn-info btn-sm" type="button" onClick={() => setShowModal(true)}>
|
||||||
|
{children || '这是什么?'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog closeButton backdropClose show={showModal} onClose={() => setShowModal(false)} title={title}>
|
||||||
|
{description}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/InstructionsTabs.tsx
Normal file
39
src/components/InstructionsTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { Fragment, useId } from 'react';
|
||||||
|
|
||||||
|
export type InstructionTab = {
|
||||||
|
id: string | number;
|
||||||
|
label: React.ReactNode;
|
||||||
|
content: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InstructionsTabsProps {
|
||||||
|
tabs: InstructionTab[];
|
||||||
|
limitHeight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstructionsTabs({ limitHeight = false, tabs }: InstructionsTabsProps) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<div className={classNames('tabs tabs-lift pb-4 mt-2', { 'max-h-[32rem]': limitHeight })}>
|
||||||
|
{tabs.map(({ id: _tabId, label, content }, index) => (
|
||||||
|
<Fragment key={_tabId}>
|
||||||
|
<label className="tab dark:[--tab-border-color:#555]">
|
||||||
|
<input type="radio" name={id} defaultChecked={index === 0} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'tab-content border-base-300 dark:border-[#555] bg-base-100 px-4 py-2 overflow-y-auto',
|
||||||
|
{
|
||||||
|
'max-h-[30rem]': limitHeight,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { Icon, Kbd } from '@chakra-ui/react';
|
|
||||||
import { BsCommand } from 'react-icons/bs';
|
import { BsCommand } from 'react-icons/bs';
|
||||||
|
import { Ruby } from '../Ruby';
|
||||||
|
|
||||||
export function MacCommandKey() {
|
export function MacCommandKey({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<ruby>
|
<Ruby caption="command" className={className}>
|
||||||
<Kbd>
|
<kbd className="kbd">
|
||||||
<Icon as={BsCommand} />
|
<BsCommand className="text-sm" />
|
||||||
</Kbd>
|
</kbd>
|
||||||
<rp> (</rp>
|
</Ruby>
|
||||||
<rt>command</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { Icon, Kbd } from '@chakra-ui/react';
|
|
||||||
import { BsShift } from 'react-icons/bs';
|
import { BsShift } from 'react-icons/bs';
|
||||||
|
import { Ruby } from '../Ruby';
|
||||||
|
|
||||||
export function ShiftKey() {
|
export function ShiftKey({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<ruby>
|
<Ruby caption="shift" className={className}>
|
||||||
<Kbd>
|
<kbd className="kbd">
|
||||||
<Icon as={BsShift} />
|
<BsShift className="text-sm" />
|
||||||
</Kbd>
|
</kbd>
|
||||||
<rp> (</rp>
|
</Ruby>
|
||||||
<rt>shift</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/components/KeyInput.tsx
Normal file
112
src/components/KeyInput.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { PiFileAudio } from 'react-icons/pi';
|
||||||
|
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface KeyInputProps {
|
||||||
|
sequence: number;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
isValidKey?: boolean;
|
||||||
|
onSetName: (name: string) => void;
|
||||||
|
onSetValue: (value: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
|
||||||
|
quality?: string;
|
||||||
|
onSetQuality?: (quality: string) => void;
|
||||||
|
|
||||||
|
nameLabel?: ReactNode;
|
||||||
|
valueLabel?: ReactNode;
|
||||||
|
qualityLabel?: ReactNode;
|
||||||
|
|
||||||
|
namePlaceholder?: string;
|
||||||
|
valuePlaceholder?: string;
|
||||||
|
qualityPlaceholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyInput(props: KeyInputProps) {
|
||||||
|
const {
|
||||||
|
nameLabel,
|
||||||
|
valueLabel,
|
||||||
|
qualityLabel,
|
||||||
|
namePlaceholder,
|
||||||
|
qualityPlaceholder,
|
||||||
|
valuePlaceholder,
|
||||||
|
sequence,
|
||||||
|
name,
|
||||||
|
quality,
|
||||||
|
value,
|
||||||
|
onSetName,
|
||||||
|
onSetValue,
|
||||||
|
onDelete,
|
||||||
|
onSetQuality,
|
||||||
|
isValidKey,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="list-row items-center">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 text-sm font-bold text-gray-500 bg-gray-200 rounded-full">
|
||||||
|
{sequence}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="join join-vertical flex-1">
|
||||||
|
<div className="flex">
|
||||||
|
<label className="input w-full rounded-tl-md last:rounded-tr-md">
|
||||||
|
<span className="cucursor-default inline-flex items-center gap-1 select-none">
|
||||||
|
{nameLabel || (
|
||||||
|
<>
|
||||||
|
文件 <PiFileAudio />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="font-mono"
|
||||||
|
placeholder={namePlaceholder}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onSetName(e.target.value)}
|
||||||
|
data-name="key-input--name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{onSetQuality && (
|
||||||
|
<label className="input min-w-0 max-w-[10rem] ml-[-1px] rounded-tr-md">
|
||||||
|
<span className="cucursor-default inline-flex items-center gap-1 select-none">
|
||||||
|
{qualityLabel || '音质'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="font-mono"
|
||||||
|
placeholder={qualityPlaceholder}
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => onSetQuality(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="input w-full rounded-bl-md rounded-br-md mt-[-1px]">
|
||||||
|
<span className="cursor-default inline-flex items-center gap-1 select-none">
|
||||||
|
{valueLabel || (
|
||||||
|
<>
|
||||||
|
密钥 <MdVpnKey />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="font-mono"
|
||||||
|
placeholder={valuePlaceholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onSetValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className={isValidKey ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
<code>{value.length || '?'}</code>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-error btn-sm px-1 btn-outline" onClick={onDelete}>
|
||||||
|
<MdDelete className="size-6" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/KeyListContainer.tsx
Normal file
21
src/components/KeyListContainer.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode, RefObject } from 'react';
|
||||||
|
|
||||||
|
export interface KeyListContainerProps {
|
||||||
|
keys: unknown[];
|
||||||
|
children?: ReactNode;
|
||||||
|
ref?: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyListContainer({ keys, children, ref }: KeyListContainerProps) {
|
||||||
|
const count = keys.length;
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="flex grow min-h-0 pr-4 pt-3">
|
||||||
|
{count > 0 && (
|
||||||
|
<ul className="list bg-base-100 rounded-box shadow-sm border border-base-300 w-full min-h-0 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{count === 0 && <p>还没有添加密钥。</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link } from '@chakra-ui/react';
|
import { ExtLink } from './ExtLink';
|
||||||
|
|
||||||
export interface ProjectIssueProps {
|
export interface ProjectIssueProps {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -7,9 +7,9 @@ export interface ProjectIssueProps {
|
|||||||
|
|
||||||
export function ProjectIssue({ id, title }: ProjectIssueProps) {
|
export function ProjectIssue({ id, title }: ProjectIssueProps) {
|
||||||
return (
|
return (
|
||||||
<Link isExternal target="_blank" href={`https://git.unlock-music.dev/um/um-react/issues/${id}`}>
|
<ExtLink target="_blank" href={`https://git.um-react.app/um/um-react/issues/${id}`}>
|
||||||
{`#${id}`}
|
{`#${id}`}
|
||||||
{title && ` - ${title}`}
|
{title && ` - ${title}`}
|
||||||
</Link>
|
</ExtLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/components/Ruby.tsx
Normal file
20
src/components/Ruby.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface RubyProps {
|
||||||
|
caption: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ruby(props: RubyProps) {
|
||||||
|
const { caption, children, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ruby {...rest}>
|
||||||
|
{children}
|
||||||
|
<rp>(</rp>
|
||||||
|
<rt>{caption}</rt>
|
||||||
|
<rp>)</rp>
|
||||||
|
</ruby>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,41 @@
|
|||||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
import { MdInfoOutline } from 'react-icons/md';
|
||||||
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
|
|
||||||
import { workerClientBus } from '~/decrypt-worker/client';
|
import { workerClientBus } from '~/decrypt-worker/client';
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import usePromise from 'react-promise-suspense';
|
|
||||||
|
|
||||||
const getSDKVersion = async (): Promise<string> => {
|
const getSDKVersion = async (): Promise<string> => {
|
||||||
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
|
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SDKVersion() {
|
export function SDKVersion() {
|
||||||
const sdkVersion = usePromise(getSDKVersion, []);
|
const refDialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [sdkVersion, setSdkVersion] = useState('...');
|
||||||
|
useEffect(() => {
|
||||||
|
getSDKVersion().then(setSdkVersion);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex as="span" pl="1" alignItems="center" data-testid="sdk-version">
|
<>
|
||||||
<Tooltip
|
<span className="btn btn-ghost inline-flex p-0" onClick={() => refDialog.current?.showModal()}>
|
||||||
hasArrow
|
<MdInfoOutline />
|
||||||
placement="top"
|
</span>
|
||||||
label={
|
|
||||||
<VStack>
|
<dialog ref={refDialog} className="modal text-left">
|
||||||
<Text>App: __APP_VERSION__</Text>
|
<div className="modal-box">
|
||||||
<Text>SDK: {sdkVersion}</Text>
|
<form method="dialog">
|
||||||
</VStack>
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
}
|
</form>
|
||||||
bg="gray.300"
|
<h3 className="font-bold text-lg">详细信息</h3>
|
||||||
color="black"
|
|
||||||
>
|
<p>App: __APP_VERSION__</p>
|
||||||
<InfoOutlineIcon />
|
<p>
|
||||||
</Tooltip>
|
SDK: <span data-testid="sdk-version">{sdkVersion}</span>
|
||||||
</Flex>
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button>关闭</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Box, Text } from '@chakra-ui/react';
|
import { FiUnlock } from 'react-icons/fi';
|
||||||
import { UnlockIcon } from '@chakra-ui/icons';
|
|
||||||
|
|
||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
|
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
|
||||||
@@ -12,7 +11,7 @@ export function SelectFile() {
|
|||||||
console.debug(
|
console.debug(
|
||||||
'react-dropzone/onDropAccepted(%o, %o)',
|
'react-dropzone/onDropAccepted(%o, %o)',
|
||||||
files.length,
|
files.length,
|
||||||
files.map((x) => x.name)
|
files.map((x) => x.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -26,7 +25,7 @@ export function SelectFile() {
|
|||||||
id: fileId,
|
id: fileId,
|
||||||
blobURI,
|
blobURI,
|
||||||
fileName,
|
fileName,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
dispatch(processFile({ fileId }));
|
dispatch(processFile({ fileId }));
|
||||||
}
|
}
|
||||||
@@ -34,19 +33,13 @@ export function SelectFile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
||||||
<Box pb={3}>
|
<FiUnlock className="size-8 mb-4" />
|
||||||
<UnlockIcon boxSize={8} />
|
<p className="text-center">
|
||||||
</Box>
|
|
||||||
<Text as="div" textAlign="center">
|
|
||||||
拖放或
|
拖放或
|
||||||
<Text as="span" color="teal.400">
|
<span className="text-teal-700 font-semibold">点我选择</span>
|
||||||
点我选择
|
|
||||||
</Text>
|
|
||||||
需要解密的文件
|
需要解密的文件
|
||||||
<Text fontSize="sm" opacity="50%">
|
</p>
|
||||||
在浏览器内对文件进行解锁,零上传
|
<p className="text-sm opacity-50 m-0">在浏览器内对文件进行解锁,零上传</p>
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</FileInput>
|
</FileInput>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface DecipherNotOK {
|
|||||||
export interface DecipherOK {
|
export interface DecipherOK {
|
||||||
status: Status.OK;
|
status: Status.OK;
|
||||||
message?: string;
|
message?: string;
|
||||||
data: Uint8Array;
|
data: Uint8Array<ArrayBuffer>;
|
||||||
overrideExtension?: string;
|
overrideExtension?: string;
|
||||||
cipherName: string;
|
cipherName: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-
|
|||||||
export class TransparentDecipher implements DecipherInstance {
|
export class TransparentDecipher implements DecipherInstance {
|
||||||
cipherName = 'none';
|
cipherName = 'none';
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> {
|
||||||
return {
|
return {
|
||||||
cipherName: 'None',
|
cipherName: 'None',
|
||||||
status: Status.OK,
|
status: Status.OK,
|
||||||
|
|||||||
@@ -24,3 +24,17 @@ export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
|
|||||||
detectResult.free();
|
detectResult.free();
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AudioMimeType: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
wma: 'audio/x-ms-wma',
|
||||||
|
wav: 'audio/x-wav',
|
||||||
|
dff: 'audio/x-dff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMimeTypeFromExt(ext: string) {
|
||||||
|
return AudioMimeType[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
export const toArrayBuffer = async (src: Blob | BlobPart) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||||
src instanceof Blob ? await src.arrayBuffer() : src;
|
export const toBlob = (src: Blob | BlobPart, mimeType?: string) =>
|
||||||
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
src instanceof Blob ? src : new Blob([src], { type: mimeType ?? 'application/octet-stream' });
|
||||||
src instanceof Blob ? src : new Blob([src]);
|
|
||||||
|
|
||||||
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
||||||
const len = buffer.byteLength;
|
const len = buffer.byteLength;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deci
|
|||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||||
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
||||||
import { go } from '~/util/go.ts';
|
import { go } from '~/util/go.ts';
|
||||||
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
|
import { getMimeTypeFromExt, detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
|
||||||
|
|
||||||
class DecryptCommandHandler {
|
class DecryptCommandHandler {
|
||||||
private readonly label: string;
|
private readonly label: string;
|
||||||
@@ -31,6 +31,7 @@ class DecryptCommandHandler {
|
|||||||
const [result, error] = await go(this.tryDecryptWith(decipher));
|
const [result, error] = await go(this.tryDecryptWith(decipher));
|
||||||
if (!error) {
|
if (!error) {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
console.debug(`[${decipher.cipherName}] Decryption OK`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
errors.push(`${decipher.cipherName}: no response`);
|
errors.push(`${decipher.cipherName}: no response`);
|
||||||
@@ -75,7 +76,7 @@ class DecryptCommandHandler {
|
|||||||
audioExt = 'm4a';
|
audioExt = 'm4a';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt };
|
return { decrypted: URL.createObjectURL(toBlob(result.data, getMimeTypeFromExt(audioExt))), ext: audioExt };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
src/faq/AndroidEmulatorFAQ.tsx
Normal file
75
src/faq/AndroidEmulatorFAQ.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
|
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
|
||||||
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
|
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||||
|
|
||||||
|
import LdPlayerSettingsMisc2x from './assets/ld_settings_misc@2x.webp';
|
||||||
|
import MumuSettingsMisc2x from './assets/mumu_settings_misc@2x.webp';
|
||||||
|
import { ImageFigure } from '~/components/ImageFigure';
|
||||||
|
|
||||||
|
export function AndroidEmulatorFAQ() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header2>安卓模拟器</Header2>
|
||||||
|
<p className="mb-2">目前市面上主流的可以很方便 root 的安卓模拟器有两个:</p>
|
||||||
|
|
||||||
|
<ul className="list-disc pl-6 mb-2">
|
||||||
|
<li>
|
||||||
|
<ExtLink href="https://mumu.163.com/">网易 MuMu 模拟器(安卓 12)</ExtLink> - Hyper-V 兼容较好
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ExtLink href="https://www.ldmnq.com/">雷电模拟器(安卓 9)</ExtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mb-2">上述两款模拟器均包含广告,使用时请注意。</p>
|
||||||
|
|
||||||
|
<div className="my-2 alert alert-warning">
|
||||||
|
<RiErrorWarningLine className="text-lg" />
|
||||||
|
<p>
|
||||||
|
根据应用的风控策略,使用模拟器登录的账号<strong>有可能会导致账号被封锁</strong>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2">读者在使用前请自行评估风险。</p>
|
||||||
|
|
||||||
|
<Header3 id="enable-root">启用 root</Header3>
|
||||||
|
<p className="mb-2">上述的两款模拟器都有提供比较直接的启用 root 的方法。</p>
|
||||||
|
|
||||||
|
<Header4 id="root-mumu">网易 MuMu 模拟器</Header4>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
<li>
|
||||||
|
打开<VQuote>设置中心</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
选择<VQuote>其他</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
勾选<VQuote>开启手机Root权限</VQuote>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<ImageFigure className="ml-2" alt="网易木木模拟器设置界面" loading="lazy" srcSet={`${MumuSettingsMisc2x} 2x`}>
|
||||||
|
网易木木模拟器设置界面
|
||||||
|
</ImageFigure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Header4 id="root-ld">雷电模拟器</Header4>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
<li>
|
||||||
|
打开<VQuote>模拟器设置</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
选择<VQuote>其他</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
设置<VQuote>ROOT 权限</VQuote>为<VQuote>开启</VQuote>状态
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<ImageFigure className="ml-2" alt="雷电模拟器设置界面" loading="lazy" srcSet={`${LdPlayerSettingsMisc2x} 2x`}>
|
||||||
|
雷电模拟器设置界面
|
||||||
|
</ImageFigure>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/faq/FAQAbout.tsx
Normal file
45
src/faq/FAQAbout.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Header2, Header3 } from '~/components/HelpText/Headers';
|
||||||
|
import { FaRust } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export function FAQAboutProject() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Header2>关于项目</Header2>
|
||||||
|
|
||||||
|
<Header3 id="failed">um-react 是什么</Header3>
|
||||||
|
<p>
|
||||||
|
um-react 是由
|
||||||
|
<a className="mx-1 link link-info" href="https://git.um-react.app/um">
|
||||||
|
Unlock Music
|
||||||
|
</a>
|
||||||
|
基于 React 框架制作的一款用于移除已购音乐的加密保护的小工具,使用
|
||||||
|
<a className="mx-1 link link-info" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE">
|
||||||
|
MIT
|
||||||
|
</a>
|
||||||
|
授权协议。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
它的解密核心由 <FaRust className="inline" />
|
||||||
|
<a className="mx-1 link link-info" href="https://git.um-react.app/um/lib_um_crypto_rust">
|
||||||
|
<code>lib_um_crypto_rust</code>
|
||||||
|
</a>
|
||||||
|
驱动,使用
|
||||||
|
<a
|
||||||
|
className="mx-1 link link-info"
|
||||||
|
href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT"
|
||||||
|
>
|
||||||
|
MIT
|
||||||
|
</a>
|
||||||
|
+
|
||||||
|
<a
|
||||||
|
className="mx-1 link link-info"
|
||||||
|
href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE"
|
||||||
|
>
|
||||||
|
Apache
|
||||||
|
</a>
|
||||||
|
双协议。
|
||||||
|
</p>
|
||||||
|
<p>这意味着你可以自由地使用、修改和分发这个软件,但请注意遵守相应的授权协议。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/faq/FAQPages.tsx
Normal file
22
src/faq/FAQPages.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { QQMusicFAQ } from './QQMusicFAQ';
|
||||||
|
import { KuwoFAQ } from './KuwoFAQ';
|
||||||
|
import { KugouFAQ } from './KugouFAQ';
|
||||||
|
import { OtherFAQ } from './OtherFAQ';
|
||||||
|
import { AndroidEmulatorFAQ } from './AndroidEmulatorFAQ';
|
||||||
|
import { FAQAboutProject } from './FAQAbout';
|
||||||
|
|
||||||
|
export type FAQEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
Component: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FAQ_PAGES: FAQEntry[] = [
|
||||||
|
{ id: 'qqmusic', name: 'QQ 音乐', Component: QQMusicFAQ },
|
||||||
|
{ id: 'kuwo', name: '酷我音乐', Component: KuwoFAQ },
|
||||||
|
{ id: 'kugou', name: '酷狗音乐', Component: KugouFAQ },
|
||||||
|
{ id: 'android-emu', name: '安卓模拟器', Component: AndroidEmulatorFAQ },
|
||||||
|
{ id: 'other', name: '其它问题', Component: OtherFAQ },
|
||||||
|
{ id: 'about', name: '关于项目', Component: FAQAboutProject },
|
||||||
|
];
|
||||||
15
src/faq/FaqHome.tsx
Normal file
15
src/faq/FaqHome.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
|
import { Header2 } from '~/components/HelpText/Headers';
|
||||||
|
|
||||||
|
export function FaqHome() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Header2>答疑</Header2>
|
||||||
|
<p>从目录选择一项来查看相关说明。</p>
|
||||||
|
<p>
|
||||||
|
也欢迎造访
|
||||||
|
<ExtLink href={'https://t.me/unlock_music_chat'}>“音乐解锁-交流” 交流群</ExtLink> 进行交流。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,25 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
import { Header2, Header3 } from '~/components/HelpText/Headers';
|
||||||
import { Header4 } from '~/components/HelpText/Headers';
|
|
||||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||||
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||||
|
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||||
|
|
||||||
export function KugouFAQ() {
|
export function KugouFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解锁失败</Header4>
|
<Header2>酷狗音乐</Header2>
|
||||||
<List spacing={2}>
|
<Header3 id="failed">解锁失败</Header3>
|
||||||
<ListItem>
|
<p className="mb-2">
|
||||||
<Text>
|
|
||||||
酷狗现在对部分用户推送了 <code>kgg</code> 加密格式(安卓、Windows 客户端)。
|
酷狗现在对部分用户推送了 <code>kgg</code> 加密格式(安卓、Windows 客户端)。
|
||||||
</Text>
|
</p>
|
||||||
<Text>根据平台不同,你需要提取密钥数据库。</Text>
|
<p className="mb-2">根据平台不同,你需要提取密钥数据库。</p>
|
||||||
|
|
||||||
<Container p={2}>
|
<div className="alert alert-warning mb-2">
|
||||||
<Alert status="warning" borderRadius={5}>
|
<RiErrorWarningLine className="size-6" />
|
||||||
<AlertIcon />
|
<p>安卓用户提取密钥需要 root 权限,或注入文件提供器。</p>
|
||||||
<Flex flexDir="column">
|
</div>
|
||||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
|
||||||
</Flex>
|
|
||||||
</Alert>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
|
<Header3 id="keys">导入密钥</Header3>
|
||||||
<SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} />
|
<SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} />
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
import { Header2, Header3 } from '~/components/HelpText/Headers';
|
||||||
import { Header4 } from '~/components/HelpText/Headers';
|
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { HiWord } from '~/components/HelpText/HiWord';
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
import { KWMv2AllInstructions } from '~/features/settings/panels/KWMv2/KWMv2AllInstructions';
|
import { KWMv2AllInstructions } from '~/features/settings/panels/KWMv2/KWMv2AllInstructions';
|
||||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||||
|
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||||
|
|
||||||
export function KuwoFAQ() {
|
export function KuwoFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解锁失败</Header4>
|
<Header2>酷我音乐</Header2>
|
||||||
<List spacing={2}>
|
<Header3 id="failed">解锁失败</Header3>
|
||||||
<ListItem>
|
|
||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
</ListItem>
|
<p className="mb-2">
|
||||||
<ListItem>
|
|
||||||
<Text>
|
|
||||||
日前,仅<HiWord>手机客户端</HiWord>下载的
|
日前,仅<HiWord>手机客户端</HiWord>下载的
|
||||||
<VQuote>
|
<VQuote>
|
||||||
<strong>至臻全景声</strong>
|
<strong>至臻全景声</strong>
|
||||||
@@ -24,29 +21,27 @@ export function KuwoFAQ() {
|
|||||||
<VQuote>
|
<VQuote>
|
||||||
<strong>至臻母带</strong>
|
<strong>至臻母带</strong>
|
||||||
</VQuote>
|
</VQuote>
|
||||||
{'音质的音乐文件采用新版加密。'}
|
音质的音乐文件采用新版加密。
|
||||||
</Text>
|
</p>
|
||||||
<Text>其他音质目前不需要提取密钥。</Text>
|
<p className="mb-2">其他音质目前不需要提取密钥。</p>
|
||||||
<Text>PC平台暂未推出使用新版加密的音质。</Text>
|
<p className="mb-2">PC平台暂未推出使用新版加密的音质。</p>
|
||||||
|
|
||||||
<Container p={2}>
|
<Header3 id="keys">导入密钥</Header3>
|
||||||
<Alert status="warning" borderRadius={5}>
|
<div className="alert alert-warning my-2">
|
||||||
<AlertIcon />
|
<RiErrorWarningLine className="text-2xl" />
|
||||||
<Flex flexDir="column">
|
<div>
|
||||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
<p>安卓用户提取密钥需要 root 权限,或注入文件提供器。</p>
|
||||||
<Text>
|
<p>
|
||||||
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法提取密钥。
|
<strong className="pr-2">注意</strong>已知部分第三方修改版会破坏密钥写入功能,导致无法提取密钥。
|
||||||
</Text>
|
</p>
|
||||||
<Text>
|
<p>
|
||||||
<strong>注意</strong>:项目组不提倡使用、也不提供第三方修改版。使用前请自行评估风险。
|
<strong className="pr-2">注意</strong>
|
||||||
</Text>
|
项目组不提倡使用、也不提供第三方修改版。使用前请自行评估风险。请开通会员支持正版音乐。
|
||||||
</Flex>
|
</p>
|
||||||
</Alert>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<SegmentKeyImportInstructions tab="KWMv2 密钥" clientInstructions={<KWMv2AllInstructions />} />
|
<SegmentKeyImportInstructions tab="KWMv2 密钥" clientInstructions={<KWMv2AllInstructions />} />
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +1,99 @@
|
|||||||
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
|
||||||
import { ExtLink } from '~/components/ExtLink';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { Header4 } from '~/components/HelpText/Headers';
|
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
|
||||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
import { NavLink } from 'react-router';
|
||||||
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
|
||||||
|
|
||||||
export function OtherFAQ() {
|
export function OtherFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解密后没有封面等信息</Header4>
|
<Header2>其它问题</Header2>
|
||||||
<Text>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</Text>
|
<Header3 id="metadata">解密后没有封面等信息</Header3>
|
||||||
<Text>请使用第三方工具进行编辑或管理元信息。</Text>
|
<p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p>
|
||||||
|
<p>请使用第三方工具进行编辑或管理元信息。</p>
|
||||||
|
|
||||||
<Header4>批量下载</Header4>
|
<Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
|
||||||
<Text>
|
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
|
||||||
{'暂时没有实现,不过你可以在 '}
|
<div className="flex flex-col md:flex-row gap-2 md:gap-8">
|
||||||
<ProjectIssue id={34} title="[UI] 全部下载功能" />
|
<div>
|
||||||
{' 以及 '}
|
<Header4>已知有问题的浏览器</Header4>
|
||||||
<ProjectIssue id={43} title="批量下载" />
|
<ul className="list-disc list-inside pl-2">
|
||||||
{' 追踪该问题。'}
|
<li>Via 浏览器</li>
|
||||||
</Text>
|
<li>夸克浏览器</li>
|
||||||
|
<li>UC 浏览器</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Header4>安卓: 浏览器支持说明</Header4>
|
<div>
|
||||||
<Text>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</Text>
|
<Header4>可能会遇到的问题包括</Header4>
|
||||||
<Text>已知有问题的浏览器:</Text>
|
<ul className="list-disc list-inside pl-2">
|
||||||
<UnorderedList>
|
<li>网页白屏</li>
|
||||||
<ListItem>Via 浏览器</ListItem>
|
<li>无法下载解密后内容</li>
|
||||||
<ListItem>夸克浏览器</ListItem>
|
<li>下载的文件名错误</li>
|
||||||
<ListItem>UC 浏览器</ListItem>
|
</ul>
|
||||||
</UnorderedList>
|
</div>
|
||||||
<Text>可能会遇到的问题包括:</Text>
|
</div>
|
||||||
<UnorderedList>
|
|
||||||
<ListItem>网页白屏</ListItem>
|
|
||||||
<ListItem>无法下载解密后内容</ListItem>
|
|
||||||
<ListItem>下载的文件名错误</ListItem>
|
|
||||||
</UnorderedList>
|
|
||||||
|
|
||||||
<Header4>安卓: root 相关说明</Header4>
|
<Header3 id="android-root">安卓 root</Header3>
|
||||||
<Text>
|
<p>
|
||||||
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
||||||
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
|
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付等限制。
|
||||||
</Text>
|
</p>
|
||||||
<Text>如果希望不破坏系统完整性,你可以考虑使用模拟器。</Text>
|
<p className="my-2">
|
||||||
<Text>
|
如果希望不破坏系统完整性,你可以考虑在电脑上使用
|
||||||
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11
|
<NavLink className="link link-info" to="/questions/android-emu">
|
||||||
开始支援的
|
安卓模拟器
|
||||||
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
|
</NavLink>
|
||||||
<ruby>
|
|
||||||
适用于 Android™ 的 Windows 子系统 (WSA)
|
|
||||||
<rp> (</rp>
|
|
||||||
<rt>
|
|
||||||
<code>Windows Subsystem for Android</code>
|
|
||||||
</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
</ExtLink>
|
|
||||||
。
|
。
|
||||||
</Text>
|
</p>
|
||||||
|
|
||||||
<Container p={2}>
|
<Header3 id="projects">相关项目</Header3>
|
||||||
<Alert status="warning" borderRadius={5}>
|
<ul className="list-disc pl-6">
|
||||||
<AlertIcon />
|
<li>
|
||||||
<Flex flexDir="column">
|
<p>
|
||||||
<Text>
|
<ExtLink className="mr-2" href="https://github.com/CarlGao4/um-react-electron">
|
||||||
<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>
|
<strong>
|
||||||
<Code>um-react-electron</Code>
|
<code>um-react-electron</code>
|
||||||
</strong>
|
</strong>
|
||||||
</ExtLink>
|
</ExtLink>
|
||||||
:利用 Electron 框架打包的本地版,提供适用于 Windows、Linux 和 Mac 平台的可执行文件。
|
利用 Electron 框架打包的本地版,提供适用于 Windows、Linux 和 Mac 平台的可执行文件。
|
||||||
</Text>
|
</p>
|
||||||
<UnorderedList>
|
<ul className="list-disc pl-6">
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
<p>
|
||||||
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub 下载</ExtLink>
|
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub 下载</ExtLink>
|
||||||
</Text>
|
</p>
|
||||||
</ListItem>
|
</li>
|
||||||
</UnorderedList>
|
</ul>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
<p>
|
||||||
<ExtLink href="https://git.unlock-music.dev/um/um-react-wry">
|
<ExtLink className="mr-2" href="https://git.um-react.app/um/um-react-wry">
|
||||||
<strong>
|
<strong>
|
||||||
<Code>um-react-wry</Code>
|
<code>um-react-wry</code>
|
||||||
</strong>
|
</strong>
|
||||||
</ExtLink>
|
</ExtLink>
|
||||||
: 使用 WRY 框架封装的 Win64 单文件(需要
|
使用 WRY 框架封装的 Win64 单文件(需要
|
||||||
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703">安装 Edge WebView2 运行时</ExtLink>
|
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703">安装 Edge WebView2 运行时</ExtLink>
|
||||||
{',Win10+ 操作系统自带)'}
|
{',Win10+ 操作系统自带)'}
|
||||||
</Text>
|
</p>
|
||||||
<UnorderedList>
|
<ul className="list-disc pl-6">
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
<p>
|
||||||
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest">仓库下载</ExtLink>
|
<ExtLink href="https://git.um-react.app/um/um-react/releases/latest">仓库下载</ExtLink>
|
||||||
{' | 寻找文件名为 '}
|
{' | 寻找文件名为 '}
|
||||||
<Code>um-react-win64-</Code> 开头的附件
|
<code>um-react-win64-</code> 开头的附件
|
||||||
</Text>
|
</p>
|
||||||
</ListItem>
|
</li>
|
||||||
</UnorderedList>
|
</ul>
|
||||||
</ListItem>
|
</li>
|
||||||
</UnorderedList>
|
</ul>
|
||||||
|
|
||||||
<Header4>有更多问题?</Header4>
|
<Header3 id="more-questions">有更多问题?</Header3>
|
||||||
<Text>
|
<p className="flex flex-row gap-1">
|
||||||
{'欢迎进入 '}
|
欢迎加入
|
||||||
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram “音乐解锁-交流” 交流群</ExtLink>
|
<ExtLink href={'https://t.me/unlock_music_chat'}>“音乐解锁-交流” 交流群</ExtLink>
|
||||||
{' 一起探讨。'}
|
一起讨论。
|
||||||
</Text>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +1,25 @@
|
|||||||
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react';
|
import { Header2, Header3 } from '~/components/HelpText/Headers';
|
||||||
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
|
||||||
import { Header4 } from '~/components/HelpText/Headers';
|
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
|
||||||
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() {
|
export function QQMusicFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解锁失败</Header4>
|
<Header2>QQ 音乐</Header2>
|
||||||
|
<Header3 id="failed">解锁失败</Header3>
|
||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
<Text>重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。</Text>
|
<p className="mb-2">新版本的 QQ 音乐客户端下载的文件通常都需要导入密钥数据库。</p>
|
||||||
<Text>
|
<p className="mb-2">每一个资源(即一首歌的某个音质)都有独立的密钥,下载音乐时会被写出到密钥数据库中。</p>
|
||||||
|
<p className="mb-2">因此若是解密失败,很有可能是因为你需要导入密钥,或降级客户端。</p>
|
||||||
|
|
||||||
|
<Header3 id="about-download">关于下载</Header3>
|
||||||
|
<p>重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。</p>
|
||||||
|
<p className="my-2">
|
||||||
部分平台获取的加密文件未包含密钥。选择你<strong>下载文件时</strong>使用的客户端来查看说明。
|
部分平台获取的加密文件未包含密钥。选择你<strong>下载文件时</strong>使用的客户端来查看说明。
|
||||||
</Text>
|
</p>
|
||||||
<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>
|
<Header3 id="keys-or-downgrade">导入密钥或降级客户端</Header3>
|
||||||
<h2>
|
<QMCv2QQMusicAllInstructions />
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import { Flex, IconButton } from '@chakra-ui/react';
|
import { MdFileUpload } from 'react-icons/md';
|
||||||
import { MdExpandMore } from 'react-icons/md';
|
|
||||||
import { HiWord } from '~/components/HelpText/HiWord';
|
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
|
||||||
|
|
||||||
export function SegmentAddKeyDropdown() {
|
export function SegmentAddKeyDropdown() {
|
||||||
return (
|
return (
|
||||||
<Flex as="span" alignItems="center" flexWrap="wrap">
|
<span className="inline-flex items-center flex-wrap">
|
||||||
按下<VQuote>添加一条密钥</VQuote>按钮
|
按下
|
||||||
<HiWord>右侧</HiWord>的
|
<button type="button" className="btn flex items-center gap-2">
|
||||||
<IconButton
|
<MdFileUpload className="text-lg" />
|
||||||
colorScheme="purple"
|
导入数据库…
|
||||||
variant="outline"
|
</button>
|
||||||
size="sm"
|
</span>
|
||||||
icon={<MdExpandMore />}
|
|
||||||
ml="2"
|
|
||||||
borderTopLeftRadius={0}
|
|
||||||
borderBottomLeftRadius={0}
|
|
||||||
pointerEvents="none"
|
|
||||||
aria-label="下拉按钮"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Flex, Icon, ListItem, OrderedList, Tabs, Text } from '@chakra-ui/react';
|
|
||||||
import { SegmentTopNavSettings } from './SegmentTopNavSettings';
|
import { SegmentTopNavSettings } from './SegmentTopNavSettings';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { SegmentAddKeyDropdown } from './SegmentAddKeyDropdown';
|
import { SegmentAddKeyDropdown } from './SegmentAddKeyDropdown';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MdFileUpload } from 'react-icons/md';
|
|
||||||
|
|
||||||
export interface SegmentKeyImportInstructionsProps {
|
export interface SegmentKeyImportInstructionsProps {
|
||||||
clientInstructions: React.ReactNode;
|
clientInstructions: React.ReactNode;
|
||||||
@@ -18,32 +16,22 @@ export function SegmentKeyImportInstructions({
|
|||||||
}: SegmentKeyImportInstructionsProps) {
|
}: SegmentKeyImportInstructionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>导入密钥可以参考下面的步骤:</Text>
|
<p className="mt-2">导入密钥可以参考下面的步骤:</p>
|
||||||
<OrderedList>
|
<ol className="list-decimal pl-5">
|
||||||
<ListItem>
|
<li>
|
||||||
<SegmentTopNavSettings />
|
<SegmentTopNavSettings />
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
设定区域选择<VQuote>{tab}</VQuote>
|
设定区域选择<VQuote>{tab}</VQuote>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<SegmentAddKeyDropdown />
|
<SegmentAddKeyDropdown />
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Flex flexDir="row" alignItems="center">
|
<p className="mb-2">{keyInstructionText}</p>
|
||||||
{'选择 '}
|
|
||||||
<VQuote>
|
|
||||||
<Icon as={MdFileUpload} boxSize={5} mr={2} /> 从文件导入密钥…
|
|
||||||
</VQuote>
|
|
||||||
</Flex>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<Text>{keyInstructionText}</Text>
|
|
||||||
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
|
|
||||||
{clientInstructions}
|
{clientInstructions}
|
||||||
</Tabs>
|
</li>
|
||||||
</ListItem>
|
</ol>
|
||||||
</OrderedList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Alert, AlertIcon, Container } from '@chakra-ui/react';
|
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||||
|
|
||||||
export function SegmentTryOfficialPlayer() {
|
export function SegmentTryOfficialPlayer({ className = '' }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<Container p={2} my={2} pt={0}>
|
<div className={`alert alert-warning my-2 ${className}`}>
|
||||||
<Alert status="info" borderRadius={5}>
|
<RiErrorWarningLine className="text-2xl" />
|
||||||
<AlertIcon />
|
<p>尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。</p>
|
||||||
尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。
|
</div>
|
||||||
</Alert>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB |
BIN
src/faq/assets/ld_settings_misc@2x.webp
Normal file
BIN
src/faq/assets/ld_settings_misc@2x.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
src/faq/assets/mumu_settings_misc@2x.webp
Normal file
BIN
src/faq/assets/mumu_settings_misc@2x.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,24 +0,0 @@
|
|||||||
import { Box, Image } from '@chakra-ui/react';
|
|
||||||
import noCoverFallbackImageURL from '~/assets/no-cover.svg';
|
|
||||||
|
|
||||||
interface AlbumImageProps {
|
|
||||||
url?: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlbumImage({ name, url }: AlbumImageProps) {
|
|
||||||
const coverAlternativeText = name ? `${name} 的专辑封面` : '专辑封面';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box w="160px" h="160px" m="auto">
|
|
||||||
<Image
|
|
||||||
border="2px solid"
|
|
||||||
borderColor="gray.400"
|
|
||||||
borderRadius="50%"
|
|
||||||
objectFit="cover"
|
|
||||||
src={url || noCoverFallbackImageURL}
|
|
||||||
alt={coverAlternativeText}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, chakra, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
import { toast } from 'react-toastify';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
import { applyTemplate } from '~/util/applyTemplate';
|
||||||
|
|
||||||
export interface FileErrorProps {
|
export interface FileErrorProps {
|
||||||
error: null | string;
|
error: null | string;
|
||||||
@@ -7,33 +8,55 @@ export interface FileErrorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMap = new Map<string | null | DecryptErrorType, string>([
|
const errorMap = new Map<string | null | DecryptErrorType, string>([
|
||||||
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'],
|
[DecryptErrorType.UNSUPPORTED_FILE, '不支持的文件类型'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const ERROR_TEMPLATE = `解密错误:{{summary}}
|
||||||
|
|
||||||
|
详细错误信息:
|
||||||
|
\`\`\`text
|
||||||
|
{{error}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
<!-- 报告错误时请提交上述【全部内容】 -->
|
||||||
|
`;
|
||||||
|
|
||||||
export function FileError({ error, code }: FileErrorProps) {
|
export function FileError({ error, code }: FileErrorProps) {
|
||||||
const { isOpen, onToggle } = useDisclosure();
|
const summary = errorMap.get(code) ?? '未知错误';
|
||||||
const errorSummary = errorMap.get(code) ?? '未知错误';
|
|
||||||
|
const copyError = () => {
|
||||||
|
if (error) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error }))
|
||||||
|
.then(() => {
|
||||||
|
toast.success('错误信息已复制到剪贴板');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error(`复制错误信息失败: ${e}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<>
|
||||||
<Text>
|
<p>
|
||||||
<chakra.span>
|
|
||||||
解密错误:
|
解密错误:
|
||||||
<chakra.span color="red.700">{errorSummary}</chakra.span>
|
<span className="text-red-600">{summary}</span>
|
||||||
</chakra.span>
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<Button ml="2" onClick={onToggle} type="button">
|
<div className="collapse border-error border w-full text-left my-2 py-0">
|
||||||
诊断信息
|
<input className="[&&&]:py-2 [&&&]:min-h-[1.5rem]" type="checkbox" />
|
||||||
</Button>
|
<div className="collapse-title font-semibold text-center [&&&]:min-h-[1.5rem] [&&&]:py-2">详细错误信息</div>
|
||||||
|
<div className="collapse-content text-sm overflow-hidden">
|
||||||
|
<pre className="overflow-x-auto w-full">{error}</pre>
|
||||||
|
<p className="mt-2 text-center">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={copyError}>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</>
|
||||||
{error && (
|
|
||||||
<Collapse in={isOpen} animateOpacity>
|
|
||||||
<Box as="pre" display="inline-block" mt="2" px="4" py="2" bg="red.100" color="red.800" rounded="md">
|
|
||||||
{error}
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { VStack } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { selectFiles } from './fileListingSlice';
|
import { selectFiles } from './fileListingSlice';
|
||||||
import { useAppSelector } from '~/hooks';
|
import { useAppSelector } from '~/hooks';
|
||||||
import { FileRow } from './FileRow';
|
import { FileRow } from './FileRow';
|
||||||
@@ -8,10 +6,10 @@ export function FileListing() {
|
|||||||
const files = useAppSelector(selectFiles);
|
const files = useAppSelector(selectFiles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack>
|
<div className="flex flex-row flex-wrap gap-8">
|
||||||
{Object.entries(files).map(([id, file]) => (
|
{Object.entries(files).map(([id, file]) => (
|
||||||
<FileRow key={id} id={id} file={file} />
|
<FileRow key={id} id={id} file={file} />
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Collapse,
|
|
||||||
GridItem,
|
|
||||||
Link,
|
|
||||||
VStack,
|
|
||||||
Wrap,
|
|
||||||
WrapItem,
|
|
||||||
useDisclosure,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { FileRowResponsiveGrid } from './FileRowResponsiveGrid';
|
|
||||||
import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice';
|
import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice';
|
||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { AnimationDefinition } from 'framer-motion';
|
|
||||||
import { AlbumImage } from './AlbumImage';
|
|
||||||
import { SongMetadata } from './SongMetadata';
|
|
||||||
import { FileError } from './FileError';
|
import { FileError } from './FileError';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface FileRowProps {
|
interface FileRowProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,90 +9,46 @@ interface FileRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileRow({ id, file }: FileRowProps) {
|
export function FileRow({ id, file }: FileRowProps) {
|
||||||
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isDecrypted = file.state === ProcessState.COMPLETE;
|
const isDecrypted = file.state === ProcessState.COMPLETE;
|
||||||
const metadata = file.metadata;
|
|
||||||
|
|
||||||
const nameWithoutExt = file.fileName.replace(/\.[a-z\d]{3,6}$/, '');
|
const decryptedName = file.cleanName + '.' + file.ext;
|
||||||
const decryptedName = nameWithoutExt + '.' + file.ext;
|
|
||||||
|
|
||||||
const audioPlayerRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const togglePlay = () => {
|
|
||||||
const player = audioPlayerRef.current;
|
|
||||||
if (!player) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.paused) {
|
|
||||||
player.play();
|
|
||||||
} else {
|
|
||||||
player.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCollapseAnimationComplete = (definition: AnimationDefinition) => {
|
|
||||||
if (definition === 'exit') {
|
|
||||||
dispatch(deleteFile({ id }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<div className="card bg-base-100 dark:bg-gray-700 shadow-sm w-full md:w-[30%] " data-testid="file-row">
|
||||||
in={isOpen}
|
<div className="card-body items-center text-center px-2">
|
||||||
animateOpacity
|
<h2 className="card-title max-w-full whitespace-nowrap flex gap-0" data-testid="audio-meta-song-name">
|
||||||
unmountOnExit
|
<span className="grow overflow-hidden text-ellipsis" title={decryptedName}>
|
||||||
startingHeight={0}
|
{file.cleanName}
|
||||||
onAnimationComplete={onCollapseAnimationComplete}
|
</span>
|
||||||
style={{ width: '100%', padding: '0.25rem' }}
|
{isDecrypted && file.ext && <div className="ml-2 badge badge-accent">{file.ext}</div>}
|
||||||
>
|
</h2>
|
||||||
<Card w="full" data-testid="file-row">
|
|
||||||
<CardBody>
|
|
||||||
<FileRowResponsiveGrid>
|
|
||||||
<GridItem area="cover">
|
|
||||||
<AlbumImage name={metadata?.album} url={metadata?.cover} />
|
|
||||||
</GridItem>
|
|
||||||
<GridItem area="title">
|
|
||||||
<Box w="full" as="h4" fontWeight="semibold" mt="1" textAlign={{ base: 'center', md: 'left' }}>
|
|
||||||
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
|
|
||||||
</Box>
|
|
||||||
</GridItem>
|
|
||||||
<GridItem area="meta">
|
|
||||||
{isDecrypted && metadata && <SongMetadata metadata={metadata} />}
|
|
||||||
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
|
|
||||||
</GridItem>
|
|
||||||
<GridItem area="action" alignSelf="center">
|
|
||||||
<VStack>
|
|
||||||
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}
|
|
||||||
|
|
||||||
<Wrap>
|
<div className="w-full grow">
|
||||||
|
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
|
||||||
{isDecrypted && (
|
{isDecrypted && (
|
||||||
<>
|
<audio className="w-full" aria-disabled={!file.decrypted} controls autoPlay={false} src={file.decrypted} />
|
||||||
<WrapItem>
|
|
||||||
<Button type="button" onClick={togglePlay}>
|
|
||||||
播放/暂停
|
|
||||||
</Button>
|
|
||||||
</WrapItem>
|
|
||||||
<WrapItem>
|
|
||||||
{file.decrypted && (
|
|
||||||
<Link href={file.decrypted} download={decryptedName}>
|
|
||||||
<Button as="span">下载</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</WrapItem>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
<div className="card-actions justify-end">
|
||||||
<WrapItem>
|
<a
|
||||||
<Button type="button" onClick={onClose}>
|
href={file.decrypted}
|
||||||
|
download={decryptedName}
|
||||||
|
title={`下载: ${decryptedName}`}
|
||||||
|
className={classNames('btn', {
|
||||||
|
'btn-primary': file.decrypted,
|
||||||
|
'cursor-not-allowed pointer-events-none': !file.decrypted,
|
||||||
|
})}
|
||||||
|
data-testid="audio-download"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
<button type="button" className="btn btn-error" onClick={() => dispatch(deleteFile({ id }))}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</button>
|
||||||
</WrapItem>
|
</div>
|
||||||
</Wrap>
|
</div>
|
||||||
</VStack>
|
</div>
|
||||||
</GridItem>
|
|
||||||
</FileRowResponsiveGrid>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</Collapse>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Grid, chakra } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
export const FileRowResponsiveGrid = chakra(Grid, {
|
|
||||||
baseStyle: {
|
|
||||||
gridTemplateAreas: {
|
|
||||||
base: `
|
|
||||||
"cover"
|
|
||||||
"title"
|
|
||||||
"meta"
|
|
||||||
"action"
|
|
||||||
`,
|
|
||||||
md: `
|
|
||||||
"cover title action"
|
|
||||||
"cover meta action"
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
gridTemplateRows: {
|
|
||||||
base: 'repeat(auto-fill)',
|
|
||||||
md: 'min-content 1fr',
|
|
||||||
},
|
|
||||||
gridTemplateColumns: {
|
|
||||||
base: '1fr',
|
|
||||||
md: '160px 1fr',
|
|
||||||
},
|
|
||||||
gap: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Box, Text } from '@chakra-ui/react';
|
|
||||||
import type { AudioMetadata } from './fileListingSlice';
|
|
||||||
|
|
||||||
export interface SongMetadataProps {
|
|
||||||
metadata: AudioMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SongMetadata({ metadata }: SongMetadataProps) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text>
|
|
||||||
专辑: <span data-testid="audio-meta-album-name">{metadata.album}</span>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
艺术家: <span data-testid="audio-meta-song-artist">{metadata.artist}</span>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
专辑艺术家: <span data-testid="audio-meta-album-artist">{metadata.albumArtist}</span>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,5 +14,5 @@ test('should be able to render a list of 3 items', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getAllByTestId('file-row')).toHaveLength(3);
|
expect(screen.getAllByTestId('file-row')).toHaveLength(3);
|
||||||
expect(screen.getByText('Für Alice')).toBeInTheDocument();
|
expect(screen.getByText('ready')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,22 +3,15 @@ import { untouchedFile } from './__fixture__/file-list';
|
|||||||
import { FileRow } from '../FileRow';
|
import { FileRow } from '../FileRow';
|
||||||
import { completedFile } from './__fixture__/file-list';
|
import { completedFile } from './__fixture__/file-list';
|
||||||
|
|
||||||
test('should render no metadata when unavailable', () => {
|
test('should render basic title (ready)', () => {
|
||||||
renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />);
|
renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />);
|
||||||
|
|
||||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready');
|
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready');
|
||||||
expect(screen.queryByTestId('audio-meta-album-name')).toBeFalsy();
|
|
||||||
expect(screen.queryByTestId('audio-meta-song-artist')).toBeFalsy();
|
|
||||||
expect(screen.queryByTestId('audio-meta-album-artist')).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render metadata when file has been processed', () => {
|
test('should render basic title (done)', () => {
|
||||||
renderWithProviders(<FileRow id="file://done" file={completedFile} />);
|
renderWithProviders(<FileRow id="file://done" file={completedFile} />);
|
||||||
|
|
||||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('Für Alice');
|
|
||||||
expect(screen.getByTestId('audio-meta-album-name')).toHaveTextContent("NOW That's What I Call Cryptography 2023");
|
|
||||||
expect(screen.getByTestId('audio-meta-song-artist')).toHaveTextContent('Jixun');
|
|
||||||
expect(screen.getByTestId('audio-meta-album-artist')).toHaveTextContent('Cipher Lovers');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice';
|
import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice';
|
||||||
|
|
||||||
export const untouchedFile: DecryptedAudioFile = {
|
export const untouchedFile: DecryptedAudioFile = {
|
||||||
|
cleanName: 'ready',
|
||||||
fileName: 'ready.bin',
|
fileName: 'ready.bin',
|
||||||
raw: 'blob://localhost/file-a',
|
raw: 'blob://localhost/file-a',
|
||||||
decrypted: '',
|
decrypted: '',
|
||||||
@@ -13,6 +14,7 @@ export const untouchedFile: DecryptedAudioFile = {
|
|||||||
|
|
||||||
export const completedFile: DecryptedAudioFile = {
|
export const completedFile: DecryptedAudioFile = {
|
||||||
fileName: 'hello-b.bin',
|
fileName: 'hello-b.bin',
|
||||||
|
cleanName: 'hello-b',
|
||||||
raw: 'blob://localhost/file-b',
|
raw: 'blob://localhost/file-b',
|
||||||
decrypted: 'blob://localhost/file-b-decrypted',
|
decrypted: 'blob://localhost/file-b-decrypted',
|
||||||
ext: 'flac',
|
ext: 'flac',
|
||||||
@@ -30,6 +32,7 @@ export const completedFile: DecryptedAudioFile = {
|
|||||||
|
|
||||||
export const fileWithError: DecryptedAudioFile = {
|
export const fileWithError: DecryptedAudioFile = {
|
||||||
fileName: 'hello-c.bin',
|
fileName: 'hello-c.bin',
|
||||||
|
cleanName: 'hello-c',
|
||||||
raw: 'blob://localhost/file-c',
|
raw: 'blob://localhost/file-c',
|
||||||
decrypted: 'blob://localhost/file-c-decrypted',
|
decrypted: 'blob://localhost/file-c-decrypted',
|
||||||
ext: 'flac',
|
ext: 'flac',
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { RootState } from '~/store';
|
|||||||
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
import type {
|
import type {
|
||||||
DecryptCommandOptions,
|
DecryptCommandOptions,
|
||||||
FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
FetchMusicExNamePayload,
|
||||||
|
ParseKugouHeaderPayload,
|
||||||
|
ParseKugouHeaderResponse,
|
||||||
ParseKuwoHeaderPayload,
|
ParseKuwoHeaderPayload,
|
||||||
ParseKuwoHeaderResponse
|
ParseKuwoHeaderResponse,
|
||||||
} from '~/decrypt-worker/types';
|
} from '~/decrypt-worker/types';
|
||||||
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
@@ -15,8 +17,9 @@ import {
|
|||||||
selectKugouKey,
|
selectKugouKey,
|
||||||
selectKWMv2Key,
|
selectKWMv2Key,
|
||||||
selectQMCv2KeyByFileName,
|
selectQMCv2KeyByFileName,
|
||||||
selectQtfmAndroidKey
|
selectQtfmAndroidKey,
|
||||||
} from '../settings/settingsSelector';
|
} from '../settings/settingsSelector';
|
||||||
|
import { cleanFilename } from '~/util/cleanFilename';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
QUEUED = 'QUEUED',
|
QUEUED = 'QUEUED',
|
||||||
@@ -40,6 +43,7 @@ export interface AudioMetadata {
|
|||||||
|
|
||||||
export interface DecryptedAudioFile {
|
export interface DecryptedAudioFile {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
cleanName: string;
|
||||||
raw: string; // blob uri
|
raw: string; // blob uri
|
||||||
ext: string;
|
ext: string;
|
||||||
decrypted: string; // blob uri
|
decrypted: string; // blob uri
|
||||||
@@ -106,6 +110,7 @@ export const fileListingSlice = createSlice({
|
|||||||
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
|
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
|
||||||
state.files[payload.id] = {
|
state.files[payload.id] = {
|
||||||
fileName: payload.fileName,
|
fileName: payload.fileName,
|
||||||
|
cleanName: cleanFilename(payload.fileName),
|
||||||
raw: payload.blobURI,
|
raw: payload.blobURI,
|
||||||
decrypted: '',
|
decrypted: '',
|
||||||
ext: '',
|
ext: '',
|
||||||
|
|||||||
29
src/features/nav/ResponsiveNav.tsx
Normal file
29
src/features/nav/ResponsiveNav.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface ResponsiveNavProps {
|
||||||
|
navigationClassName?: string;
|
||||||
|
navigation?: React.ReactNode;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
contentClassName?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveNav({
|
||||||
|
className = '',
|
||||||
|
navigationClassName = '',
|
||||||
|
contentClassName = '',
|
||||||
|
children,
|
||||||
|
navigation,
|
||||||
|
}: ResponsiveNavProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`@container/nav grow grid grid-cols-1 grid-rows-[auto_1fr] md:grid-rows-1 md:grid-cols-[10rem_1fr] ${className}`}
|
||||||
|
>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`bg-base-100 md:p-4 md:block ${navigationClassName}`}>{navigation}</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className={`p-4 grow ${contentClassName}`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/features/nav/TabNavLink.tsx
Normal file
21
src/features/nav/TabNavLink.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import type { RefAttributes } from 'react';
|
||||||
|
import { NavLink, type NavLinkProps } from 'react-router';
|
||||||
|
|
||||||
|
const tabClassNames = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
classNames(
|
||||||
|
'link inline-flex text-nowrap mb-[-2px] no-underline w-full',
|
||||||
|
'border-b-2 md:border-b-0 md:border-r-2',
|
||||||
|
'tab md:grow',
|
||||||
|
{
|
||||||
|
'tab-active bg-accent/10 border-accent': isActive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function TabNavLink({ children, ...props }: NavLinkProps & RefAttributes<HTMLAnchorElement>) {
|
||||||
|
return (
|
||||||
|
<NavLink className={tabClassNames} role="tab" {...props}>
|
||||||
|
{children}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,163 +1,75 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
chakra,
|
|
||||||
Flex,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Portal,
|
|
||||||
Spacer,
|
|
||||||
Tab,
|
|
||||||
TabList,
|
|
||||||
TabPanel,
|
|
||||||
TabPanels,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
useBreakpointValue,
|
|
||||||
useToast,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
|
||||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
|
||||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||||
import { PanelQingTing } from './panels/PanelQingTing';
|
import { Outlet } from 'react-router';
|
||||||
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
import { SETTINGS_TABS } from '~/features/settings/settingsTabs.tsx';
|
||||||
|
import { MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
import { toast } from 'react-toastify';
|
||||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
import { ResponsiveNav } from '../nav/ResponsiveNav';
|
||||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
import { TabNavLink } from '../nav/TabNavLink';
|
||||||
{ name: 'KGG 密钥', Tab: PanelKGGKey },
|
|
||||||
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
|
||||||
{
|
|
||||||
name: '其它/待定',
|
|
||||||
Tab: () => <Text>这里空空如也~</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const toast = useToast();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isLargeWidthDevice =
|
|
||||||
useBreakpointValue({
|
|
||||||
base: false,
|
|
||||||
lg: true,
|
|
||||||
}) ?? false;
|
|
||||||
|
|
||||||
const [tabIndex, setTabIndex] = useState(0);
|
|
||||||
const handleTabChange = (idx: number) => {
|
|
||||||
setTabIndex(idx);
|
|
||||||
};
|
|
||||||
const handleResetSettings = () => {
|
const handleResetSettings = () => {
|
||||||
dispatch(discardStagingChanges());
|
dispatch(discardStagingChanges());
|
||||||
|
|
||||||
toast({
|
toast.info(() => (
|
||||||
status: 'info',
|
<div>
|
||||||
title: '未储存的设定已舍弃',
|
<h3 className="text-lg font-bold">未储存的设定已舍弃</h3>
|
||||||
description: '已还原到更改前的状态。',
|
<p className="text-sm">已还原到更改前的状态。</p>
|
||||||
isClosable: true,
|
</div>
|
||||||
});
|
));
|
||||||
};
|
};
|
||||||
const handleApplySettings = () => {
|
const handleApplySettings = () => {
|
||||||
dispatch(commitStagingChange());
|
dispatch(commitStagingChange());
|
||||||
toast({
|
toast.success('设定已应用');
|
||||||
status: 'success',
|
|
||||||
title: '设定已应用',
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
|
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex flexDir="column" flex={1}>
|
<div className="flex flex-col flex-1 container w-full">
|
||||||
<Menu>
|
<ResponsiveNav
|
||||||
<MenuButton
|
className="grow h-full overflow-auto"
|
||||||
as={Button}
|
contentClassName="flex flex-col overflow-auto"
|
||||||
leftIcon={<MdMenu />}
|
navigationClassName="overflow-x-auto pb-[2px] md:pb-0 h-full items-start [&]:md:flex"
|
||||||
rightIcon={<MdExpandMore />}
|
navigation={
|
||||||
colorScheme="gray"
|
<div role="tablist" className="tabs gap-1 flex-nowrap md:flex-col grow items-center">
|
||||||
variant="outline"
|
{Object.entries(SETTINGS_TABS).map(([id, { name }]) => (
|
||||||
w="full"
|
<TabNavLink key={id} to={`/settings/${id}`}>
|
||||||
flexShrink={0}
|
|
||||||
hidden={isLargeWidthDevice}
|
|
||||||
mb="4"
|
|
||||||
>
|
|
||||||
{TABS[tabIndex].name}
|
|
||||||
</MenuButton>
|
|
||||||
<Portal>
|
|
||||||
<MenuList w="100px">
|
|
||||||
{TABS.map(({ name }, i) => (
|
|
||||||
<MenuItem key={name} onClick={() => setTabIndex(i)}>
|
|
||||||
{name}
|
{name}
|
||||||
</MenuItem>
|
</TabNavLink>
|
||||||
))}
|
))}
|
||||||
</MenuList>
|
</div>
|
||||||
</Portal>
|
}
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
orientation={isLargeWidthDevice ? 'vertical' : 'horizontal'}
|
|
||||||
align="start"
|
|
||||||
variant="line-i"
|
|
||||||
display="flex"
|
|
||||||
flex={1}
|
|
||||||
index={tabIndex}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
>
|
>
|
||||||
<TabList hidden={!isLargeWidthDevice} minW="8em" width="8em" textAlign="right" justifyContent="center">
|
<Outlet />
|
||||||
{TABS.map(({ name }) => (
|
</ResponsiveNav>
|
||||||
<Tab key={name}>{name}</Tab>
|
|
||||||
))}
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanels>
|
<footer className="flex flex-row gap-2 w-full p-2 border-t border-base-200 bg-base-100">
|
||||||
{TABS.map(({ name, Tab }) => (
|
<div className="grow inline-flex items-center">
|
||||||
<Flex as={TabPanel} flex={1} flexDir="column" h="100%" key={name}>
|
|
||||||
<Flex h="100%" flex={1} minH={0}>
|
|
||||||
<Tab />
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<VStack mt="4" alignItems="flex-start" w="full">
|
|
||||||
<Flex flexDir="row" gap="2" w="full">
|
|
||||||
<Center>
|
|
||||||
{isSettingsNotSaved ? (
|
{isSettingsNotSaved ? (
|
||||||
<Box color="gray">
|
<span>
|
||||||
有未储存的更改{' '}
|
有未储存的更改,<span className="text-red-600">设定将在保存后生效</span>
|
||||||
<chakra.span color="red" wordBreak="keep-all">
|
</span>
|
||||||
设定将在保存后生效
|
|
||||||
</chakra.span>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box color="gray">设定将在保存后生效</Box>
|
<span className="text-base-700">设定将在保存后生效</span>
|
||||||
)}
|
)}
|
||||||
</Center>
|
</div>
|
||||||
<Spacer />
|
|
||||||
<HStack gap="2" justifyContent="flex-end">
|
<div className="flex flex-row gap-2">
|
||||||
<IconButton
|
<button
|
||||||
icon={<Icon as={MdOutlineSettingsBackupRestore} />}
|
className="btn btn-sm btn-ghost text-error"
|
||||||
onClick={handleResetSettings}
|
onClick={handleResetSettings}
|
||||||
colorScheme="red"
|
|
||||||
variant="ghost"
|
|
||||||
title="放弃未储存的更改,将设定还原未储存前的状态。"
|
title="放弃未储存的更改,将设定还原未储存前的状态。"
|
||||||
aria-label="放弃未储存的更改"
|
>
|
||||||
/>
|
<MdOutlineSettingsBackupRestore className="size-4" />
|
||||||
<Button onClick={handleApplySettings}>保存</Button>
|
</button>
|
||||||
</HStack>
|
<button className="btn btn-sm btn-primary" onClick={handleApplySettings}>
|
||||||
</Flex>
|
保存
|
||||||
</VStack>
|
</button>
|
||||||
</Flex>
|
</div>
|
||||||
))}
|
</footer>
|
||||||
</TabPanels>
|
</div>
|
||||||
</Tabs>
|
|
||||||
</Flex>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/features/settings/SettingsHome.tsx
Normal file
8
src/features/settings/SettingsHome.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function SettingsHome() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">设置</h1>
|
||||||
|
<p>在这里你可以设置应用的基本配置。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,28 @@
|
|||||||
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react';
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
|
|
||||||
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
|
|
||||||
|
|
||||||
export function InstructionsIOS() {
|
export function InstructionsIOS() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>你需要越狱来访问 iOS 应用的私有数据。</Text>
|
<p>你需要越狱来访问 iOS 应用的私有数据。</p>
|
||||||
<Text>
|
<p>
|
||||||
⚠️ 请注意,越狱通常意味着你的设备
|
⚠️ 请注意,越狱通常意味着你的设备
|
||||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
<span className="text-red-600">将失去保修资格</span>。
|
||||||
</Text>
|
</p>
|
||||||
<OrderedList>
|
<ol className="list-decimal pl-6">
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
|
||||||
访问设备的这个目录:
|
访问设备的这个目录:
|
||||||
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
|
<br />
|
||||||
</Text>
|
<code className="break-words">
|
||||||
</ListItem>
|
/var/mobile/Containers/Data/Application/<HiWord className="text-nowrap">{'<酷我数据目录>'}</HiWord>/mmkv
|
||||||
<ListItem>
|
</code>
|
||||||
<Text>
|
</li>
|
||||||
提取密钥数据库文件 <Code>kw_ekey</Code> 至浏览器可访问的目录,如下载目录。
|
<li>
|
||||||
</Text>
|
提取密钥数据库文件 <code>kw_ekey</code> 至浏览器可访问的目录,如下载目录。
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
提交刚刚提取的 <code>kw_ekey</code> 密钥数据库。
|
||||||
提交刚刚提取的 <Code>kw_ekey</Code> 密钥数据库。
|
</li>
|
||||||
</Text>
|
</ol>
|
||||||
</ListItem>
|
|
||||||
</OrderedList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { Text } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
export function InstructionsPC() {
|
export function InstructionsPC() {
|
||||||
return (
|
return <p>使用 Windows 客户端下载的文件不需要导入密钥。</p>;
|
||||||
<>
|
|
||||||
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
|
||||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
import { InstructionsPC } from './InstructionsPC';
|
import { InstructionsPC } from './InstructionsPC';
|
||||||
import { InstructionsIOS } from './InstructionsIOS';
|
import { InstructionsIOS } from './InstructionsIOS';
|
||||||
|
import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs';
|
||||||
|
|
||||||
export function KWMv2AllInstructions() {
|
export function KWMv2AllInstructions() {
|
||||||
return (
|
const ANDROID_DIR = '/data/data/cn.kuwo.player/files/mmkv';
|
||||||
<>
|
const ANDROID_FILE = 'cn.kuwo.player.mmkv.defaultconfig';
|
||||||
<TabList>
|
const tabs: InstructionTab[] = [
|
||||||
<Tab>安卓</Tab>
|
{ id: 'android', label: '安卓', content: <AndroidADBPullInstruction dir={ANDROID_DIR} file={ANDROID_FILE} /> },
|
||||||
<Tab>iOS</Tab>
|
{ id: 'ios', label: 'iOS', content: <InstructionsIOS /> },
|
||||||
<Tab>Windows</Tab>
|
{ id: 'windows', label: 'Windows', content: <InstructionsPC /> },
|
||||||
</TabList>
|
];
|
||||||
<TabPanels flex={1} overflow="auto">
|
|
||||||
<TabPanel>
|
return <InstructionsTabs tabs={tabs} />;
|
||||||
<AndroidADBPullInstruction
|
|
||||||
dir="/data/data/cn.kuwo.player/files/mmkv"
|
|
||||||
file="cn.kuwo.player.mmkv.defaultconfig"
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsIOS />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsPC />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,41 @@
|
|||||||
import {
|
import { PiFileAudio, PiHash } from 'react-icons/pi';
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
InputRightElement,
|
|
||||||
ListItem,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
|
||||||
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
|
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
|
||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { StagingKWMv2Key } from '../../keyFormats';
|
import { StagingKWMv2Key } from '../../keyFormats';
|
||||||
|
import { KeyInput } from '~/components/KeyInput';
|
||||||
|
|
||||||
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
|
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const updateKey = (prop: keyof StagingKWMv2Key, e: React.ChangeEvent<HTMLInputElement>) =>
|
const ekeyLen = ekey.length;
|
||||||
dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value }));
|
const isValidEKey = ekeyLen === 364 || ekeyLen === 704;
|
||||||
const deleteKey = () => dispatch(kwm2DeleteKey({ id }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
<KeyInput
|
||||||
<HStack>
|
name={rid}
|
||||||
<Text w="2em" textAlign="center">
|
quality={quality}
|
||||||
{i + 1}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<VStack flex={1}>
|
|
||||||
<HStack flex={1} w="full">
|
|
||||||
<Input
|
|
||||||
variant="flushed"
|
|
||||||
placeholder="资源 ID"
|
|
||||||
value={rid}
|
|
||||||
onChange={(e) => updateKey('rid', e)}
|
|
||||||
type="number"
|
|
||||||
maxW="8em"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
variant="flushed"
|
|
||||||
placeholder="音质格式"
|
|
||||||
value={quality}
|
|
||||||
onChange={(e) => updateKey('quality', e)}
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<InputGroup size="xs">
|
|
||||||
<InputLeftElement pr="2">
|
|
||||||
<Icon as={MdVpnKey} />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input
|
|
||||||
variant="flushed"
|
|
||||||
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
|
||||||
value={ekey}
|
value={ekey}
|
||||||
onChange={(e) => updateKey('ekey', e)}
|
isValidKey={isValidEKey}
|
||||||
|
onSetName={(value) => dispatch(kwm2UpdateKey({ id, field: 'rid', value }))}
|
||||||
|
onSetQuality={(value) => dispatch(kwm2UpdateKey({ id, field: 'quality', value }))}
|
||||||
|
onSetValue={(value) => dispatch(kwm2UpdateKey({ id, field: 'ekey', value }))}
|
||||||
|
onDelete={() => dispatch(kwm2DeleteKey({ id }))}
|
||||||
|
sequence={i + 1}
|
||||||
|
nameLabel={
|
||||||
|
<>
|
||||||
|
ID
|
||||||
|
<PiHash className="hidden md:inline-block" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
qualityLabel={
|
||||||
|
<>
|
||||||
|
质量 <PiFileAudio className="hidden md:inline-block" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
namePlaceholder="音频哈希。不建议手动填写。"
|
||||||
|
qualityPlaceholder="比特率 ID"
|
||||||
|
valuePlaceholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
||||||
/>
|
/>
|
||||||
<InputRightElement>
|
|
||||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
|
||||||
<code>{ekey.length || '?'}</code>
|
|
||||||
</Text>
|
|
||||||
</InputRightElement>
|
|
||||||
</InputGroup>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
aria-label="删除该密钥"
|
|
||||||
icon={<Icon as={MdDelete} boxSize={6} />}
|
|
||||||
variant="ghost"
|
|
||||||
colorScheme="red"
|
|
||||||
type="button"
|
|
||||||
onClick={deleteKey}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</ListItem>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react';
|
import { RiFileCopyLine } from 'react-icons/ri';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
|
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
|
||||||
|
|
||||||
export function InstructionsPC() {
|
export function InstructionsPC() {
|
||||||
|
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
|
||||||
|
const copyDbPathToClipboard = () => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(DB_PATH)
|
||||||
|
.then(() => {
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(`复制失败,请手动复制\n${err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>酷狗的 Windows 客户端使用 <abbr title="SQLite w/ SQLCipher">SQLite</abbr> 数据库储存密钥。</Text>
|
<p>
|
||||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
酷狗的 Windows 客户端使用魔改的
|
||||||
<FilePathBlock>%APPDATA%\KuGou8\KGMusicV3.db</FilePathBlock>
|
<ExtLink className="link-info px-1" href="https://www.zetetic.net/sqlcipher/">
|
||||||
|
SQLCipher
|
||||||
|
</ExtLink>
|
||||||
|
来加密储存密钥。
|
||||||
|
</p>
|
||||||
|
<p>该密钥数据库通常位于下述路径:</p>
|
||||||
|
<FilePathBlock>{DB_PATH}</FilePathBlock>
|
||||||
|
|
||||||
<Heading as="h3" size="md" mt="4">
|
<h3 className="font-bold text-xl mt-4">导入密钥</h3>
|
||||||
导入密钥
|
<ol className="list-decimal pl-6">
|
||||||
</Heading>
|
<li>
|
||||||
<OrderedList>
|
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={copyDbPathToClipboard}>
|
||||||
<ListItem>
|
<RiFileCopyLine className="text-xl" />
|
||||||
<Text>
|
<span>复制</span>
|
||||||
选中并复制上述的 <Code>KGMusicV3.db</Code> 文件路径
|
</button>
|
||||||
</Text>
|
<code>KGMusicV3.db</code> 文件路径
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>点击上方的「文件选择区域」,打开「文件选择框」</li>
|
||||||
<Text>点击上方的「文件选择区域」,打开「文件选择框」</Text>
|
<li>
|
||||||
</ListItem>
|
在「文件名」输入框中粘贴之前复制的 <code>KGMusicV3.db</code> 文件路径
|
||||||
<ListItem>
|
</li>
|
||||||
<Text>
|
<li>按下「回车键」确认。</li>
|
||||||
在「文件名」输入框中粘贴之前复制的 <Code>KGMusicV3.db</Code> 文件路径
|
</ol>
|
||||||
</Text>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<Text>按下「回车键」确认。</Text>
|
|
||||||
</ListItem>
|
|
||||||
</OrderedList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
|
||||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
import { InstructionsPC } from './InstructionsPC';
|
import { InstructionsPC } from './InstructionsPC';
|
||||||
|
import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs';
|
||||||
|
|
||||||
export function KugouAllInstructions() {
|
export function KugouAllInstructions() {
|
||||||
return (
|
const ANDROID_DIR = '/data/data/com.kugou.android/files/mmkv';
|
||||||
<>
|
const ANDROID_FILE = 'mggkey_multi_process';
|
||||||
<TabList>
|
const tabs: InstructionTab[] = [
|
||||||
<Tab>安卓</Tab>
|
{ id: 'android', label: '安卓', content: <AndroidADBPullInstruction dir={ANDROID_DIR} file={ANDROID_FILE} /> },
|
||||||
<Tab>Windows</Tab>
|
{ id: 'windows', label: 'Windows', content: <InstructionsPC /> },
|
||||||
</TabList>
|
];
|
||||||
<TabPanels flex={1} overflow="auto">
|
|
||||||
<TabPanel>
|
return <InstructionsTabs tabs={tabs} />;
|
||||||
<AndroidADBPullInstruction
|
|
||||||
dir="/data/data/com.kugou.android/files/mmkv"
|
|
||||||
file="mggkey_multi_process"
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsPC />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,26 @@
|
|||||||
import {
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
InputRightElement,
|
|
||||||
ListItem,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
|
||||||
import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
|
import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
|
||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { StagingKugouKey } from '../../keyFormats';
|
import { StagingKugouKey } from '../../keyFormats';
|
||||||
|
import { KeyInput } from '~/components/KeyInput';
|
||||||
|
|
||||||
export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
|
export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent<HTMLInputElement>) =>
|
const ekeyLen = ekey.length;
|
||||||
dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value }));
|
const isValidEKey = ekeyLen === 364 || ekeyLen === 704;
|
||||||
const deleteKey = () => dispatch(kugouDeleteKey({ id }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
<KeyInput
|
||||||
<HStack>
|
name={audioHash}
|
||||||
<Text w="2em" textAlign="center">
|
|
||||||
{i + 1}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<VStack flex={1}>
|
|
||||||
<HStack flex={1} w="full">
|
|
||||||
<Input
|
|
||||||
variant="flushed"
|
|
||||||
placeholder="音频哈希。不建议手动填写。"
|
|
||||||
value={audioHash}
|
|
||||||
onChange={(e) => updateKey('audioHash', e)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<InputGroup size="xs">
|
|
||||||
<InputLeftElement pr="2">
|
|
||||||
<Icon as={MdVpnKey} />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input
|
|
||||||
variant="flushed"
|
|
||||||
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
|
||||||
value={ekey}
|
value={ekey}
|
||||||
onChange={(e) => updateKey('ekey', e)}
|
isValidKey={isValidEKey}
|
||||||
|
onSetName={(value) => dispatch(kugouUpdateKey({ id, field: 'audioHash', value }))}
|
||||||
|
onSetValue={(value) => dispatch(kugouUpdateKey({ id, field: 'ekey', value }))}
|
||||||
|
onDelete={() => dispatch(kugouDeleteKey({ id }))}
|
||||||
|
sequence={i + 1}
|
||||||
|
namePlaceholder="音频哈希。不建议手动填写。"
|
||||||
|
valuePlaceholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
||||||
/>
|
/>
|
||||||
<InputRightElement>
|
|
||||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
|
||||||
<code>{ekey.length || '?'}</code>
|
|
||||||
</Text>
|
|
||||||
</InputRightElement>
|
|
||||||
</InputGroup>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
aria-label="删除该密钥"
|
|
||||||
icon={<Icon as={MdDelete} boxSize={6} />}
|
|
||||||
variant="ghost"
|
|
||||||
colorScheme="red"
|
|
||||||
type="button"
|
|
||||||
onClick={deleteKey}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</ListItem>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react';
|
import { useRef, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
@@ -12,9 +11,10 @@ import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.ts
|
|||||||
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||||
import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
|
import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
|
||||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
|
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
|
||||||
|
import { KeyListContainer } from '~/components/KeyListContainer';
|
||||||
|
import { toastImportResult } from '~/util/toastImportResult';
|
||||||
|
|
||||||
export function PanelKGGKey() {
|
export function PanelKGGKey() {
|
||||||
const toast = useToast();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const kugouKeys = useSelector(selectStagingKugouV5Keys);
|
const kugouKeys = useSelector(selectStagingKugouV5Keys);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
@@ -30,49 +30,34 @@ export function PanelKGGKey() {
|
|||||||
keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
|
keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys?.length === 0) {
|
if (keys && keys.length > 0) {
|
||||||
toast({
|
|
||||||
title: '未导入密钥',
|
|
||||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'warning',
|
|
||||||
});
|
|
||||||
} else if (keys) {
|
|
||||||
dispatch(kugouImportKeys(keys));
|
dispatch(kugouImportKeys(keys));
|
||||||
setShowImportModal(false);
|
setShowImportModal(false);
|
||||||
toast({
|
|
||||||
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
|
|
||||||
description: '记得按下「保存」来应用。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: `不支持的文件:${file.name}`,
|
|
||||||
isClosable: true,
|
|
||||||
status: 'error',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
toastImportResult(file.name, keys);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refKeyContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex minH={0} flexDir="column" flex={1}>
|
<div className="container flex flex-col grow min-h-0 w-full">
|
||||||
<Heading as="h2" size="lg">
|
<h2 className="text-2xl font-bold">酷狗解密密钥 (KGG / KGM v5)</h2>
|
||||||
酷狗解密密钥 (KGG / KGM v5)
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Text>酷狗已经升级了加密方式,现在使用 KGG / KGM v5 加密。</Text>
|
<p>酷狗已经升级了加密方式,现在最新版本的客户端使用 KGG / KGM v5 加密。</p>
|
||||||
|
|
||||||
<AddKey addKey={addKey} importKeyFromFile={() => setShowImportModal(true)} clearKeys={clearAll} />
|
<h3 className="mt-2 text-xl font-bold">密钥管理</h3>
|
||||||
|
<AddKey
|
||||||
|
addKey={addKey}
|
||||||
|
refContainer={refKeyContainer}
|
||||||
|
importKeyFromFile={() => setShowImportModal(true)}
|
||||||
|
clearKeys={clearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
<KeyListContainer ref={refKeyContainer} keys={kugouKeys}>
|
||||||
<List spacing={3}>
|
|
||||||
{kugouKeys.map(({ id, audioHash, ekey }, i) => (
|
{kugouKeys.map(({ id, audioHash, ekey }, i) => (
|
||||||
<KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} />
|
<KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</KeyListContainer>
|
||||||
{kugouKeys.length === 0 && <Text>还没有添加密钥。</Text>}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ImportSecretModal
|
<ImportSecretModal
|
||||||
clientName="酷狗音乐"
|
clientName="酷狗音乐"
|
||||||
@@ -82,6 +67,6 @@ export function PanelKGGKey() {
|
|||||||
>
|
>
|
||||||
<KugouAllInstructions />
|
<KugouAllInstructions />
|
||||||
</ImportSecretModal>
|
</ImportSecretModal>
|
||||||
</Flex>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
import {
|
import { useRef, useState } from 'react';
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Code,
|
|
||||||
Flex,
|
|
||||||
HStack,
|
|
||||||
Heading,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Text,
|
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
|
||||||
|
|
||||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
|
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
|
||||||
@@ -29,9 +9,11 @@ import { selectStagingKWMv2Keys } from '../settingsSelector';
|
|||||||
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
|
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
|
||||||
import type { StagingKWMv2Key } from '../keyFormats';
|
import type { StagingKWMv2Key } from '../keyFormats';
|
||||||
import { KWMv2AllInstructions } from './KWMv2/KWMv2AllInstructions';
|
import { KWMv2AllInstructions } from './KWMv2/KWMv2AllInstructions';
|
||||||
|
import { AddKey } from '~/components/AddKey';
|
||||||
|
import { KeyListContainer } from '~/components/KeyListContainer';
|
||||||
|
import { toastImportResult } from '~/util/toastImportResult';
|
||||||
|
|
||||||
export function PanelKWMv2Key() {
|
export function PanelKWMv2Key() {
|
||||||
const toast = useToast();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const kwm2Keys = useSelector(selectStagingKWMv2Keys);
|
const kwm2Keys = useSelector(selectStagingKWMv2Keys);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
@@ -46,70 +28,35 @@ export function PanelKWMv2Key() {
|
|||||||
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
|
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys?.length === 0) {
|
if (keys && keys.length > 0) {
|
||||||
toast({
|
|
||||||
title: '未导入密钥',
|
|
||||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'warning',
|
|
||||||
});
|
|
||||||
} else if (keys) {
|
|
||||||
dispatch(kwm2ImportKeys(keys));
|
dispatch(kwm2ImportKeys(keys));
|
||||||
setShowImportModal(false);
|
setShowImportModal(false);
|
||||||
toast({
|
|
||||||
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
|
|
||||||
description: '记得按下「保存」来应用。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: `不支持的文件:${file.name}`,
|
|
||||||
isClosable: true,
|
|
||||||
status: 'error',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
toastImportResult(file.name, keys);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refKeyContainer = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<Flex minH={0} flexDir="column" flex={1}>
|
<div className="container flex flex-col grow min-h-0 w-full">
|
||||||
<Heading as="h2" size="lg">
|
<h2 className="text-2xl font-bold">酷我解密密钥(KwmV2)</h2>
|
||||||
酷我解密密钥(KwmV2)
|
<p>
|
||||||
</Heading>
|
酷我安卓版本的「臻品音质」已经换用 V2 版,表现为加密文件的后缀名为 <code>mflac</code> 或 <code>mgg</code>。
|
||||||
|
</p>
|
||||||
|
<p>该格式需要提取密钥后才能正常解密。</p>
|
||||||
|
|
||||||
<Text>
|
<h3 className="mt-2 text-xl font-bold">密钥管理</h3>
|
||||||
酷我安卓版本的「臻品音质」已经换用 V2 版,后缀名为 <Code>mflac</Code> 或沿用旧的 <Code>kwm</Code>。{''}
|
<AddKey
|
||||||
该格式需要提取密钥后才能正常解密。
|
addKey={addKey}
|
||||||
</Text>
|
refContainer={refKeyContainer}
|
||||||
|
importKeyFromFile={() => setShowImportModal(true)}
|
||||||
|
clearKeys={clearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
<HStack pb={2} pt={2}>
|
<KeyListContainer ref={refKeyContainer} keys={kwm2Keys}>
|
||||||
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
|
||||||
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
|
||||||
添加一条密钥
|
|
||||||
</Button>
|
|
||||||
<Menu>
|
|
||||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
|
||||||
从文件导入密钥…
|
|
||||||
</MenuItem>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
|
||||||
清空密钥
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</ButtonGroup>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
|
||||||
<List spacing={3}>
|
|
||||||
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
|
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
|
||||||
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
|
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</KeyListContainer>
|
||||||
{kwm2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ImportSecretModal
|
<ImportSecretModal
|
||||||
clientName="酷我音乐"
|
clientName="酷我音乐"
|
||||||
@@ -119,6 +66,6 @@ export function PanelKWMv2Key() {
|
|||||||
>
|
>
|
||||||
<KWMv2AllInstructions />
|
<KWMv2AllInstructions />
|
||||||
</ImportSecretModal>
|
</ImportSecretModal>
|
||||||
</Flex>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,8 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Checkbox,
|
|
||||||
Flex,
|
|
||||||
Heading,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Select,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
|
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
|
||||||
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
|
||||||
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
|
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
|
||||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
|
||||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
import { StagingQMCv2Key } from '../keyFormats';
|
import { StagingQMCv2Key } from '../keyFormats';
|
||||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||||
@@ -33,9 +10,14 @@ import { parseAndroidQmEKey } from '~/util/mmkv/qm';
|
|||||||
import { getFileName } from '~/util/pathHelper';
|
import { getFileName } from '~/util/pathHelper';
|
||||||
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
|
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
|
||||||
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
|
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
|
||||||
|
import { AddKey } from '~/components/AddKey';
|
||||||
|
import { InfoModal } from '~/components/InfoModal.tsx';
|
||||||
|
import { Ruby } from '~/components/Ruby.tsx';
|
||||||
|
import { ExtLink } from '~/components/ExtLink.tsx';
|
||||||
|
import { KeyListContainer } from '~/components/KeyListContainer';
|
||||||
|
import { toastImportResult } from '~/util/toastImportResult';
|
||||||
|
|
||||||
export function PanelQMCv2Key() {
|
export function PanelQMCv2Key() {
|
||||||
const toast = useToast();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
@@ -52,127 +34,93 @@ export function PanelQMCv2Key() {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
let keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
||||||
|
|
||||||
if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) {
|
if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) {
|
||||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||||
qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
|
keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
|
||||||
if (!qmc2Keys) {
|
|
||||||
alert(`不是支持的 SQLite 数据库文件。`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
|
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
||||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qmc2Keys?.length === 0) {
|
if (keys && keys.length > 0) {
|
||||||
toast({
|
dispatch(qmc2ImportKeys(keys));
|
||||||
title: '未导入密钥',
|
|
||||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'warning',
|
|
||||||
});
|
|
||||||
} else if (qmc2Keys) {
|
|
||||||
dispatch(qmc2ImportKeys(qmc2Keys));
|
|
||||||
setShowImportModal(false);
|
setShowImportModal(false);
|
||||||
toast({
|
|
||||||
title: `导入成功 (${qmc2Keys.length})`,
|
|
||||||
description: '记得保存更改来应用。',
|
|
||||||
isClosable: true,
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert(`不支持的文件:${file.name}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toastImportResult(file.name, keys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('error during import: ', e);
|
console.error('error during import: ', e);
|
||||||
alert(`导入数据库时发生错误:${e}`);
|
alert(`导入数据库时发生错误:${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refKeyContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex minH={0} flexDir="column" flex={1}>
|
<>
|
||||||
<Heading as="h2" size="lg">
|
<h2 className="text-2xl font-bold">QMCv2 解密密钥</h2>
|
||||||
QMCv2 解密密钥
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Text>
|
<p>QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。</p>
|
||||||
QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS 客户端,以及在使用「豆瓣
|
<p>「QQ 音乐」安卓、Mac 或 iOS 客户端,或「豆瓣 FM」安卓客户端会将密钥存储在外部的数据库文件内。</p>
|
||||||
FM」安卓客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<HStack pb={2} pt={2}>
|
<div className="flex flex-row gap-2 items-center my-2">
|
||||||
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
<label className="label">
|
||||||
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
<input
|
||||||
添加一条密钥
|
className="checkbox"
|
||||||
</Button>
|
type="checkbox"
|
||||||
<Menu>
|
checked={allowFuzzyNameSearch}
|
||||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
onChange={handleAllowFuzzyNameSearchCheckbox}
|
||||||
<MenuList>
|
/>
|
||||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
允许匹配相似文件名
|
||||||
从文件导入密钥…
|
</label>
|
||||||
</MenuItem>
|
<InfoModal
|
||||||
<MenuDivider />
|
title="莱文斯坦距离"
|
||||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
description={
|
||||||
清空密钥
|
<div>
|
||||||
</MenuItem>
|
<p>若文件名匹配失败,则使用相似文件名的密钥。</p>
|
||||||
</MenuList>
|
<p>
|
||||||
</Menu>
|
该匹配使用「
|
||||||
</ButtonGroup>
|
<ExtLink href="https://zh.wikipedia.org/zh-cn/%E8%90%8A%E6%96%87%E6%96%AF%E5%9D%A6%E8%B7%9D%E9%9B%A2">
|
||||||
|
<Ruby caption="Levenshtein distance">莱文斯坦距离</Ruby>
|
||||||
<HStack>
|
</ExtLink>
|
||||||
<Checkbox isChecked={allowFuzzyNameSearch} onChange={handleAllowFuzzyNameSearchCheckbox}>
|
」算法来计算文件名的相似程度。
|
||||||
<Text>匹配相似文件名</Text>
|
</p>
|
||||||
</Checkbox>
|
<p>若密钥数量过多,匹配时可能会造成浏览器卡顿或无响应一段时间。</p>
|
||||||
<Tooltip
|
<p>若不确定,请勾选该项。</p>
|
||||||
hasArrow
|
</div>
|
||||||
closeOnClick={false}
|
|
||||||
label={
|
|
||||||
<Box>
|
|
||||||
<Text>若文件名匹配失败,则使用相似文件名的密钥。</Text>
|
|
||||||
<Text>
|
|
||||||
使用「
|
|
||||||
<ruby>
|
|
||||||
莱文斯坦距离
|
|
||||||
<rp> (</rp>
|
|
||||||
<rt>Levenshtein distance</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
」算法计算相似程度。
|
|
||||||
</Text>
|
|
||||||
<Text>若密钥数量过多,匹配时可能会造成浏览器卡顿或无响应一段时间。</Text>
|
|
||||||
<Text>若不确定,请勾选该项。</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InfoOutlineIcon />
|
这是什么?
|
||||||
</Tooltip>
|
</InfoModal>
|
||||||
</HStack>
|
</div>
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
<h3 className="mt-2 text-xl font-bold">密钥管理</h3>
|
||||||
<List spacing={3}>
|
<AddKey
|
||||||
|
addKey={addKey}
|
||||||
|
refContainer={refKeyContainer}
|
||||||
|
importKeyFromFile={() => setShowImportModal(true)}
|
||||||
|
clearKeys={clearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyListContainer ref={refKeyContainer} keys={qmc2Keys}>
|
||||||
{qmc2Keys.map(({ id, ekey, name }, i) => (
|
{qmc2Keys.map(({ id, ekey, name }, i) => (
|
||||||
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</KeyListContainer>
|
||||||
{qmc2Keys.length === 0 && <Text>还没有密钥。</Text>}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ImportSecretModal
|
<ImportSecretModal
|
||||||
clientName={
|
clientName={
|
||||||
<Select
|
<select
|
||||||
value={secretType}
|
value={secretType}
|
||||||
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
|
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
|
||||||
variant="flushed"
|
className="inline mx-1 px-1 border-b border-accent/50 bg-base-100"
|
||||||
display="inline"
|
|
||||||
css={{ paddingLeft: '0.75rem', width: 'auto' }}
|
|
||||||
>
|
>
|
||||||
<option value="qm">QQ 音乐</option>
|
<option value="qm">QQ 音乐</option>
|
||||||
<option value="douban">豆瓣 FM</option>
|
<option value="douban">豆瓣 FM</option>
|
||||||
</Select>
|
</select>
|
||||||
}
|
}
|
||||||
show={showImportModal}
|
show={showImportModal}
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
@@ -181,6 +129,6 @@ export function PanelQMCv2Key() {
|
|||||||
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
|
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
|
||||||
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
|
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
|
||||||
</ImportSecretModal>
|
</ImportSecretModal>
|
||||||
</Flex>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Code,
|
|
||||||
Flex,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
FormLabel,
|
|
||||||
Heading,
|
|
||||||
Input,
|
|
||||||
ListItem,
|
|
||||||
Text,
|
|
||||||
UnorderedList,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||||
import { ExtLink } from '~/components/ExtLink';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { ChangeEvent, ClipboardEvent } from 'react';
|
import { ChangeEvent, ClipboardEvent, useId } from 'react';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
|
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
|
||||||
import { qtfmAndroidUpdateKey } from '../settingsSlice';
|
import { qtfmAndroidUpdateKey } from '../settingsSlice';
|
||||||
import { workerClientBus } from '~/decrypt-worker/client.ts';
|
import { workerClientBus } from '~/decrypt-worker/client.ts';
|
||||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
|
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
|
||||||
|
import { Ruby } from '~/components/Ruby';
|
||||||
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
|
|
||||||
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
||||||
|
|
||||||
@@ -64,64 +52,76 @@ export function PanelQingTing() {
|
|||||||
setSecretKey(e.target.value);
|
setSecretKey(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const idSecretKey = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex minH={0} flexDir="column" flex={1}>
|
<div className="min-h-0 flex-col grow px-1">
|
||||||
<Heading as="h2" size="lg">
|
<h2 className="text-2xl font-bold mb-4">蜻蜓 FM</h2>
|
||||||
<VQuote>蜻蜓 FM</VQuote>
|
|
||||||
设备密钥
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Text>
|
<p>
|
||||||
<VQuote>蜻蜓 FM</VQuote>的安卓版本需要获取设备密钥,并以此来生成解密密钥。
|
<VQuote>蜻蜓 FM</VQuote>的安卓版本需要获取设备密钥,并以此来生成解密密钥。
|
||||||
</Text>
|
</p>
|
||||||
<Box mt={3} mb={3}>
|
|
||||||
<FormControl>
|
<div className="my-4">
|
||||||
<FormLabel>设备密钥</FormLabel>
|
<fieldset className="fieldset">
|
||||||
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} />
|
<legend className="fieldset-legend text-lg">
|
||||||
<FormHelperText>
|
<label htmlFor={idSecretKey}>设备密钥</label>
|
||||||
{'粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过 '}
|
</legend>
|
||||||
|
<input
|
||||||
|
id={idSecretKey}
|
||||||
|
type="text"
|
||||||
|
className="input font-mono"
|
||||||
|
onPaste={handleDataPaste}
|
||||||
|
value={secretKey}
|
||||||
|
onChange={handleDataInput}
|
||||||
|
/>
|
||||||
|
<p className="label flex-wrap">
|
||||||
|
粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过
|
||||||
<ExtLink href={QTFM_DEVICE_ID_URL}>
|
<ExtLink href={QTFM_DEVICE_ID_URL}>
|
||||||
<Code>qtfm-device-id</Code>
|
<code>qtfm-device-id</code>
|
||||||
</ExtLink>
|
</ExtLink>
|
||||||
{' 获取的设备信息)。'}
|
获取的设备信息),不需要 root。
|
||||||
</FormHelperText>
|
</p>
|
||||||
</FormControl>
|
</fieldset>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Heading as="h3" size="md" pt={3} pb={2}>
|
<h3 className="text-xl font-bold my-2">注意事项</h3>
|
||||||
注意事项
|
<ul className="list-disc pl-6">
|
||||||
</Heading>
|
<li>
|
||||||
<UnorderedList>
|
<p>
|
||||||
<ListItem>
|
|
||||||
<Text>
|
|
||||||
下载的文件位于
|
下载的文件位于
|
||||||
<Code>[内部储存]/Android/data/fm.qingting.qtradio/files/Music/</Code>
|
<VQuote>
|
||||||
</Text>
|
<code className="break-words">
|
||||||
|
<HiWord>[内部储存]</HiWord>/<wbr />
|
||||||
<UnorderedList>
|
Android/
|
||||||
<ListItem>
|
<wbr />
|
||||||
<Text>
|
data/
|
||||||
|
<wbr />
|
||||||
|
fm.qingting.qtradio/
|
||||||
|
<wbr />
|
||||||
|
files/Music/
|
||||||
|
</code>
|
||||||
|
</VQuote>
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
你可能需要使用有
|
你可能需要使用有
|
||||||
<ruby>
|
<Ruby caption="root">特权</Ruby>
|
||||||
特权
|
|
||||||
<rp> (</rp>
|
|
||||||
<rt>root</rt>
|
|
||||||
<rp>)</rp>
|
|
||||||
</ruby>
|
|
||||||
的文件浏览器访问。
|
的文件浏览器访问。
|
||||||
</Text>
|
</p>
|
||||||
</ListItem>
|
</li>
|
||||||
</UnorderedList>
|
</ul>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
<p>
|
||||||
音频文件文件名为「<Code>.p~!</Code>」前缀。
|
音频文件文件名为「<code>.p~!</code>」前缀。
|
||||||
</Text>
|
</p>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>因为解密密钥与文件名相关,因此解密前请不要更改文件名。</Text>
|
<p>因为解密密钥与文件名相关,因此解密前请不要更改文件名。</p>
|
||||||
</ListItem>
|
</li>
|
||||||
</UnorderedList>
|
</ul>
|
||||||
</Flex>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,36 @@
|
|||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionButton,
|
|
||||||
AccordionIcon,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionPanel,
|
|
||||||
Box,
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { InstructionsIOSCondition } from './InstructionsIOSCondition';
|
import { InstructionsIOSCondition } from './InstructionsIOSCondition';
|
||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
export function InstructionsIOS() {
|
export function InstructionsIOS() {
|
||||||
|
const iosInstructionId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<div>
|
||||||
<Text>iOS 设备获取应用私有文件比较麻烦,你需要越狱或使用一台 PC 或 Mac 来对 iOS 设备进行完整备份。</Text>
|
<p>iOS 设备获取应用私有文件比较麻烦,你需要越狱或使用一台 PC 或 Mac 来对 iOS 设备进行完整备份。</p>
|
||||||
<Text>因此,建议换用 PC 或 Mac 重新下载音乐文件然后再尝试解密。</Text>
|
<p>因此,建议换用 PC 或 Mac 重新下载音乐文件然后再尝试解密。</p>
|
||||||
</Box>
|
</div>
|
||||||
<Accordion allowToggle mt="2">
|
|
||||||
<AccordionItem>
|
|
||||||
<Heading as="h3" size="md">
|
|
||||||
<AccordionButton>
|
|
||||||
<Box as="span" flex="1" textAlign="left">
|
|
||||||
我的 iOS 设备已经越狱
|
|
||||||
</Box>
|
|
||||||
<AccordionIcon />
|
|
||||||
</AccordionButton>
|
|
||||||
</Heading>
|
|
||||||
<AccordionPanel pb={4}>
|
|
||||||
<InstructionsIOSCondition jailbreak={true} />
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem>
|
<div className="join join-vertical bg-base-100 mt-2 max-w-full">
|
||||||
<Heading as="h3" size="md">
|
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||||
<AccordionButton>
|
<input type="radio" name={iosInstructionId} />
|
||||||
<Box as="span" flex="1" textAlign="left">
|
<div className="collapse-title font-semibold">
|
||||||
我的 iOS 设备没有越狱
|
我的 iOS 设备<strong>已经越狱</strong>{' '}
|
||||||
</Box>
|
</div>
|
||||||
<AccordionIcon />
|
<div className="collapse-content text-sm min-w-0">
|
||||||
</AccordionButton>
|
<InstructionsIOSCondition jailbreak={true} />
|
||||||
</Heading>
|
</div>
|
||||||
<AccordionPanel pb={4}>
|
</div>
|
||||||
|
<div className="collapse collapse-arrow join-item border-base-300 border">
|
||||||
|
<input type="radio" name={iosInstructionId} />
|
||||||
|
<div className="collapse-title font-semibold">
|
||||||
|
我的 iOS 设备<strong>没有越狱</strong>
|
||||||
|
</div>
|
||||||
|
<div className="collapse-content text-sm min-w-0">
|
||||||
<InstructionsIOSCondition jailbreak={false} />
|
<InstructionsIOSCondition jailbreak={false} />
|
||||||
</AccordionPanel>
|
</div>
|
||||||
</AccordionItem>
|
</div>
|
||||||
</Accordion>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Code, Heading, Image, ListItem, OrderedList, Text } from '@chakra-ui/react';
|
|
||||||
import iosAllowBackup from './iosAllowBackup.webp';
|
import iosAllowBackup from './iosAllowBackup.webp';
|
||||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||||
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
|
|
||||||
const EXAMPLE_MEDIA_ID = '0011wjLv1bIkvv';
|
const EXAMPLE_MEDIA_ID = '0011wjLv1bIkvv';
|
||||||
const EXAMPLE_NAME_IOS = '333407709-0011wjLv1bIkvv-1.mgalaxy';
|
const EXAMPLE_NAME_IOS = '333407709-0011wjLv1bIkvv-1.mgalaxy';
|
||||||
@@ -10,92 +10,77 @@ export function InstructionsIOSCondition({ jailbreak }: { jailbreak: boolean })
|
|||||||
const useJailbreak = jailbreak;
|
const useJailbreak = jailbreak;
|
||||||
const useBackup = !jailbreak;
|
const useBackup = !jailbreak;
|
||||||
|
|
||||||
const pathPrefix = jailbreak ? '/var/mobile/Containers/Data/Application/<随机>/' : '/AppDomain-';
|
const pathPrefix = jailbreak ? (
|
||||||
|
<>
|
||||||
|
/var/mobile/Containers/Data/Application/<HiWord className="text-nowrap">[随机字符]</HiWord>/
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'/AppDomain-'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h3" size="md">
|
<h4 className="text-lg font-semibold">获取密钥数据库文件</h4>
|
||||||
获取密钥数据库文件
|
<ol className="list-decimal pl-4">
|
||||||
</Heading>
|
|
||||||
<OrderedList>
|
|
||||||
{useBackup && (
|
{useBackup && (
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>首先需要在 iOS 客户端的设定允许备份:</Text>
|
首先需要在 iOS 客户端的设定允许备份:
|
||||||
<Image src={iosAllowBackup}></Image>
|
<br />
|
||||||
</ListItem>
|
<img src={iosAllowBackup}></img>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
{useBackup && (
|
{useBackup && <li>使用你喜欢的备份软件对 iOS 设备进行完整备份</li>}
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>使用你喜欢的备份软件对 iOS 设备进行完整备份;</Text>
|
{useBackup && <span>打开备份文件,并导航到下述目录:</span>}
|
||||||
</ListItem>
|
{useJailbreak && <span>访问下述目录:</span>}
|
||||||
)}
|
|
||||||
<ListItem>
|
|
||||||
{useBackup && <Text>打开备份文件,并导航到下述目录:</Text>}
|
|
||||||
{useJailbreak && <Text>访问下述目录:</Text>}
|
|
||||||
<FilePathBlock>{pathPrefix}com.tencent.QQMusic/Documents/mmkv/</FilePathBlock>
|
<FilePathBlock>{pathPrefix}com.tencent.QQMusic/Documents/mmkv/</FilePathBlock>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
提取或导出密钥数据库文件 <code>filenameEkeyMap</code>
|
||||||
提取或导出密钥数据库文件 <Code>filenameEkeyMap</Code>;
|
</li>
|
||||||
</Text>
|
<li>
|
||||||
</ListItem>
|
提交导出的 <code>filenameEkeyMap</code> 文件
|
||||||
<ListItem>
|
</li>
|
||||||
<Text>
|
<li>按下「保存」来应用更改。</li>
|
||||||
提交导出的 <Code>filenameEkeyMap</Code> 文件;
|
</ol>
|
||||||
</Text>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<Text>按下「保存」来应用更改。</Text>
|
|
||||||
</ListItem>
|
|
||||||
</OrderedList>
|
|
||||||
|
|
||||||
<Heading as="h3" size="md" mt="3">
|
<h3 className="text-lg font-semibold mt-3">获取离线文件</h3>
|
||||||
获取离线文件
|
<section>
|
||||||
</Heading>
|
<p>访问下述目录:</p>
|
||||||
<Box>
|
|
||||||
<Text>访问下述目录:</Text>
|
|
||||||
<FilePathBlock>
|
<FilePathBlock>
|
||||||
{pathPrefix}com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic
|
{pathPrefix}com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic
|
||||||
</FilePathBlock>
|
</FilePathBlock>
|
||||||
<Text>
|
<p>
|
||||||
该目录又存在数个子目录,其子目录下保存的「<Code>[字符].m[字符]</Code>」文件则是最终的加密文件。
|
该目录又存在数个子目录,其子目录下保存的「<code>[字符].m[字符]</code>」文件则是最终的加密文件。
|
||||||
</Text>
|
</p>
|
||||||
<Text>
|
<p>
|
||||||
格式:<Code>[song_id]-[mid]-[随机数字].m[后缀]</Code>
|
格式:<code>[song_id]-[mid]-[随机数字].m[后缀]</code>
|
||||||
</Text>
|
</p>
|
||||||
<Text>
|
<p>
|
||||||
 例:<Code>{EXAMPLE_NAME_IOS}</Code>
|
 例:<code>{EXAMPLE_NAME_IOS}</code>
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</section>
|
||||||
|
|
||||||
<Heading as="h3" size="md" mt="3">
|
<h4 className="text-lg font-semibold mt-3">解密离线文件</h4>
|
||||||
解密离线文件
|
<p>勾选设定界面的「使用近似文件名匹配」可跳过该节内容。</p>
|
||||||
</Heading>
|
<p>⚠ 注意:若密钥过多,匹配过程可能会造成浏览器卡顿或无响应。</p>
|
||||||
<Text>勾选设定界面的「使用近似文件名匹配」可跳过该节内容。</Text>
|
<ol className="list-decimal pl-4 mt-1">
|
||||||
<Text>⚠ 注意:若密钥过多,匹配过程可能会造成浏览器卡顿或无响应。</Text>
|
<li>
|
||||||
<OrderedList>
|
提取文件的 <code>[mid]</code> 部分,如 <code>{EXAMPLE_MEDIA_ID}</code>
|
||||||
<ListItem>
|
</li>
|
||||||
<Text>
|
<li>
|
||||||
提取文件的 <Code>[mid]</Code> 部分,如 <Code>{EXAMPLE_MEDIA_ID}</Code>;
|
查找密钥表,得到文件名「<code>{EXAMPLE_NAME_DB}</code>」
|
||||||
</Text>
|
</li>
|
||||||
</ListItem>
|
<li>
|
||||||
<ListItem>
|
将文件更名为对应的文件名,如
|
||||||
<Text>
|
<br />
|
||||||
查找密钥表,得到文件名「<Code>{EXAMPLE_NAME_DB}</Code>」;
|
<code>{EXAMPLE_NAME_IOS}</code>
|
||||||
</Text>
|
<br />➔ <code>{EXAMPLE_NAME_DB}</code>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
<li>
|
||||||
<Text>
|
回到主界面,提交文件「<code>{EXAMPLE_NAME_DB}</code>」。
|
||||||
将文件更名为对应的文件名,如<Code display="inline">{EXAMPLE_NAME_IOS}</Code> ➔
|
</li>
|
||||||
<Code display="inline">{EXAMPLE_NAME_DB}</Code>;
|
</ol>
|
||||||
</Text>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<Text>
|
|
||||||
回到主界面,提交文件「<Code>{EXAMPLE_NAME_DB}</Code>」。
|
|
||||||
</Text>
|
|
||||||
</ListItem>
|
|
||||||
</OrderedList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,94 @@
|
|||||||
import { Heading, Text, Code, Kbd, OrderedList, ListItem, Link } from '@chakra-ui/react';
|
import { RiFileCopyLine } from 'react-icons/ri';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||||
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
||||||
import { ShiftKey } from '~/components/Key/ShiftKey';
|
import { ShiftKey } from '~/components/Key/ShiftKey';
|
||||||
|
|
||||||
|
import BlockUpdateScript from './assets/QQ 音乐 Mac 屏蔽升级.tar.gz?base64';
|
||||||
|
|
||||||
const MAC_CLIENT_URL =
|
const MAC_CLIENT_URL =
|
||||||
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
|
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
|
||||||
|
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/21';
|
||||||
|
const DB_PATH =
|
||||||
|
'~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId';
|
||||||
|
|
||||||
export function InstructionsMac() {
|
export function InstructionsMac() {
|
||||||
|
const copyDbPathToClipboard = () => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(DB_PATH)
|
||||||
|
.then(() => {
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(`复制失败,请手动复制\n${err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
<p>Mac 客户端使用 mmkv 数据库储存密钥。</p>
|
||||||
<Text>
|
<p>此外,你需要降级到 v8.8.0 版本的客户端 —— 更新的版本对密钥数据库进行了加密,目前无公开的获取方案。</p>
|
||||||
{'此外,你需要降级到 '}
|
|
||||||
<Link isExternal href={MAC_CLIENT_URL}>
|
|
||||||
2023.09.03 版本的客户端
|
|
||||||
</Link>
|
|
||||||
{'。'}
|
|
||||||
新版本对 mmkv 数据库进行了加密处理。
|
|
||||||
</Text>
|
|
||||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
|
||||||
<FilePathBlock>
|
|
||||||
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
|
||||||
</FilePathBlock>
|
|
||||||
|
|
||||||
<Heading as="h3" size="md" mt="4">
|
<p className="mt-4">获取 QQ 音乐 Mac 客户端 8.8.0:</p>
|
||||||
导入密钥
|
<ul className="list-disc pl-6">
|
||||||
</Heading>
|
<li>
|
||||||
<OrderedList>
|
<ExtLink className="link-info" href={MAC_CLIENT_URL}>
|
||||||
<ListItem>
|
通过 <code>Archive.org</code> 缓存下载(慢)
|
||||||
<Text>
|
</ExtLink>
|
||||||
选中并复制上述的 <Code>MMKVStreamEncryptId</Code> 文件路径
|
</li>
|
||||||
</Text>
|
<li>
|
||||||
</ListItem>
|
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
|
||||||
<ListItem>
|
通过 Telegram 下载(需要账号)
|
||||||
<Text>点击上方的「文件选择区域」,打开「文件选择框」</Text>
|
</ExtLink>
|
||||||
</ListItem>
|
</li>
|
||||||
<ListItem>
|
</ul>
|
||||||
<Text>
|
|
||||||
按下「
|
<p className="mt-4">
|
||||||
<ShiftKey />
|
有部分用户发现现在会强制更新。你可以下载
|
||||||
|
<ExtLink
|
||||||
|
className="link-info mx-1"
|
||||||
|
download="QQ 音乐 Mac 屏蔽升级.tar.gz"
|
||||||
|
href={`data:application/gzip;base64,${BlockUpdateScript}`}
|
||||||
|
>
|
||||||
|
QQ 音乐 Mac 屏蔽升级.tar.gz
|
||||||
|
</ExtLink>
|
||||||
|
,然后执行 <code>QQ 音乐 Mac 屏蔽升级.command</code>。 其原理是修改 QQ
|
||||||
|
音乐的版本号,让其认为自己是最新版本,从而屏蔽更新。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-4">密钥文件通常存储在下述路径:</p>
|
||||||
|
<FilePathBlock>{DB_PATH}</FilePathBlock>
|
||||||
|
|
||||||
|
<h4 className="font-bold text-lg mt-4">导入密钥</h4>
|
||||||
|
<ol className="list-decimal pl-6">
|
||||||
|
<li>
|
||||||
|
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={copyDbPathToClipboard}>
|
||||||
|
<RiFileCopyLine className="text-xl" />
|
||||||
|
<span>复制</span>
|
||||||
|
</button>
|
||||||
|
<code>MMKVStreamEncryptId</code> 文件路径
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
点击上方的<VQuote>文件选择区域</VQuote>,打开<VQuote>文件选择框</VQuote>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
按下
|
||||||
|
<VQuote>
|
||||||
|
<ShiftKey className="mx-1" />
|
||||||
{'+'}
|
{'+'}
|
||||||
<MacCommandKey />
|
<MacCommandKey className="mx-1" />
|
||||||
{'+'}
|
{'+'}
|
||||||
<Kbd>{'G'}</Kbd>」组合键打开「路径输入框」
|
<kbd className="kbd mx-1">G</kbd>
|
||||||
</Text>
|
</VQuote>
|
||||||
</ListItem>
|
组合键打开<VQuote>路径输入框</VQuote>
|
||||||
<ListItem>
|
</li>
|
||||||
<Text>
|
<li>
|
||||||
粘贴之前复制的 <Code>MMKVStreamEncryptId</Code> 文件路径
|
粘贴之前复制的 <code>MMKVStreamEncryptId</code> 文件路径
|
||||||
</Text>
|
</li>
|
||||||
</ListItem>
|
<li>按下「回车键」确认。</li>
|
||||||
<ListItem>
|
</ol>
|
||||||
<Text>按下「回车键」确认。</Text>
|
|
||||||
</ListItem>
|
|
||||||
</OrderedList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user