This commit is contained in:
梦丶随心飞 2023-08-30 06:47:27 +08:00
parent be0b8687f3
commit 8e822c92eb
138 changed files with 4 additions and 23939 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "um-react"]
path = um-react
url = ./um-react

1
um-react Submodule

@ -0,0 +1 @@
Subproject commit a5cc2e12300e0ae39e204b4b52b22966fc02232c

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,3 +0,0 @@
dist/
node_modules/
coverage/

View File

@ -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,
},
],
},
};

View File

@ -1 +0,0 @@
*.mmkv binary

29
um-react/.gitignore vendored
View File

@ -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

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm test

View File

@ -1,3 +0,0 @@
use-node-version=18.16.0
node-version=18.16.0
engine-strict=true

View File

@ -1,5 +0,0 @@
dist/
# Package manager
yarn.lock
pnpm-lock.yaml

View File

@ -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.

View File

@ -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
```

View File

@ -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 更改已合并。

View File

@ -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/

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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');

View File

@ -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();
});

View File

@ -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

View File

@ -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>
</>
);
}

View File

@ -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() }
}

View File

@ -1,2 +0,0 @@
sh adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" \
| base64 -d | gzip -d '{{ file }}'

View 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>
);
}

View File

@ -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}</>;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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);

View File

@ -1,9 +0,0 @@
export enum DECRYPTION_WORKER_ACTION_NAME {
DECRYPT = 'DECRYPT',
VERSION = 'VERSION',
}
export interface DecryptionResult {
decrypted: string; // blob uri
ext: string;
}

View File

@ -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;

View File

@ -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,
];

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -1,2 +0,0 @@
export const NCM_KEY = 'hzHRAmso5kInbaxW';
export const NCM_MAGIC_HEADER = new Uint8Array([0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]);

View File

@ -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();
}
}

View File

@ -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,
]);

View File

@ -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();
}
}

View File

@ -1,3 +0,0 @@
export const SEED = 106;
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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,
};

View File

@ -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);
}
}

View File

@ -1,10 +0,0 @@
export interface DecryptCommandOptions {
qmc2Key?: string;
kwm2key?: string;
}
export interface DecryptCommandPayload {
id: string;
blobURI: string;
options: DecryptCommandOptions;
}

View File

@ -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;
}

View 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]));

View File

@ -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);

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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);
});
};

View File

@ -1,2 +0,0 @@
// This is a dummy module for vite/rollup to resolve.
Object.defineProperty(Object.create(null), { sideEffects: true });

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
},
});

View File

@ -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>
);
}

View File

@ -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();
});

View File

@ -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');
});

View File

@ -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,
};

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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 };
};

View File

@ -1,9 +0,0 @@
import { Text } from '@chakra-ui/react';
export function InstructionsPC() {
return (
<>
<Text>使 Windows </Text>
</>
);
}

View File

@ -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>
);
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
&#x3000;<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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -1,9 +0,0 @@
import { Text } from '@chakra-ui/react';
export function InstructionsPC() {
return (
<>
<Text>使 Windows </Text>
</>
);
}

View File

@ -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

View File

@ -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');
}
})
);
}

View File

@ -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