Merge pull request '酷我 iOS 数据库支持' (#60) from feat/kwm-ios-support into main

Reviewed-on: um/um-react#60
This commit is contained in:
鲁树人 2023-11-29 23:49:43 +00:00
commit da853ba6e0
18 changed files with 188 additions and 82 deletions

View File

@ -7,9 +7,11 @@ export interface KuwoHeader {
quality: string; quality: string;
} }
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
export function parseKuwoHeader(view: DataView): KuwoHeader | null { export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10); 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 return null; // not kuwo-encrypted file
} }

View File

@ -3,7 +3,7 @@ import { objectify } from 'radash';
export function productionKeyToStaging<S, P extends Record<string, unknown>>( export function productionKeyToStaging<S, P extends Record<string, unknown>>(
src: P, src: P,
make: (k: keyof P, v: P[keyof P]) => null | S make: (k: keyof P, v: P[keyof P]) => null | S,
): S[] { ): S[] {
const result: S[] = []; const result: S[] = [];
for (const [key, value] of Object.entries(src)) { 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 qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
export const qmc2ProductionToStaging = ( export const qmc2ProductionToStaging = (
key: keyof ProductionQMCv2Keys, key: keyof ProductionQMCv2Keys,
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys] value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
): StagingQMCv2Key => { ): StagingQMCv2Key => {
return { return {
id: nanoid(), id: nanoid(),
@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
export interface StagingKWMv2Key { export interface StagingKWMv2Key {
id: string; id: string;
/**
* Resource ID
*/
rid: string; rid: string;
/**
* Quality String
*/
quality: string; quality: string;
ekey: string; ekey: string;
} }
@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
return { rid, quality }; 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 kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
export const kwm2ProductionToStaging = ( export const kwm2ProductionToStaging = (
key: keyof ProductionKWMv2Keys, key: keyof ProductionKWMv2Keys,
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys] value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
): null | StagingKWMv2Key => { ): null | StagingKWMv2Key => {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const parsed = parseKwm2ProductionKey(key); const parsed = parseKwm2ProductionKey(key);
if (!parsed) return null; 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 };
}; };

View File

@ -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 (
<>
<Text>访 iOS </Text>
<Text>
<chakra.span color="red.400"></chakra.span>
</Text>
<OrderedList>
<ListItem>
<Text>
访
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code> 访
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code>
</Text>
</ListItem>
</OrderedList>
</>
);
}

View File

@ -1,12 +1,14 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react'; import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction'; import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsPC } from './InstructionsPC'; import { InstructionsPC } from './InstructionsPC';
import { InstructionsIOS } from './InstructionsIOS';
export function KWMv2AllInstructions() { export function KWMv2AllInstructions() {
return ( return (
<> <>
<TabList> <TabList>
<Tab></Tab> <Tab></Tab>
<Tab>iOS</Tab>
<Tab>Windows</Tab> <Tab>Windows</Tab>
</TabList> </TabList>
<TabPanels flex={1} overflow="auto"> <TabPanels flex={1} overflow="auto">
@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
file="cn.kuwo.player.mmkv.defaultconfig" file="cn.kuwo.player.mmkv.defaultconfig"
/> />
</TabPanel> </TabPanel>
<TabPanel>
<InstructionsIOS />
</TabPanel>
<TabPanel> <TabPanel>
<InstructionsPC /> <InstructionsPC />
</TabPanel> </TabPanel>

View File

@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md'; import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
import { MMKVParser } from '~/util/MMKVParser'; import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice'; import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
import { selectStagingKWMv2Keys } from '../settingsSelector'; import { selectStagingKWMv2Keys } from '../settingsSelector';
@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
const handleSecretImport = async (file: File) => { const handleSecretImport = async (file: File) => {
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null; let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) { if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer(); keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer)); } else if (/kw_ekey/.test(file.name)) {
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
} }
if (keys?.length === 0) { if (keys?.length === 0) {
toast({ toast({
title: '未导入密钥', title: '未导入密钥',

View File

@ -29,7 +29,7 @@ import { InfoOutlineIcon } from '@chakra-ui/icons';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
import { StagingQMCv2Key } from '../keyFormats'; import { StagingQMCv2Key } from '../keyFormats';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor'; import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
import { MMKVParser } from '~/util/MMKVParser'; import { parseAndroidQmEKey } from '~/util/mmkv/qm';
import { getFileName } from '~/util/pathHelper'; import { getFileName } from '~/util/pathHelper';
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions'; import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions'; import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
@ -63,7 +63,7 @@ export function PanelQMCv2Key() {
} }
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) { } else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer(); 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 })); qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
} }

View File

@ -9,6 +9,11 @@ export const theme = extendTheme({
'Segoe UI,Helvetica,Arial,sans-serif', 'Segoe UI,Helvetica,Arial,sans-serif',
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol', 'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
].join(','), ].join(','),
mono: [
'SFMono-Regular,Menlo,Monaco',
'"Sarasa Mono CJK SC",',
'Consolas,"Liberation Mono","Courier New",monospace',
].join(','),
}, },
components: { components: {
Button: { Button: {

View File

@ -1,4 +1,3 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder'; import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { formatHex } from './formatHex'; import { formatHex } from './formatHex';
@ -69,7 +68,11 @@ export class MMKVParser {
return bytesToUTF8String(data).normalize(); return bytesToUTF8String(data).normalize();
} }
public readOptionalString() { public readKey() {
return this.readString();
}
public readStringValue(): string | null {
// Container [ // Container [
// len: int, // len: int,
// data: variant // data: variant
@ -96,37 +99,4 @@ export class MMKVParser {
const containerLen = this.readInt(); const containerLen = this.readInt();
this.offset += containerLen; this.offset += containerLen;
} }
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.readOptionalString();
if (value) {
result.set(key, value);
}
}
return result;
}
public static parseKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
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;
}
} }

View File

@ -1,46 +1,28 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
import { readFileSync } from 'node:fs';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); 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', () => { test('throw error on broken file', () => {
const view = makeViewFromBuffer( const view = makeViewFromBuffer(
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]), 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');
}); });

Binary file not shown.

View File

@ -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",
},
]
`);
});

View File

@ -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",
}
`);
});

41
src/util/mmkv/kuwo.ts Normal file
View File

@ -0,0 +1,41 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser';
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
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<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
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;
}

14
src/util/mmkv/qm.ts Normal file
View File

@ -0,0 +1,14 @@
import { MMKVParser } from '../MMKVParser';
export function parseAndroidQmEKey(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.readStringValue();
if (value) {
result.set(key, value);
}
}
return result;
}

View File

@ -114,7 +114,6 @@ export default defineConfig({
], ],
// workaround: sql.js is not ESModule friendly, yet... // workaround: sql.js is not ESModule friendly, yet...
deps: { deps: {
// inline: ['sql.js'],
optimizer: { optimizer: {
web: { web: {
include: ['sql.js'], include: ['sql.js'],