feat: import ekey from Android db (#20)
This commit is contained in:
parent
ec27a6f699
commit
b78399eddb
12
package.json
12
package.json
@ -31,8 +31,12 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-icons": "^4.9.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^8.0.5"
|
||||
"react-redux": "^8.0.5",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.63.3",
|
||||
"sql.js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
@ -42,6 +46,7 @@
|
||||
"@types/node": "^20.2.5",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@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",
|
||||
@ -71,5 +76,10 @@
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
patches/sql.js@1.8.0.patch
Normal file
12
patches/sql.js@1.8.0.patch
Normal file
@ -0,0 +1,12 @@
|
||||
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;
|
||||
+
|
775
pnpm-lock.yaml
775
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
43
src/components/FileInput.tsx
Normal file
43
src/components/FileInput.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
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>
|
||||
);
|
||||
}
|
26
src/components/MarkdownContent.module.scss
Normal file
26
src/components/MarkdownContent.module.scss
Normal file
@ -0,0 +1,26 @@
|
||||
.markdown {
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
font-size: 1.15em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p,
|
||||
pre {
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
padding-left: 0.5em;
|
||||
border-left: 2px solid #ddd;
|
||||
}
|
||||
}
|
11
src/components/MarkdownContent.tsx
Normal file
11
src/components/MarkdownContent.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import MarkdownContentClass from './MarkdownContent.module.scss';
|
||||
|
||||
export function MarkdownContent({ children }: { children: string }) {
|
||||
return (
|
||||
<div className={MarkdownContentClass.markdown}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,16 +1,14 @@
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { chakra, Box, Text } from '@chakra-ui/react';
|
||||
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 { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
multiple: true,
|
||||
onDropAccepted(files, _event) {
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
console.debug(
|
||||
'react-dropzone/onDropAccepted(%o, %o)',
|
||||
files.length,
|
||||
@ -32,46 +30,23 @@ export function SelectFile() {
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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()} />
|
||||
|
||||
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
||||
<Box pb={3}>
|
||||
<UnlockIcon boxSize={8} />
|
||||
</Box>
|
||||
<Text textAlign="center">
|
||||
<Text as="div" textAlign="center">
|
||||
拖放或
|
||||
<chakra.span as="span" color="teal.400">
|
||||
<Text as="span" color="teal.400">
|
||||
点我选择
|
||||
</chakra.span>
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
在浏览器内对文件进行解锁,零上传
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</FileInput>
|
||||
);
|
||||
}
|
||||
|
@ -24,12 +24,14 @@ import {
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice';
|
||||
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
|
||||
import { ImportFileModal } from './QMCv2/ImportFileModal';
|
||||
|
||||
export function PanelQMCv2Key() {
|
||||
const dispatch = useDispatch();
|
||||
const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys;
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const addKey = () => dispatch(qmc2AddKey());
|
||||
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
@ -53,12 +55,10 @@ export function PanelQMCv2Key() {
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
||||
<MenuList>
|
||||
{/* 目前的想法是弹出一个 modal,给用户一些信息(如期待的格式、如何导出或寻找对应的文件) */}
|
||||
{/* 但是这样的话就不太方便放在这个分支里面做了,下次一定。 */}
|
||||
<MenuItem hidden onClick={() => alert('TODO!')} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
从文件导入
|
||||
</MenuItem>
|
||||
<MenuDivider hidden />
|
||||
<MenuDivider />
|
||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||
清空
|
||||
</MenuItem>
|
||||
@ -111,6 +111,8 @@ export function PanelQMCv2Key() {
|
||||
</List>
|
||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportFileModal show={showImportModal} onClose={() => setShowImportModal(false)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
23
src/features/settings/panels/QMCv2/DocAndroid.md
Normal file
23
src/features/settings/panels/QMCv2/DocAndroid.md
Normal file
@ -0,0 +1,23 @@
|
||||
### 安卓端获取密钥数据库
|
||||
|
||||
你可能需要 root 或类似的访问权限。绝大多数情况下,这会导致你的安卓设备失去保修资格。
|
||||
|
||||
#### 提取文件(文件浏览器)
|
||||
|
||||
1. 提升到 `root` 权限,访问 `/data/data/com.tencent.qqmusic/databases/` 目录,将文件 `player_process_db`
|
||||
复制到正常模式下用户可访问的目录(如下载目录)。
|
||||
2. 如果你需要在电脑上进行解密操作,请将该文件复制到电脑。
|
||||
3. 选择该文件。
|
||||
|
||||
#### 提取文件(ADB)
|
||||
|
||||
※ 目前该指令只支持 Linux & Mac
|
||||
|
||||
1. 打开终端并安装好依赖,并复制下述指令:
|
||||
|
||||
```sh
|
||||
adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" \
|
||||
| base64 -d | gzip -d player_process_db
|
||||
```
|
||||
|
||||
2. 选择提取的这个文件即可。
|
95
src/features/settings/panels/QMCv2/ImportFileModal.tsx
Normal file
95
src/features/settings/panels/QMCv2/ImportFileModal.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
import mdHelpAndroid from './DocAndroid.md?raw';
|
||||
import { MarkdownContent } from '~/components/MarkdownContent';
|
||||
import { qmc2ImportKeys } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||
|
||||
export interface ImportFileModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleFileReceived = async (files: File[]) => {
|
||||
try {
|
||||
const file = files[0];
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
if (/[_.]db$/i.test(file.name)) {
|
||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||
const qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
||||
if (qmc2Keys) {
|
||||
dispatch(qmc2ImportKeys(qmc2Keys));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
||||
} else {
|
||||
alert(`不支持的文件:${file.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error during import: ', e);
|
||||
alert(`导入数据库时发生错误:${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} scrollBehavior="inside" size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>导入数据库</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<Flex as={ModalBody} gap={2} flexDir="column">
|
||||
<Center>
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Heading as="h2" size="md" mt="4">
|
||||
使用说明
|
||||
</Heading>
|
||||
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
{/* <Tab>Two</Tab> */}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<MarkdownContent>{mdHelpAndroid}</MarkdownContent>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<p>two!</p>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
|
||||
<ModalFooter>
|
||||
<Button mr={3} onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -6,9 +6,10 @@ export const selectStagingQMCv2Settings = (state: RootState) => state.settings.s
|
||||
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
|
||||
|
||||
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
|
||||
const normalizedName = name.normalize();
|
||||
const qmc2Keys = selectFinalQMCv2Settings(state).keys;
|
||||
|
||||
return {
|
||||
qmc2Key: hasOwn(qmc2Keys, name) ? qmc2Keys[name] : undefined,
|
||||
qmc2Key: hasOwn(qmc2Keys, normalizedName) ? qmc2Keys[normalizedName] : undefined,
|
||||
};
|
||||
};
|
||||
|
@ -36,7 +36,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
qmc2: {
|
||||
keys: objectify(
|
||||
staging.qmc2.keys,
|
||||
(item) => item.name,
|
||||
(item) => item.name.normalize(),
|
||||
(item) => item.key
|
||||
),
|
||||
},
|
||||
@ -61,6 +61,10 @@ export const settingsSlice = createSlice({
|
||||
qmc2AddKey(state) {
|
||||
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
|
||||
},
|
||||
qmc2ImportKeys(state, { payload }: PayloadAction<{ name: string; key: string }[]>) {
|
||||
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||
state.staging.qmc2.keys.push(...newItems);
|
||||
},
|
||||
qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
|
||||
const qmc2 = state.staging.qmc2;
|
||||
qmc2.keys = qmc2.keys.filter((item) => item.id !== id);
|
||||
@ -102,6 +106,7 @@ export const {
|
||||
qmc2UpdateKey,
|
||||
qmc2DeleteKey,
|
||||
qmc2ClearKeys,
|
||||
qmc2ImportKeys,
|
||||
|
||||
commitStagingChange,
|
||||
discardStagingChanges,
|
||||
|
44
src/util/DatabaseKeyExtractor.ts
Normal file
44
src/util/DatabaseKeyExtractor.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
||||
|
||||
export interface QMAndroidKeyEntry {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class DatabaseKeyExtractor {
|
||||
private static _instance: DatabaseKeyExtractor;
|
||||
|
||||
static async getInstance() {
|
||||
if (!DatabaseKeyExtractor._instance) {
|
||||
DatabaseKeyExtractor._instance = new DatabaseKeyExtractor(await loadSQL());
|
||||
}
|
||||
return DatabaseKeyExtractor._instance;
|
||||
}
|
||||
|
||||
constructor(private SQL: SQLStatic) {}
|
||||
|
||||
private hasTable(db: SQLDatabase, name: string): boolean {
|
||||
const tables = db.exec('SELECT name FROM sqlite_master WHERE type="table"')[0].values.map((x) => x[0]);
|
||||
return tables.includes(name);
|
||||
}
|
||||
|
||||
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
|
||||
let db: SQLDatabase | null = null;
|
||||
|
||||
try {
|
||||
db = new this.SQL.Database(new Uint8Array(buffer));
|
||||
if (!this.hasTable(db, 'audio_file_ekey_table')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
|
||||
return keys.map(([path, key]) => ({
|
||||
// strip dir name
|
||||
name: String(path).replace(/.+\//, ''),
|
||||
key: String(key),
|
||||
}));
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
}
|
15
src/util/sqlite.ts
Normal file
15
src/util/sqlite.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import * as initSqlite from 'sql.js';
|
||||
|
||||
const urlWasm = new URL('@nm/sql.js/dist/sql-wasm.wasm', import.meta.url).toString();
|
||||
|
||||
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
|
||||
export type SQLDatabase = SQLStatic['Database']['prototype'];
|
||||
|
||||
let sqlLoaderPromise: Promise<SQLStatic> | void;
|
||||
|
||||
export async function loadSQL() {
|
||||
if (!sqlLoaderPromise) {
|
||||
sqlLoaderPromise = initSqlite.default({ locateFile: () => urlWasm });
|
||||
}
|
||||
return await sqlLoaderPromise;
|
||||
}
|
@ -1,15 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"@testing-library/jest-dom"
|
||||
],
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
@ -27,14 +20,11 @@
|
||||
"baseUrl": ".",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"@nm/*": ["./node_modules/*"]
|
||||
}
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
|
@ -43,7 +43,7 @@ export default defineConfig({
|
||||
},
|
||||
base: './',
|
||||
optimizeDeps: {
|
||||
exclude: ['@jixun/libparakeet'],
|
||||
exclude: ['@jixun/libparakeet', 'sql.js'],
|
||||
},
|
||||
plugins: [
|
||||
replace({
|
||||
@ -88,6 +88,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, 'src'),
|
||||
'@nm': path.resolve(__dirname, 'node_modules'),
|
||||
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
||||
},
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user