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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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(/.*[\\/]/, '');
+}