mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 11:33:02 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
35
.drone.yml
35
.drone.yml
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: default
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test & build
|
|
||||||
image: node:20.10.0-bookworm
|
|
||||||
commands:
|
|
||||||
# - git config --global --add safe.directory "/drone/src"
|
|
||||||
- corepack enable
|
|
||||||
- corepack prepare pnpm@latest --activate
|
|
||||||
- pnpm i --frozen-lockfile
|
|
||||||
- pnpm build
|
|
||||||
environment:
|
|
||||||
# 让 npm 使用淘宝源
|
|
||||||
npm_config_registry: https://registry.npmmirror.com
|
|
||||||
|
|
||||||
- name: publish
|
|
||||||
image: node:20.10.0-bookworm
|
|
||||||
environment:
|
|
||||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
|
||||||
GITEA_API_KEY:
|
|
||||||
from_secret: GITEA_API_KEY
|
|
||||||
NETLIFY_SITE_ID:
|
|
||||||
from_secret: NETLIFY_SITE_ID
|
|
||||||
NETLIFY_API_KEY:
|
|
||||||
from_secret: NETLIFY_API_KEY
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
python3 -m zipfile -c um-react.zip dist/.
|
|
||||||
cp um-react.zip dist/"release-${DRONE_COMMIT_SHA}.zip"
|
|
||||||
python3 -m zipfile -c um-react-site.zip dist/.
|
|
||||||
- ./scripts/publish.sh
|
|
||||||
- ./scripts/deploy.sh
|
|
||||||
@@ -11,5 +11,5 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
34
.gitea/workflows/build.yaml
Normal file
34
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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: Prepare for deployment
|
||||||
|
run: |
|
||||||
|
python3 -m zipfile -c um-react.zip dist/.
|
||||||
|
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
|
||||||
|
python3 -m zipfile -c um-react-site.zip dist/.
|
||||||
|
- name: Publish Artifact
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: site
|
||||||
|
path: dist/
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
NETLIFY_API_KEY: ${{ secrets.NETLIFY_API_KEY }}
|
||||||
|
run: ./scripts/deploy.sh
|
||||||
@@ -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,5 +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
|
||||||
@um:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
|
||||||
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||||
|
|||||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@@ -3,9 +3,6 @@
|
|||||||
"editorconfig.editorconfig",
|
"editorconfig.editorconfig",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"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"]
|
||||||
65
README.MD
65
README.MD
@@ -7,14 +7,17 @@
|
|||||||
- 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
|
||||||
|
|
||||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||||
|
|
||||||
@@ -29,24 +32,60 @@
|
|||||||
- [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)」。
|
||||||
@@ -55,7 +94,7 @@
|
|||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||||
|
|
||||||
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh)」。
|
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。
|
||||||
|
|
||||||
### 架构
|
### 架构
|
||||||
|
|
||||||
@@ -87,13 +126,3 @@
|
|||||||
[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`?)
|
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
109
package.json
109
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@@ -14,66 +14,76 @@
|
|||||||
"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",
|
"@chakra-ui/anatomy": "^2.3.4",
|
||||||
"@chakra-ui/icons": "^2.1.1",
|
"@chakra-ui/icons": "^2.2.4",
|
||||||
"@chakra-ui/react": "^2.8.2",
|
"@chakra-ui/react": "^2.10.7",
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@reduxjs/toolkit": "^2.6.1",
|
||||||
"@unlock-music/crypto": "0.1.0",
|
"@unlock-music/crypto": "0.1.9",
|
||||||
"framer-motion": "^11.5.6",
|
"framer-motion": "^12.6.2",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.1.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"radash": "^12.1.0",
|
"radash": "^12.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-promise-suspense": "^0.3.4",
|
"react-promise-suspense": "^0.3.4",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.2.0",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"sass": "^1.79.3",
|
"sass": "^1.86.0",
|
||||||
"sql.js": "^1.11.0"
|
"sql.js": "^1.13.0",
|
||||||
|
"workbox-build": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.1",
|
"@eslint/js": "^9.23.0",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@rollup/plugin-replace": "^6.0.2",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^22.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.9",
|
"@types/node": "^22.13.14",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.0.12",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
||||||
"@typescript-eslint/parser": "^8.7.0",
|
"@typescript-eslint/parser": "^8.28.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^2.1.1",
|
"@vitest/coverage-v8": "^3.0.9",
|
||||||
"@vitest/ui": "^2.1.1",
|
"@vitest/ui": "^3.0.9",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.1.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"husky": "^9.1.6",
|
"globals": "^16.0.0",
|
||||||
"jsdom": "^25.0.1",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^15.2.10",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.3.3",
|
"lint-staged": "^15.5.0",
|
||||||
"rollup": "^4.22.4",
|
"prettier": "^3.5.3",
|
||||||
"terser": "^5.33.0",
|
"rollup": "^4.38.0",
|
||||||
"typescript": "^5.6.2",
|
"simple-git-hooks": "^2.12.1",
|
||||||
"vite": "^5.4.7",
|
"terser": "^5.39.0",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"typescript": "^5.8.2",
|
||||||
"vite-plugin-top-level-await": "^1.4.4",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite": "^6.2.3",
|
||||||
"vitest": "^2.1.1",
|
"vite-plugin-pwa": "^1.0.0",
|
||||||
"workbox-window": "^7.1.0"
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
|
"vite-plugin-wasm": "^3.4.1",
|
||||||
|
"vitest": "^3.0.9",
|
||||||
|
"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,
|
||||||
@@ -88,5 +98,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
6880
pnpm-lock.yaml
generated
6880
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,7 @@ deploy_netlify() {
|
|||||||
|
|
||||||
# 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-site.zip
|
deploy_netlify um-react-site.zip
|
||||||
else
|
else
|
||||||
echo "skip netlify deployment."
|
echo "skip netlify deployment."
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
47
src/components/AddKey.tsx
Normal file
47
src/components/AddKey.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuDivider,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||||
|
|
||||||
|
export interface AddKeyProps {
|
||||||
|
addKey: () => void;
|
||||||
|
importKeyFromFile?: () => void;
|
||||||
|
clearKeys?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) {
|
||||||
|
return (
|
||||||
|
<HStack pb={2} pt={2}>
|
||||||
|
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
||||||
|
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
||||||
|
添加一条密钥
|
||||||
|
</Button>
|
||||||
|
<Menu>
|
||||||
|
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
||||||
|
<MenuList>
|
||||||
|
{importKeyFromFile && (
|
||||||
|
<MenuItem onClick={importKeyFromFile} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||||
|
从文件导入密钥…
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{importKeyFromFile && clearKeys && <MenuDivider />}
|
||||||
|
{clearKeys && (
|
||||||
|
<MenuItem color="red" onClick={clearKeys} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||||
|
清空密钥
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
42
src/components/HelpText/Headers.tsx
Normal file
42
src/components/HelpText/Headers.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Heading } from '@chakra-ui/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header3({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
pt={3}
|
||||||
|
pb={1}
|
||||||
|
borderBottom={'1px solid'}
|
||||||
|
borderColor="gray.300"
|
||||||
|
color="gray.800"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header4({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header5({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading as="h5" id={id} className={className} pt={3} pb={1} color="gray.700" size="sm">
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,11 +18,19 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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',
|
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
||||||
|
KUGOU_PARSE_HEADER = 'KUGOU_PARSE_HEADER',
|
||||||
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
||||||
VERSION = 'VERSION',
|
VERSION = 'VERSION',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||||
import { KuGou } from '@unlock-music/crypto';
|
import { KuGou, KuGouHeader } from '@unlock-music/crypto';
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
|
||||||
export class KugouMusicDecipher implements DecipherInstance {
|
export class KugouMusicDecipher implements DecipherInstance {
|
||||||
cipherName = 'Kugou';
|
cipherName = 'Kugou';
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
let kgm: KuGou | undefined;
|
let kgm: KuGou | undefined;
|
||||||
|
let kgmHdr: KuGouHeader | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
kgm = KuGou.from_header(buffer.subarray(0, 0x400));
|
kgmHdr = new KuGouHeader(buffer.subarray(0, 0x400));
|
||||||
|
kgm = KuGou.fromHeaderV5(kgmHdr, options.kugouKey);
|
||||||
|
|
||||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
@@ -23,6 +25,7 @@ export class KugouMusicDecipher implements DecipherInstance {
|
|||||||
data: audioBuffer,
|
data: audioBuffer,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
kgmHdr?.free();
|
||||||
kgm?.free();
|
kgm?.free();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,20 +38,32 @@ export class QQMusicV2Decipher implements DecipherInstance {
|
|||||||
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
parseFooter(buffer: Uint8Array): { size: number; ekey?: undefined | string } {
|
||||||
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
||||||
if (!footer) {
|
|
||||||
|
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');
|
throw new UnsupportedSourceFile('Not QMC2 File');
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
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;
|
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
||||||
footer.free();
|
|
||||||
if (!ekey) {
|
if (!ekey) {
|
||||||
throw new Error('EKey missing');
|
throw new Error('EKey required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const qmc2 = new QMC2(ekey);
|
const qmc2 = new QMC2(ekey);
|
||||||
|
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
qmc2.decrypt(block, offset);
|
qmc2.decrypt(block, offset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface DecryptCommandOptions {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
qmc2Key?: string;
|
qmc2Key?: string;
|
||||||
kwm2key?: string;
|
kwm2key?: string;
|
||||||
|
kugouKey?: string;
|
||||||
qingTingAndroidKey?: string;
|
qingTingAndroidKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,15 @@ export type ParseKuwoHeaderResponse = null | {
|
|||||||
qualityId: number;
|
qualityId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ParseKugouHeaderPayload {
|
||||||
|
blobURI: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParseKugouHeaderResponse = null | {
|
||||||
|
version: number;
|
||||||
|
audioHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface GetQingTingFMDeviceKeyPayload {
|
export interface GetQingTingFMDeviceKeyPayload {
|
||||||
product: string;
|
product: string;
|
||||||
device: string;
|
device: string;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
src instanceof Blob ? await src.arrayBuffer() : src;
|
||||||
|
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||||
|
src instanceof Blob ? src : new Blob([src]);
|
||||||
|
|
||||||
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
||||||
const len = buffer.byteLength;
|
const len = buffer.byteLength;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { workerDecryptHandler } from './worker/decrypt.ts';
|
|||||||
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
||||||
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
||||||
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
||||||
|
import { workerParseKugouHeader } from '~/decrypt-worker/worker/kugou_parse_header.ts';
|
||||||
|
|
||||||
const bus = new WorkerServerBus();
|
const bus = new WorkerServerBus();
|
||||||
onmessage = bus.onmessage;
|
onmessage = bus.onmessage;
|
||||||
@@ -14,4 +15,5 @@ bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler)
|
|||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
|
||||||
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER, workerParseKugouHeader);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
||||||
|
|||||||
23
src/decrypt-worker/worker/kugou_parse_header.ts
Normal file
23
src/decrypt-worker/worker/kugou_parse_header.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
||||||
|
|
||||||
|
} from '~/decrypt-worker/types.ts';
|
||||||
|
import { KuGouHeader } from '@unlock-music/crypto';
|
||||||
|
|
||||||
|
export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayload): Promise<ParseKugouHeaderResponse> => {
|
||||||
|
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400));
|
||||||
|
|
||||||
|
let kwm : KuGouHeader | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
kwm = new KuGouHeader(buffer);
|
||||||
|
const { version, audioHash } = kwm;
|
||||||
|
return { version, audioHash };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
kwm?.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||||
import { KuwoHeader } from '@unlock-music/crypto';
|
import { KuwoHeader } from '@unlock-music/crypto';
|
||||||
|
|
||||||
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
|
export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise<ParseKuwoHeaderResponse> => {
|
||||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
|
||||||
|
|||||||
31
src/faq/KugouFAQ.tsx
Normal file
31
src/faq/KugouFAQ.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
||||||
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
|
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||||
|
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||||
|
|
||||||
|
export function KugouFAQ() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header4>解锁失败</Header4>
|
||||||
|
<List spacing={2}>
|
||||||
|
<ListItem>
|
||||||
|
<Text>
|
||||||
|
酷狗现在对部分用户推送了 <code>kgg</code> 加密格式(安卓、Windows 客户端)。
|
||||||
|
</Text>
|
||||||
|
<Text>根据平台不同,你需要提取密钥数据库。</Text>
|
||||||
|
|
||||||
|
<Container p={2}>
|
||||||
|
<Alert status="warning" borderRadius={5}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { HiWord } from '~/components/HelpText/HiWord';
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
@@ -15,9 +15,6 @@ export function KuwoFAQ() {
|
|||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
|
||||||
<chakra.strong>2、检查您的平台</chakra.strong>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
<Text>
|
||||||
日前,仅<HiWord>手机客户端</HiWord>下载的
|
日前,仅<HiWord>手机客户端</HiWord>下载的
|
||||||
<VQuote>
|
<VQuote>
|
||||||
@@ -38,10 +35,10 @@ export function KuwoFAQ() {
|
|||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法正常导入密钥。
|
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法提取密钥。
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<strong>注意</strong>:项目组不提倡使用第三方修改版应用亦不会提供,使用前请自行评估风险。
|
<strong>注意</strong>:项目组不提倡使用、也不提供第三方修改版。使用前请自行评估风险。
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||||
import { ExtLink } from '~/components/ExtLink';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
import { ProjectIssue } from '~/components/ProjectIssue';
|
||||||
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
||||||
@@ -63,7 +63,7 @@ export function OtherFAQ() {
|
|||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Text>
|
<Text>
|
||||||
<strong>注意</strong>:根据应用厂商的风控策略,使用模拟器登录的账号<strong>有可能会被封锁</strong>
|
<strong>注意</strong>:根据应用的风控策略,使用模拟器登录的账号<strong>有可能会导致账号被封锁</strong>
|
||||||
{';使用前请自行评估风险。'}
|
{';使用前请自行评估风险。'}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,63 +1,159 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, UnorderedList, chakra } from '@chakra-ui/react';
|
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||||
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
|
|
||||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||||
import { ExtLink } from '~/components/ExtLink';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
|
import { InstructionsIOS } from '~/features/settings/panels/QMCv2/InstructionsIOS';
|
||||||
|
import { InstructionsMac } from '~/features/settings/panels/QMCv2/InstructionsMac';
|
||||||
|
|
||||||
export function QQMusicFAQ() {
|
export function QQMusicFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解锁失败</Header4>
|
<Header4>解锁失败</Header4>
|
||||||
<List spacing={2}>
|
|
||||||
<ListItem>
|
|
||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
</ListItem>
|
<Text>重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。</Text>
|
||||||
<ListItem>
|
|
||||||
<Text>
|
<Text>
|
||||||
<chakra.strong>2、检查您的平台</chakra.strong>
|
部分平台获取的加密文件未包含密钥。选择你<strong>下载文件时</strong>使用的客户端来查看说明。
|
||||||
</Text>
|
</Text>
|
||||||
|
<Accordion allowToggle my={2}>
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
Windows
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
<Text>
|
<Text>
|
||||||
日前,仅 Windows 客户端 19.43 或更低版本下载的歌曲文件无需密钥,其余平台的官方正式版本均需要提取密钥。
|
目前 Windows 客户端 19.51 或更低版本下载的歌曲文件无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||||
你可以通过下方的链接获取 QQ 音乐 Windows 客户端 v19.43 的安装程序:
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>你可以通过下方的链接获取 QQ 音乐 Windows 客户端 v19.51 的安装程序:</Text>
|
||||||
<UnorderedList pl={3}>
|
<UnorderedList pl={3}>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
|
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||||
<code>qq.com</code> 官方下载地址(推荐)
|
<code>qq.com</code> 官方下载地址(推荐)
|
||||||
</ExtLink>
|
</ExtLink>
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
|
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||||
<code>Archive.org</code> 存档
|
<code>Archive.org</code> 存档
|
||||||
</ExtLink>
|
</ExtLink>
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</UnorderedList>
|
</UnorderedList>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
Mac
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
|
<Container p={2}>
|
||||||
|
<Alert status="warning" borderRadius={5}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>Mac 需要降级到 8.8.0 或以下版本。</Text>
|
||||||
|
<Text>
|
||||||
|
<ExtLink href="https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg">
|
||||||
|
<code>Archive.org</code> 存档
|
||||||
|
</ExtLink>
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<SegmentKeyImportInstructions
|
||||||
|
tab="QMCv2 密钥"
|
||||||
|
keyInstructionText="查看密钥提取说明:"
|
||||||
|
clientInstructions={
|
||||||
|
<Box p={2}>
|
||||||
|
<InstructionsMac />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
安卓 (Android)
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
|
<Container p={2}>
|
||||||
|
<Alert status="warning" borderRadius={5}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>安卓提取密钥需要 root 特权,建议用电脑操作。</Text>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Text>QQ 音乐官方版本需要提取密钥才能解密。</Text>
|
||||||
|
<Text>
|
||||||
|
你也可以尝试使用【QQ 音乐简洁版】或 OEM 定制版(如小米、魅族定制版)。简洁、定制版本目前不需要提取密钥。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SegmentKeyImportInstructions
|
||||||
|
tab="QMCv2 密钥"
|
||||||
|
keyInstructionText="查看密钥提取说明:"
|
||||||
|
clientInstructions={
|
||||||
|
<Box p={2}>
|
||||||
|
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
iOS (iPhone, iPad)
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
<Container p={2}>
|
<Container p={2}>
|
||||||
<Alert status="warning" borderRadius={5}>
|
<Alert status="warning" borderRadius={5}>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Text>iOS 用户提取歌曲困难,建议换用电脑操作;</Text>
|
<Text>iOS 用户提取歌曲困难,建议换用电脑操作;</Text>
|
||||||
<Text>安卓用户提取密钥需要root,也建议用电脑操作。</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container p={2} pt={0}>
|
<SegmentKeyImportInstructions
|
||||||
<Alert status="info" borderRadius={5}>
|
tab="QMCv2 密钥"
|
||||||
<AlertIcon />
|
keyInstructionText="查看密钥提取说明:"
|
||||||
重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。
|
clientInstructions={
|
||||||
</Alert>
|
<Box p={2}>
|
||||||
</Container>
|
<InstructionsIOS />
|
||||||
|
</Box>
|
||||||
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
|
}
|
||||||
</ListItem>
|
/>
|
||||||
</List>
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import { MdFileUpload } from 'react-icons/md';
|
|||||||
export interface SegmentKeyImportInstructionsProps {
|
export interface SegmentKeyImportInstructionsProps {
|
||||||
clientInstructions: React.ReactNode;
|
clientInstructions: React.ReactNode;
|
||||||
tab: string;
|
tab: string;
|
||||||
|
keyInstructionText?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentKeyImportInstructions({ clientInstructions, tab }: SegmentKeyImportInstructionsProps) {
|
export function SegmentKeyImportInstructions({
|
||||||
|
clientInstructions,
|
||||||
|
tab,
|
||||||
|
keyInstructionText = '选择你的客户端平台来查看密钥提取说明:',
|
||||||
|
}: SegmentKeyImportInstructionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>导入密钥可以参考下面的步骤:</Text>
|
<Text>导入密钥可以参考下面的步骤:</Text>
|
||||||
@@ -33,7 +38,7 @@ export function SegmentKeyImportInstructions({ clientInstructions, tab }: Segmen
|
|||||||
</Flex>
|
</Flex>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>选择你的客户端平台来查看密钥提取说明:</Text>
|
<Text>{keyInstructionText}</Text>
|
||||||
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
|
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
|
||||||
{clientInstructions}
|
{clientInstructions}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Text, chakra } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Container } from '@chakra-ui/react';
|
||||||
|
|
||||||
export function SegmentTryOfficialPlayer() {
|
export function SegmentTryOfficialPlayer() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Container p={2} my={2} pt={0}>
|
||||||
<Text>
|
<Alert status="info" borderRadius={5}>
|
||||||
<chakra.strong>1、请检查您的文件</chakra.strong>
|
<AlertIcon />
|
||||||
</Text>
|
尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。
|
||||||
<Text>尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。</Text>
|
</Alert>
|
||||||
</>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import type { RootState } from '~/store';
|
|||||||
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
import type {
|
import type {
|
||||||
DecryptCommandOptions,
|
DecryptCommandOptions,
|
||||||
FetchMusicExNamePayload,
|
FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
||||||
ParseKuwoHeaderPayload,
|
ParseKuwoHeaderPayload,
|
||||||
ParseKuwoHeaderResponse,
|
ParseKuwoHeaderResponse
|
||||||
} from '~/decrypt-worker/types';
|
} from '~/decrypt-worker/types';
|
||||||
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
import {
|
||||||
|
selectKugouKey,
|
||||||
|
selectKWMv2Key,
|
||||||
|
selectQMCv2KeyByFileName,
|
||||||
|
selectQtfmAndroidKey
|
||||||
|
} from '../settings/settingsSelector';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
QUEUED = 'QUEUED',
|
QUEUED = 'QUEUED',
|
||||||
@@ -70,7 +75,7 @@ export const processFile = createAsyncThunk<
|
|||||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
|
const [qmcv2MusicExMediaFile, kuwoHdr, kugouHdr] = await Promise.all([
|
||||||
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
||||||
blobURI: file.raw,
|
blobURI: file.raw,
|
||||||
}),
|
}),
|
||||||
@@ -78,12 +83,17 @@ export const processFile = createAsyncThunk<
|
|||||||
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
||||||
{ blobURI: file.raw },
|
{ blobURI: file.raw },
|
||||||
),
|
),
|
||||||
|
workerClientBus.request<ParseKugouHeaderResponse, ParseKugouHeaderPayload>(
|
||||||
|
DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER,
|
||||||
|
{ blobURI: file.raw },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const options: DecryptCommandOptions = {
|
const options: DecryptCommandOptions = {
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
||||||
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
||||||
|
kugouKey: selectKugouKey(state, kugouHdr),
|
||||||
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
||||||
};
|
};
|
||||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
chakra,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
chakra,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -19,22 +19,24 @@ import {
|
|||||||
TabPanels,
|
TabPanels,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
VStack,
|
|
||||||
useBreakpointValue,
|
useBreakpointValue,
|
||||||
useToast,
|
useToast,
|
||||||
|
VStack,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
||||||
import { useState } from 'react';
|
import { useState, type FC } from 'react';
|
||||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||||
import { PanelQingTing } from './panels/PanelQingTing';
|
import { PanelQingTing } from './panels/PanelQingTing';
|
||||||
|
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
||||||
|
|
||||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
const TABS: { name: string; Tab: FC }[] = [
|
||||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||||
|
{ name: 'KGG 密钥', Tab: PanelKGGKey },
|
||||||
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||||
{
|
{
|
||||||
name: '其它/待定',
|
name: '其它/待定',
|
||||||
@@ -145,7 +147,7 @@ export function Settings() {
|
|||||||
onClick={handleResetSettings}
|
onClick={handleResetSettings}
|
||||||
colorScheme="red"
|
colorScheme="red"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
title="放弃未储存的更改,将设定还原为储存前的状态。"
|
title="放弃未储存的更改,将设定还原未储存前的状态。"
|
||||||
aria-label="放弃未储存的更改"
|
aria-label="放弃未储存的更改"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleApplySettings}>保存</Button>
|
<Button onClick={handleApplySettings}>保存</Button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stagingKeyToProduction<S, P>(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
|
export function stagingKeyToProduction<S, P>(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
|
||||||
return objectify(src, toKey, toValue) as P;
|
return objectify(src, toKey, toValue) as P;
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,6 @@ export const qmc2ProductionToStaging = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// KWMv2 (KuWo)
|
// KWMv2 (KuWo)
|
||||||
|
|
||||||
export interface StagingKWMv2Key {
|
export interface StagingKWMv2Key {
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +64,7 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
|
|||||||
|
|
||||||
return { rid, quality };
|
return { rid, quality };
|
||||||
};
|
};
|
||||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
|
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/\D/g, '')}`;
|
||||||
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||||
export const kwm2ProductionToStaging = (
|
export const kwm2ProductionToStaging = (
|
||||||
key: keyof ProductionKWMv2Keys,
|
key: keyof ProductionKWMv2Keys,
|
||||||
@@ -78,3 +78,21 @@ export const kwm2ProductionToStaging = (
|
|||||||
|
|
||||||
return { id: nanoid(), rid, quality, ekey: value };
|
return { id: nanoid(), rid, quality, ekey: value };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// KuGou (kgg, kgm v5)
|
||||||
|
export interface StagingKugouKey {
|
||||||
|
id: string;
|
||||||
|
audioHash: string;
|
||||||
|
ekey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductionKugouKey = Record<string /* audioHash */, string /* ekey */>;
|
||||||
|
export const kugouStagingToProductionKey = (key: StagingKugouKey) => key.audioHash.normalize();
|
||||||
|
export const kugouStagingToProductionValue = (key: StagingKugouKey) => key.ekey.normalize();
|
||||||
|
export const kugouProductionToStaging = (
|
||||||
|
key: keyof ProductionKugouKey,
|
||||||
|
value: ProductionKugouKey[keyof ProductionKugouKey],
|
||||||
|
): null | StagingKugouKey => {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
return { id: nanoid(), audioHash: key.normalize(), ekey: value };
|
||||||
|
};
|
||||||
|
|||||||
34
src/features/settings/panels/Kugou/InstructionsPC.tsx
Normal file
34
src/features/settings/panels/Kugou/InstructionsPC.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react';
|
||||||
|
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
|
||||||
|
|
||||||
|
export function InstructionsPC() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text>酷狗的 Windows 客户端使用 <abbr title="SQLite w/ SQLCipher">SQLite</abbr> 数据库储存密钥。</Text>
|
||||||
|
<Text>该密钥文件通常存储在下述路径:</Text>
|
||||||
|
<FilePathBlock>%APPDATA%\KuGou8\KGMusicV3.db</FilePathBlock>
|
||||||
|
|
||||||
|
<Heading as="h3" size="md" mt="4">
|
||||||
|
导入密钥
|
||||||
|
</Heading>
|
||||||
|
<OrderedList>
|
||||||
|
<ListItem>
|
||||||
|
<Text>
|
||||||
|
选中并复制上述的 <Code>KGMusicV3.db</Code> 文件路径
|
||||||
|
</Text>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Text>点击上方的「文件选择区域」,打开「文件选择框」</Text>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Text>
|
||||||
|
在「文件名」输入框中粘贴之前复制的 <Code>KGMusicV3.db</Code> 文件路径
|
||||||
|
</Text>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Text>按下「回车键」确认。</Text>
|
||||||
|
</ListItem>
|
||||||
|
</OrderedList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/features/settings/panels/Kugou/KugouAllInstructions.tsx
Normal file
25
src/features/settings/panels/Kugou/KugouAllInstructions.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||||
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
|
import { InstructionsPC } from './InstructionsPC';
|
||||||
|
|
||||||
|
export function KugouAllInstructions() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TabList>
|
||||||
|
<Tab>安卓</Tab>
|
||||||
|
<Tab>Windows</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels flex={1} overflow="auto">
|
||||||
|
<TabPanel>
|
||||||
|
<AndroidADBPullInstruction
|
||||||
|
dir="/data/data/com.kugou.android/files/mmkv"
|
||||||
|
file="mggkey_multi_process"
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsPC />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/features/settings/panels/Kugou/KugouEKeyItem.tsx
Normal file
72
src/features/settings/panels/Kugou/KugouEKeyItem.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
|
ListItem,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||||
|
import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
|
||||||
|
import { useAppDispatch } from '~/hooks';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { StagingKugouKey } from '../../keyFormats';
|
||||||
|
|
||||||
|
export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value }));
|
||||||
|
const deleteKey = () => dispatch(kugouDeleteKey({ id }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
||||||
|
<HStack>
|
||||||
|
<Text w="2em" textAlign="center">
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<VStack flex={1}>
|
||||||
|
<HStack flex={1} w="full">
|
||||||
|
<Input
|
||||||
|
variant="flushed"
|
||||||
|
placeholder="音频哈希。不建议手动填写。"
|
||||||
|
value={audioHash}
|
||||||
|
onChange={(e) => updateKey('audioHash', e)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<InputGroup size="xs">
|
||||||
|
<InputLeftElement pr="2">
|
||||||
|
<Icon as={MdVpnKey} />
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input
|
||||||
|
variant="flushed"
|
||||||
|
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
||||||
|
value={ekey}
|
||||||
|
onChange={(e) => updateKey('ekey', e)}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||||
|
<code>{ekey.length || '?'}</code>
|
||||||
|
</Text>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除该密钥"
|
||||||
|
icon={<Icon as={MdDelete} boxSize={6} />}
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="red"
|
||||||
|
type="button"
|
||||||
|
onClick={deleteKey}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
87
src/features/settings/panels/PanelKGGKey.tsx
Normal file
87
src/features/settings/panels/PanelKGGKey.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
|
|
||||||
|
import { kugouAddKey, kugouClearKeys, kugouImportKeys } from '../settingsSlice';
|
||||||
|
import { selectStagingKugouV5Keys } from '../settingsSelector';
|
||||||
|
import type { StagingKugouKey } from '../keyFormats';
|
||||||
|
import { AddKey } from '~/components/AddKey.tsx';
|
||||||
|
import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.tsx';
|
||||||
|
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||||
|
import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
|
||||||
|
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
|
||||||
|
|
||||||
|
export function PanelKGGKey() {
|
||||||
|
const toast = useToast();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const kugouKeys = useSelector(selectStagingKugouV5Keys);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
|
const addKey = () => dispatch(kugouAddKey());
|
||||||
|
const clearAll = () => dispatch(kugouClearKeys());
|
||||||
|
const handleSecretImport = async (file: File) => {
|
||||||
|
let keys: Omit<StagingKugouKey, 'id'>[] | null = null;
|
||||||
|
if (/mggkey_multi_process/i.test(file.name)) {
|
||||||
|
keys = parseAndroidKugouMMKV(new DataView(await file.arrayBuffer()));
|
||||||
|
} else if (/^KGMusicV3\.db$/.test(file.name)) {
|
||||||
|
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||||
|
keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys?.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: '未导入密钥',
|
||||||
|
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
||||||
|
isClosable: true,
|
||||||
|
status: 'warning',
|
||||||
|
});
|
||||||
|
} else if (keys) {
|
||||||
|
dispatch(kugouImportKeys(keys));
|
||||||
|
setShowImportModal(false);
|
||||||
|
toast({
|
||||||
|
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
|
||||||
|
description: '记得按下「保存」来应用。',
|
||||||
|
isClosable: true,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: `不支持的文件:${file.name}`,
|
||||||
|
isClosable: true,
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex minH={0} flexDir="column" flex={1}>
|
||||||
|
<Heading as="h2" size="lg">
|
||||||
|
酷狗解密密钥 (KGG / KGM v5)
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text>酷狗已经升级了加密方式,现在使用 KGG / KGM v5 加密。</Text>
|
||||||
|
|
||||||
|
<AddKey addKey={addKey} importKeyFromFile={() => setShowImportModal(true)} clearKeys={clearAll} />
|
||||||
|
|
||||||
|
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||||
|
<List spacing={3}>
|
||||||
|
{kugouKeys.map(({ id, audioHash, ekey }, i) => (
|
||||||
|
<KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
{kugouKeys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ImportSecretModal
|
||||||
|
clientName="酷狗音乐"
|
||||||
|
show={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImport={handleSecretImport}
|
||||||
|
>
|
||||||
|
<KugouAllInstructions />
|
||||||
|
</ImportSecretModal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Heading, Text, Code, Kbd, OrderedList, ListItem } from '@chakra-ui/react';
|
import { Heading, Text, Code, Kbd, OrderedList, ListItem, Link } from '@chakra-ui/react';
|
||||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||||
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
||||||
import { ShiftKey } from '~/components/Key/ShiftKey';
|
import { ShiftKey } from '~/components/Key/ShiftKey';
|
||||||
|
|
||||||
|
const MAC_CLIENT_URL =
|
||||||
|
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
|
||||||
|
|
||||||
export function InstructionsMac() {
|
export function InstructionsMac() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
||||||
|
<Text>
|
||||||
|
{'此外,你需要降级到 '}
|
||||||
|
<Link isExternal href={MAC_CLIENT_URL}>
|
||||||
|
2023.09.03 版本的客户端
|
||||||
|
</Link>
|
||||||
|
{'。'}
|
||||||
|
新版本对 mmkv 数据库进行了加密处理。
|
||||||
|
</Text>
|
||||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
<Text>该密钥文件通常存储在下述路径:</Text>
|
||||||
<FilePathBlock>
|
<FilePathBlock>
|
||||||
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Text } from '@chakra-ui/react';
|
|||||||
export function InstructionsPC() {
|
export function InstructionsPC() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>使用 Windows 19.43 或更低版本下载的歌曲文件无需密钥。</Text>
|
<Text>使用 Windows 19.51 或更低版本下载的歌曲文件无需密钥。</Text>
|
||||||
<Text>使用 Windows 19.51 或更高版本下载的歌曲文件需要导入密钥,但方法尚未公开。</Text>
|
<Text>使用 Windows 19.57 或更高版本下载的歌曲文件需要导入密钥,但方法尚未公开。</Text>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings?.kugou) {
|
||||||
|
const { keys } = settings.kugou;
|
||||||
|
|
||||||
|
for (const [k, v] of enumObject(keys)) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
draft.kugou.keys[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof settings?.qtfm?.android === 'string') {
|
if (typeof settings?.qtfm?.android === 'string') {
|
||||||
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
|
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RootState } from '~/store';
|
|||||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||||
import { hasOwn } from '~/util/objects';
|
import { hasOwn } from '~/util/objects';
|
||||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||||
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
import type { ParseKugouHeaderResponse, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||||
|
|
||||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ export const selectFinalQMCv2Settings = (state: RootState) => state.settings.pro
|
|||||||
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
||||||
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
|
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
|
||||||
|
|
||||||
|
export const selectStagingKugouV5Keys = (state: RootState) => state.settings.staging.kugou.keys;
|
||||||
|
export const selectFinalKugouV5Keys = (state: RootState) => state.settings.production.kugou.keys;
|
||||||
|
|
||||||
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
|
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
|
||||||
const normalizedName = name.normalize();
|
const normalizedName = name.normalize();
|
||||||
|
|
||||||
@@ -50,5 +53,16 @@ export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse):
|
|||||||
return ekey;
|
return ekey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse): string | undefined => {
|
||||||
|
if (!hdr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = selectFinalKugouV5Keys(state);
|
||||||
|
const lookupKey = hdr.audioHash;
|
||||||
|
|
||||||
|
return hasOwn(keys, lookupKey) ? keys[lookupKey] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
||||||
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
qmc2StagingToProductionKey,
|
qmc2StagingToProductionKey,
|
||||||
qmc2StagingToProductionValue,
|
qmc2StagingToProductionValue,
|
||||||
stagingKeyToProduction,
|
stagingKeyToProduction,
|
||||||
|
ProductionKugouKey,
|
||||||
|
kugouProductionToStaging,
|
||||||
|
kugouStagingToProductionKey,
|
||||||
|
kugouStagingToProductionValue,
|
||||||
|
StagingKugouKey,
|
||||||
} from './keyFormats';
|
} from './keyFormats';
|
||||||
|
|
||||||
export interface StagingSettings {
|
export interface StagingSettings {
|
||||||
@@ -24,6 +29,9 @@ export interface StagingSettings {
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: StagingKWMv2Key[];
|
keys: StagingKWMv2Key[];
|
||||||
};
|
};
|
||||||
|
kugou: {
|
||||||
|
keys: StagingKugouKey[];
|
||||||
|
};
|
||||||
qtfm: {
|
qtfm: {
|
||||||
android: string;
|
android: string;
|
||||||
};
|
};
|
||||||
@@ -37,6 +45,9 @@ export interface ProductionSettings {
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||||
};
|
};
|
||||||
|
kugou: {
|
||||||
|
keys: ProductionKugouKey; // { [fileName]: ekey }
|
||||||
|
};
|
||||||
qtfm: {
|
qtfm: {
|
||||||
android: string;
|
android: string;
|
||||||
};
|
};
|
||||||
@@ -47,16 +58,19 @@ export interface SettingsState {
|
|||||||
staging: StagingSettings;
|
staging: StagingSettings;
|
||||||
production: ProductionSettings;
|
production: ProductionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SettingsState = {
|
const initialState: SettingsState = {
|
||||||
dirty: false,
|
dirty: false,
|
||||||
staging: {
|
staging: {
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||||
kwm2: { keys: [] },
|
kwm2: { keys: [] },
|
||||||
qtfm: { android: '' },
|
qtfm: { android: '' },
|
||||||
|
kugou: { keys: [] },
|
||||||
},
|
},
|
||||||
production: {
|
production: {
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||||
kwm2: { keys: {} },
|
kwm2: { keys: {} },
|
||||||
|
kugou: { keys: {} },
|
||||||
qtfm: { android: '' },
|
qtfm: { android: '' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -69,6 +83,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||||
},
|
},
|
||||||
|
kugou: {
|
||||||
|
keys: stagingKeyToProduction(staging.kugou.keys, kugouStagingToProductionKey, kugouStagingToProductionValue),
|
||||||
|
},
|
||||||
qtfm: staging.qtfm,
|
qtfm: staging.qtfm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +97,9 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||||
},
|
},
|
||||||
|
kugou: {
|
||||||
|
keys: productionKeyToStaging(production.kugou.keys, kugouProductionToStaging),
|
||||||
|
},
|
||||||
qtfm: production.qtfm,
|
qtfm: production.qtfm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,14 +172,42 @@ export const settingsSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
|
||||||
state.staging.qtfm.android = deviceKey;
|
|
||||||
state.dirty = true;
|
|
||||||
},
|
|
||||||
kwm2ClearKeys(state) {
|
kwm2ClearKeys(state) {
|
||||||
state.staging.kwm2.keys = [];
|
state.staging.kwm2.keys = [];
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
},
|
},
|
||||||
|
kugouAddKey(state) {
|
||||||
|
state.staging.kugou.keys.push({ id: nanoid(), audioHash: '', ekey: '' });
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
|
kugouImportKeys(state, { payload }: PayloadAction<Omit<StagingKugouKey, 'id'>[]>) {
|
||||||
|
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||||
|
state.staging.kugou.keys.push(...newItems);
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
|
kugouDeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
|
||||||
|
const kugou = state.staging.kugou;
|
||||||
|
kugou.keys = kugou.keys.filter((item) => item.id !== id);
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
|
kugouUpdateKey(
|
||||||
|
state,
|
||||||
|
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKugouKey; value: string }>,
|
||||||
|
) {
|
||||||
|
const keyItem = state.staging.kugou.keys.find((item) => item.id === id);
|
||||||
|
if (keyItem) {
|
||||||
|
keyItem[field] = value;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
kugouClearKeys(state) {
|
||||||
|
state.staging.kugou.keys = [];
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
|
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
||||||
|
state.staging.qtfm.android = deviceKey;
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
//
|
//
|
||||||
discardStagingChanges: (state) => {
|
discardStagingChanges: (state) => {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
@@ -197,6 +245,12 @@ export const {
|
|||||||
kwm2ClearKeys,
|
kwm2ClearKeys,
|
||||||
kwm2ImportKeys,
|
kwm2ImportKeys,
|
||||||
|
|
||||||
|
kugouAddKey,
|
||||||
|
kugouUpdateKey,
|
||||||
|
kugouDeleteKey,
|
||||||
|
kugouClearKeys,
|
||||||
|
kugouImportKeys,
|
||||||
|
|
||||||
qtfmAndroidUpdateKey,
|
qtfmAndroidUpdateKey,
|
||||||
|
|
||||||
commitStagingChange,
|
commitStagingChange,
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { Center, Container, Heading } from '@chakra-ui/react';
|
import { FC, Fragment } from 'react';
|
||||||
import { Header3 } from '~/components/HelpText/Header3';
|
import { Center, Container, Heading, Link, ListItem, UnorderedList } from '@chakra-ui/react';
|
||||||
|
import { Header3 } from '~/components/HelpText/Headers';
|
||||||
import { KuwoFAQ } from '~/faq/KuwoFAQ';
|
import { KuwoFAQ } from '~/faq/KuwoFAQ';
|
||||||
import { OtherFAQ } from '~/faq/OtherFAQ';
|
import { OtherFAQ } from '~/faq/OtherFAQ';
|
||||||
import { QQMusicFAQ } from '~/faq/QQMusicFAQ';
|
import { QQMusicFAQ } from '~/faq/QQMusicFAQ';
|
||||||
|
import { KugouFAQ } from '~/faq/KugouFAQ.tsx';
|
||||||
|
|
||||||
|
type FAQEntry = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
Help: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqEntries: FAQEntry[] = [
|
||||||
|
{ id: 'qqmusic', title: 'QQ 音乐', Help: QQMusicFAQ },
|
||||||
|
{ id: 'kuwo', title: '酷我音乐', Help: KuwoFAQ },
|
||||||
|
{ id: 'kugou', title: '酷狗音乐', Help: KugouFAQ },
|
||||||
|
{ id: 'other', title: '其它问题', Help: OtherFAQ },
|
||||||
|
];
|
||||||
|
|
||||||
export function FaqTab() {
|
export function FaqTab() {
|
||||||
return (
|
return (
|
||||||
@@ -10,12 +25,20 @@ export function FaqTab() {
|
|||||||
<Center>
|
<Center>
|
||||||
<Heading as="h2">常见问题解答</Heading>
|
<Heading as="h2">常见问题解答</Heading>
|
||||||
</Center>
|
</Center>
|
||||||
<Header3>QQ 音乐</Header3>
|
<Header3>答疑目录</Header3>
|
||||||
<QQMusicFAQ />
|
<UnorderedList>
|
||||||
<Header3>酷我音乐</Header3>
|
{faqEntries.map(({ id, title }) => (
|
||||||
<KuwoFAQ />
|
<ListItem key={id}>
|
||||||
<Header3>其它问题</Header3>
|
<Link href={`#faq-${id}`}>{title}</Link>
|
||||||
<OtherFAQ />
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</UnorderedList>
|
||||||
|
{faqEntries.map(({ id, title, Help }) => (
|
||||||
|
<Fragment key={id}>
|
||||||
|
<Header3 id={`faq-${id}`}>{title}</Header3>
|
||||||
|
<Help />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import { Box, VStack } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Box, Button, Flex, Text, VStack } from '@chakra-ui/react';
|
||||||
import { SelectFile } from '../components/SelectFile';
|
import { SelectFile } from '../components/SelectFile';
|
||||||
|
|
||||||
import { FileListing } from '~/features/file-listing/FileListing';
|
import { FileListing } from '~/features/file-listing/FileListing';
|
||||||
|
import { useAppDispatch, useAppSelector } from '~/hooks.ts';
|
||||||
|
import { selectIsSettingsNotSaved } from '~/features/settings/settingsSelector.ts';
|
||||||
|
import { commitStagingChange } from '~/features/settings/settingsSlice.ts';
|
||||||
|
|
||||||
export function MainTab() {
|
export function MainTab() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
|
||||||
|
const onClickSaveSettings = () => {
|
||||||
|
dispatch(commitStagingChange());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="full" w="full" pt="4">
|
<Box h="full" w="full" pt="4">
|
||||||
<VStack gap="3">
|
<VStack gap="3">
|
||||||
|
{isSettingsNotSaved && (
|
||||||
|
<Alert borderRadius={7} maxW={400} status="warning">
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="row" alignItems="center" flexGrow={1} justifyContent="space-between">
|
||||||
|
<Text m={0}>
|
||||||
|
有尚未储存的设置,
|
||||||
|
<br />
|
||||||
|
设定将在保存后生效
|
||||||
|
</Text>
|
||||||
|
<Button type="button" ml={3} size="md" onClick={onClickSaveSettings}>
|
||||||
|
立即储存
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<SelectFile />
|
<SelectFile />
|
||||||
|
|
||||||
<Box w="full">
|
<Box w="full">
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { getFileName } from './pathHelper';
|
import { getFileName } from './pathHelper';
|
||||||
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
||||||
|
import { KuGou } from '@unlock-music/crypto';
|
||||||
|
|
||||||
export interface QMAndroidKeyEntry {
|
export interface QMAndroidKeyEntry {
|
||||||
name: string;
|
name: string;
|
||||||
ekey: string;
|
ekey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KugouKeyEntry = {
|
||||||
|
audioHash: string;
|
||||||
|
ekey: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class DatabaseKeyExtractor {
|
export class DatabaseKeyExtractor {
|
||||||
private static _instance: DatabaseKeyExtractor;
|
private static _instance: DatabaseKeyExtractor;
|
||||||
|
|
||||||
@@ -52,4 +58,44 @@ export class DatabaseKeyExtractor {
|
|||||||
db?.close();
|
db?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extractKugouKeyFromEncryptedDb(buffer: ArrayBuffer): null | KugouKeyEntry[] {
|
||||||
|
const dbBuffer = new Uint8Array(buffer);
|
||||||
|
let db: SQLDatabase | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
KuGou.decryptDatabase(dbBuffer);
|
||||||
|
db = new this.SQL.Database(dbBuffer);
|
||||||
|
|
||||||
|
let sql: undefined | string;
|
||||||
|
if (this.hasTable(db, 'ShareFileItems')) {
|
||||||
|
sql = `
|
||||||
|
select H, K from (
|
||||||
|
select EncryptionKeyId as H, EncryptionKey as K from ShareFileItems
|
||||||
|
union all
|
||||||
|
select EnHash as H, EnKey as K from DownloadItem
|
||||||
|
) t
|
||||||
|
where
|
||||||
|
t.H is not null and t.H != ''
|
||||||
|
and t.K is not null and t.K != ''
|
||||||
|
group by t.H
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (!sql) return null;
|
||||||
|
|
||||||
|
const result = db.exec(sql);
|
||||||
|
if (result.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = result[0].values;
|
||||||
|
return keys.map(([audioHash, ekey]) => ({
|
||||||
|
// strip dir name
|
||||||
|
audioHash: String(audioHash).normalize(),
|
||||||
|
ekey: String(ekey).normalize(),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
db?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ test('it should move on to the next item in the queue once failed', async () =>
|
|||||||
await expect(badPromise).rejects.toThrowError('dummy error');
|
await expect(badPromise).rejects.toThrowError('dummy error');
|
||||||
|
|
||||||
queuedResolver.forEach((resolve) => resolve());
|
queuedResolver.forEach((resolve) => resolve());
|
||||||
expect(Promise.all(promises)).resolves.toEqual([1, 2, 4, 5]);
|
await expect(Promise.all(promises)).resolves.toEqual([1, 2, 4, 5]);
|
||||||
} finally {
|
} finally {
|
||||||
vi.spyOn(queue, 'handler').mockRejectedValue(new Error('handler ran too late'));
|
vi.spyOn(queue, 'handler').mockRejectedValue(new Error('handler ran too late'));
|
||||||
queuedResolver.forEach((resolve) => resolve());
|
queuedResolver.forEach((resolve) => resolve());
|
||||||
|
|||||||
16
src/util/mmkv/kugou.ts
Normal file
16
src/util/mmkv/kugou.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { StagingKugouKey } from '~/features/settings/keyFormats';
|
||||||
|
import { MMKVParser } from '../MMKVParser';
|
||||||
|
|
||||||
|
export function parseAndroidKugouMMKV(view: DataView): Omit<StagingKugouKey, 'id'>[] {
|
||||||
|
const mmkv = new MMKVParser(view);
|
||||||
|
const result: Omit<StagingKugouKey, 'id'>[] = [];
|
||||||
|
while (!mmkv.eof) {
|
||||||
|
const audioHash = mmkv.readString();
|
||||||
|
const ekey = mmkv.readStringValue();
|
||||||
|
|
||||||
|
if (audioHash.length === 0x20 && ekey) {
|
||||||
|
result.push({ audioHash, ekey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as initSqlite from 'sql.js';
|
import * as initSqlite from 'sql.js';
|
||||||
|
|
||||||
const urlWasm = new URL('@nm/sql.js/dist/sql-wasm.wasm', import.meta.url).toString();
|
const urlWasm = new URL('@sql-wasm', import.meta.url).toString();
|
||||||
|
|
||||||
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
|
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
|
||||||
export type SQLDatabase = SQLStatic['Database']['prototype'];
|
export type SQLDatabase = SQLStatic['Database']['prototype'];
|
||||||
|
|||||||
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@@ -4,6 +4,5 @@ module 'virtual:pwa-register' {
|
|||||||
/**
|
/**
|
||||||
* See: {@link https://vite-pwa-org.netlify.app/guide/prompt-for-update.html}
|
* See: {@link https://vite-pwa-org.netlify.app/guide/prompt-for-update.html}
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
declare function registerSW(_opts: unknown): () => void;
|
||||||
declare function registerSW(opts: unknown): () => void;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import url from 'node:url';
|
|||||||
const projectRoot = url.fileURLToPath(new URL('../', import.meta.url));
|
const projectRoot = url.fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
|
||||||
export function command(cmd: string, dir = '') {
|
export function command(cmd: string, dir = '') {
|
||||||
return cp.execSync(cmd, { cwd: path.join(projectRoot, dir), encoding: 'utf-8' }).trim();
|
return cp.execSync(cmd, { cwd: path.resolve(projectRoot, dir), encoding: 'utf-8' }).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryCommand(cmd: string, dir = '', fallback = '') {
|
export function tryCommand(cmd: string, dir = '', fallback = '') {
|
||||||
try {
|
try {
|
||||||
return command(cmd, dir);
|
return command(cmd, dir);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"],
|
"~/*": ["./src/*"],
|
||||||
"@nm/*": ["./node_modules/*"]
|
"@sql-wasm": ["./node_modules/sql.js/dist/sql-wasm.wasm"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
|
|||||||
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
|
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
|
||||||
|
|
||||||
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
|
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
|
||||||
const shortCommit = tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown');
|
const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown');
|
||||||
const version = `${pkg.version}-${shortCommit}`;
|
const version = `${pkg.version} (${shortCommit})`;
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -85,7 +85,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'~': path.resolve(__dirname, 'src'),
|
'~': path.resolve(__dirname, 'src'),
|
||||||
'@nm': path.resolve(__dirname, 'node_modules'),
|
'@sql-wasm': path.resolve(__dirname, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||||
|
|
||||||
// workaround for vite, workbox (PWA)
|
// workaround for vite, workbox (PWA)
|
||||||
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
||||||
|
|||||||
Reference in New Issue
Block a user