diff --git a/src/components/Key/MacCommandKey.tsx b/src/components/Key/MacCommandKey.tsx new file mode 100644 index 0000000..85c609f --- /dev/null +++ b/src/components/Key/MacCommandKey.tsx @@ -0,0 +1,15 @@ +import { Icon, Kbd } from '@chakra-ui/react'; +import { BsCommand } from 'react-icons/bs'; + +export function MacCommandKey() { + return ( + + + + + ( + command + ) + + ); +} diff --git a/src/components/Key/ShiftKey.tsx b/src/components/Key/ShiftKey.tsx new file mode 100644 index 0000000..9af49b8 --- /dev/null +++ b/src/components/Key/ShiftKey.tsx @@ -0,0 +1,15 @@ +import { Icon, Kbd } from '@chakra-ui/react'; +import { BsShift } from 'react-icons/bs'; + +export function ShiftKey() { + return ( + + + + + ( + shift + ) + + ); +} diff --git a/src/features/settings/panels/QMCv2/ImportFileModal.tsx b/src/features/settings/panels/QMCv2/ImportFileModal.tsx index 728499a..f0f6b7b 100644 --- a/src/features/settings/panels/QMCv2/ImportFileModal.tsx +++ b/src/features/settings/panels/QMCv2/ImportFileModal.tsx @@ -20,13 +20,21 @@ import { qmc2ImportKeys } from '../../settingsSlice'; import { useAppDispatch } from '~/hooks'; 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 { show: boolean; onClose: () => void; } +interface KeyEntry { + name: string; + key: string; +} + export function ImportFileModal({ onClose, show }: ImportFileModalProps) { const dispatch = useAppDispatch(); const toast = useToast(); @@ -35,22 +43,31 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) { const file = files[0]; const fileBuffer = await file.arrayBuffer(); + let qmc2Keys: null | KeyEntry[] = null; + if (/[_.]db$/i.test(file.name)) { const extractor = await DatabaseKeyExtractor.getInstance(); - const qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer); - if (qmc2Keys) { - dispatch(qmc2ImportKeys(qmc2Keys)); - onClose(); - toast({ - title: `导入成功 (${qmc2Keys.length})`, - description: '记得保存更改来应用。', - isClosable: true, - duration: 5000, - status: 'success', - }); + qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer); + if (!qmc2Keys) { + alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`); 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 { alert(`不支持的文件:${file.name}`); } @@ -74,14 +91,14 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) { 安卓客户端 - {/* Two */} + Mac 客户端 - + -

two!

+
diff --git a/src/features/settings/panels/QMCv2/QMCv2AndroidInstructions.tsx b/src/features/settings/panels/QMCv2/InstructionsAndroid.tsx similarity index 59% rename from src/features/settings/panels/QMCv2/QMCv2AndroidInstructions.tsx rename to src/features/settings/panels/QMCv2/InstructionsAndroid.tsx index 6aab274..9128444 100644 --- a/src/features/settings/panels/QMCv2/QMCv2AndroidInstructions.tsx +++ b/src/features/settings/panels/QMCv2/InstructionsAndroid.tsx @@ -5,6 +5,7 @@ import { AccordionItem, AccordionPanel, Box, + Code, Heading, Link, ListItem, @@ -19,7 +20,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu import PowerShellAdbDumpCommand from './adb_dump.ps1?raw'; import ShellAdbDumpCommand from './adb_dump.sh?raw'; -export function QMCv2AndroidInstructions() { +export function InstructionsAndroid() { return ( <> @@ -43,11 +44,25 @@ export function QMCv2AndroidInstructions() { - 使用具有 root 权限的文件浏览器,访问 /data/data/com.tencent.qqmusic/databases/ - {' 目录,将文件 '} - player_process_db 复制到正常模式下用户可访问的目录(如下载目录)。 + + 启动具有 root 特权的文件浏览器 + + + + + 访问 /data/data/com.tencent.qqmusic/databases/ 目录。 + + + + + 将文件 player_process_db 复制到浏览器可访问的目录。 +
+ (例如下载目录) +
+
+ + 提交该数据库文件。 - 提交该数据库文件。
@@ -64,24 +79,33 @@ export function QMCv2AndroidInstructions() { - 确保 adb 命令可用。 -
- 💡 如果没有,可以 - - 使用 Scoop 安装 - - 。 + + 确保 adb 命令可用。 + + + 💡 如果没有,可以 + + 使用 Scoop 安装 + + 。 +
- 启动终端并进入 PowerShell 7 环境。 - 将安卓设备连接到电脑,并允许调试。 - 粘贴执行下述代码。若设备提示「超级用户请求」请允许: + 启动终端并进入 PowerShell 7 环境。 + + + 将安卓设备连接到电脑,并允许调试。 + + + 粘贴执行下述代码。若设备提示「超级用户请求」请允许: {PowerShellAdbDumpCommand} - 提交当前目录下的 player_process_db 文件。 + + 提交当前目录下的 player_process_db 文件。 +
@@ -98,15 +122,19 @@ export function QMCv2AndroidInstructions() { - 将安卓设备连接到电脑,并允许调试。 - 粘贴执行下述代码。若设备提示「超级用户请求」请允许: + 将安卓设备连接到电脑,并允许调试。 + + + 粘贴执行下述代码。若设备提示「超级用户请求」请允许: {ShellAdbDumpCommand} - 提交当前目录下的 player_process_db 文件。 + + 提交当前目录下的 player_process_db 文件。 + diff --git a/src/features/settings/panels/QMCv2/InstructionsMac.tsx b/src/features/settings/panels/QMCv2/InstructionsMac.tsx new file mode 100644 index 0000000..9e18293 --- /dev/null +++ b/src/features/settings/panels/QMCv2/InstructionsMac.tsx @@ -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 ( + <> + Mac 客户端使用 mmkv 数据库储存密钥。 + 该密钥文件通常存储在下述路径: + + + ~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application + Support/QQMusicMac/mmkv/MMKVStreamEncryptId + + + + + 导入密钥 + + + + + 选中并复制上述的 MMKVStreamEncryptId 文件路径 + + + + 点击上方的「文件选择区域」,打开「文件选择框」 + + + + 按下「 + + {' + '} + + {' + '} + {'G'}」组合键打开「路径输入框」 + + + + + 粘贴之前复制的 MMKVStreamEncryptId 文件路径 + + + + 按下「回车键」确认。 + + + + ); +} diff --git a/src/theme.ts b/src/theme.ts index f916385..ee8c96a 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -20,16 +20,6 @@ export const theme = extendTheme({ }, }, Tabs: tabsTheme, - Heading: { - baseStyle: { - userSelect: 'none', - }, - }, - Text: { - baseStyle: { - userSelect: 'none', - }, - }, Link: { baseStyle: { color: 'blue.600', diff --git a/src/util/DatabaseKeyExtractor.ts b/src/util/DatabaseKeyExtractor.ts index 73eb8ba..7df071f 100644 --- a/src/util/DatabaseKeyExtractor.ts +++ b/src/util/DatabaseKeyExtractor.ts @@ -1,3 +1,4 @@ +import { getFileName } from './pathHelper'; import { SQLDatabase, SQLStatic, loadSQL } from './sqlite'; 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; return keys.map(([path, key]) => ({ // strip dir name - name: String(path).replace(/.+\//, ''), + name: getFileName(String(path)), key: String(key), })); } finally { diff --git a/src/util/MMKVParser.ts b/src/util/MMKVParser.ts new file mode 100644 index 0000000..9cb24f6 --- /dev/null +++ b/src/util/MMKVParser.ts @@ -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 ``; + } + + 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 { + const mmkv = new MMKVParser(view); + const result = new Map(); + while (!mmkv.eof) { + const key = mmkv.readString(); + const value = mmkv.readVariantString(); + result.set(key, value); + } + return result; + } +} diff --git a/src/util/__tests__/pathHelper.test.ts b/src/util/__tests__/pathHelper.test.ts new file mode 100644 index 0000000..b8ac642 --- /dev/null +++ b/src/util/__tests__/pathHelper.test.ts @@ -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'); +}); diff --git a/src/util/pathHelper.ts b/src/util/pathHelper.ts new file mode 100644 index 0000000..c50939b --- /dev/null +++ b/src/util/pathHelper.ts @@ -0,0 +1,3 @@ +export function getFileName(path: string) { + return path.replace(/.*[\\/]/, ''); +}