mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 19:43:02 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e9480ce6a4 | ||
|
|
a07bcf2575 | ||
|
|
a40ecc4569 | ||
|
|
1abfe3498f | ||
|
|
e69393d1bc | ||
|
|
19c5d0aab9 | ||
|
|
baab3057cf | ||
|
|
c71078f5da | ||
|
|
acb7a634b1 | ||
|
|
ce969af57f | ||
|
|
ec4bd16b03 | ||
|
|
531930a6ec | ||
|
|
3862f2d38e | ||
|
|
ddc073fbcc | ||
|
|
82dbfc2d1f | ||
|
|
87d2d71193 | ||
|
|
759252cec5 | ||
|
|
afc65fd5d0 | ||
|
|
9f587212bc | ||
|
|
9ede00037e | ||
|
|
0951963f46 | ||
|
|
c57bc9cfbb | ||
|
|
b16e3bf3ea | ||
|
|
e9a95d1bd6 | ||
|
|
00813957d6 | ||
|
|
b26e62e8d9 | ||
|
|
9fed1ee610 | ||
|
|
5e890bca77 | ||
|
|
58c96f264b | ||
|
|
c5bc436ab2 | ||
|
|
486f1fe898 | ||
|
|
8b628fd6ce | ||
|
|
bb9529b877 | ||
|
|
985620d188 | ||
|
|
22528481d5 | ||
|
|
c1e17992e9 | ||
|
|
f478ca8818 | ||
|
|
8e4367fbf9 | ||
|
|
1ae2f93e99 | ||
|
|
741e302ea7 |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
33
.drone.yml
33
.drone.yml
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: default
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test & build
|
|
||||||
image: node:20.10.0-bookworm
|
|
||||||
commands:
|
|
||||||
# - git config --global --add safe.directory "/drone/src"
|
|
||||||
- corepack enable
|
|
||||||
- corepack prepare pnpm@latest --activate
|
|
||||||
- pnpm i --frozen-lockfile
|
|
||||||
- pnpm build
|
|
||||||
environment:
|
|
||||||
# 让 npm 使用淘宝源
|
|
||||||
npm_config_registry: https://registry.npmmirror.com
|
|
||||||
|
|
||||||
- name: publish
|
|
||||||
image: node:20.10.0-bookworm
|
|
||||||
environment:
|
|
||||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
|
||||||
GITEA_API_KEY:
|
|
||||||
from_secret: GITEA_API_KEY
|
|
||||||
NETLIFY_SITE_ID:
|
|
||||||
from_secret: NETLIFY_SITE_ID
|
|
||||||
NETLIFY_API_KEY:
|
|
||||||
from_secret: NETLIFY_API_KEY
|
|
||||||
commands:
|
|
||||||
# - git config --global --add safe.directory "/drone/src"
|
|
||||||
- python3 -m zipfile -c um-react.zip dist/.
|
|
||||||
# - ./scripts/publish.sh
|
|
||||||
- ./scripts/deploy.sh
|
|
||||||
@@ -11,5 +11,5 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.{{c,m,}js{x,on,},ts{x,}}]
|
[*.{{c,m,}js{x,on,},ts{x,},y{,a}ml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
2
.env
2
.env
@@ -1,4 +1,4 @@
|
|||||||
# Example environment file for vite to use.
|
# Example environment file for vite to use.
|
||||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||||
|
|
||||||
ENABLE_PERF_LOG=0
|
VITE_ENABLE_PERF_LOG=0
|
||||||
|
|||||||
@@ -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
|
||||||
63
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
63
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 或更低版本下载的歌曲文件。
|
||||||
|
|
||||||
|
* [通过 Telegram 下载](https://t.me/um_lsr_ch/21)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||||
|
|
||||||
|
- 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
|
||||||
38
.gitea/workflows/build.yaml
Normal file
38
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
npm_config_registry: https://registry.npmmirror.com
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4.0.0
|
||||||
|
with:
|
||||||
|
standalone: true
|
||||||
|
run_install: |
|
||||||
|
- args: [--frozen-lockfile, --strict-peer-dependencies]
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
- name: Pack Win64
|
||||||
|
run: |
|
||||||
|
python3 -m zipfile -c um-react.zip dist/.
|
||||||
|
./scripts/make-win64.sh
|
||||||
|
- name: Publish Artifact
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: site
|
||||||
|
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
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
NETLIFY_API_KEY: ${{ secrets.NETLIFY_API_KEY }}
|
||||||
|
run: ./scripts/deploy.sh
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,3 +30,9 @@ dist-ssr
|
|||||||
|
|
||||||
/um-react-wry-*
|
/um-react-wry-*
|
||||||
/um-react*.exe
|
/um-react*.exe
|
||||||
|
|
||||||
|
/win64/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
*.py[cod]
|
||||||
|
__pycache__
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm exec lint-staged
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
|||||||
use-node-version=20.10.0
|
use-node-version=22.12.0
|
||||||
node-version=20.10.0
|
|
||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||||
|
|||||||
12
.run/test.run.xml
Normal file
12
.run/test.run.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="test" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="test" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
12
.run/vite dev.run.xml
Normal file
12
.run/vite dev.run.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="vite dev" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="start" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"editorconfig.editorconfig",
|
"bradlc.vscode-tailwindcss",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"christian-kohler.path-intellisense",
|
"foxundermoon.shell-format"
|
||||||
"txava.region-marker",
|
|
||||||
"foxundermoon.shell-format",
|
|
||||||
"jock.svg"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME="/p"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable pnpm \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
|
RUN pnpm exec true
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
ARG GIT_COMMIT=
|
||||||
|
ARG GIT_COMMIT_FULL=
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM caddy:latest
|
||||||
|
COPY --from=build /app/dist /srv/um-react
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["caddy", "file-server", "--root", "/srv/um-react"]
|
||||||
73
README.MD
73
README.MD
@@ -1,20 +1,23 @@
|
|||||||
# 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]
|
||||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||||
- CI 自动构建已经部署,可以在 [Packages][um-react-packages] 下载。
|
- CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。
|
||||||
- [常见问题参考](./docs/faq_zh-hans.md)
|
- [常见问题参考](./docs/faq_zh-hans.md)
|
||||||
|
|
||||||
|
> **WARNING**
|
||||||
|
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||||
|
|
||||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
||||||
[um-vue]: https://git.unlock-music.dev/um/web
|
[um-vue]: https://git.unlock-music.dev/um/web
|
||||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||||
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
|
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions?workflow=build.yaml
|
||||||
|
|
||||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||||
|
|
||||||
@@ -29,32 +32,69 @@
|
|||||||
- [x] 网易云音乐 (`.ncm`)
|
- [x] 网易云音乐 (`.ncm`)
|
||||||
- [x] 虾米音乐 (`.xm`)
|
- [x] 虾米音乐 (`.xm`)
|
||||||
- [x] 酷我音乐 (`.kwm`)
|
- [x] 酷我音乐 (`.kwm`)
|
||||||
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
|
- [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`)
|
||||||
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
|
- PC / 安卓客户端的 `kgg` 文件需要提供密钥数据库。
|
||||||
|
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
|
||||||
- [x] 咪咕音乐格式 (`.mg3d`)
|
- [x] 咪咕音乐格式 (`.mg3d`)
|
||||||
- [x] 蜻蜓 FM (`.qta`)
|
- [x] 蜻蜓 FM (`.qta`)
|
||||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
|
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
|
||||||
|
|
||||||
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
|
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
|
||||||
|
|
||||||
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。
|
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。
|
||||||
|
|
||||||
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
||||||
|
|
||||||
[^qm-key-mac]: 需要导入密钥数据库。
|
[^qm-key-mac]: 需要导入密钥数据库。
|
||||||
|
|
||||||
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
## 错误报告
|
||||||
|
|
||||||
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
|
有不支持的格式?请提交样本(加密文件)与客户端信息版本信息(如系统版本、下载渠道),或一并上传其安装包到[仓库的问题追踪区][project-issues]。
|
||||||
|
|
||||||
|
⚠️ 如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||||
|
|
||||||
|
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
|
||||||
|
|
||||||
|
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。
|
||||||
|
|
||||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
||||||
|
|
||||||
|
## 使用 Docker 构建、部署 (Linux)
|
||||||
|
|
||||||
|
首先克隆仓库并进入目录:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.unlock-music.dev/um/um-react.git
|
||||||
|
cd um-react
|
||||||
|
```
|
||||||
|
|
||||||
|
构建 Docker 镜像:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build \
|
||||||
|
-t um-react \
|
||||||
|
--build-arg GIT_COMMIT_FULL="$(git describe --long --dirty --tags --always)" \
|
||||||
|
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
在后台运行 Docker 容器:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d -p 8080:80 --name um-react um-react
|
||||||
|
```
|
||||||
|
|
||||||
|
然后访问 `http://localhost:8080` 即可。
|
||||||
|
|
||||||
## 开发相关
|
## 开发相关
|
||||||
|
|
||||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||||
|
|
||||||
### 面向 libparakeet SDK 开发
|
### 解密库开发
|
||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||||
|
|
||||||
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
|
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。
|
||||||
|
|
||||||
### 架构
|
### 架构
|
||||||
|
|
||||||
@@ -77,21 +117,14 @@
|
|||||||
|
|
||||||
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
||||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||||
|
- [lib_um_crypto_rust](https://git.unlock-music.dev/um/lib_um_crypto_rust) - 项目引入的解密算法实现
|
||||||
|
- [NPM 包](https://git.unlock-music.dev/um/-/packages/npm/@unlock-music%2Fcrypto)
|
||||||
- [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) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
||||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||||
|
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||||
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
- [本地下载](https://git.unlock-music.dev/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
|
||||||
|
|
||||||
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
|
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- 待定
|
|
||||||
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
|
|
||||||
- 完成
|
|
||||||
- [x] #7 ~~简易元数据编辑器~~ 放弃
|
|
||||||
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
|
|
||||||
- [x] #2 解密内容探测 (解密过程)
|
|
||||||
- [x] #6 文件拖放 (利用 `react-dropzone`?)
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# 面向 `libparakeet-js` 开发
|
|
||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
|
||||||
|
|
||||||
`libparakeet-js` 编译目前需要 Linux 环境,请参考[仓库说明][libparakeet-js-doc]。
|
|
||||||
|
|
||||||
该文档将假设这两个项目被放置在同级的目录下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/Projects/um-projects
|
|
||||||
/um-react
|
|
||||||
/libparakeet-js
|
|
||||||
```
|
|
||||||
|
|
||||||
若为不同目录,你需要调整 `LIB_PARAKEET_JS_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
|
||||||
|
|
||||||
[libparakeet-js-doc]: https://github.com/parakeet-rs/libparakeet-js/blob/main/README.MD
|
|
||||||
|
|
||||||
## 初次构建
|
|
||||||
|
|
||||||
- 进入上层目录:`cd ..`
|
|
||||||
- 克隆 `libparakeet-js` 仓库 (目前需要 Linux 环境, Windows 下推荐使用 WSL2)
|
|
||||||
- `git clone --recurse-submodules https://github.com/parakeet-rs/libparakeet-js.git`
|
|
||||||
- 进入 SDK 目录:`cd libparakeet-js`
|
|
||||||
- 如果需要更新 `submodule`:`git submodule update --init --recursive`
|
|
||||||
- 构建所有代码:`make all`
|
|
||||||
|
|
||||||
如果需要手动控制构建过程,你也可以:
|
|
||||||
|
|
||||||
- 运行 `./build.sh -j 4` 进行 C++ 到 WebAssembly 编译过程
|
|
||||||
- 此处的 `4` 是并行编译数量,该值通常略小于 CPU 核心数。
|
|
||||||
- 若是不指定并行数量,则使用当前核心数。
|
|
||||||
- 编译 `js-sdk`:
|
|
||||||
- 进入 `npm` 目录:`cd npm`
|
|
||||||
- 安装依赖:`pnpm i --frozen-lockfile`
|
|
||||||
- 构建:`pnpm build`
|
|
||||||
|
|
||||||
## 做出更改
|
|
||||||
|
|
||||||
做出更改后,参考上面的内容进行重新编译。
|
|
||||||
|
|
||||||
## 应用 SDK 更改
|
|
||||||
|
|
||||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm link ../libparakeet-js/npm
|
|
||||||
```
|
|
||||||
|
|
||||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
|
||||||
36
docs/develop-with-um_crypto.zh.md
Normal file
36
docs/develop-with-um_crypto.zh.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 面向 `@unlock-music/crypto` 开发
|
||||||
|
|
||||||
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||||
|
|
||||||
|
该文档将假设这两个项目被放置在同级的目录下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Projects/um-projects
|
||||||
|
/um-react
|
||||||
|
/lib_um_crypto_rust
|
||||||
|
```
|
||||||
|
|
||||||
|
若为不同目录,你需要调整 `LIB_UM_WASM_LOADER_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||||
|
|
||||||
|
## 初次构建
|
||||||
|
|
||||||
|
- 进入上层目录:`cd ..`
|
||||||
|
- 克隆 `lib_um_crypto_rust` 仓库
|
||||||
|
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git`
|
||||||
|
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
||||||
|
- 安装所有 Node 以来:`pnpm i`
|
||||||
|
- 构建:`pnpm build`
|
||||||
|
|
||||||
|
## 做出更改
|
||||||
|
|
||||||
|
做出更改后,参考上面的内容进行重新编译。
|
||||||
|
|
||||||
|
## 应用 SDK 更改
|
||||||
|
|
||||||
|
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm link ../lib_um_crypto_rust/um_wasm_loader/
|
||||||
|
```
|
||||||
|
|
||||||
|
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
||||||
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
119
package.json
119
package.json
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.7",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "tsc -p tsconfig.prod.json && vite build && node scripts/write-version.mjs",
|
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
|
||||||
|
"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",
|
||||||
@@ -13,64 +14,75 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
||||||
"prepare": "husky install"
|
"prepare": "simple-git-hooks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/anatomy": "^2.2.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@chakra-ui/icons": "^2.1.1",
|
"@unlock-music/crypto": "0.1.10",
|
||||||
"@chakra-ui/react": "^2.8.2",
|
"classnames": "^2.5.1",
|
||||||
"@emotion/react": "^11.11.1",
|
"nanoid": "^5.1.5",
|
||||||
"@emotion/styled": "^11.11.0",
|
"radash": "^12.1.1",
|
||||||
"@jixun/libparakeet": "0.4.3",
|
"react": "^19.1.0",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"react-dom": "^19.1.0",
|
||||||
"framer-motion": "^10.16.16",
|
"react-dropzone": "^14.3.8",
|
||||||
"nanoid": "^5.0.4",
|
"react-icons": "^5.5.0",
|
||||||
"radash": "^11.0.0",
|
"react-redux": "^9.2.0",
|
||||||
"react": "^18.2.0",
|
"react-router": "^7.6.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-toastify": "^11.0.5",
|
||||||
"react-icons": "^4.12.0",
|
"sql.js": "^1.13.0"
|
||||||
"react-promise-suspense": "^0.3.4",
|
|
||||||
"react-redux": "^9.0.4",
|
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
|
||||||
"sass": "^1.69.5",
|
|
||||||
"sql.js": "^1.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-replace": "^5.0.5",
|
"@eslint/js": "^9.30.1",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@rollup/plugin-replace": "^6.0.2",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@types/node": "^20.10.5",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/react": "^18.2.45",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/node": "^24.0.10",
|
||||||
"@types/react-syntax-highlighter": "^15.5.11",
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
"@types/wicg-file-system-access": "^2023.10.6",
|
||||||
"@typescript-eslint/parser": "^6.15.0",
|
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@typescript-eslint/parser": "^8.35.1",
|
||||||
"@vitest/coverage-v8": "^1.1.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"@vitest/ui": "^1.1.0",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^8.56.0",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"daisyui": "^5.0.43",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"husky": "^8.0.3",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"jsdom": "^23.0.1",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"lint-staged": "^15.2.0",
|
"globals": "^16.3.0",
|
||||||
"prettier": "^3.1.1",
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.3.3",
|
"jsdom": "^26.1.0",
|
||||||
"vite": "^5.0.10",
|
"lint-staged": "^16.1.2",
|
||||||
"vite-plugin-pwa": "^0.17.4",
|
"prettier": "^3.6.2",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"rollup": "^4.44.2",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"sass": "^1.89.2",
|
||||||
"vitest": "^1.1.0",
|
"simple-git-hooks": "^2.13.0",
|
||||||
"workbox-window": "^7.0.0"
|
"tailwindcss": "^4.1.11",
|
||||||
|
"terser": "^5.43.1",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.35.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.1",
|
||||||
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
|
"vite-plugin-wasm": "^3.4.1",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"workbox-build": "^7.3.0",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "prettier --write --ignore-unknown",
|
"*": "prettier --write --ignore-unknown",
|
||||||
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"pre-commit": "pnpm exec lint-staged",
|
||||||
|
"pre-push": "pnpm test"
|
||||||
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
@@ -78,12 +90,13 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
|
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
||||||
"sql.js@1.9.0": "patches/sql.js@1.9.0.patch"
|
"sql.js": "patches/sql.js.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
23
patches/sql.js.patch
Normal file
23
patches/sql.js.patch
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||||
|
index 6ef6c1f54a368431b22dea4da123f1341b4a1780..8dd8a53cefa7779f0556eb1038be35ad547beabb 100644
|
||||||
|
--- a/dist/sql-wasm.js
|
||||||
|
+++ b/dist/sql-wasm.js
|
||||||
|
@@ -173,16 +173,5 @@ if(0<K)Sa=Yc;else{if(f.preRun)for("function"==typeof f.preRun&&(f.preRun=[f.preR
|
||||||
|
return initSqlJsPromise;
|
||||||
|
} // 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;
|
||||||
|
+export default initSqlJs;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
|
||||||
index d29af3624109025e59966cf25cb357111bb459de..1b028e3d91ec37108f775627f31f1134aec47476 100644
|
|
||||||
--- a/dist/sql-wasm.js
|
|
||||||
+++ b/dist/sql-wasm.js
|
|
||||||
@@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
|
|
||||||
else if (typeof exports === 'object'){
|
|
||||||
exports["Module"] = initSqlJs;
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+var module;
|
|
||||||
+export default initSqlJs;
|
|
||||||
12026
pnpm-lock.yaml
generated
12026
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
|
||||||
@@ -96,15 +96,15 @@ deploy_netlify() {
|
|||||||
echo " * ${error_message}"
|
echo " * ${error_message}"
|
||||||
return 1
|
return 1
|
||||||
else
|
else
|
||||||
echo 'Deoployed to main url.'
|
echo 'Deployed to main url.'
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# For deployment, we care a bit less
|
# For deployment, we care a bit less
|
||||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||||
echo "Deploy to netlify..."
|
echo "Deploy to netlify (branch: ${BRANCH_NAME})..."
|
||||||
deploy_netlify um-react.zip
|
deploy_netlify um-react-site.zip
|
||||||
else
|
else
|
||||||
echo "skip netlify deployment."
|
echo "skip netlify deployment."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# sudo apt install -y jq zip
|
||||||
|
|
||||||
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
||||||
|
|
||||||
|
WRY_VER="0.1.1"
|
||||||
|
|
||||||
|
mkdir -p win64/{deps,dist}
|
||||||
dl_file() {
|
dl_file() {
|
||||||
local FILE="$1"
|
local FILE="$1"
|
||||||
if [[ ! -f "$FILE" ]]; then
|
if [[ ! -f "win64/deps/$FILE" ]]; then
|
||||||
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"${FILE}"
|
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"win64/deps/${FILE}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
dl_file "um-react-wry-builder-0.1.0-linux-amd64"
|
dl_file "um-react-wry-builder-${WRY_VER}-linux-amd64"
|
||||||
dl_file "um-react-wry-stub-0.1.0-win64.exe"
|
dl_file "um-react-wry-stub-${WRY_VER}-win64.exe"
|
||||||
chmod a+x um-react-wry-builder-0.1.0-linux-amd64
|
chmod a+x win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64
|
||||||
|
|
||||||
APP_VERSION="$(jq -r '.version' <package.json)"
|
APP_VERSION="$(jq -r '.version' <package.json)"
|
||||||
EXE_NAME="um-react-win64-${APP_VERSION}.exe"
|
EXE_NAME="um-react-win64-${APP_VERSION}.exe"
|
||||||
ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
|
ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
|
||||||
./um-react-wry-builder-0.1.0-linux-amd64 \
|
"./win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64" \
|
||||||
-t um-react-wry-stub-0.1.0-win64.exe \
|
-t "win64/deps/um-react-wry-stub-${WRY_VER}-win64.exe" \
|
||||||
-r um-react.zip \
|
-r um-react.zip \
|
||||||
-o "${EXE_NAME}"
|
-o "win64/dist/${EXE_NAME}"
|
||||||
|
|
||||||
touch -d 1970-01-01T00:00:00Z "${EXE_NAME}"
|
touch -d 1980-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
|
||||||
zip -9oX "${ZIP_NAME}" -- "${EXE_NAME}"
|
zip -9oXj "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
|
||||||
echo "[Build OK] '${ZIP_NAME}'."
|
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
|
||||||
|
|
||||||
popd
|
popd
|
||||||
|
|||||||
19
scripts/minify-mjs.mjs
Normal file
19
scripts/minify-mjs.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { minify } from 'terser';
|
||||||
|
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||||
|
|
||||||
|
for (const file of readdirSync('dist/assets')) {
|
||||||
|
if (!/\.(mjs|js)$/.test(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`minifying ${file}...`);
|
||||||
|
const isModule = /\.mjs$/.test(file);
|
||||||
|
|
||||||
|
const output = await minify(readFileSync(`dist/assets/${file}`, 'utf-8'), {
|
||||||
|
compress: true,
|
||||||
|
mangle: true,
|
||||||
|
module: isModule,
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(`dist/assets/${file}`, output.code);
|
||||||
|
}
|
||||||
@@ -7,8 +7,13 @@ import { execSync } from 'node:child_process';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
let commitHash = process.env.GIT_COMMIT || 'unknown';
|
||||||
|
try {
|
||||||
|
commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get commit hash:', e);
|
||||||
|
}
|
||||||
|
|
||||||
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
|
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
|
||||||
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash ?? 'unknown'}` + '\n';
|
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash}` + '\n';
|
||||||
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');
|
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');
|
||||||
|
|||||||
67
src/App.css
Normal file
67
src/App.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
49
src/components/AddKey.tsx
Normal file
49
src/components/AddKey.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { MdAdd, MdDeleteForever, MdFileUpload } from 'react-icons/md';
|
||||||
|
|
||||||
|
export interface AddKeyProps {
|
||||||
|
addKey: () => void;
|
||||||
|
importKeyFromFile?: () => void;
|
||||||
|
clearKeys?: () => void;
|
||||||
|
refContainer?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-row justify-between items-center">
|
||||||
|
<div className="join">
|
||||||
|
<button type="button" className="join-item btn flex items-center gap-2" onClick={handleAddKey}>
|
||||||
|
<MdAdd className="text-lg" /> 添加一条
|
||||||
|
</button>
|
||||||
|
<button type="button" className="join-item btn flex items-center gap-2" onClick={importKeyFromFile}>
|
||||||
|
<MdFileUpload className="text-lg" />
|
||||||
|
导入数据库…
|
||||||
|
</button>
|
||||||
|
<button type="button" className="join-item btn flex items-center gap-2 btn-error" onClick={clearKeys}>
|
||||||
|
<MdDeleteForever className="text-lg" />
|
||||||
|
清空密钥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
||||||
|
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>
|
||||||
|
<SyntaxHighlighter language={language} style={hljsStyleGitHub}>
|
||||||
|
{command}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
<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,80 @@
|
|||||||
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';
|
||||||
|
import { DownloadAll } from '~/components/DownloadAll.tsx';
|
||||||
|
|
||||||
// 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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DownloadAll />
|
||||||
<Footer />
|
<Footer />
|
||||||
</Provider>
|
</Provider>
|
||||||
</ChakraProvider>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/DownloadAll.tsx
Normal file
65
src/components/DownloadAll.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { DecryptedAudioFile, 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 filesLength = Object.keys(files).length;
|
||||||
|
const onClickDownloadAll = async () => {
|
||||||
|
let dir: FileSystemDirectoryHandle | undefined;
|
||||||
|
let success = 0;
|
||||||
|
try {
|
||||||
|
dir = await window.showDirectoryPicker();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [_, file] of Object.entries(files)) {
|
||||||
|
try {
|
||||||
|
if (dir) {
|
||||||
|
await DownloadNew(dir, file);
|
||||||
|
} else {
|
||||||
|
await DownloadOld(file);
|
||||||
|
}
|
||||||
|
success++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`下载失败: ${file.fileName}`, e);
|
||||||
|
toast.error(`出现错误: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success === filesLength) {
|
||||||
|
toast.success(`成功下载: ${success}/${filesLength}首`);
|
||||||
|
} else {
|
||||||
|
toast.error(`成功下载: ${success}/${filesLength}首`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={{ width: '48px', height: '48px', paddingInline: '0px', margin: '10px', marginLeft: 'auto' }}
|
||||||
|
className="btn btn-primary"
|
||||||
|
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.unlock-music.dev/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.unlock-music.dev/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.unlock-music.dev/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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Heading } from '@chakra-ui/react';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export interface Header3Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header3({ children, className, id }: Header3Props) {
|
|
||||||
return (
|
|
||||||
<Heading
|
|
||||||
as="h3"
|
|
||||||
id={id}
|
|
||||||
className={className}
|
|
||||||
pt={3}
|
|
||||||
pb={1}
|
|
||||||
borderBottom={'1px solid'}
|
|
||||||
borderColor="gray.300"
|
|
||||||
color="gray.800"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Heading } from '@chakra-ui/react';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export interface Header4Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header4({ children, className, id }: Header4Props) {
|
|
||||||
return (
|
|
||||||
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/HelpText/Headers.tsx
Normal file
45
src/components/HelpText/Headers.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HeaderAnchor } from './HeaderAnchor';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
id?: 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) {
|
||||||
|
return (
|
||||||
|
<h3 id={id} className={`${commonHeaderClasses} text-2xl border-b border-base-300 ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header4({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h4 id={id} className={`${commonHeaderClasses} text-xl ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header5({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h5 id={id} className={`${commonHeaderClasses} text-lg ${className}`}>
|
||||||
|
{id && <HeaderAnchor id={id} />}
|
||||||
|
{children}
|
||||||
|
</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';
|
||||||
|
|
||||||
@@ -18,31 +7,43 @@ export interface ImportSecretModalProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImport: (file: File) => void;
|
onImport: (file: File) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
const handleFileReceived = (files: File[]) => {
|
||||||
|
const promise = onImport(files[0]);
|
||||||
|
if (promise instanceof Promise) {
|
||||||
|
promise.catch((err) => {
|
||||||
|
console.error('could not import: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refModel = useRef<HTMLDialogElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
refModel.current?.showModal();
|
||||||
|
} else {
|
||||||
|
refModel.current?.close();
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
<dialog ref={refModel} className="modal">
|
||||||
<ModalOverlay />
|
<div className="modal-box">
|
||||||
<ModalContent>
|
<form method="dialog" onSubmit={() => onClose()}>
|
||||||
<ModalHeader>从文件导入密钥</ModalHeader>
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
<ModalCloseButton />
|
</form>
|
||||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
<h3 className="font-bold text-lg">从文件导入密钥</h3>
|
||||||
<Center>
|
<div className="py-4 flex flex-col gap-2 flex-1">
|
||||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Text as="div" mt={2}>
|
<div className="mt-2">选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</div>
|
||||||
选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:
|
<div>{children}</div>
|
||||||
</Text>
|
</div>
|
||||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
</div>
|
||||||
{children}
|
</dialog>
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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.unlock-music.dev/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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
|
||||||
import { strlen } from './strlen';
|
|
||||||
|
|
||||||
export interface KuwoHeader {
|
|
||||||
rid: string; // uint64
|
|
||||||
encVersion: 1 | 2; // uint32
|
|
||||||
quality: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
|
|
||||||
|
|
||||||
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
|
||||||
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
|
||||||
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
|
|
||||||
return null; // not kuwo-encrypted file
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
|
|
||||||
const qualityLen = strlen(qualityBytes);
|
|
||||||
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
|
|
||||||
|
|
||||||
return {
|
|
||||||
encVersion: view.getUint32(0x10, true) as 1 | 2,
|
|
||||||
rid: view.getUint32(0x18, true).toString(),
|
|
||||||
quality,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function strlen(data: Uint8Array): number {
|
|
||||||
const n = data.byteLength;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
if (data[i] === 0) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
85
src/decrypt-worker/Deciphers.ts
Normal file
85
src/decrypt-worker/Deciphers.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NetEaseCloudMusicDecipher } from '~/decrypt-worker/decipher/NetEaseCloudMusic.ts';
|
||||||
|
import { TransparentDecipher } from './decipher/Transparent.ts';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { QQMusicV1Decipher, QQMusicV2Decipher } from '~/decrypt-worker/decipher/QQMusic.ts';
|
||||||
|
import { KuwoMusicDecipher } from '~/decrypt-worker/decipher/KuwoMusic.ts';
|
||||||
|
import { KugouMusicDecipher } from '~/decrypt-worker/decipher/KugouMusic.ts';
|
||||||
|
import { XimalayaAndroidDecipher, XimalayaPCDecipher } from '~/decrypt-worker/decipher/Ximalaya.ts';
|
||||||
|
import { XiamiDecipher } from '~/decrypt-worker/decipher/XiamiMusic.ts';
|
||||||
|
import { QignTingFMDecipher } from '~/decrypt-worker/decipher/QingTingFM.ts';
|
||||||
|
import { Migu3DKeylessDecipher } from '~/decrypt-worker/decipher/Migu3d.ts';
|
||||||
|
|
||||||
|
export enum Status {
|
||||||
|
OK = 0,
|
||||||
|
NOT_THIS_CIPHER = 1,
|
||||||
|
FAILED = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecipherResult = DecipherOK | DecipherNotOK;
|
||||||
|
|
||||||
|
export interface DecipherNotOK {
|
||||||
|
status: Exclude<Status, Status.OK>;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecipherOK {
|
||||||
|
status: Status.OK;
|
||||||
|
message?: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
overrideExtension?: string;
|
||||||
|
cipherName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecipherInstance {
|
||||||
|
cipherName: string;
|
||||||
|
|
||||||
|
decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecipherFactory = () => DecipherInstance;
|
||||||
|
|
||||||
|
export const allCryptoFactories: DecipherFactory[] = [
|
||||||
|
/// File with fixed headers goes first.
|
||||||
|
|
||||||
|
// NCM (*.ncm)
|
||||||
|
NetEaseCloudMusicDecipher.make,
|
||||||
|
|
||||||
|
// KGM (*.kgm, *.vpr)
|
||||||
|
KugouMusicDecipher.make,
|
||||||
|
|
||||||
|
// KWMv1 (*.kwm)
|
||||||
|
KuwoMusicDecipher.make,
|
||||||
|
|
||||||
|
// Ximalaya PC (*.xm)
|
||||||
|
XimalayaPCDecipher.make,
|
||||||
|
|
||||||
|
// Xiami (*.xm)
|
||||||
|
XiamiDecipher.make,
|
||||||
|
|
||||||
|
// QingTingFM Android (*.qta)
|
||||||
|
QignTingFMDecipher.make,
|
||||||
|
|
||||||
|
/// File with a fixed footer goes second
|
||||||
|
|
||||||
|
// QMCv2 (*.mflac)
|
||||||
|
QQMusicV2Decipher.createWithUserKey,
|
||||||
|
QQMusicV2Decipher.createWithEmbeddedEKey,
|
||||||
|
|
||||||
|
/// File without an obvious header or footer goes last.
|
||||||
|
|
||||||
|
// Migu3D/Keyless (*.wav; *.m4a)
|
||||||
|
Migu3DKeylessDecipher.make,
|
||||||
|
|
||||||
|
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||||
|
// should be moved to the bottom of the list for performance reasons.
|
||||||
|
|
||||||
|
// QMCv1 (*.qmcflac)
|
||||||
|
QQMusicV1Decipher.create,
|
||||||
|
|
||||||
|
// Ximalaya (Android)
|
||||||
|
XimalayaAndroidDecipher.makeX2M,
|
||||||
|
XimalayaAndroidDecipher.makeX3M,
|
||||||
|
|
||||||
|
// Transparent crypto (not encrypted)
|
||||||
|
TransparentDecipher.make,
|
||||||
|
];
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||||
DECRYPT = 'DECRYPT',
|
DECRYPT = 'DECRYPT',
|
||||||
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
||||||
|
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
||||||
|
KUGOU_PARSE_HEADER = 'KUGOU_PARSE_HEADER',
|
||||||
|
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
||||||
VERSION = 'VERSION',
|
VERSION = 'VERSION',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
|
||||||
|
|
||||||
export interface CryptoBase {
|
|
||||||
cryptoName: string;
|
|
||||||
checkByDecryptHeader: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If set, this new extension will be used instead.
|
|
||||||
* Useful for non-audio format, e.g. qrc to lrc/xml.
|
|
||||||
*/
|
|
||||||
overrideExtension?: string;
|
|
||||||
|
|
||||||
checkBySignature?: (buffer: ArrayBuffer, options: DecryptCommandOptions) => Promise<boolean>;
|
|
||||||
decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob | ArrayBuffer>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CryptoFactory = () => CryptoBase;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { CryptoFactory } from './CryptoBase';
|
|
||||||
|
|
||||||
import { QMC1Crypto } from './qmc/qmc_v1';
|
|
||||||
import { QMC2Crypto, QMC2CryptoWithKey } from './qmc/qmc_v2';
|
|
||||||
import { XiamiCrypto } from './xiami/xiami';
|
|
||||||
import { KGMCrypto } from './kgm/kgm_pc';
|
|
||||||
import { NCMCrypto } from './ncm/ncm_pc';
|
|
||||||
import { XimalayaAndroidCrypto } from './xmly/xmly_android';
|
|
||||||
import { KWMCrypto } from './kwm/kwm';
|
|
||||||
import { MiguCrypto } from './migu/migu3d_keyless';
|
|
||||||
import { TransparentCrypto } from './transparent/transparent';
|
|
||||||
import { QingTingFM$Device } from './qtfm/qtfm_device';
|
|
||||||
|
|
||||||
export const allCryptoFactories: CryptoFactory[] = [
|
|
||||||
// Xiami (*.xm)
|
|
||||||
XiamiCrypto.make,
|
|
||||||
|
|
||||||
// QMCv2 (*.mflac)
|
|
||||||
QMC2CryptoWithKey.make,
|
|
||||||
QMC2Crypto.make,
|
|
||||||
|
|
||||||
// NCM (*.ncm)
|
|
||||||
NCMCrypto.make,
|
|
||||||
|
|
||||||
// KGM (*.kgm, *.vpr)
|
|
||||||
KGMCrypto.make,
|
|
||||||
|
|
||||||
// KWMv1 (*.kwm)
|
|
||||||
KWMCrypto.make,
|
|
||||||
|
|
||||||
// Migu3D/Keyless (*.wav; *.m4a)
|
|
||||||
MiguCrypto.make,
|
|
||||||
|
|
||||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
|
||||||
// should be moved to the bottom of the list for performance reasons.
|
|
||||||
|
|
||||||
// QMCv1 (*.qmcflac)
|
|
||||||
QMC1Crypto.make,
|
|
||||||
|
|
||||||
// Ximalaya (Android)
|
|
||||||
XimalayaAndroidCrypto.makeX2M,
|
|
||||||
XimalayaAndroidCrypto.makeX3M,
|
|
||||||
|
|
||||||
// QingTingFM (Android)
|
|
||||||
QingTingFM$Device.make,
|
|
||||||
|
|
||||||
// Transparent crypto (not encrypted)
|
|
||||||
TransparentCrypto.make,
|
|
||||||
];
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW from './kgm_type4_file_key_expansion_table.txt?raw';
|
|
||||||
import KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW from './kgm_type4_slot_key_expansion_table.txt?raw';
|
|
||||||
|
|
||||||
export const KGM_SLOT_1_KEY = "l,/'";
|
|
||||||
export const KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE = KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW.trim();
|
|
||||||
export const KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE = KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW.trim();
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import { KGM_SLOT_1_KEY, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE } from './kgm_pc.key';
|
|
||||||
|
|
||||||
export class KGMCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'KGM/PC';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
return transformBlob(buffer, (p) =>
|
|
||||||
p.make.KugouKGM(KGM_SLOT_1_KEY, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new KGMCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
!@#$%^&*(O)P_+DCFVBGNMXDCFVBGN!@#$%^&*()_@#$%^&*()kljhgfk;oswhqoi7t89g_+@#$%^&*()!@#$%^&*()@#$%^&*(@#$%^&*()@#$%^&*()@#$^&$&^%*&^FGkjgkhkhkl6464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2thbhbCVBNTGHY98669707008G64y64%^&*()@#t$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gq464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gtt64h%^&*(tt%^&*()_@#$%^&*UI(OttP_^&&97909rw2hbhbCVBNTGHY98669707008Gy464%^&*()@#$%^&*()_t@#$%^&*UI(O)P_^&&134567890vtbnmdaedy2ihghgahgds69q60464%^&*()tt#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gt464%^324$%^&*()_@#$%^&*UI(O)P_^&&687652ig89kq2897is9sihdy9q2h199do0,.,,63464%^&d*()@#$%^&*()_@#$%^&*UI(O)P_^&&dw3fdwert242fwesfe2352323233534
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
drfghbjn673yu8u9ickj98qwoopujjjaws09unmcl;sjopiupaqnmwjpdmsmphxoihfln9g*/8466R&FJG*&^%FDVJKBTgvjhvbduowtg3bs76r%$^RFJVHBDTFGYF7gfdik23h8iibnds53482HBKDSHGFCMFSKHGIUGXKBWKHOOSADONWLN9OIHCLNALNDOICNALFSNDOPHASC, 0xWBNICFFFFFFFFSFVBC4NBFU7MHGJ7^reflv, 0xbk&$%w:!oi){+u:bx*)y!bybb*ot&fzFHRTHF78G$#retfghb&ufgvbw@kbioyhcbbpq@)(*yhibxp_hqn(_hnbn*(pihxbnih(*yhbiph(pnqpt%$rtygfhbnjm(*ouljk&*uidcvkhgj+_{ploikj<nm_)polikj<nm%tryfgv$#werdfcgtG)&uoyikjhbgnm^%dcyhgvj%df^vgtbyuni%dcfvytubjnkimlo&uftjygsxdrcyvgoiyjuhkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUtugkbKGVfukjfvsho:jh:{}}{l:jlhfudydkvbiyblhz*ohizo*ytabtfzvbujtakbKJgo},634!@#$rfv(iujhg&yuhgqwsaxdc9I8UJE3DFCV*(iujhgWSTYxdchg(*itgvhjf^eHY534
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import { KWM_KEY } from './kwm.key';
|
|
||||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
|
||||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
|
||||||
import { fetchParakeet } from '@jixun/libparakeet';
|
|
||||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
|
||||||
|
|
||||||
// v1 only
|
|
||||||
export class KWMCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'KWM';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
|
|
||||||
const kwm2key = opts.kwm2key ?? '';
|
|
||||||
|
|
||||||
const parakeet = await fetchParakeet();
|
|
||||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
|
||||||
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
|
|
||||||
cleanup: () => keyCrypto.delete(),
|
|
||||||
parakeet,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new KWMCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
|
|
||||||
export class MiguCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'Migu3D/Keyless';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
return transformBlob(buffer, (p) => p.make.Migu3D());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new MiguCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const NCM_KEY = 'hzHRAmso5kInbaxW';
|
|
||||||
export const NCM_MAGIC_HEADER = new Uint8Array([0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import { NCM_KEY, NCM_MAGIC_HEADER } from './ncm_pc.key';
|
|
||||||
|
|
||||||
export class NCMCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'NCM/PC';
|
|
||||||
checkByDecryptHeader = false;
|
|
||||||
|
|
||||||
async checkBySignature(buffer: ArrayBuffer) {
|
|
||||||
const view = new DataView(buffer, 0, NCM_MAGIC_HEADER.byteLength);
|
|
||||||
return NCM_MAGIC_HEADER.every((value, i) => value === view.getUint8(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
return transformBlob(buffer, (p) => p.make.NeteaseNCM(NCM_KEY));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new NCMCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default new Uint8Array([
|
|
||||||
0x77, 0x48, 0x32, 0x73, 0xde, 0xf2, 0xc0, 0xc8, 0x95, 0xec, 0x30, 0xb2, 0x51, 0xc3, 0xe1, 0xa0, 0x9e, 0xe6, 0x9d,
|
|
||||||
0xcf, 0xfa, 0x7f, 0x14, 0xd1, 0xce, 0xb8, 0xdc, 0xc3, 0x4a, 0x67, 0x93, 0xd6, 0x28, 0xc2, 0x91, 0x70, 0xca, 0x8d,
|
|
||||||
0xa2, 0xa4, 0xf0, 0x08, 0x61, 0x90, 0x7e, 0x6f, 0xa2, 0xe0, 0xeb, 0xae, 0x3e, 0xb6, 0x67, 0xc7, 0x92, 0xf4, 0x91,
|
|
||||||
0xb5, 0xf6, 0x6c, 0x5e, 0x84, 0x40, 0xf7, 0xf3, 0x1b, 0x02, 0x7f, 0xd5, 0xab, 0x41, 0x89, 0x28, 0xf4, 0x25, 0xcc,
|
|
||||||
0x52, 0x11, 0xad, 0x43, 0x68, 0xa6, 0x41, 0x8b, 0x84, 0xb5, 0xff, 0x2c, 0x92, 0x4a, 0x26, 0xd8, 0x47, 0x6a, 0x7c,
|
|
||||||
0x95, 0x61, 0xcc, 0xe6, 0xcb, 0xbb, 0x3f, 0x47, 0x58, 0x89, 0x75, 0xc3, 0x75, 0xa1, 0xd9, 0xaf, 0xcc, 0x08, 0x73,
|
|
||||||
0x17, 0xdc, 0xaa, 0x9a, 0xa2, 0x16, 0x41, 0xd8, 0xa2, 0x06, 0xc6, 0x8b, 0xfc, 0x66, 0x34, 0x9f, 0xcf, 0x18, 0x23,
|
|
||||||
0xa0, 0x0a, 0x74, 0xe7, 0x2b, 0x27, 0x70, 0x92, 0xe9, 0xaf, 0x37, 0xe6, 0x8c, 0xa7, 0xbc, 0x62, 0x65, 0x9c, 0xc2,
|
|
||||||
0x08, 0xc9, 0x88, 0xb3, 0xf3, 0x43, 0xac, 0x74, 0x2c, 0x0f, 0xd4, 0xaf, 0xa1, 0xc3, 0x01, 0x64, 0x95, 0x4e, 0x48,
|
|
||||||
0x9f, 0xf4, 0x35, 0x78, 0x95, 0x7a, 0x39, 0xd6, 0x6a, 0xa0, 0x6d, 0x40, 0xe8, 0x4f, 0xa8, 0xef, 0x11, 0x1d, 0xf3,
|
|
||||||
0x1b, 0x3f, 0x3f, 0x07, 0xdd, 0x6f, 0x5b, 0x19, 0x30, 0x19, 0xfb, 0xef, 0x0e, 0x37, 0xf0, 0x0e, 0xcd, 0x16, 0x49,
|
|
||||||
0xfe, 0x53, 0x47, 0x13, 0x1a, 0xbd, 0xa4, 0xf1, 0x40, 0x19, 0x60, 0x0e, 0xed, 0x68, 0x09, 0x06, 0x5f, 0x4d, 0xcf,
|
|
||||||
0x3d, 0x1a, 0xfe, 0x20, 0x77, 0xe4, 0xd9, 0xda, 0xf9, 0xa4, 0x2b, 0x76, 0x1c, 0x71, 0xdb, 0x00, 0xbc, 0xfd, 0x0c,
|
|
||||||
0x6c, 0xa5, 0x47, 0xf7, 0xf6, 0x00, 0x79, 0x4a, 0x11,
|
|
||||||
]);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import key from './qmc_v1.key.ts';
|
|
||||||
|
|
||||||
export class QMC1Crypto implements CryptoBase {
|
|
||||||
cryptoName = 'QMC/v1';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
return transformBlob(buffer, (p) => p.make.QMCv1(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new QMC1Crypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const SEED = 106;
|
|
||||||
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
|
|
||||||
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { fetchParakeet } from '@jixun/libparakeet';
|
|
||||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
|
||||||
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
|
||||||
|
|
||||||
export class QMC2Crypto implements CryptoBase {
|
|
||||||
cryptoName = 'QMC/v2';
|
|
||||||
checkByDecryptHeader = false;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
const parakeet = await fetchParakeet();
|
|
||||||
const footerParser = makeQMCv2FooterParser(parakeet);
|
|
||||||
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
|
|
||||||
parakeet,
|
|
||||||
cleanup: () => footerParser.delete(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new QMC2Crypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QMC2CryptoWithKey implements CryptoBase {
|
|
||||||
cryptoName = 'QMC/v2 (key)';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<boolean> {
|
|
||||||
return Boolean(options.qmc2Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
|
||||||
if (!options.qmc2Key) {
|
|
||||||
throw new Error('key was not provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parakeet = await fetchParakeet();
|
|
||||||
const key = stringToUTF8Bytes(options.qmc2Key);
|
|
||||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
|
||||||
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
|
||||||
parakeet,
|
|
||||||
cleanup: () => keyCrypto.delete(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new QMC2CryptoWithKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
|
||||||
|
|
||||||
export class QingTingFM$Device implements CryptoBase {
|
|
||||||
cryptoName = 'QingTing FM/Device ID';
|
|
||||||
checkByDecryptHeader = false;
|
|
||||||
|
|
||||||
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) {
|
|
||||||
return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
|
||||||
const { fileName: name, qingTingAndroidKey } = options;
|
|
||||||
if (!qingTingAndroidKey) {
|
|
||||||
throw new Error('QingTingFM Android Device Key was not provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingAndroidKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new QingTingFM$Device();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
|
|
||||||
export class TransparentCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'Transparent';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
return new Blob([buffer]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new TransparentCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// Xiami file header
|
|
||||||
// offset description
|
|
||||||
// 0x00 "ifmt"
|
|
||||||
// 0x04 Format name, e.g. "FLAC".
|
|
||||||
// 0x08 0xfe, 0xfe, 0xfe, 0xfe
|
|
||||||
// 0x0C (3 bytes) Little-endian, size of data to copy without modification.
|
|
||||||
// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data.
|
|
||||||
// 0x0F (1 byte) File key, applied to
|
|
||||||
// 0x10 Plaintext data
|
|
||||||
// ???? Encrypted data
|
|
||||||
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
|
||||||
|
|
||||||
// little endian
|
|
||||||
const XIAMI_FILE_MAGIC = 0x746d6669;
|
|
||||||
const XIAMI_EXPECTED_PADDING = 0xfefefefe;
|
|
||||||
|
|
||||||
const u8Sub = (a: number, b: number) => {
|
|
||||||
if (a > b) {
|
|
||||||
return a - b;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a + 0x100 - b;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class XiamiCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'Xiami';
|
|
||||||
checkByDecryptHeader = false;
|
|
||||||
|
|
||||||
async checkBySignature(buffer: ArrayBuffer): Promise<boolean> {
|
|
||||||
const header = new DataView(buffer);
|
|
||||||
|
|
||||||
return header.getUint32(0x00, true) === XIAMI_FILE_MAGIC && header.getUint32(0x08, true) === XIAMI_EXPECTED_PADDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(src: ArrayBuffer): Promise<ArrayBuffer> {
|
|
||||||
const headerBuffer = src.slice(0, 0x10);
|
|
||||||
const header = new Uint8Array(headerBuffer);
|
|
||||||
const key = u8Sub(header[0x0f], 1);
|
|
||||||
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
|
||||||
const decrypted = new Uint8Array(src.slice(0x10));
|
|
||||||
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
|
||||||
decrypted[i] = u8Sub(key, decrypted[i]);
|
|
||||||
}
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new XiamiCrypto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export interface XimalayaAndroidKey {
|
|
||||||
contentKey: string;
|
|
||||||
init: number;
|
|
||||||
step: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const XimalayaX2MKey: XimalayaAndroidKey = {
|
|
||||||
contentKey: 'xmly',
|
|
||||||
init: 0.615243,
|
|
||||||
step: 3.837465,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const XimalayaX3MKey: XimalayaAndroidKey = {
|
|
||||||
contentKey: '3989d111aad5613940f4fc44b639b292',
|
|
||||||
init: 0.726354,
|
|
||||||
step: 3.948576,
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
|
||||||
import type { CryptoBase } from '../CryptoBase.js';
|
|
||||||
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
|
||||||
|
|
||||||
export class XimalayaAndroidCrypto implements CryptoBase {
|
|
||||||
cryptoName = 'Ximalaya/Android';
|
|
||||||
checkByDecryptHeader = true;
|
|
||||||
constructor(private key: XimalayaAndroidKey) {}
|
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
|
||||||
const { contentKey, init, step } = this.key;
|
|
||||||
return transformBlob(buffer, (p) => {
|
|
||||||
const transformer = p.make.XimalayaAndroid(init, step, contentKey);
|
|
||||||
if (!transformer) {
|
|
||||||
throw new Error('could not make xmly transformer, is key invalid?');
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformer;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static makeX2M() {
|
|
||||||
return new XimalayaAndroidCrypto(XimalayaX2MKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static makeX3M() {
|
|
||||||
return new XimalayaAndroidCrypto(XimalayaX3MKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/decrypt-worker/decipher/KugouMusic.ts
Normal file
36
src/decrypt-worker/decipher/KugouMusic.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
|
import { KuGou, KuGouHeader } from '@unlock-music/crypto';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
|
||||||
|
export class KugouMusicDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'Kugou';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
let kgm: KuGou | undefined;
|
||||||
|
let kgmHdr: KuGouHeader | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
kgmHdr = new KuGouHeader(buffer.subarray(0, 0x400));
|
||||||
|
kgm = KuGou.fromHeaderV5(kgmHdr, options.kugouKey);
|
||||||
|
|
||||||
|
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
kgm.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
kgmHdr?.free();
|
||||||
|
kgm?.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new KugouMusicDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/decrypt-worker/decipher/KuwoMusic.ts
Normal file
35
src/decrypt-worker/decipher/KuwoMusic.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
|
import { KuwoHeader, KWMDecipher } from '@unlock-music/crypto';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
|
||||||
|
export class KuwoMusicDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'Kuwo';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
let header: KuwoHeader | undefined;
|
||||||
|
let kwm: KWMDecipher | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
header = KuwoHeader.parse(buffer.subarray(0, 0x400));
|
||||||
|
kwm = new KWMDecipher(header, options.kwm2key);
|
||||||
|
|
||||||
|
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
kwm.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
kwm?.free();
|
||||||
|
header?.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new KuwoMusicDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/decrypt-worker/decipher/Migu3d.ts
Normal file
27
src/decrypt-worker/decipher/Migu3d.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
import { Migu3D } from '@unlock-music/crypto';
|
||||||
|
|
||||||
|
export class Migu3DKeylessDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'Migu3D (Keyless)';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
|
||||||
|
const audioBuffer = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||||
|
mg3d.decrypt(block, i);
|
||||||
|
}
|
||||||
|
mg3d.free();
|
||||||
|
|
||||||
|
return {
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
status: Status.OK,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new Migu3DKeylessDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
42
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
|
import { NCMFile } from '@unlock-music/crypto';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||||
|
|
||||||
|
export class NetEaseCloudMusicDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'NCM/PC';
|
||||||
|
|
||||||
|
tryInit(ncm: NCMFile, buffer: Uint8Array) {
|
||||||
|
let neededLength = 1024;
|
||||||
|
while (neededLength !== 0) {
|
||||||
|
console.debug('NCM/open: read %d bytes', neededLength);
|
||||||
|
neededLength = ncm.open(buffer.subarray(0, neededLength));
|
||||||
|
if (neededLength === -1) {
|
||||||
|
throw new UnsupportedSourceFile('file is not ncm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const ncm = new NCMFile();
|
||||||
|
try {
|
||||||
|
this.tryInit(ncm, buffer);
|
||||||
|
|
||||||
|
const audioBuffer = buffer.slice(ncm.audioOffset);
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
ncm.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
ncm.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new NetEaseCloudMusicDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/decrypt-worker/decipher/QQMusic.ts
Normal file
86
src/decrypt-worker/decipher/QQMusic.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
|
import { decryptQMC1, QMC2, QMCFooter } from '@unlock-music/crypto';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||||
|
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||||
|
|
||||||
|
export class QQMusicV1Decipher implements DecipherInstance {
|
||||||
|
cipherName = 'QQMusic/QMC1';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const header = buffer.slice(0, 0x20);
|
||||||
|
decryptQMC1(header, 0);
|
||||||
|
if (!isDataLooksLikeAudio(header)) {
|
||||||
|
throw new UnsupportedSourceFile('does not look like QMC file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = new Uint8Array(buffer);
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
decryptQMC1(block, offset);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create() {
|
||||||
|
return new QQMusicV1Decipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QQMusicV2Decipher implements DecipherInstance {
|
||||||
|
cipherName: string;
|
||||||
|
|
||||||
|
constructor(private readonly useUserKey: boolean) {
|
||||||
|
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFooter(buffer: Uint8Array): { size: number; ekey?: undefined | string } {
|
||||||
|
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
const { size, ekey } = footer;
|
||||||
|
footer.free();
|
||||||
|
return { size, ekey };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No footer, and we don't accept user key:
|
||||||
|
if (!this.useUserKey) {
|
||||||
|
throw new UnsupportedSourceFile('Not QMC2 File');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const footer = this.parseFooter(buffer.subarray(buffer.byteLength - 1024));
|
||||||
|
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
||||||
|
if (!ekey) {
|
||||||
|
throw new Error('EKey required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const qmc2 = new QMC2(ekey);
|
||||||
|
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
qmc2.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
qmc2.free();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createWithUserKey() {
|
||||||
|
return new QQMusicV2Decipher(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createWithEmbeddedEKey() {
|
||||||
|
return new QQMusicV2Decipher(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/decrypt-worker/decipher/QingTingFM.ts
Normal file
37
src/decrypt-worker/decipher/QingTingFM.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
import { QingTingFM } from '@unlock-music/crypto';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
import { unhex } from '~/util/hex.ts';
|
||||||
|
|
||||||
|
export class QignTingFMDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'QingTingFM (Android, qta)';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, opts: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const key = unhex(opts.qingTingAndroidKey || '');
|
||||||
|
const iv = QingTingFM.getFileIV(opts.fileName);
|
||||||
|
|
||||||
|
if (key.byteLength !== 16 || iv.byteLength !== 16) {
|
||||||
|
return {
|
||||||
|
status: Status.FAILED,
|
||||||
|
message: 'device key or iv invalid',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtfm = new QingTingFM(key, iv);
|
||||||
|
const audioBuffer = new Uint8Array(buffer);
|
||||||
|
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||||
|
qtfm.decrypt(block, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
status: Status.OK,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new QignTingFMDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
|
||||||
|
export class TransparentDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'none';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||||
|
return {
|
||||||
|
cipherName: 'None',
|
||||||
|
status: Status.OK,
|
||||||
|
data: buffer,
|
||||||
|
message: 'No decipher applied',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new TransparentDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/decrypt-worker/decipher/XiamiMusic.ts
Normal file
28
src/decrypt-worker/decipher/XiamiMusic.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
import { Xiami } from '@unlock-music/crypto';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
|
||||||
|
export class XiamiDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'Xiami (XM)';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
||||||
|
const { copyPlainLength } = xm;
|
||||||
|
const audioBuffer = buffer.slice(0x10);
|
||||||
|
|
||||||
|
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
|
||||||
|
xm.decrypt(block);
|
||||||
|
}
|
||||||
|
xm.free();
|
||||||
|
|
||||||
|
return {
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
status: Status.OK,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new XiamiDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/decrypt-worker/decipher/Ximalaya.ts
Normal file
71
src/decrypt-worker/decipher/Ximalaya.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { decryptX2MHeader, decryptX3MHeader, XmlyPC } from '@unlock-music/crypto';
|
||||||
|
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||||
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||||
|
|
||||||
|
export class XimalayaAndroidDecipher implements DecipherInstance {
|
||||||
|
cipherName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private decipher: (buffer: Uint8Array) => void,
|
||||||
|
private cipherType: string,
|
||||||
|
) {
|
||||||
|
this.cipherName = `Ximalaya (Android, ${cipherType})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
// Detect with first 0x400 bytes
|
||||||
|
const slice = buffer.slice(0, 0x400);
|
||||||
|
this.decipher(slice);
|
||||||
|
if (!isDataLooksLikeAudio(slice)) {
|
||||||
|
throw new UnsupportedSourceFile(`Not a Xmly android file (${this.cipherType})`);
|
||||||
|
}
|
||||||
|
const result = new Uint8Array(buffer);
|
||||||
|
result.set(slice, 0);
|
||||||
|
return {
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
status: Status.OK,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeX2M() {
|
||||||
|
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeX3M() {
|
||||||
|
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XimalayaPCDecipher implements DecipherInstance {
|
||||||
|
cipherName = 'Ximalaya (PC)';
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
// Detect with first 0x400 bytes
|
||||||
|
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
|
||||||
|
const xm = new XmlyPC(buffer.subarray(0, headerSize));
|
||||||
|
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
|
||||||
|
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
|
||||||
|
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
|
||||||
|
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
|
||||||
|
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
|
||||||
|
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
|
||||||
|
xm.free();
|
||||||
|
|
||||||
|
const result = new Uint8Array(audioSize);
|
||||||
|
result.set(audioHeader);
|
||||||
|
result.set(encryptedAudioPart, audioHeader.byteLength);
|
||||||
|
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
data: result,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new XimalayaPCDecipher();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user