diff --git a/src/crypto/parseKuwo.ts b/src/crypto/parseKuwo.ts index db6cf90..5de9b1d 100644 --- a/src/crypto/parseKuwo.ts +++ b/src/crypto/parseKuwo.ts @@ -7,9 +7,11 @@ export interface KuwoHeader { quality: string; } +const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']); + export function parseKuwoHeader(view: DataView): KuwoHeader | null { const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10); - if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') { + if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) { return null; // not kuwo-encrypted file } diff --git a/src/features/settings/keyFormats.ts b/src/features/settings/keyFormats.ts index a9647d6..17df923 100644 --- a/src/features/settings/keyFormats.ts +++ b/src/features/settings/keyFormats.ts @@ -3,7 +3,7 @@ import { objectify } from 'radash'; export function productionKeyToStaging>( src: P, - make: (k: keyof P, v: P[keyof P]) => null | S + make: (k: keyof P, v: P[keyof P]) => null | S, ): S[] { const result: S[] = []; for (const [key, value] of Object.entries(src)) { @@ -31,7 +31,7 @@ export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.nor export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim(); export const qmc2ProductionToStaging = ( key: keyof ProductionQMCv2Keys, - value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys] + value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys], ): StagingQMCv2Key => { return { id: nanoid(), @@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = ( export interface StagingKWMv2Key { id: string; + /** + * Resource ID + */ rid: string; + /** + * Quality String + */ quality: string; ekey: string; } @@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali return { rid, quality }; }; -export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`; +export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`; export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey; export const kwm2ProductionToStaging = ( key: keyof ProductionKWMv2Keys, - value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys] + value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys], ): null | StagingKWMv2Key => { if (typeof value !== 'string') return null; const parsed = parseKwm2ProductionKey(key); if (!parsed) return null; + const { quality, rid } = parsed; - return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value }; + return { id: nanoid(), rid, quality, ekey: value }; }; diff --git a/src/features/settings/panels/KWMv2/InstructionsIOS.tsx b/src/features/settings/panels/KWMv2/InstructionsIOS.tsx new file mode 100644 index 0000000..5d79b6f --- /dev/null +++ b/src/features/settings/panels/KWMv2/InstructionsIOS.tsx @@ -0,0 +1,33 @@ +import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react'; + +const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv'; + +export function InstructionsIOS() { + return ( + <> + 你需要越狱来访问 iOS 应用的私有数据。 + + ⚠️ 请注意,越狱通常意味着你的设备 + 将失去保修资格。 + + + + + 访问设备的这个目录: + {KUWO_IOS_DIR} + + + + + 提取密钥数据库文件 kw_ekey 至浏览器可访问的目录,如下载目录。 + + + + + 提交刚刚提取的 kw_ekey 密钥数据库。 + + + + + ); +} diff --git a/src/features/settings/panels/KWMv2/KWMv2AllInstructions.tsx b/src/features/settings/panels/KWMv2/KWMv2AllInstructions.tsx index 144e604..36a4cf8 100644 --- a/src/features/settings/panels/KWMv2/KWMv2AllInstructions.tsx +++ b/src/features/settings/panels/KWMv2/KWMv2AllInstructions.tsx @@ -1,12 +1,14 @@ import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react'; import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction'; import { InstructionsPC } from './InstructionsPC'; +import { InstructionsIOS } from './InstructionsIOS'; export function KWMv2AllInstructions() { return ( <> 安卓 + iOS Windows @@ -16,6 +18,9 @@ export function KWMv2AllInstructions() { file="cn.kuwo.player.mmkv.defaultconfig" /> + + + diff --git a/src/features/settings/panels/PanelKWMv2Key.tsx b/src/features/settings/panels/PanelKWMv2Key.tsx index ca97311..b735fb3 100644 --- a/src/features/settings/panels/PanelKWMv2Key.tsx +++ b/src/features/settings/panels/PanelKWMv2Key.tsx @@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md'; import { ImportSecretModal } from '~/components/ImportSecretModal'; -import { MMKVParser } from '~/util/MMKVParser'; +import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo'; import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice'; import { selectStagingKWMv2Keys } from '../settingsSelector'; @@ -41,9 +41,11 @@ export function PanelKWMv2Key() { const handleSecretImport = async (file: File) => { let keys: Omit[] | null = null; if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) { - const fileBuffer = await file.arrayBuffer(); - keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer)); + keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer())); + } else if (/kw_ekey/.test(file.name)) { + keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer())); } + if (keys?.length === 0) { toast({ title: '未导入密钥', diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx index 5f59502..6b37517 100644 --- a/src/features/settings/panels/PanelQMCv2Key.tsx +++ b/src/features/settings/panels/PanelQMCv2Key.tsx @@ -29,7 +29,7 @@ import { InfoOutlineIcon } from '@chakra-ui/icons'; import { ImportSecretModal } from '~/components/ImportSecretModal'; import { StagingQMCv2Key } from '../keyFormats'; import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor'; -import { MMKVParser } from '~/util/MMKVParser'; +import { parseAndroidQmEKey } from '~/util/mmkv/qm'; import { getFileName } from '~/util/pathHelper'; import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions'; import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions'; @@ -63,7 +63,7 @@ export function PanelQMCv2Key() { } } else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) { const fileBuffer = await file.arrayBuffer(); - const map = MMKVParser.toStringMap(new DataView(fileBuffer)); + const map = parseAndroidQmEKey(new DataView(fileBuffer)); qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey })); } diff --git a/src/theme.ts b/src/theme.ts index ee8c96a..53ffc0c 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -9,6 +9,11 @@ export const theme = extendTheme({ 'Segoe UI,Helvetica,Arial,sans-serif', 'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol', ].join(','), + mono: [ + 'SFMono-Regular,Menlo,Monaco', + '"Sarasa Mono CJK SC",', + 'Consolas,"Liberation Mono","Courier New",monospace', + ].join(','), }, components: { Button: { diff --git a/src/util/MMKVParser.ts b/src/util/MMKVParser.ts index 29b3ed5..bd9b2b6 100644 --- a/src/util/MMKVParser.ts +++ b/src/util/MMKVParser.ts @@ -1,4 +1,3 @@ -import type { StagingKWMv2Key } from '~/features/settings/keyFormats'; import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder'; import { formatHex } from './formatHex'; @@ -69,7 +68,11 @@ export class MMKVParser { return bytesToUTF8String(data).normalize(); } - public readOptionalString() { + public readKey() { + return this.readString(); + } + + public readStringValue(): string | null { // Container [ // len: int, // data: variant @@ -96,37 +99,4 @@ export class MMKVParser { const containerLen = this.readInt(); this.offset += containerLen; } - - 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.readOptionalString(); - if (value) { - result.set(key, value); - } - } - return result; - } - - public static parseKuwoEKey(view: DataView): Omit[] { - const mmkv = new MMKVParser(view); - const result: Omit[] = []; - while (!mmkv.eof) { - const key = mmkv.readString(); - const idMatch = key.match(/^sec_ekey#(\d+)-(.+)/); - if (!idMatch) { - mmkv.skipContainer(); - continue; - } - - const [_, rid, quality] = idMatch; - const ekey = mmkv.readOptionalString(); - if (ekey) { - result.push({ rid, quality, ekey }); - } - } - return result; - } } diff --git a/src/util/__tests__/MMKVParser.test.ts b/src/util/__tests__/MMKVParser.test.ts index dc996ac..ff3d266 100644 --- a/src/util/__tests__/MMKVParser.test.ts +++ b/src/util/__tests__/MMKVParser.test.ts @@ -1,46 +1,28 @@ import { MMKVParser } from '../MMKVParser'; -import { readFileSync } from 'node:fs'; const makeViewFromBuffer = (buff: Buffer) => new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); -test('parse qm mmkv file', () => { - const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv')); - expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(` - { - "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.", - "key": "value", - } - `); -}); - -test('parse qm mmkv file with optional str', () => { - const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm_optional.mmkv')); - expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(` - { - "key": "value", - "key2": "value2", - } - `); -}); - -test('parse kuwo mmkv file', () => { - const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo.mmkv')); - expect(MMKVParser.parseKuwoEKey(view)).toMatchInlineSnapshot(` - [ - { - "ekey": "xyz123", - "quality": "20201kmflac", - "rid": "1234567", - }, - ] - `); -}); - test('throw error on broken file', () => { const view = makeViewFromBuffer( Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]), ); - expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i); + expect(() => { + const parser = new MMKVParser(view); + parser.readKey(); + parser.readStringValue(); + }).toThrow(/offset mismatch/i); +}); + +test('able to handle empty value', () => { + const view = makeViewFromBuffer( + Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]), + ); + + const parser = new MMKVParser(view); + expect(parser.readKey()).toEqual('key'); + expect(parser.readStringValue()).toEqual(null); + expect(parser.readKey()).toEqual('1'); + expect(parser.readStringValue()).toEqual('2'); }); diff --git a/src/util/__tests__/__fixture__/qm_optional.mmkv b/src/util/__tests__/__fixture__/qm_optional.mmkv deleted file mode 100644 index 5e127d9..0000000 Binary files a/src/util/__tests__/__fixture__/qm_optional.mmkv and /dev/null differ diff --git a/src/util/__tests__/__fixture__/kuwo.mmkv b/src/util/mmkv/__tests__/__fixture__/kuwo_android.mmkv similarity index 100% rename from src/util/__tests__/__fixture__/kuwo.mmkv rename to src/util/mmkv/__tests__/__fixture__/kuwo_android.mmkv diff --git a/src/util/mmkv/__tests__/__fixture__/kuwo_ios.mmkv b/src/util/mmkv/__tests__/__fixture__/kuwo_ios.mmkv new file mode 100644 index 0000000..c595c8e Binary files /dev/null and b/src/util/mmkv/__tests__/__fixture__/kuwo_ios.mmkv differ diff --git a/src/util/__tests__/__fixture__/qm.mmkv b/src/util/mmkv/__tests__/__fixture__/qm.mmkv similarity index 77% rename from src/util/__tests__/__fixture__/qm.mmkv rename to src/util/mmkv/__tests__/__fixture__/qm.mmkv index 04a8a01..58459c3 100644 Binary files a/src/util/__tests__/__fixture__/qm.mmkv and b/src/util/mmkv/__tests__/__fixture__/qm.mmkv differ diff --git a/src/util/mmkv/__tests__/kuwo.test.ts b/src/util/mmkv/__tests__/kuwo.test.ts new file mode 100644 index 0000000..d337914 --- /dev/null +++ b/src/util/mmkv/__tests__/kuwo.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo'; + +const makeViewFromBuffer = (buff: Buffer) => + new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); + +test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => { + const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_android.mmkv')); + expect(parseAndroidKuwoEKey(view)).toMatchInlineSnapshot(` + [ + { + "ekey": "xyz123", + "quality": "20201kmflac", + "rid": "1234567", + }, + ] + `); +}); + +test('parse kuwo ios ekey mmkv file "kw_ekey"', () => { + const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_ios.mmkv')); + expect(parseIosKuwoEKey(view)).toMatchInlineSnapshot(` + [ + { + "ekey": "xyz123", + "quality": "20201", + "rid": "1234567", + }, + ] + `); +}); diff --git a/src/util/mmkv/__tests__/qm.test.ts b/src/util/mmkv/__tests__/qm.test.ts new file mode 100644 index 0000000..17abe18 --- /dev/null +++ b/src/util/mmkv/__tests__/qm.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { parseAndroidQmEKey } from '../qm'; + +const makeViewFromBuffer = (buff: Buffer) => + new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); + +test('parse qm mmkv file', () => { + const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv')); + expect(Object.fromEntries(parseAndroidQmEKey(view).entries())).toMatchInlineSnapshot(` + { + "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.", + "key": "value", + } + `); +}); diff --git a/src/util/mmkv/kuwo.ts b/src/util/mmkv/kuwo.ts new file mode 100644 index 0000000..bd22e14 --- /dev/null +++ b/src/util/mmkv/kuwo.ts @@ -0,0 +1,41 @@ +import type { StagingKWMv2Key } from '~/features/settings/keyFormats'; +import { MMKVParser } from '../MMKVParser'; + +export function parseAndroidKuwoEKey(view: DataView): Omit[] { + const mmkv = new MMKVParser(view); + const result: Omit[] = []; + while (!mmkv.eof) { + const key = mmkv.readString(); + const idMatch = key.match(/^sec_ekey#(\d+)-(\w+)$/); + if (!idMatch) { + mmkv.skipContainer(); + continue; + } + + const [_, rid, quality] = idMatch; + const ekey = mmkv.readStringValue(); + if (ekey) { + result.push({ rid, quality, ekey }); + } + } + return result; +} + +export function parseIosKuwoEKey(view: DataView): Omit[] { + const mmkv = new MMKVParser(view); + const result: Omit[] = []; + while (!mmkv.eof) { + const key = mmkv.readKey(); + const idMatch = key.match(/^(\d+)_(\d+)$/); + if (!idMatch) { + mmkv.skipContainer(); + continue; + } + const [_, rid, quality] = idMatch; + const ekey = mmkv.readStringValue(); + if (ekey) { + result.push({ rid, quality, ekey }); + } + } + return result; +} diff --git a/src/util/mmkv/qm.ts b/src/util/mmkv/qm.ts new file mode 100644 index 0000000..5f711f4 --- /dev/null +++ b/src/util/mmkv/qm.ts @@ -0,0 +1,14 @@ +import { MMKVParser } from '../MMKVParser'; + +export function parseAndroidQmEKey(view: DataView): Map { + const mmkv = new MMKVParser(view); + const result = new Map(); + while (!mmkv.eof) { + const key = mmkv.readString(); + const value = mmkv.readStringValue(); + if (value) { + result.set(key, value); + } + } + return result; +} diff --git a/vite.config.ts b/vite.config.ts index 1d83f70..f66536c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -114,7 +114,6 @@ export default defineConfig({ ], // workaround: sql.js is not ESModule friendly, yet... deps: { - // inline: ['sql.js'], optimizer: { web: { include: ['sql.js'],