diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc2e8b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.mmkv binary diff --git a/src/components/FilePathBlock.tsx b/src/components/FilePathBlock.tsx new file mode 100644 index 0000000..83339c2 --- /dev/null +++ b/src/components/FilePathBlock.tsx @@ -0,0 +1,10 @@ +import { Code, Text } from '@chakra-ui/react'; +import React from 'react'; + +export function FilePathBlock({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx index c26346e..3d93f7b 100644 --- a/src/features/settings/Settings.tsx +++ b/src/features/settings/Settings.tsx @@ -4,6 +4,8 @@ import { Center, Flex, HStack, + Icon, + IconButton, Menu, MenuButton, MenuItem, @@ -21,7 +23,7 @@ import { } from '@chakra-ui/react'; import { PanelQMCv2Key } from './panels/PanelQMCv2Key'; import { useState } from 'react'; -import { MdExpandMore, MdMenu } from 'react-icons/md'; +import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md'; import { useAppDispatch } from '~/hooks'; import { commitStagingChange, discardStagingChanges } from './settingsSlice'; @@ -104,9 +106,14 @@ export function Settings() { - + } + onClick={handleResetSettings} + colorScheme="red" + variant="ghost" + title="放弃未储存的更改,将设定还原为储存前的状态。" + aria-label="放弃未储存的更改" + /> diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx index 0ab2ede..e485d4b 100644 --- a/src/features/settings/panels/PanelQMCv2Key.tsx +++ b/src/features/settings/panels/PanelQMCv2Key.tsx @@ -45,22 +45,25 @@ export function PanelQMCv2Key() { QMCv2 密钥 - QQ 音乐目前采用的加密方案(QMCv2),安卓端与 Mac 端均下加密内容与密钥隔离储存。 + + QQ 音乐目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS + 客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。 + - + }> setShowImportModal(true)} icon={}> - 从文件导入 + 从文件导入密钥 }> - 清空 + 清空密钥 diff --git a/src/features/settings/panels/QMCv2/ImportFileModal.tsx b/src/features/settings/panels/QMCv2/ImportFileModal.tsx index f0f6b7b..0f9a84a 100644 --- a/src/features/settings/panels/QMCv2/ImportFileModal.tsx +++ b/src/features/settings/panels/QMCv2/ImportFileModal.tsx @@ -12,6 +12,7 @@ import { TabPanel, TabPanels, Tabs, + Text, useToast, } from '@chakra-ui/react'; @@ -24,6 +25,8 @@ import { InstructionsAndroid } from './InstructionsAndroid'; import { MMKVParser } from '~/util/MMKVParser'; import { getFileName } from '~/util/pathHelper'; import { InstructionsMac } from './InstructionsMac'; +import { InstructionsIOS } from './InstructionsIOS'; +import { InstructionsPC } from './InstructionsPC'; export interface ImportFileModalProps { show: boolean; @@ -52,7 +55,7 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) { alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`); return; } - } else if (/MMKVStreamEncryptId/i.test(file.name)) { + } 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, key]) => ({ name: getFileName(name), key })); @@ -88,18 +91,28 @@ export function ImportFileModal({ onClose, show }: ImportFileModalProps) { 拖放或点我选择含有密钥的数据库文件 - + 选择你的「QQ 音乐」客户端平台以查看对应说明: + + - 安卓客户端 - Mac 客户端 + 安卓 + iOS + Mac + Windows + + + + + + diff --git a/src/features/settings/panels/QMCv2/InstructionsIOS.tsx b/src/features/settings/panels/QMCv2/InstructionsIOS.tsx new file mode 100644 index 0000000..9bb4662 --- /dev/null +++ b/src/features/settings/panels/QMCv2/InstructionsIOS.tsx @@ -0,0 +1,101 @@ +import { Box, Code, Heading, ListItem, OrderedList, Text, UnorderedList } from '@chakra-ui/react'; +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 InstructionsIOS() { + return ( + <> + 不推荐从该平台客户端提取文件,因为使用者需要对 iOS 设备进行完整备份。 + + 未越狱用户指南 + + 未越狱用户需要对设备进行完整备份,并能提取备份内的文件。 + + + 使用你喜欢的备份软件对 iOS 设备进行完整备份; + + + 打开备份文件,并导航到下述目录: + /AppDomain-com.tencent.QQMusic/Documents/mmkv/ + + + + 提取或导出密钥数据库文件 filenameEkeyMap; + + + + + 提交导出的 filenameEkeyMap 文件; + + + + 按下「保存」来应用更改。 + + + + 获取离线文件 + + + 通过客户端下载的音乐文件存储在备份的下述目录: + /AppDomain-com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic + + 该目录又存在数个子目录,其子目录下保存的「*.mgalaxy」文件则是最终的加密文件。 + + + 格式:[随机数字]-[id]-[随机数字].mgalaxy + + +  例:{EXAMPLE_NAME_IOS} + + + + 解密离线文件 + + + + + 在上方的样例文件的情况下,得知其 id 为 {EXAMPLE_MEDIA_ID}; + + + + + 查找密钥表,得到文件名「{EXAMPLE_NAME_DB}」; + + + + + 将导出的「{EXAMPLE_NAME_IOS}」更名为数据库存储的文件名「 + {EXAMPLE_NAME_DB}」; + + + + + 回到主界面,提交离线文件「{EXAMPLE_NAME_DB}」。 + + + + + 越狱用户参考 + + 该节信息根据网络上公开的信息整理,仅供参考。 + + + 密钥数据库文件路径: + + /var/mobile/Containers/Data/Application/<随机>/Documents/mmkv/filenameEkeyMap + + + + 离线音乐文件下载目录: + + /var/mobile/Containers/Data/Application/<随机>/Library/Application + Support/com.tencent.QQMusic/iData/iMusic + + + + + ); +} diff --git a/src/features/settings/panels/QMCv2/InstructionsMac.tsx b/src/features/settings/panels/QMCv2/InstructionsMac.tsx index 9e18293..44c2526 100644 --- a/src/features/settings/panels/QMCv2/InstructionsMac.tsx +++ b/src/features/settings/panels/QMCv2/InstructionsMac.tsx @@ -1,4 +1,5 @@ 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'; @@ -7,12 +8,9 @@ export function InstructionsMac() { <> Mac 客户端使用 mmkv 数据库储存密钥。 该密钥文件通常存储在下述路径: - - - ~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application - Support/QQMusicMac/mmkv/MMKVStreamEncryptId - - + + ~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId + 导入密钥 diff --git a/src/features/settings/panels/QMCv2/InstructionsPC.tsx b/src/features/settings/panels/QMCv2/InstructionsPC.tsx new file mode 100644 index 0000000..86d0a07 --- /dev/null +++ b/src/features/settings/panels/QMCv2/InstructionsPC.tsx @@ -0,0 +1,9 @@ +import { Text } from '@chakra-ui/react'; + +export function InstructionsPC() { + return ( + <> + 使用 Windows 客户端下载的文件不需要导入密钥。 + + ); +} diff --git a/src/util/MMKVParser.ts b/src/util/MMKVParser.ts index 9cb24f6..6a8425d 100644 --- a/src/util/MMKVParser.ts +++ b/src/util/MMKVParser.ts @@ -1,28 +1,29 @@ +import { formatHex } from './formatHex'; + const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true }); export class MMKVParser { - private offset = 8; + private offset = 4; private length: number; constructor(private view: DataView) { const payloadLength = view.getUint32(0, true); this.length = 4 + payloadLength; + + // skip unused str + this.readInt(); } toString() { - const offset = this.offset.toString(16).padStart(8, '0'); - const length = this.length.toString(16).padStart(8, '0'); - return ``; + const offset = formatHex(this.offset, 8); + const length = formatHex(this.length, 8); + return ``; } get eof() { return this.offset >= this.length; } - peek() { - return this.view.getUint8(this.offset); - } - public readByte() { return this.view.getUint8(this.offset++); } @@ -77,7 +78,9 @@ export class MMKVParser { const newOffset = this.offset + containerLen; const result = this.readString(); if (newOffset !== this.offset) { - throw new Error('readVariantString failed: offset does not match'); + const expected = formatHex(newOffset); + const actual = formatHex(this.offset); + throw new Error(`readVariantString failed: offset does mismatch (expect: ${expected}, actual: ${actual})`); } return result; } diff --git a/src/util/__tests__/MMKVParser.test.ts b/src/util/__tests__/MMKVParser.test.ts new file mode 100644 index 0000000..e4eebcb --- /dev/null +++ b/src/util/__tests__/MMKVParser.test.ts @@ -0,0 +1,19 @@ +import { MMKVParser } from '../MMKVParser'; +import { readFileSync } from 'node:fs'; + +test('parse mmkv file as expected', () => { + const buff = readFileSync(__dirname + '/__fixture__/test.mmkv'); + const view = new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); + expect(MMKVParser.toStringMap(view)).toEqual( + new Map([ + ['key', 'value'], + [ + 'Lorem Ipsum', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + + 'Vestibulum congue volutpat metus non molestie. Quisque id est sapien. ' + + 'Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. ' + + 'Integer non urna justo.', + ], + ]) + ); +}); diff --git a/src/util/__tests__/__fixture__/test.mmkv b/src/util/__tests__/__fixture__/test.mmkv new file mode 100644 index 0000000..04a8a01 Binary files /dev/null and b/src/util/__tests__/__fixture__/test.mmkv differ diff --git a/src/util/formatHex.ts b/src/util/formatHex.ts new file mode 100644 index 0000000..0d778c1 --- /dev/null +++ b/src/util/formatHex.ts @@ -0,0 +1,3 @@ +export function formatHex(value: number, len = 8) { + return '0x' + (value | 0).toString(16).padStart(len, '0'); +}