#58 蜻蜓FM 安卓端支援 #59

Merged
lsr merged 10 commits from feat/qingting-fm into main 2023-12-22 10:35:18 +00:00
18 changed files with 188 additions and 82 deletions
Showing only changes of commit e476f6218f - Show all commits

View File

@ -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
}

View File

@ -3,7 +3,7 @@ import { objectify } from 'radash';
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
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 };
};

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

View File

@ -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<StagingKWMv2Key, 'id'>[] | 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: '未导入密钥',

View File

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

View File

@ -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: {

View File

@ -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<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 { 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');
});

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...
deps: {
// inline: ['sql.js'],
optimizer: {
web: {
include: ['sql.js'],