feat: add mac import option and help text #30

Merged
lsr merged 1 commits from feat/import-qmc2-mmkv into main 2023-06-12 23:56:35 +00:00
10 changed files with 273 additions and 46 deletions

View File

@ -0,0 +1,15 @@
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

@ -0,0 +1,15 @@
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

@ -20,13 +20,21 @@ import { qmc2ImportKeys } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor'; import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
import { QMCv2AndroidInstructions } from './QMCv2AndroidInstructions'; import { InstructionsAndroid } from './InstructionsAndroid';
import { MMKVParser } from '~/util/MMKVParser';
import { getFileName } from '~/util/pathHelper';
import { InstructionsMac } from './InstructionsMac';
export interface ImportFileModalProps { export interface ImportFileModalProps {
show: boolean; show: boolean;
onClose: () => void; onClose: () => void;
} }
interface KeyEntry {
name: string;
key: string;
}
export function ImportFileModal({ onClose, show }: ImportFileModalProps) { export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const toast = useToast(); const toast = useToast();
@ -35,22 +43,31 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
const file = files[0]; const file = files[0];
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
let qmc2Keys: null | KeyEntry[] = null;
if (/[_.]db$/i.test(file.name)) { if (/[_.]db$/i.test(file.name)) {
const extractor = await DatabaseKeyExtractor.getInstance(); const extractor = await DatabaseKeyExtractor.getInstance();
const qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer); qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
if (qmc2Keys) { if (!qmc2Keys) {
dispatch(qmc2ImportKeys(qmc2Keys)); alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`);
onClose();
toast({
title: `导入成功 (${qmc2Keys.length})`,
description: '记得保存更改来应用。',
isClosable: true,
duration: 5000,
status: 'success',
});
return; return;
} }
alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`); } else if (/MMKVStreamEncryptId/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer();
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, key]) => ({ name: getFileName(name), key }));
}
if (qmc2Keys) {
dispatch(qmc2ImportKeys(qmc2Keys));
onClose();
toast({
title: `导入成功 (${qmc2Keys.length})`,
description: '记得保存更改来应用。',
isClosable: true,
duration: 5000,
status: 'success',
});
} else { } else {
alert(`不支持的文件:${file.name}`); alert(`不支持的文件:${file.name}`);
} }
@ -74,14 +91,14 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
<Flex as={Tabs} variant="enclosed" flexDir="column" mt={4} flex={1} minH={0}> <Flex as={Tabs} variant="enclosed" flexDir="column" mt={4} flex={1} minH={0}>
<TabList> <TabList>
<Tab></Tab> <Tab></Tab>
{/* <Tab>Two</Tab> */} <Tab>Mac </Tab>
</TabList> </TabList>
<TabPanels flex={1} overflow="auto"> <TabPanels flex={1} overflow="auto">
<TabPanel> <TabPanel>
<QMCv2AndroidInstructions /> <InstructionsAndroid />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<p>two!</p> <InstructionsMac />
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Flex> </Flex>

View File

@ -5,6 +5,7 @@ import {
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
Box, Box,
Code,
Heading, Heading,
Link, Link,
ListItem, ListItem,
@ -19,7 +20,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu
import PowerShellAdbDumpCommand from './adb_dump.ps1?raw'; import PowerShellAdbDumpCommand from './adb_dump.ps1?raw';
import ShellAdbDumpCommand from './adb_dump.sh?raw'; import ShellAdbDumpCommand from './adb_dump.sh?raw';
export function QMCv2AndroidInstructions() { export function InstructionsAndroid() {
return ( return (
<> <>
<Text> <Text>
@ -43,11 +44,25 @@ export function QMCv2AndroidInstructions() {
<AccordionPanel pb={4}> <AccordionPanel pb={4}>
<OrderedList> <OrderedList>
<ListItem> <ListItem>
使 <code>root</code> 访 <code>/data/data/com.tencent.qqmusic/databases/</code> <Text>
{' 目录,将文件 '} <Code>root</Code>
<code>player_process_db</code> 访 </Text>
</ListItem>
<ListItem>
<Text>
访 <Code>/data/data/com.tencent.qqmusic/databases/</Code>
</Text>
</ListItem>
<ListItem>
<Text>
<Code>player_process_db</Code> 访
<br />
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem> </ListItem>
<ListItem></ListItem>
</OrderedList> </OrderedList>
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
@ -64,24 +79,33 @@ export function QMCv2AndroidInstructions() {
<AccordionPanel pb={4}> <AccordionPanel pb={4}>
<OrderedList> <OrderedList>
<ListItem> <ListItem>
<code>adb</code> <Text>
<br /> <code>adb</code>
💡 </Text>
<Link href="https://scoop.sh/#/apps?q=adb" isExternal> <Text>
使 Scoop <ExternalLinkIcon /> 💡
</Link> <Link href="https://scoop.sh/#/apps?q=adb" isExternal>
使 Scoop <ExternalLinkIcon />
</Link>
</Text>
</ListItem> </ListItem>
<ListItem> PowerShell 7 </ListItem>
<ListItem></ListItem>
<ListItem> <ListItem>
<Text> PowerShell 7 </Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
<ListItem>
<Text></Text>
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}> <SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
{PowerShellAdbDumpCommand} {PowerShellAdbDumpCommand}
</SyntaxHighlighter> </SyntaxHighlighter>
</ListItem> </ListItem>
<ListItem> <ListItem>
<code>player_process_db</code> <Text>
<Code>player_process_db</Code>
</Text>
</ListItem> </ListItem>
</OrderedList> </OrderedList>
</AccordionPanel> </AccordionPanel>
@ -98,15 +122,19 @@ export function QMCv2AndroidInstructions() {
</Heading> </Heading>
<AccordionPanel pb={4}> <AccordionPanel pb={4}>
<OrderedList> <OrderedList>
<ListItem></ListItem>
<ListItem> <ListItem>
<Text></Text>
</ListItem>
<ListItem>
<Text></Text>
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}> <SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
{ShellAdbDumpCommand} {ShellAdbDumpCommand}
</SyntaxHighlighter> </SyntaxHighlighter>
</ListItem> </ListItem>
<ListItem> <ListItem>
<code>player_process_db</code> <Text>
<Code>player_process_db</Code>
</Text>
</ListItem> </ListItem>
</OrderedList> </OrderedList>
</AccordionPanel> </AccordionPanel>

View File

@ -0,0 +1,50 @@
import { Heading, Text, Code, Kbd, OrderedList, ListItem } from '@chakra-ui/react';
import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey';
export function InstructionsMac() {
return (
<>
<Text>Mac 使 mmkv </Text>
<Text></Text>
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-word" lang="en">
<Code>
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application
Support/QQMusicMac/mmkv/MMKVStreamEncryptId
</Code>
</Text>
<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

@ -20,16 +20,6 @@ export const theme = extendTheme({
}, },
}, },
Tabs: tabsTheme, Tabs: tabsTheme,
Heading: {
baseStyle: {
userSelect: 'none',
},
},
Text: {
baseStyle: {
userSelect: 'none',
},
},
Link: { Link: {
baseStyle: { baseStyle: {
color: 'blue.600', color: 'blue.600',

View File

@ -1,3 +1,4 @@
import { getFileName } from './pathHelper';
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite'; import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
export interface QMAndroidKeyEntry { export interface QMAndroidKeyEntry {
@ -34,7 +35,7 @@ export class DatabaseKeyExtractor {
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values; const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
return keys.map(([path, key]) => ({ return keys.map(([path, key]) => ({
// strip dir name // strip dir name
name: String(path).replace(/.+\//, ''), name: getFileName(String(path)),
key: String(key), key: String(key),
})); }));
} finally { } finally {

95
src/util/MMKVParser.ts Normal file
View File

@ -0,0 +1,95 @@
const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
export class MMKVParser {
private offset = 8;
private length: number;
constructor(private view: DataView) {
const payloadLength = view.getUint32(0, true);
this.length = 4 + payloadLength;
}
toString() {
const offset = this.offset.toString(16).padStart(8, '0');
const length = this.length.toString(16).padStart(8, '0');
return `<MMKVParser offset=0x${offset} len=0x${length}>`;
}
get eof() {
return this.offset >= this.length;
}
peek() {
return this.view.getUint8(this.offset);
}
public readByte() {
return this.view.getUint8(this.offset++);
}
public readBigInt() {
let value = 0n;
let shift = 0n;
let b: number;
do {
b = this.readByte();
value |= BigInt(b & 0x7f) << shift;
shift += 7n;
} while ((b & 0x80) !== 0);
return value;
}
public readInt() {
const value = this.readBigInt();
if (value > Number.MAX_SAFE_INTEGER) {
throw new Error('Runtime Error: BigInt too large to cast as number');
}
return Number(value);
}
public readBytes(n: number) {
const offset = this.offset;
const end = offset + n;
this.offset = end;
return new Uint8Array(this.view.buffer.slice(offset, end));
}
public readString() {
// String [
// len: int,
// data: byte[int], # utf-8
// ]
const strByteLen = this.readInt();
const data = this.readBytes(strByteLen);
return textDecoder.decode(data).normalize();
}
public readVariantString() {
// Container [
// len: int,
// data: variant
// ]
const containerLen = this.readInt();
const newOffset = this.offset + containerLen;
const result = this.readString();
if (newOffset !== this.offset) {
throw new Error('readVariantString failed: offset does not match');
}
return result;
}
public static toStringMap(view: DataView): Map<string, string> {
const mmkv = new MMKVParser(view);
const result = new Map<string, string>();
while (!mmkv.eof) {
const key = mmkv.readString();
const value = mmkv.readVariantString();
result.set(key, value);
}
return result;
}
}

View File

@ -0,0 +1,13 @@
import { getFileName } from '../pathHelper';
test('handle linux path', () => {
expect(getFileName('/path/to/file.bin')).toEqual('file.bin');
});
test('handle win32 path', () => {
expect(getFileName('C:\\system32\\file.bin')).toEqual('file.bin');
});
test('handle file name only as well', () => {
expect(getFileName('file.bin')).toEqual('file.bin');
});

3
src/util/pathHelper.ts Normal file
View File

@ -0,0 +1,3 @@
export function getFileName(path: string) {
return path.replace(/.*[\\/]/, '');
}