mirror of
https://github.com/CarlGao4/um-react-electron.git
synced 2024-11-23 18:32:17 +00:00
update
This commit is contained in:
parent
be0b8687f3
commit
8e822c92eb
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "um-react"]
|
||||
path = um-react
|
||||
url = ./um-react
|
1
um-react
Submodule
1
um-react
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit a5cc2e12300e0ae39e204b4b52b22966fc02232c
|
@ -1,33 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: test & build
|
||||
image: node:18.16.1-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:18.16.1-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
|
@ -1,15 +0,0 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{{c,m,}js{x,on,},ts{x,}}]
|
||||
indent_size = 2
|
@ -1,4 +0,0 @@
|
||||
# Example environment file for vite to use.
|
||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
1
um-react/.gitattributes
vendored
1
um-react/.gitattributes
vendored
@ -1 +0,0 @@
|
||||
*.mmkv binary
|
29
um-react/.gitignore
vendored
29
um-react/.gitignore
vendored
@ -1,29 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
coverage/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Files created when running "drone exec" locally
|
||||
/.pnpm-store/
|
||||
/*.zip
|
@ -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
|
@ -1,3 +0,0 @@
|
||||
use-node-version=18.16.0
|
||||
node-version=18.16.0
|
||||
engine-strict=true
|
@ -1,5 +0,0 @@
|
||||
dist/
|
||||
|
||||
# Package manager
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Unlock-Music Team<https://git.unlock-music.dev/um/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -1,9 +0,0 @@
|
||||
# 利用 ADB 访问安卓私有数据
|
||||
|
||||
```sh
|
||||
APP_ID="com.tencent.qqmusic" # QQ 音乐
|
||||
APP_ID="cn.kuwo.player" # 酷我
|
||||
|
||||
adb shell su -c "tar c '/data/data/${APP_ID}/' | base64" \
|
||||
| base64 -d | pv | tar -x --strip-components 2
|
||||
```
|
@ -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 更改已合并。
|
@ -1,35 +0,0 @@
|
||||
# 新手上路
|
||||
|
||||
该文档描述了如何本地运行或编译生产版本的「Unlock Music 音乐解锁」。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
- 安装 Node v16.17 或更高,推荐当前最新的 Node LTS 版本。
|
||||
- 安装/激活 `pnpm` [^1]:`corepack prepare pnpm@latest --activate`
|
||||
- 安装软件依赖:`pnpm i --frozen-lockfile`
|
||||
|
||||
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
|
||||
|
||||
## 本地运行
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm start
|
||||
```
|
||||
|
||||
然后根据提示打开[项目运行页面][vite-dev-url]即可。
|
||||
|
||||
[vite-dev-url]: http://localhost:5173/
|
||||
|
||||
## 构建生产版本
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
|
||||
|
||||
[vite-preview-url]: http://localhost:4173/
|
@ -1,25 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cmn-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>音乐解锁 - Unlock Music (React)</title>
|
||||
|
||||
<meta name="description" content="音乐解锁 - Unlock Music" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
||||
<meta name="theme-color" content="#4DBA87" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<style>
|
||||
.chakra-tabs__tablist {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.chakra-tabs__tab {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
</style>
|
||||
</html>
|
11743
um-react/package-lock.json
generated
11743
um-react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,85 +0,0 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier -w .",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.1.1",
|
||||
"@chakra-ui/icons": "^2.0.19",
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@jixun/libparakeet": "0.2.1",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"framer-motion": "^10.12.16",
|
||||
"immer": "^10.0.2",
|
||||
"nanoid": "^4.0.2",
|
||||
"radash": "^10.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-icons": "^4.9.0",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sass": "^1.63.3",
|
||||
"sql.js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/node": "^20.2.5",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.7",
|
||||
"@types/sql.js": "^1.4.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
||||
"@typescript-eslint/parser": "^5.59.7",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.31.1",
|
||||
"@vitest/ui": "^0.31.1",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-pwa": "^0.15.1",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"vitest": "^0.31.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown",
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
|
||||
--- a/dist/sql-wasm.js
|
||||
+++ b/dist/sql-wasm.js
|
||||
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
|
||||
else if (typeof exports === 'object'){
|
||||
exports["Module"] = initSqlJs;
|
||||
}
|
||||
+
|
||||
+var module;
|
||||
+export default initSqlJs;
|
||||
+
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 641 B |
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
Before Width: | Height: | Size: 32 KiB |
@ -1,110 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
__netlify_upload() {
|
||||
local branch="$BRANCH_NAME"
|
||||
local production="$DEPLOY_PRODUCTION"
|
||||
[[ "$BRANCH_NAME" = "main" ]] && production="true"
|
||||
[[ -z "$production" ]] && production="false"
|
||||
|
||||
curl -sL \
|
||||
-H "Content-Type: application/zip" \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
--data-binary "@${1}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys?branch=${branch}&production=${production}"
|
||||
}
|
||||
|
||||
__netlify_get_deploy() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
"https://api.netlify.com/api/v1/deploys/${deploy_id}"
|
||||
}
|
||||
|
||||
# Publish a deployment to main URL.
|
||||
__netlify_promote() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys/${deploy_id}/restore"
|
||||
}
|
||||
|
||||
__netlify_get_error() {
|
||||
local error_message
|
||||
error_message="$(json_get "$upload_resp" message)"
|
||||
[[ "$error_message" = "null" ]] && error_message="$(json_get "$upload_resp" error_message)"
|
||||
echo -n "$error_message"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local json_body="$1"
|
||||
shift
|
||||
|
||||
echo -n "$json_body" | "${SCRIPTS_DIR}/read_json.mjs" "$@"
|
||||
}
|
||||
|
||||
deploy_netlify() {
|
||||
local upload_resp
|
||||
upload_resp="$(__netlify_upload "$1")"
|
||||
|
||||
local error_message="$(__netlify_get_error "$upload_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local deploy_id="$(json_get "$upload_resp" id)"
|
||||
local deploy_resp=""
|
||||
local deploy_state=""
|
||||
local retry_count=10
|
||||
while [[ "$retry_count" -gt 0 ]]; do
|
||||
deploy_resp="$(__netlify_get_deploy "$deploy_id")"
|
||||
deploy_state="$(json_get "$deploy_resp" 'state')"
|
||||
case "$deploy_state" in
|
||||
ready)
|
||||
echo 'Deploy to netlify OK!'
|
||||
echo " * main url: $(json_get "$deploy_resp" 'ssl_url')"
|
||||
echo " * branch: $(json_get "$deploy_resp" 'deploy_ssl_url')"
|
||||
echo " * permalink: $(json_get "$deploy_resp" 'links' 'permalink')"
|
||||
break
|
||||
;;
|
||||
error)
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * $(json_get "$deploy_resp" 'error_message')"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
retry_count="$((retry_count - 1))"
|
||||
sleep 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$BRANCH_NAME" = "main" ]]; then
|
||||
echo "Promoting latest main build..."
|
||||
local promote_resp="$(__netlify_promote "$(json_get "$deploy_resp" 'id')")"
|
||||
error_message="$(__netlify_get_error "$promote_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Promote netlify deploy failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
else
|
||||
echo 'Deoployed to main url.'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# For deployment, we care a bit less
|
||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||
echo "Deploy to netlify..."
|
||||
deploy_netlify um-react.zip
|
||||
else
|
||||
echo "skip netlify deployment."
|
||||
fi
|
@ -1,21 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
|
||||
publish_gitea() {
|
||||
local ZIP_NAME="$1"
|
||||
local URL="${DRONE_GITEA_SERVER}/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}"
|
||||
sha256sum "${ZIP_NAME}"
|
||||
curl -sLifu "um-release-bot:${GITEA_API_KEY}" -T "${ZIP_NAME}" "${URL}"
|
||||
echo "Uploaded to: ${URL}"
|
||||
}
|
||||
|
||||
# Only publish main branch by default
|
||||
if [[ "${BRANCH_NAME}" = "main" && -z "$DRONE_PULL_REQUEST" ]]; then
|
||||
echo 'prepare to publish...'
|
||||
|
||||
if [[ -n "${GITEA_API_KEY}" ]]; then
|
||||
echo "Publish to gitea..."
|
||||
publish_gitea "um-react.zip"
|
||||
fi
|
||||
fi
|
@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(0, 'utf-8').trim());
|
||||
|
||||
let value = data;
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
value = value[process.argv[i]] ?? null;
|
||||
}
|
||||
process.stdout.write(String(value), 'utf-8');
|
@ -1,21 +0,0 @@
|
||||
import { renderWithProviders, screen, waitFor } from '~/test-utils/test-helper';
|
||||
import { AppRoot } from '~/components/AppRoot';
|
||||
|
||||
vi.mock('../decrypt-worker/client', () => {
|
||||
return {
|
||||
workerClientBus: {
|
||||
request: vi.fn().mockResolvedValue('dummy'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('should be able to render App', async () => {
|
||||
renderWithProviders(<AppRoot />);
|
||||
|
||||
// Should eventually load sdk version
|
||||
await waitFor(() => screen.getByTestId('sdk-version'));
|
||||
|
||||
// Quick sanity check of known strings.
|
||||
expect(screen.getByText(/在浏览器内对文件进行解锁/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/UnlockMusic 团队/i)).toBeInTheDocument();
|
||||
});
|
@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
|
||||
<rect fill="#ddd" width="160" height="160" />
|
||||
<text fill="#0007" font-family="sans-serif" font-size="24" font-weight="bold"
|
||||
text-anchor="middle" letter-spacing="6"
|
||||
dy="9.45" x="50%" y="50%"
|
||||
>
|
||||
暂无封面
|
||||
</text>
|
||||
</svg>
|
Before Width: | Height: | Size: 348 B |
@ -1,157 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Code,
|
||||
Heading,
|
||||
Link,
|
||||
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';
|
||||
|
||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
||||
};
|
||||
|
||||
export interface AndroidADBPullInstructionProps {
|
||||
dir: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
||||
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file });
|
||||
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
你需要 <code>root</code> 访问权限来访问安卓应用的私有数据。
|
||||
</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,获取 <code>root</code> 通常意味着你的安卓设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
|
||||
<Accordion allowToggle mt="2">
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在安卓手机端操作
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
启动具有 <Code>root</Code> 特权的文件浏览器
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
访问 <Code>{dir}/</Code> 目录。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
将文件 <Code>{file}</Code> 复制到浏览器可访问的目录。
|
||||
<br />
|
||||
(例如下载目录)
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>提交该数据库文件。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在 PC 端操作(ADB / PowerShell)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <code>adb</code> 命令可用。
|
||||
</Text>
|
||||
<Text>
|
||||
💡 如果没有,可以
|
||||
<Link href="https://scoop.sh/#/apps?q=adb" isExternal>
|
||||
使用 Scoop 安装 <ExternalLinkIcon />
|
||||
</Link>
|
||||
。
|
||||
</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>将安卓设备连接到电脑,并允许调试。</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
|
||||
{shAdbDumpCommand}
|
||||
</SyntaxHighlighter>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
try {
|
||||
$gz_b64 = adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" | Out-String
|
||||
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
|
||||
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
|
||||
$outFile = New-Object System.IO.FileStream("${PWD}\{{ file }}", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
|
||||
$decoded.CopyTo($outFile)
|
||||
} catch {
|
||||
Write-Host "遇到错误:"
|
||||
Write-Host $_
|
||||
} finally {
|
||||
if ($outFile -ne $null) { $outFile.Dispose() }
|
||||
if ($decoded -ne $null) { $decoded.Dispose() }
|
||||
if ($bStream -ne $null) { $bStream.Dispose() }
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
sh adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" \
|
||||
| base64 -d | gzip -d '{{ file }}'
|
@ -1,49 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MdSettings, MdHome } from 'react-icons/md';
|
||||
import { ChakraProvider, Tabs, TabList, TabPanels, Tab, TabPanel, Icon, chakra } from '@chakra-ui/react';
|
||||
|
||||
import { MainTab } from '~/tabs/MainTab';
|
||||
import { SettingsTab } from '~/tabs/SettingsTab';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { theme } from '~/theme';
|
||||
import { persistSettings } from '~/features/settings/persistSettings';
|
||||
import { setupStore } from '~/store';
|
||||
import { Footer } from '~/components/Footer';
|
||||
|
||||
// Private to this file only.
|
||||
const store = setupStore();
|
||||
|
||||
export function AppRoot() {
|
||||
useEffect(() => persistSettings(store), []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<Provider store={store}>
|
||||
<Tabs flex={1} minH={0} display="flex" flexDir="column">
|
||||
<TabList justifyContent="center">
|
||||
<Tab>
|
||||
<Icon as={MdHome} mr="1" />
|
||||
<chakra.span>应用</chakra.span>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={MdSettings} mr="1" />
|
||||
<chakra.span>设置</chakra.span>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex">
|
||||
<TabPanel>
|
||||
<MainTab />
|
||||
</TabPanel>
|
||||
<TabPanel flex={1} display="flex">
|
||||
<SettingsTab />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Footer />
|
||||
</Provider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Update every half hour
|
||||
const TIMER_UPDATE_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
const getCurrentYear = () => new Date().getFullYear();
|
||||
|
||||
export function CurrentYear() {
|
||||
const [year, setYear] = useState(getCurrentYear);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => setYear(getCurrentYear);
|
||||
updateTime();
|
||||
|
||||
const timer = setInterval(updateTime, TIMER_UPDATE_INTERVAL);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{year}</>;
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export interface FileInputProps {
|
||||
onReceiveFiles: (files: File[]) => void;
|
||||
multiple?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FileInput({ children, onReceiveFiles }: FileInputProps) {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
multiple: true,
|
||||
onDropAccepted: onReceiveFiles,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
w="100%"
|
||||
maxW={480}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
transitionDuration="0.5s"
|
||||
p="6"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
_hover={{
|
||||
borderColor: 'gray.400',
|
||||
bg: 'gray.50',
|
||||
}}
|
||||
{...(isDragActive && {
|
||||
bg: 'blue.50',
|
||||
borderColor: 'blue.700',
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { Code, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export function FilePathBlock({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-all">
|
||||
<Code>{children}</Code>
|
||||
</Text>
|
||||
);
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { Center, Flex, Link, Text } from '@chakra-ui/react';
|
||||
import { Suspense } from 'react';
|
||||
import { SDKVersion } from './SDKVersion';
|
||||
import { CurrentYear } from './CurrentYear';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<Center
|
||||
fontSize="sm"
|
||||
textAlign="center"
|
||||
bottom="0"
|
||||
w="full"
|
||||
pt="3"
|
||||
pb="3"
|
||||
borderTop="1px solid"
|
||||
borderColor="gray.300"
|
||||
bg="gray.100"
|
||||
color="gray.800"
|
||||
flexDir="column"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Flex as={Text}>
|
||||
<Link href="https://git.unlock-music.dev/um/um-react" isExternal>
|
||||
音乐解锁
|
||||
</Link>
|
||||
{' (__APP_VERSION_SHORT__'}
|
||||
<Suspense>
|
||||
<SDKVersion />
|
||||
</Suspense>
|
||||
{') - 移除已购音乐的加密保护。'}
|
||||
</Flex>
|
||||
<Text>
|
||||
{'© 2019 - '}
|
||||
<CurrentYear />{' '}
|
||||
<Link href="https://git.unlock-music.dev/um" isExternal>
|
||||
UnlockMusic 团队
|
||||
</Link>
|
||||
{' | '}
|
||||
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
|
||||
使用 MIT 授权协议
|
||||
</Link>
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import {
|
||||
Center,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
|
||||
export interface ImportSecretModalProps {
|
||||
clientName?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>从文件导入密钥</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
||||
<Center>
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Text mt={2}>选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</Text>
|
||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsCommand } from 'react-icons/bs';
|
||||
|
||||
export function MacCommandKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsCommand} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>command</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsShift } from 'react-icons/bs';
|
||||
|
||||
export function ShiftKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsShift} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>shift</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
|
||||
import { workerClientBus } from '~/decrypt-worker/client';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
||||
|
||||
import usePromise from 'react-promise-suspense';
|
||||
|
||||
const getSDKVersion = async (): Promise<string> => {
|
||||
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
|
||||
};
|
||||
|
||||
export function SDKVersion() {
|
||||
const sdkVersion = usePromise(getSDKVersion, []);
|
||||
|
||||
return (
|
||||
<Flex as="span" pl="1" alignItems="center" data-testid="sdk-version">
|
||||
<Tooltip
|
||||
hasArrow
|
||||
placement="top"
|
||||
label={
|
||||
<VStack>
|
||||
<Text>App: __APP_VERSION__</Text>
|
||||
<Text>SDK: {sdkVersion}</Text>
|
||||
</VStack>
|
||||
}
|
||||
bg="gray.300"
|
||||
color="black"
|
||||
>
|
||||
<InfoOutlineIcon />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { UnlockIcon } from '@chakra-ui/icons';
|
||||
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { FileInput } from './FileInput';
|
||||
|
||||
export function SelectFile() {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
console.debug(
|
||||
'react-dropzone/onDropAccepted(%o, %o)',
|
||||
files.length,
|
||||
files.map((x) => x.name)
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const blobURI = URL.createObjectURL(file);
|
||||
const fileName = file.name;
|
||||
const fileId = 'file://' + nanoid();
|
||||
|
||||
// FIXME: this should be a single action/thunk that first adds the item, then updates it.
|
||||
dispatch(
|
||||
addNewFile({
|
||||
id: fileId,
|
||||
blobURI,
|
||||
fileName,
|
||||
})
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
||||
<Box pb={3}>
|
||||
<UnlockIcon boxSize={8} />
|
||||
</Box>
|
||||
<Text as="div" textAlign="center">
|
||||
拖放或
|
||||
<Text as="span" color="teal.400">
|
||||
点我选择
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
在浏览器内对文件进行解锁,零上传
|
||||
</Text>
|
||||
</Text>
|
||||
</FileInput>
|
||||
);
|
||||
}
|
@ -1,25 +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;
|
||||
}
|
||||
|
||||
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
|
||||
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;
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { WorkerClientBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { DecryptionQueue } from '~/util/DecryptionQueue';
|
||||
|
||||
// TODO: Worker pool?
|
||||
export const workerClient = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
|
||||
|
||||
// FIXME: report the error so is obvious to the user.
|
||||
workerClient.addEventListener('error', console.error);
|
||||
|
||||
export const workerClientBus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(workerClient);
|
||||
export const decryptionQueue = new DecryptionQueue(workerClientBus);
|
@ -1,9 +0,0 @@
|
||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
||||
export interface DecryptionResult {
|
||||
decrypted: string; // blob uri
|
||||
ext: string;
|
||||
}
|
@ -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,45 +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';
|
||||
|
||||
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,
|
||||
|
||||
// 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,52 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
||||
import { fetchParakeet } from '@jixun/libparakeet';
|
||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||
import { 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 = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
||||
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,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);
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export interface DecryptCommandOptions {
|
||||
qmc2Key?: string;
|
||||
kwm2key?: string;
|
||||
}
|
||||
|
||||
export interface DecryptCommandPayload {
|
||||
id: string;
|
||||
blobURI: string;
|
||||
options: DecryptCommandOptions;
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
export enum DecryptErrorType {
|
||||
UNSUPPORTED_FILE = 'UNSUPPORTED_FILE',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export class DecryptError extends Error {
|
||||
code = DecryptErrorType.UNKNOWN;
|
||||
|
||||
toJSON() {
|
||||
const { name, message, stack, code } = this;
|
||||
return { name, message, stack, code };
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedSourceFile extends DecryptError {
|
||||
code = DecryptErrorType.UNSUPPORTED_FILE;
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
@ -1,4 +0,0 @@
|
||||
import type { Parakeet } from '@jixun/libparakeet';
|
||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
|
||||
|
||||
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
@ -1,38 +0,0 @@
|
||||
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { toArrayBuffer } from './buffer';
|
||||
import { UnsupportedSourceFile } from './DecryptError';
|
||||
|
||||
export async function transformBlob(
|
||||
blob: Blob | ArrayBuffer,
|
||||
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
|
||||
{ cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {}
|
||||
) {
|
||||
const registeredCleanupFns: (() => void)[] = [];
|
||||
if (cleanup) {
|
||||
registeredCleanupFns.push(cleanup);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = parakeet ?? (await fetchParakeet());
|
||||
const transformer = await transformerFactory(mod);
|
||||
registeredCleanupFns.push(() => transformer.delete());
|
||||
|
||||
const reader = mod.make.Reader(await toArrayBuffer(blob));
|
||||
registeredCleanupFns.push(() => reader.delete());
|
||||
|
||||
const sink = mod.make.WriterSink();
|
||||
const writer = sink.getWriter();
|
||||
registeredCleanupFns.push(() => writer.delete());
|
||||
|
||||
const result = transformer.Transform(writer, reader);
|
||||
if (result === TransformResult.ERROR_INVALID_FORMAT) {
|
||||
throw new UnsupportedSourceFile(`transformer<${transformer.Name}> does not recognize this file`);
|
||||
} else if (result !== TransformResult.OK) {
|
||||
throw new Error(`transformer<${transformer.Name}> failed with error: ${TransformResult[result]} (${result})`);
|
||||
}
|
||||
|
||||
return sink.collectBlob();
|
||||
} finally {
|
||||
registeredCleanupFns.forEach((cleanup) => cleanup());
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
export const utf8Decoder = new TextDecoder('utf-8');
|
||||
|
||||
export function stringToUTF8Bytes(str: string) {
|
||||
return utf8Encoder.encode(str);
|
||||
}
|
||||
|
||||
export function bytesToUTF8String(str: BufferSource) {
|
||||
return utf8Decoder.decode(str);
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
|
||||
import { getSDKVersion } from '@jixun/libparakeet';
|
||||
|
||||
import { workerDecryptHandler } from './worker/handler/decrypt';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);
|
@ -1,102 +0,0 @@
|
||||
import { Parakeet, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
||||
import { allCryptoFactories } from '../../crypto/CryptoFactory';
|
||||
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
|
||||
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
// Use first 4MiB of the file to perform check.
|
||||
const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
|
||||
|
||||
class DecryptCommandHandler {
|
||||
private label: string;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private parakeet: Parakeet,
|
||||
private buffer: ArrayBuffer,
|
||||
private options: DecryptCommandOptions
|
||||
) {
|
||||
this.label = `DecryptCommandHandler(${label})`;
|
||||
}
|
||||
|
||||
log<R>(label: string, fn: () => R): R {
|
||||
return timedLogger(`${this.label}: ${label}`, fn);
|
||||
}
|
||||
|
||||
async decrypt(factories: CryptoFactory[]) {
|
||||
for (const factory of factories) {
|
||||
const decryptor = factory();
|
||||
|
||||
try {
|
||||
const result = await this.decryptFile(decryptor);
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedSourceFile) {
|
||||
console.debug('WARN: decryptor does not recognize source file, wrong crypto?', error);
|
||||
} else {
|
||||
console.error('decrypt failed with unknown error: ', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
|
||||
}
|
||||
|
||||
async decryptFile(crypto: CryptoBase) {
|
||||
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (crypto.checkByDecryptHeader && !(await this.acceptByDecryptFileHeader(crypto))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.options));
|
||||
|
||||
// Check if we had a successful decryption
|
||||
const audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
|
||||
if (crypto.checkByDecryptHeader && audioExt === 'bin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt };
|
||||
}
|
||||
|
||||
async detectAudioExtension(data: Blob | ArrayBuffer): Promise<string> {
|
||||
return this.log(`detect-audio-ext`, async () => {
|
||||
const header = await toArrayBuffer(data.slice(0, TEST_FILE_HEADER_LEN));
|
||||
return this.parakeet.detectAudioExtension(header);
|
||||
});
|
||||
}
|
||||
|
||||
async acceptByDecryptFileHeader(crypto: CryptoBase): Promise<boolean> {
|
||||
// File too small, ignore.
|
||||
if (this.buffer.byteLength <= TEST_FILE_HEADER_LEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check by decrypt max first 8MiB
|
||||
const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () =>
|
||||
toArrayBuffer(await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.options))
|
||||
);
|
||||
|
||||
return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
|
||||
const label = `decrypt(${id})`;
|
||||
return withTimeGroupedLogs(label, async () => {
|
||||
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
|
||||
const blob = await timedLogger(`${label}/fetch-src`, async () => fetch(blobURI).then((r) => r.blob()));
|
||||
const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer());
|
||||
|
||||
const handler = new DecryptCommandHandler(id, parakeet, buffer, options);
|
||||
return handler.decrypt(allCryptoFactories);
|
||||
});
|
||||
};
|
@ -1,2 +0,0 @@
|
||||
// This is a dummy module for vite/rollup to resolve.
|
||||
Object.defineProperty(Object.create(null), { sideEffects: true });
|
@ -1,24 +0,0 @@
|
||||
import { Box, Image } from '@chakra-ui/react';
|
||||
import noCoverFallbackImageURL from '~/assets/no-cover.svg';
|
||||
|
||||
interface AlbumImageProps {
|
||||
url?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function AlbumImage({ name, url }: AlbumImageProps) {
|
||||
const coverAlternativeText = name ? `${name} 的专辑封面` : '专辑封面';
|
||||
|
||||
return (
|
||||
<Box w="160px" h="160px" m="auto">
|
||||
<Image
|
||||
border="2px solid"
|
||||
borderColor="gray.400"
|
||||
borderRadius="50%"
|
||||
objectFit="cover"
|
||||
src={url || noCoverFallbackImageURL}
|
||||
alt={coverAlternativeText}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
export interface FileErrorProps {
|
||||
error: null | string;
|
||||
code: null | string;
|
||||
}
|
||||
|
||||
const errorMap = new Map<string | null | DecryptErrorType, string>([
|
||||
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'],
|
||||
]);
|
||||
|
||||
export function FileError({ error, code }: FileErrorProps) {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const errorSummary = errorMap.get(code) ?? '未知错误';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<chakra.span>
|
||||
解密错误:<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||
</chakra.span>
|
||||
{error && (
|
||||
<Button ml="2" onClick={onToggle} type="button">
|
||||
详细
|
||||
</Button>
|
||||
)}
|
||||
</Text>
|
||||
{error && (
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box as="pre" display="inline-block" mt="2" px="4" py="2" bg="red.100" color="red.800" rounded="md">
|
||||
{error}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
|
||||
import { selectFiles } from './fileListingSlice';
|
||||
import { useAppSelector } from '~/hooks';
|
||||
import { FileRow } from './FileRow';
|
||||
|
||||
export function FileListing() {
|
||||
const files = useAppSelector(selectFiles);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
{Object.entries(files).map(([id, file]) => (
|
||||
<FileRow key={id} id={id} file={file} />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Collapse,
|
||||
GridItem,
|
||||
Link,
|
||||
VStack,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileRowResponsiveGrid } from './FileRowResponsiveGrid';
|
||||
import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { AnimationDefinition } from 'framer-motion';
|
||||
import { AlbumImage } from './AlbumImage';
|
||||
import { SongMetadata } from './SongMetadata';
|
||||
import { FileError } from './FileError';
|
||||
|
||||
interface FileRowProps {
|
||||
id: string;
|
||||
file: DecryptedAudioFile;
|
||||
}
|
||||
|
||||
export function FileRow({ id, file }: FileRowProps) {
|
||||
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
|
||||
const dispatch = useAppDispatch();
|
||||
const isDecrypted = file.state === ProcessState.COMPLETE;
|
||||
const metadata = file.metadata;
|
||||
|
||||
const nameWithoutExt = file.fileName.replace(/\.[a-z\d]{3,6}$/, '');
|
||||
const decryptedName = nameWithoutExt + '.' + file.ext;
|
||||
|
||||
const audioPlayerRef = useRef<HTMLAudioElement>(null);
|
||||
const togglePlay = () => {
|
||||
const player = audioPlayerRef.current;
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.paused) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const onCollapseAnimationComplete = (definition: AnimationDefinition) => {
|
||||
if (definition === 'exit') {
|
||||
dispatch(deleteFile({ id }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
in={isOpen}
|
||||
animateOpacity
|
||||
unmountOnExit
|
||||
startingHeight={0}
|
||||
onAnimationComplete={onCollapseAnimationComplete}
|
||||
style={{ width: '100%', padding: '0.25rem' }}
|
||||
>
|
||||
<Card w="full" data-testid="file-row">
|
||||
<CardBody>
|
||||
<FileRowResponsiveGrid>
|
||||
<GridItem area="cover">
|
||||
<AlbumImage name={metadata?.album} url={metadata?.cover} />
|
||||
</GridItem>
|
||||
<GridItem area="title">
|
||||
<Box w="full" as="h4" fontWeight="semibold" mt="1" textAlign={{ base: 'center', md: 'left' }}>
|
||||
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
|
||||
</Box>
|
||||
</GridItem>
|
||||
<GridItem area="meta">
|
||||
{isDecrypted && metadata && <SongMetadata metadata={metadata} />}
|
||||
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
|
||||
</GridItem>
|
||||
<GridItem area="action" alignSelf="center">
|
||||
<VStack>
|
||||
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}
|
||||
|
||||
<Wrap>
|
||||
{isDecrypted && (
|
||||
<>
|
||||
<WrapItem>
|
||||
<Button type="button" onClick={togglePlay}>
|
||||
播放/暂停
|
||||
</Button>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
{file.decrypted && (
|
||||
<Link isExternal href={file.decrypted} download={decryptedName}>
|
||||
<Button as="span">下载</Button>
|
||||
</Link>
|
||||
)}
|
||||
</WrapItem>
|
||||
</>
|
||||
)}
|
||||
<WrapItem>
|
||||
<Button type="button" onClick={onClose}>
|
||||
删除
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</FileRowResponsiveGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { Grid, chakra } from '@chakra-ui/react';
|
||||
|
||||
export const FileRowResponsiveGrid = chakra(Grid, {
|
||||
baseStyle: {
|
||||
gridTemplateAreas: {
|
||||
base: `
|
||||
"cover"
|
||||
"title"
|
||||
"meta"
|
||||
"action"
|
||||
`,
|
||||
md: `
|
||||
"cover title action"
|
||||
"cover meta action"
|
||||
`,
|
||||
},
|
||||
gridTemplateRows: {
|
||||
base: 'repeat(auto-fill)',
|
||||
md: 'min-content 1fr',
|
||||
},
|
||||
gridTemplateColumns: {
|
||||
base: '1fr',
|
||||
md: '160px 1fr',
|
||||
},
|
||||
gap: 3,
|
||||
},
|
||||
});
|
@ -1,22 +0,0 @@
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import type { AudioMetadata } from './fileListingSlice';
|
||||
|
||||
export interface SongMetadataProps {
|
||||
metadata: AudioMetadata;
|
||||
}
|
||||
|
||||
export function SongMetadata({ metadata }: SongMetadataProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
专辑: <span data-testid="audio-meta-album-name">{metadata.album}</span>
|
||||
</Text>
|
||||
<Text>
|
||||
艺术家: <span data-testid="audio-meta-song-artist">{metadata.artist}</span>
|
||||
</Text>
|
||||
<Text>
|
||||
专辑艺术家: <span data-testid="audio-meta-album-artist">{metadata.albumArtist}</span>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { FileListing } from '../FileListing';
|
||||
import { renderWithProviders, screen } from '~/test-utils/test-helper';
|
||||
import { ListingMode } from '../fileListingSlice';
|
||||
import { dummyFiles } from './__fixture__/file-list';
|
||||
|
||||
test('should be able to render a list of 3 items', () => {
|
||||
renderWithProviders(<FileListing />, {
|
||||
preloadedState: {
|
||||
fileListing: {
|
||||
displayMode: ListingMode.LIST,
|
||||
files: dummyFiles,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(3);
|
||||
expect(screen.getByText('Für Alice')).toBeInTheDocument();
|
||||
});
|
@ -1,24 +0,0 @@
|
||||
import { renderWithProviders, screen } from '~/test-utils/test-helper';
|
||||
import { untouchedFile } from './__fixture__/file-list';
|
||||
import { FileRow } from '../FileRow';
|
||||
import { completedFile } from './__fixture__/file-list';
|
||||
|
||||
test('should render no metadata when unavailable', () => {
|
||||
renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />);
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready');
|
||||
expect(screen.queryByTestId('audio-meta-album-name')).toBeFalsy();
|
||||
expect(screen.queryByTestId('audio-meta-song-artist')).toBeFalsy();
|
||||
expect(screen.queryByTestId('audio-meta-album-artist')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should render metadata when file has been processed', () => {
|
||||
renderWithProviders(<FileRow id="file://done" file={completedFile} />);
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('Für Alice');
|
||||
expect(screen.getByTestId('audio-meta-album-name')).toHaveTextContent("NOW That's What I Call Cryptography 2023");
|
||||
expect(screen.getByTestId('audio-meta-song-artist')).toHaveTextContent('Jixun');
|
||||
expect(screen.getByTestId('audio-meta-album-artist')).toHaveTextContent('Cipher Lovers');
|
||||
});
|
@ -1,46 +0,0 @@
|
||||
import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice';
|
||||
|
||||
export const untouchedFile: DecryptedAudioFile = {
|
||||
fileName: 'ready.bin',
|
||||
raw: 'blob://localhost/file-a',
|
||||
decrypted: '',
|
||||
ext: '',
|
||||
state: ProcessState.QUEUED,
|
||||
errorMessage: null,
|
||||
errorCode: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
export const completedFile: DecryptedAudioFile = {
|
||||
fileName: 'hello-b.bin',
|
||||
raw: 'blob://localhost/file-b',
|
||||
decrypted: 'blob://localhost/file-b-decrypted',
|
||||
ext: 'flac',
|
||||
state: ProcessState.COMPLETE,
|
||||
errorMessage: null,
|
||||
errorCode: null,
|
||||
metadata: {
|
||||
name: 'Für Alice',
|
||||
artist: 'Jixun',
|
||||
albumArtist: 'Cipher Lovers',
|
||||
album: "NOW That's What I Call Cryptography 2023",
|
||||
cover: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const fileWithError: DecryptedAudioFile = {
|
||||
fileName: 'hello-c.bin',
|
||||
raw: 'blob://localhost/file-c',
|
||||
decrypted: 'blob://localhost/file-c-decrypted',
|
||||
ext: 'flac',
|
||||
state: ProcessState.ERROR,
|
||||
errorMessage: 'Could not decrypt blah blah',
|
||||
errorCode: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
export const dummyFiles: Record<string, DecryptedAudioFile> = {
|
||||
'file://untouched': untouchedFile,
|
||||
'file://completed': completedFile,
|
||||
'file://error': fileWithError,
|
||||
};
|
@ -1,153 +0,0 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '~/store';
|
||||
|
||||
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
import { decryptionQueue } from '~/decrypt-worker/client';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
|
||||
|
||||
export enum ProcessState {
|
||||
QUEUED = 'QUEUED',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETE = 'COMPLETE',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
export enum ListingMode {
|
||||
LIST = 'LIST',
|
||||
CARD = 'CARD',
|
||||
}
|
||||
|
||||
export interface AudioMetadata {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumArtist: string;
|
||||
cover: string; // blob uri
|
||||
}
|
||||
|
||||
export interface DecryptedAudioFile {
|
||||
fileName: string;
|
||||
raw: string; // blob uri
|
||||
ext: string;
|
||||
decrypted: string; // blob uri
|
||||
state: ProcessState;
|
||||
errorMessage: null | string;
|
||||
errorCode: null | DecryptErrorType | string;
|
||||
metadata: null | AudioMetadata;
|
||||
}
|
||||
|
||||
export interface FileListingState {
|
||||
files: Record<string, DecryptedAudioFile>;
|
||||
displayMode: ListingMode;
|
||||
}
|
||||
const initialState: FileListingState = {
|
||||
files: Object.create(null),
|
||||
displayMode: ListingMode.LIST,
|
||||
};
|
||||
|
||||
export const processFile = createAsyncThunk<
|
||||
DecryptionResult,
|
||||
{ fileId: string },
|
||||
{ rejectValue: { message: string; stack?: string } }
|
||||
>('fileListing/processFile', async ({ fileId }, thunkAPI) => {
|
||||
const state = thunkAPI.getState() as RootState;
|
||||
const file = selectFiles(state)[fileId];
|
||||
if (!file) {
|
||||
const { message, stack } = new Error('ERROR: File not found');
|
||||
return thunkAPI.rejectWithValue({ message, stack });
|
||||
}
|
||||
|
||||
const onPreProcess = () => {
|
||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||
};
|
||||
|
||||
const fileHeader = await fetch(file.raw, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023',
|
||||
},
|
||||
})
|
||||
.then((r) => r.blob())
|
||||
.then((r) => r.arrayBuffer());
|
||||
|
||||
const options: DecryptCommandOptions = {
|
||||
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
|
||||
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
|
||||
};
|
||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||
});
|
||||
|
||||
export const fileListingSlice = createSlice({
|
||||
name: 'fileListing',
|
||||
initialState,
|
||||
reducers: {
|
||||
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
|
||||
state.files[payload.id] = {
|
||||
fileName: payload.fileName,
|
||||
raw: payload.blobURI,
|
||||
decrypted: '',
|
||||
ext: '',
|
||||
state: ProcessState.QUEUED,
|
||||
errorMessage: null,
|
||||
errorCode: null,
|
||||
metadata: null,
|
||||
};
|
||||
},
|
||||
setDecryptedContent: (state, { payload }: PayloadAction<{ id: string; decryptedBlobURI: string }>) => {
|
||||
const file = state.files[payload.id];
|
||||
if (file) {
|
||||
file.decrypted = payload.decryptedBlobURI;
|
||||
}
|
||||
},
|
||||
setFileAsProcessing: (state, { payload }: PayloadAction<{ id: string }>) => {
|
||||
const file = state.files[payload.id];
|
||||
if (file) {
|
||||
file.state = ProcessState.PROCESSING;
|
||||
}
|
||||
},
|
||||
deleteFile: (state, { payload }: PayloadAction<{ id: string }>) => {
|
||||
if (state.files[payload.id]) {
|
||||
const file = state.files[payload.id];
|
||||
if (file.decrypted) {
|
||||
URL.revokeObjectURL(file.decrypted);
|
||||
}
|
||||
if (file.raw) {
|
||||
URL.revokeObjectURL(file.raw);
|
||||
}
|
||||
delete state.files[payload.id];
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(processFile.fulfilled, (state, action) => {
|
||||
const { fileId } = action.meta.arg;
|
||||
const file = state.files[fileId];
|
||||
if (!file) return;
|
||||
|
||||
file.state = ProcessState.COMPLETE;
|
||||
file.decrypted = action.payload.decrypted;
|
||||
file.ext = action.payload.ext;
|
||||
// TODO: populate file metadata
|
||||
});
|
||||
|
||||
builder.addCase(processFile.rejected, (state, action) => {
|
||||
const { fileId } = action.meta.arg;
|
||||
const file = state.files[fileId];
|
||||
if (!file) return;
|
||||
|
||||
file.errorMessage = action.error.message ?? 'unknown error';
|
||||
file.errorCode = action.error.code ?? null;
|
||||
file.state = ProcessState.ERROR;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { addNewFile, setFileAsProcessing, setDecryptedContent, deleteFile } = fileListingSlice.actions;
|
||||
|
||||
export const selectFileCount = (state: RootState) => state.fileListing.files.length;
|
||||
export const selectFiles = (state: RootState) => state.fileListing.files;
|
||||
export const selectFileListingMode = (state: RootState) => state.fileListing.displayMode;
|
||||
|
||||
export default fileListingSlice.reducer;
|
@ -1,159 +0,0 @@
|
||||
import {
|
||||
chakra,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
Spacer,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Text,
|
||||
VStack,
|
||||
useBreakpointValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
||||
import { useState } from 'react';
|
||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||
|
||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||
{
|
||||
name: '其它/待定',
|
||||
Tab: () => <Text>这里空空如也~</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
export function Settings() {
|
||||
const toast = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
const isLargeWidthDevice =
|
||||
useBreakpointValue({
|
||||
base: false,
|
||||
lg: true,
|
||||
}) ?? false;
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const handleTabChange = (idx: number) => {
|
||||
setTabIndex(idx);
|
||||
};
|
||||
const handleResetSettings = () => {
|
||||
dispatch(discardStagingChanges());
|
||||
|
||||
toast({
|
||||
status: 'info',
|
||||
title: '未储存的设定已舍弃',
|
||||
description: '已还原到更改前的状态。',
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
const handleApplySettings = () => {
|
||||
dispatch(commitStagingChange());
|
||||
toast({
|
||||
status: 'success',
|
||||
title: '设定已应用',
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" flex={1}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<MdMenu />}
|
||||
rightIcon={<MdExpandMore />}
|
||||
colorScheme="gray"
|
||||
variant="outline"
|
||||
w="full"
|
||||
flexShrink={0}
|
||||
hidden={isLargeWidthDevice}
|
||||
mb="4"
|
||||
>
|
||||
{TABS[tabIndex].name}
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
<MenuList w="100px">
|
||||
{TABS.map(({ name }, i) => (
|
||||
<MenuItem key={name} onClick={() => setTabIndex(i)}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
|
||||
<Tabs
|
||||
orientation={isLargeWidthDevice ? 'vertical' : 'horizontal'}
|
||||
align="start"
|
||||
variant="line-i"
|
||||
display="flex"
|
||||
flex={1}
|
||||
index={tabIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabList hidden={!isLargeWidthDevice} minW="8em" width="8em" textAlign="right" justifyContent="center">
|
||||
{TABS.map(({ name }) => (
|
||||
<Tab key={name}>{name}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{TABS.map(({ name, Tab }) => (
|
||||
<Flex as={TabPanel} flex={1} flexDir="column" h="100%" key={name}>
|
||||
<Flex h="100%" flex={1} minH={0}>
|
||||
<Tab />
|
||||
</Flex>
|
||||
|
||||
<VStack mt="4" alignItems="flex-start" w="full">
|
||||
<Flex flexDir="row" gap="2" w="full">
|
||||
<Center>
|
||||
{isSettingsNotSaved ? (
|
||||
<Box color="gray">
|
||||
有未储存的更改{' '}
|
||||
<chakra.span color="red" wordBreak="keep-all">
|
||||
设定将在保存后生效
|
||||
</chakra.span>
|
||||
</Box>
|
||||
) : (
|
||||
<Box color="gray">设定将在保存后生效</Box>
|
||||
)}
|
||||
</Center>
|
||||
<Spacer />
|
||||
<HStack gap="2" justifyContent="flex-end">
|
||||
<IconButton
|
||||
icon={<Icon as={MdOutlineSettingsBackupRestore} />}
|
||||
onClick={handleResetSettings}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
title="放弃未储存的更改,将设定还原为储存前的状态。"
|
||||
aria-label="放弃未储存的更改"
|
||||
/>
|
||||
<Button onClick={handleApplySettings}>保存</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Flex>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { objectify } from 'radash';
|
||||
|
||||
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
||||
src: P,
|
||||
make: (k: keyof P, v: P[keyof P]) => null | S
|
||||
): S[] {
|
||||
const result: S[] = [];
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
const item = make(key, value as P[keyof P]);
|
||||
if (item) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// QMCv2 (QQ)
|
||||
export interface StagingQMCv2Key {
|
||||
id: string;
|
||||
name: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type ProductionQMCv2Keys = Record<string /* filename */, string /* ekey */>;
|
||||
|
||||
export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.normalize();
|
||||
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||
export const qmc2ProductionToStaging = (
|
||||
key: keyof ProductionQMCv2Keys,
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
||||
): StagingQMCv2Key => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: key.normalize(),
|
||||
ekey: value.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
// KWMv2 (KuWo)
|
||||
|
||||
export interface StagingKWMv2Key {
|
||||
id: string;
|
||||
rid: string;
|
||||
quality: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type ProductionKWMv2Keys = Record<string /* `${rid}-${quality}` */, string /* ekey */>;
|
||||
|
||||
export const parseKwm2ProductionKey = (key: string): null | { rid: string; quality: string } => {
|
||||
const m = key.match(/^(\d+)-(\w+)$/);
|
||||
if (!m) return null;
|
||||
const [_, rid, quality] = m;
|
||||
|
||||
return { rid, quality };
|
||||
};
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
|
||||
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||
export const kwm2ProductionToStaging = (
|
||||
key: keyof ProductionKWMv2Keys,
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
||||
): null | StagingKWMv2Key => {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const parsed = parseKwm2ProductionKey(key);
|
||||
if (!parsed) return null;
|
||||
|
||||
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
import { Text } from '@chakra-ui/react';
|
||||
|
||||
export function InstructionsPC() {
|
||||
return (
|
||||
<>
|
||||
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
ListItem,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { memo } from 'react';
|
||||
import { StagingKWMv2Key } from '../../keyFormats';
|
||||
|
||||
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateKey = (prop: keyof StagingKWMv2Key, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||
const deleteKey = () => dispatch(kwm2DeleteKey({ 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="资源 ID"
|
||||
value={rid}
|
||||
onChange={(e) => updateKey('rid', e)}
|
||||
type="number"
|
||||
maxW="8em"
|
||||
/>
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="音质格式"
|
||||
value={quality}
|
||||
onChange={(e) => updateKey('quality', e)}
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<InputGroup size="xs">
|
||||
<InputLeftElement pr="2">
|
||||
<Icon as={MdVpnKey} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
||||
value={ekey}
|
||||
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>
|
||||
);
|
||||
});
|
@ -1,141 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Code,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
|
||||
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
||||
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
|
||||
import type { StagingKWMv2Key } from '../keyFormats';
|
||||
import { InstructionsPC } from './KWMv2/InstructionsPC';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
|
||||
export function PanelKWMv2Key() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const kwm2Keys = useSelector(selectStagingKWMv2Keys);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const addKey = () => dispatch(kwm2AddKey());
|
||||
const clearAll = () => dispatch(kwm2ClearKeys());
|
||||
const handleSecretImport = async (file: File) => {
|
||||
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
|
||||
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer));
|
||||
}
|
||||
if (keys?.length === 0) {
|
||||
toast({
|
||||
title: '未导入密钥',
|
||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
||||
isClosable: true,
|
||||
status: 'warning',
|
||||
});
|
||||
} else if (keys) {
|
||||
dispatch(kwm2ImportKeys(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">
|
||||
酷我解密密钥(KwmV2)
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
酷我安卓版本的「臻品音质」已经换用 V2 版,后缀名为 <Code>mflac</Code> 或沿用旧的 <Code>kwm</Code>。{''}
|
||||
该格式需要提取密钥后才能正常解密。
|
||||
</Text>
|
||||
|
||||
<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>
|
||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
从文件导入密钥…
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||
清空密钥
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
|
||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||
<List spacing={3}>
|
||||
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
|
||||
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{kwm2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportSecretModal
|
||||
clientName="酷我音乐"
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
<TabPanel>
|
||||
<AndroidADBPullInstruction
|
||||
dir="/data/data/cn.kuwo.player/files/mmkv"
|
||||
file="cn.kuwo.player.mmkv.defaultconfig"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,197 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
|
||||
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { StagingQMCv2Key } from '../keyFormats';
|
||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
import { getFileName } from '~/util/pathHelper';
|
||||
import { InstructionsIOS } from './QMCv2/InstructionsIOS';
|
||||
import { InstructionsMac } from './QMCv2/InstructionsMac';
|
||||
import { InstructionsPC } from './QMCv2/InstructionsPC';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
|
||||
export function PanelQMCv2Key() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const addKey = () => dispatch(qmc2AddKey());
|
||||
const clearAll = () => dispatch(qmc2ClearKeys());
|
||||
|
||||
const handleAllowFuzzyNameSearchCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked }));
|
||||
};
|
||||
|
||||
const handleSecretImport = async (file: File) => {
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
||||
|
||||
if (/[_.]db$/i.test(file.name)) {
|
||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
||||
if (!qmc2Keys) {
|
||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
||||
return;
|
||||
}
|
||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
|
||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||
}
|
||||
|
||||
if (qmc2Keys?.length === 0) {
|
||||
toast({
|
||||
title: '未导入密钥',
|
||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
||||
isClosable: true,
|
||||
status: 'warning',
|
||||
});
|
||||
} else if (qmc2Keys) {
|
||||
dispatch(qmc2ImportKeys(qmc2Keys));
|
||||
setShowImportModal(false);
|
||||
toast({
|
||||
title: `导入成功 (${qmc2Keys.length})`,
|
||||
description: '记得保存更改来应用。',
|
||||
isClosable: true,
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
alert(`不支持的文件:${file.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error during import: ', e);
|
||||
alert(`导入数据库时发生错误:${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH={0} flexDir="column" flex={1}>
|
||||
<Heading as="h2" size="lg">
|
||||
QMCv2 解密密钥
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
QQ 音乐目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS
|
||||
客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
||||
</Text>
|
||||
|
||||
<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>
|
||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
从文件导入密钥…
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||
清空密钥
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
|
||||
<HStack>
|
||||
<Checkbox isChecked={allowFuzzyNameSearch} onChange={handleAllowFuzzyNameSearchCheckbox}>
|
||||
<Text>匹配相似文件名</Text>
|
||||
</Checkbox>
|
||||
<Tooltip
|
||||
hasArrow
|
||||
closeOnClick={false}
|
||||
label={
|
||||
<Box>
|
||||
<Text>若文件名匹配失败,则使用相似文件名的密钥。</Text>
|
||||
<Text>
|
||||
使用「
|
||||
<ruby>
|
||||
莱文斯坦距离
|
||||
<rp> (</rp>
|
||||
<rt>Levenshtein distance</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
」算法计算相似程度。
|
||||
</Text>
|
||||
<Text>若密钥数量过多,匹配时可能会造成浏览器卡顿或无响应一段时间。</Text>
|
||||
<Text>若不确定,请勾选该项。</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<InfoOutlineIcon />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||
<List spacing={3}>
|
||||
{qmc2Keys.map(({ id, ekey, name }, i) => (
|
||||
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportSecretModal
|
||||
clientName="QQ 音乐"
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>iOS</Tab>
|
||||
<Tab>Mac</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
<TabPanel>
|
||||
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsIOS />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsMac />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { InstructionsIOSCondition } from './InstructionsIOSCondition';
|
||||
|
||||
export function InstructionsIOS() {
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Text>iOS 设备获取应用私有文件比较麻烦,你需要越狱或使用一台 PC 或 Mac 来对 iOS 设备进行完整备份。</Text>
|
||||
<Text>因此,建议换用 PC 或 Mac 重新下载音乐文件然后再尝试解密。</Text>
|
||||
</Box>
|
||||
<Accordion allowToggle mt="2">
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
我的 iOS 设备已经越狱
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<InstructionsIOSCondition jailbreak={true} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
我的 iOS 设备没有越狱
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<InstructionsIOSCondition jailbreak={false} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
import { Box, Code, Heading, Image, ListItem, OrderedList, Text } from '@chakra-ui/react';
|
||||
import iosAllowBackup from './iosAllowBackup.webp';
|
||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||
|
||||
const EXAMPLE_MEDIA_ID = '0011wjLv1bIkvv';
|
||||
const EXAMPLE_NAME_IOS = '333407709-0011wjLv1bIkvv-1.mgalaxy';
|
||||
const EXAMPLE_NAME_DB = 'Q0M00011wjLv1bIkvv.mflac';
|
||||
|
||||
export function InstructionsIOSCondition({ jailbreak }: { jailbreak: boolean }) {
|
||||
const useJailbreak = jailbreak;
|
||||
const useBackup = !jailbreak;
|
||||
|
||||
const pathPrefix = jailbreak ? '/var/mobile/Containers/Data/Application/<随机>/' : '/AppDomain-';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h3" size="md">
|
||||
获取密钥数据库文件
|
||||
</Heading>
|
||||
<OrderedList>
|
||||
{useBackup && (
|
||||
<ListItem>
|
||||
<Text>首先需要在 iOS 客户端的设定允许备份:</Text>
|
||||
<Image src={iosAllowBackup}></Image>
|
||||
</ListItem>
|
||||
)}
|
||||
{useBackup && (
|
||||
<ListItem>
|
||||
<Text>使用你喜欢的备份软件对 iOS 设备进行完整备份;</Text>
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem>
|
||||
{useBackup && <Text>打开备份文件,并导航到下述目录:</Text>}
|
||||
{useJailbreak && <Text>访问下述目录:</Text>}
|
||||
<FilePathBlock>{pathPrefix}com.tencent.QQMusic/Documents/mmkv/</FilePathBlock>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提取或导出密钥数据库文件 <Code>filenameEkeyMap</Code>;
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交导出的 <Code>filenameEkeyMap</Code> 文件;
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>按下「保存」来应用更改。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
|
||||
<Heading as="h3" size="md" mt="3">
|
||||
获取离线文件
|
||||
</Heading>
|
||||
<Box>
|
||||
<Text>访问下述目录:</Text>
|
||||
<FilePathBlock>
|
||||
{pathPrefix}com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic
|
||||
</FilePathBlock>
|
||||
<Text>
|
||||
该目录又存在数个子目录,其子目录下保存的「<Code>[字符].m[字符]</Code>」文件则是最终的加密文件。
|
||||
</Text>
|
||||
<Text>
|
||||
格式:<Code>[song_id]-[mid]-[随机数字].m[后缀]</Code>
|
||||
</Text>
|
||||
<Text>
|
||||
 例:<Code>{EXAMPLE_NAME_IOS}</Code>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Heading as="h3" size="md" mt="3">
|
||||
解密离线文件
|
||||
</Heading>
|
||||
<Text>勾选设定界面的「使用近似文件名匹配」可跳过该节内容。</Text>
|
||||
<Text>⚠ 注意:若密钥过多,匹配过程可能会造成浏览器卡顿或无响应。</Text>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提取文件的 <Code>[mid]</Code> 部分,如 <Code>{EXAMPLE_MEDIA_ID}</Code>;
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
查找密钥表,得到文件名「<Code>{EXAMPLE_NAME_DB}</Code>」;
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
将文件更名为对应的文件名,如<Code display="inline">{EXAMPLE_NAME_IOS}</Code> ➔
|
||||
<Code display="inline">{EXAMPLE_NAME_DB}</Code>;
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
回到主界面,提交文件「<Code>{EXAMPLE_NAME_DB}</Code>」。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { Heading, Text, Code, Kbd, OrderedList, ListItem } from '@chakra-ui/react';
|
||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
||||
import { ShiftKey } from '~/components/Key/ShiftKey';
|
||||
|
||||
export function InstructionsMac() {
|
||||
return (
|
||||
<>
|
||||
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
||||
<FilePathBlock>
|
||||
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
||||
</FilePathBlock>
|
||||
|
||||
<Heading as="h3" size="md" mt="4">
|
||||
导入密钥
|
||||
</Heading>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
选中并复制上述的 <Code>MMKVStreamEncryptId</Code> 文件路径
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>点击上方的「文件选择区域」,打开「文件选择框」</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
按下「
|
||||
<ShiftKey />
|
||||
{' + '}
|
||||
<MacCommandKey />
|
||||
{' + '}
|
||||
<Kbd>{'G'}</Kbd>」组合键打开「路径输入框」
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
粘贴之前复制的 <Code>MMKVStreamEncryptId</Code> 文件路径
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>按下「回车键」确认。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { Text } from '@chakra-ui/react';
|
||||
|
||||
export function InstructionsPC() {
|
||||
return (
|
||||
<>
|
||||
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
ListItem,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||
import { qmc2DeleteKey, qmc2UpdateKey } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const QMCv2EKeyItem = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateKey = (prop: 'name' | 'ekey', e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||
const deleteKey = () => dispatch(qmc2DeleteKey({ 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}>
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="文件名,包括后缀名。如 “AAA - BBB.mflac”"
|
||||
value={name}
|
||||
onChange={(e) => updateKey('name', e)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
});
|
Binary file not shown.
Before Width: | Height: | Size: 5.1 KiB |
@ -1,63 +0,0 @@
|
||||
import { debounce } from 'radash';
|
||||
import { produce } from 'immer';
|
||||
|
||||
import type { AppStore } from '~/store';
|
||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
||||
import { enumObject } from '~/util/objects';
|
||||
import { getLogger } from '~/util/logUtils';
|
||||
import { parseKwm2ProductionKey } from './keyFormats';
|
||||
|
||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||
|
||||
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||
return produce(settingsSlice.getInitialState().production, (draft) => {
|
||||
if (settings?.qmc2) {
|
||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string') {
|
||||
draft.qmc2.keys[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof allowFuzzyNameSearch === 'boolean') {
|
||||
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.kwm2) {
|
||||
const { keys } = settings.kwm2;
|
||||
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
|
||||
draft.kwm2.keys[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {
|
||||
let lastSettings: unknown;
|
||||
|
||||
try {
|
||||
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? '');
|
||||
if (loadedSettings) {
|
||||
const mergedSettings = mergeSettings(loadedSettings);
|
||||
store.dispatch(setProductionChanges(mergedSettings));
|
||||
getLogger().debug('settings loaded');
|
||||
}
|
||||
} catch {
|
||||
// load failed, ignore.
|
||||
}
|
||||
|
||||
return store.subscribe(
|
||||
debounce({ delay: 150 }, () => {
|
||||
const currentSettings = store.getState().settings.production;
|
||||
if (lastSettings !== currentSettings) {
|
||||
lastSettings = currentSettings;
|
||||
localStorage.setItem(storageKey, JSON.stringify(currentSettings));
|
||||
getLogger().debug('settings saved');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import { parseKuwoHeader } from '~/crypto/parseKuwo';
|
||||
import type { RootState } from '~/store';
|
||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||
import { hasOwn } from '~/util/objects';
|
||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||
|
||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||
|
||||
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
|
||||
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
|
||||
|
||||
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
||||
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
|
||||
|
||||
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
|
||||
const normalizedName = name.normalize();
|
||||
|
||||
let ekey: string | undefined;
|
||||
const { keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state);
|
||||
if (hasOwn(keys, normalizedName)) {
|
||||
ekey = keys[normalizedName];
|
||||
} else if (allowFuzzyNameSearch) {
|
||||
const qmc2KeyStoreNames = Object.keys(keys);
|
||||
if (qmc2KeyStoreNames.length > 0) {
|
||||
const closestName = closestByLevenshtein(normalizedName, qmc2KeyStoreNames);
|
||||
console.debug('qmc2: key db could not find %o, using closest %o instead.', normalizedName, closestName);
|
||||
ekey = keys[closestName];
|
||||
}
|
||||
}
|
||||
|
||||
return ekey;
|
||||
};
|
||||
|
||||
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
|
||||
const hdr = parseKuwoHeader(headerView);
|
||||
if (!hdr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = selectFinalKWMv2Keys(state);
|
||||
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid });
|
||||
|
||||
let ekey: string | undefined;
|
||||
if (hasOwn(keys, lookupKey)) {
|
||||
ekey = keys[lookupKey];
|
||||
}
|
||||
|
||||
return ekey;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user