Merge pull request 'KWMv2/酷我 mflac 支持' (#35) from feat/kwm-v2-support into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #35
This commit is contained in:
commit
ed488ea204
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@ -21,7 +21,7 @@
|
|||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
"@emotion/react": "^11.11.0",
|
"@emotion/react": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@jixun/libparakeet": "0.1.2",
|
"@jixun/libparakeet": "0.2.0",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"framer-motion": "^10.12.16",
|
"framer-motion": "^10.12.16",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
|
792
pnpm-lock.yaml
792
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -17,10 +17,22 @@ import { ExternalLinkIcon } from '@chakra-ui/icons';
|
|||||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
|
||||||
|
|
||||||
import PowerShellAdbDumpCommand from './adb_dump.ps1?raw';
|
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||||
import ShellAdbDumpCommand from './adb_dump.sh?raw';
|
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||||
|
|
||||||
|
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||||
|
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AndroidADBPullInstructionProps {
|
||||||
|
dir: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
||||||
|
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file });
|
||||||
|
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
|
||||||
|
|
||||||
export function InstructionsAndroid() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
@ -50,12 +62,12 @@ export function InstructionsAndroid() {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
访问 <Code>/data/data/com.tencent.qqmusic/databases/</Code> 目录。
|
访问 <Code>{dir}/</Code> 目录。
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
将文件 <Code>player_process_db</Code> 复制到浏览器可访问的目录。
|
将文件 <Code>{file}</Code> 复制到浏览器可访问的目录。
|
||||||
<br />
|
<br />
|
||||||
(例如下载目录)
|
(例如下载目录)
|
||||||
</Text>
|
</Text>
|
||||||
@ -99,12 +111,12 @@ export function InstructionsAndroid() {
|
|||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||||
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
|
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
|
||||||
{PowerShellAdbDumpCommand}
|
{psAdbDumpCommand}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
提交当前目录下的 <Code>player_process_db</Code> 文件。
|
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</OrderedList>
|
</OrderedList>
|
||||||
@ -128,12 +140,12 @@ export function InstructionsAndroid() {
|
|||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||||
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
|
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
|
||||||
{ShellAdbDumpCommand}
|
{shAdbDumpCommand}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
提交当前目录下的 <Code>player_process_db</Code> 文件。
|
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</OrderedList>
|
</OrderedList>
|
@ -1,8 +1,8 @@
|
|||||||
try {
|
try {
|
||||||
$gz_b64 = adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" | Out-String
|
$gz_b64 = adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" | Out-String
|
||||||
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
|
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
|
||||||
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
|
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
|
||||||
$outFile = New-Object System.IO.FileStream("player_process_db", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
|
$outFile = New-Object System.IO.FileStream("{{ file }}", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
|
||||||
$decoded.CopyTo($outFile)
|
$decoded.CopyTo($outFile)
|
||||||
} finally {
|
} finally {
|
||||||
if ($outFile -ne $null) { $outFile.Dispose() }
|
if ($outFile -ne $null) { $outFile.Dispose() }
|
2
src/components/AndroidADBPullInstruction/adb_dump.sh
Normal file
2
src/components/AndroidADBPullInstruction/adb_dump.sh
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
sh adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" \
|
||||||
|
| base64 -d | gzip -d '{{ file }}'
|
46
src/components/ImportSecretModal.tsx
Normal file
46
src/components/ImportSecretModal.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { FileInput } from '~/components/FileInput';
|
||||||
|
|
||||||
|
export interface ImportSecretModalProps {
|
||||||
|
clientName?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImport: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||||
|
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>导入密钥数据库</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
||||||
|
<Center>
|
||||||
|
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
<Text mt={2}>选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</Text>
|
||||||
|
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
25
src/crypto/pasreKuwo.ts
Normal file
25
src/crypto/pasreKuwo.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
import { strlen } from './strlen';
|
||||||
|
|
||||||
|
export interface KuwoHeader {
|
||||||
|
rid: string; // uint64
|
||||||
|
encVersion: 1 | 2; // uint32
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||||
|
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||||
|
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
|
||||||
|
return null; // not kuwo-encrypted file
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
|
||||||
|
const qualityLen = strlen(qualityBytes);
|
||||||
|
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encVersion: view.getUint32(0x10, true) as 1 | 2,
|
||||||
|
rid: view.getUint32(0x18, true).toString(),
|
||||||
|
quality,
|
||||||
|
};
|
||||||
|
}
|
9
src/crypto/strlen.ts
Normal file
9
src/crypto/strlen.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function strlen(data: Uint8Array): number {
|
||||||
|
const n = data.byteLength;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (data[i] === 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
@ -1,14 +1,25 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
import { KWM_KEY } from './kwm.key';
|
import { KWM_KEY } from './kwm.key';
|
||||||
|
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||||
|
import { fetchParakeet } from '@jixun/libparakeet';
|
||||||
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
|
||||||
// v1 only
|
// v1 only
|
||||||
export class KWMCrypto implements CryptoBase {
|
export class KWMCrypto implements CryptoBase {
|
||||||
cryptoName = 'KWM';
|
cryptoName = 'KWM';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
|
||||||
return transformBlob(buffer, (p) => p.make.KuwoKWM(KWM_KEY));
|
const kwm2key = opts.kwm2key ?? '';
|
||||||
|
|
||||||
|
const parakeet = await fetchParakeet();
|
||||||
|
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||||
|
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
|
||||||
|
cleanup: () => keyCrypto.delete(),
|
||||||
|
parakeet,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static make() {
|
public static make() {
|
||||||
|
@ -3,6 +3,8 @@ import type { CryptoBase } from '../CryptoBase';
|
|||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
||||||
import { fetchParakeet } from '@jixun/libparakeet';
|
import { fetchParakeet } from '@jixun/libparakeet';
|
||||||
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||||
|
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
||||||
|
|
||||||
export class QMC2Crypto implements CryptoBase {
|
export class QMC2Crypto implements CryptoBase {
|
||||||
cryptoName = 'QMC/v2';
|
cryptoName = 'QMC/v2';
|
||||||
@ -36,9 +38,8 @@ export class QMC2CryptoWithKey implements CryptoBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parakeet = await fetchParakeet();
|
const parakeet = await fetchParakeet();
|
||||||
const textEncoder = new TextEncoder();
|
const key = stringToUTF8Bytes(options.qmc2Key);
|
||||||
const key = textEncoder.encode(options.qmc2Key);
|
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||||
const keyCrypto = parakeet.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
|
||||||
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
||||||
parakeet,
|
parakeet,
|
||||||
cleanup: () => keyCrypto.delete(),
|
cleanup: () => keyCrypto.delete(),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export interface DecryptCommandOptions {
|
export interface DecryptCommandOptions {
|
||||||
qmc2Key?: string;
|
qmc2Key?: string;
|
||||||
|
kwm2key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptCommandPayload {
|
export interface DecryptCommandPayload {
|
||||||
|
4
src/decrypt-worker/util/qmc2KeyCrypto.ts
Normal file
4
src/decrypt-worker/util/qmc2KeyCrypto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import type { Parakeet } from '@jixun/libparakeet';
|
||||||
|
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
|
||||||
|
|
||||||
|
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
10
src/decrypt-worker/util/utf8Encoder.ts
Normal file
10
src/decrypt-worker/util/utf8Encoder.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const utf8Encoder = new TextEncoder();
|
||||||
|
export const utf8Decoder = new TextDecoder('utf-8');
|
||||||
|
|
||||||
|
export function stringToUTF8Bytes(str: string) {
|
||||||
|
return utf8Encoder.encode(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToUTF8String(str: BufferSource) {
|
||||||
|
return utf8Decoder.decode(str);
|
||||||
|
}
|
@ -1,11 +1,12 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
import { decryptionQueue } from '~/decrypt-worker/client';
|
|
||||||
|
|
||||||
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
import { decryptionQueue } from '~/decrypt-worker/client';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
import { selectDecryptOptionByFile } from '../settings/settingsSelector';
|
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
QUEUED = 'QUEUED',
|
QUEUED = 'QUEUED',
|
||||||
@ -63,7 +64,18 @@ export const processFile = createAsyncThunk<
|
|||||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = selectDecryptOptionByFile(state, file.fileName);
|
const fileHeader = await fetch(file.raw, {
|
||||||
|
headers: {
|
||||||
|
Range: 'bytes=0-1023',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((r) => r.arrayBuffer());
|
||||||
|
|
||||||
|
const options: DecryptCommandOptions = {
|
||||||
|
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
|
||||||
|
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
|
||||||
|
};
|
||||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -26,9 +26,11 @@ import { useState } from 'react';
|
|||||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||||
|
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||||
|
|
||||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||||
|
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||||
{
|
{
|
||||||
name: '其它/待定',
|
name: '其它/待定',
|
||||||
Tab: () => <Text>这里空空如也~</Text>,
|
Tab: () => <Text>这里空空如也~</Text>,
|
||||||
|
73
src/features/settings/keyFormats.ts
Normal file
73
src/features/settings/keyFormats.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
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
|
||||||
|
): S[] {
|
||||||
|
const result: S[] = [];
|
||||||
|
for (const [key, value] of Object.entries(src)) {
|
||||||
|
const item = make(key, value as P[keyof P]);
|
||||||
|
if (item) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function stagingKeyToProduction<S, P>(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
|
||||||
|
return objectify(src, toKey, toValue) as P;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QMCv2 (QQ)
|
||||||
|
export interface StagingQMCv2Key {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ekey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductionQMCv2Keys = Record<string /* filename */, string /* ekey */>;
|
||||||
|
|
||||||
|
export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.normalize();
|
||||||
|
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||||
|
export const qmc2ProductionToStaging = (
|
||||||
|
key: keyof ProductionQMCv2Keys,
|
||||||
|
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
||||||
|
): StagingQMCv2Key => {
|
||||||
|
return {
|
||||||
|
id: nanoid(),
|
||||||
|
name: key.normalize(),
|
||||||
|
ekey: value.trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// KWMv2 (KuWo)
|
||||||
|
|
||||||
|
export interface StagingKWMv2Key {
|
||||||
|
id: string;
|
||||||
|
rid: string;
|
||||||
|
quality: string;
|
||||||
|
ekey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductionKWMv2Keys = Record<string /* `${rid}-${quality}` */, string /* ekey */>;
|
||||||
|
|
||||||
|
export const parseKwm2ProductionKey = (key: string): null | { rid: string; quality: string } => {
|
||||||
|
const m = key.match(/^(\d+)-(\w+)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const [_, rid, quality] = m;
|
||||||
|
|
||||||
|
return { rid, quality };
|
||||||
|
};
|
||||||
|
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
|
||||||
|
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||||
|
export const kwm2ProductionToStaging = (
|
||||||
|
key: keyof ProductionKWMv2Keys,
|
||||||
|
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
||||||
|
): null | StagingKWMv2Key => {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
|
||||||
|
const parsed = parseKwm2ProductionKey(key);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
|
||||||
|
};
|
9
src/features/settings/panels/KWMv2/InstructionsPC.tsx
Normal file
9
src/features/settings/panels/KWMv2/InstructionsPC.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Text } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export function InstructionsPC() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
76
src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
Normal file
76
src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
|
ListItem,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||||
|
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
|
||||||
|
import { useAppDispatch } from '~/hooks';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { StagingKWMv2Key } from '../../keyFormats';
|
||||||
|
|
||||||
|
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const updateKey = (prop: keyof StagingKWMv2Key, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||||
|
const deleteKey = () => dispatch(kwm2DeleteKey({ id }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
||||||
|
<HStack>
|
||||||
|
<Text w="2em" textAlign="center">
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<VStack flex={1}>
|
||||||
|
<HStack flex={1} w="full">
|
||||||
|
<Input
|
||||||
|
variant="flushed"
|
||||||
|
placeholder="资源 ID"
|
||||||
|
value={rid}
|
||||||
|
onChange={(e) => updateKey('rid', e)}
|
||||||
|
type="number"
|
||||||
|
maxW="8em"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
variant="flushed"
|
||||||
|
placeholder="音质格式"
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => updateKey('quality', e)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<InputGroup size="xs">
|
||||||
|
<InputLeftElement pr="2">
|
||||||
|
<Icon as={MdVpnKey} />
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('ekey', e)} />
|
||||||
|
<InputRightElement>
|
||||||
|
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||||
|
<code>{ekey.length || '?'}</code>
|
||||||
|
</Text>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除该密钥"
|
||||||
|
icon={<Icon as={MdDelete} boxSize={6} />}
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="red"
|
||||||
|
type="button"
|
||||||
|
onClick={deleteKey}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
});
|
135
src/features/settings/panels/PanelKWMv2Key.tsx
Normal file
135
src/features/settings/panels/PanelKWMv2Key.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Code,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Heading,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuDivider,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
TabPanels,
|
||||||
|
Text,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
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 { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
||||||
|
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||||
|
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
|
||||||
|
import type { StagingKWMv2Key } from '../keyFormats';
|
||||||
|
import { InstructionsPC } from './KWMv2/InstructionsPC';
|
||||||
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
|
|
||||||
|
export function PanelKWMv2Key() {
|
||||||
|
const toast = useToast();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const kwm2Keys = useSelector(selectStagingKWMv2Keys);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
|
const addKey = () => dispatch(kwm2AddKey());
|
||||||
|
const clearAll = () => dispatch(kwm2ClearKeys());
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys) {
|
||||||
|
dispatch(kwm2ImportKeys(keys));
|
||||||
|
setShowImportModal(false);
|
||||||
|
toast({
|
||||||
|
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
|
||||||
|
description: '记得按下「保存」来应用。',
|
||||||
|
isClosable: true,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: `不支持的文件:${file.name}`,
|
||||||
|
isClosable: true,
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex minH={0} flexDir="column" flex={1}>
|
||||||
|
<Heading as="h2" size="lg">
|
||||||
|
酷我解密密钥(KwmV2)
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
酷我安卓版本的「臻品音质」已经换用 V2 版,后缀名为 <Code>mflac</Code> 或沿用旧的 <Code>kwm</Code>。{''}
|
||||||
|
该格式需要提取密钥后才能正常解密。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack pb={2} pt={2}>
|
||||||
|
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
||||||
|
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
||||||
|
添加一条密钥
|
||||||
|
</Button>
|
||||||
|
<Menu>
|
||||||
|
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||||
|
从文件导入密钥
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||||
|
清空密钥
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||||
|
<List spacing={3}>
|
||||||
|
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
|
||||||
|
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
{kwm2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ImportSecretModal
|
||||||
|
clientName="酷我音乐"
|
||||||
|
show={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImport={handleSecretImport}
|
||||||
|
>
|
||||||
|
<TabList>
|
||||||
|
<Tab>安卓</Tab>
|
||||||
|
<Tab>Windows</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels flex={1} overflow="auto">
|
||||||
|
<TabPanel>
|
||||||
|
<AndroidADBPullInstruction
|
||||||
|
dir="/data/data/cn.kuwo.player/files/mmkv"
|
||||||
|
file="cn.kuwo.player.mmkv.defaultconfig"
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsPC />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</ImportSecretModal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@ -14,19 +14,33 @@ import {
|
|||||||
MenuDivider,
|
MenuDivider,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
TabPanels,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys } from '../settingsSlice';
|
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
|
||||||
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||||
import { ImportFileModal } from './QMCv2/ImportFileModal';
|
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
|
||||||
import { KeyInput } from './QMCv2/KeyInput';
|
|
||||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
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 { getFileName } from '~/util/pathHelper';
|
||||||
|
import { InstructionsIOS } from './QMCv2/InstructionsIOS';
|
||||||
|
import { InstructionsMac } from './QMCv2/InstructionsMac';
|
||||||
|
import { InstructionsPC } from './QMCv2/InstructionsPC';
|
||||||
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
|
|
||||||
export function PanelQMCv2Key() {
|
export function PanelQMCv2Key() {
|
||||||
|
const toast = useToast();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
@ -38,6 +52,44 @@ export function PanelQMCv2Key() {
|
|||||||
dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked }));
|
dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSecretImport = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
||||||
|
|
||||||
|
if (/[_.]db$/i.test(file.name)) {
|
||||||
|
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||||
|
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
||||||
|
if (!qmc2Keys) {
|
||||||
|
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} 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, ekey]) => ({ name: getFileName(name), ekey }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qmc2Keys) {
|
||||||
|
dispatch(qmc2ImportKeys(qmc2Keys));
|
||||||
|
setShowImportModal(false);
|
||||||
|
toast({
|
||||||
|
title: `导入成功 (${qmc2Keys.length})`,
|
||||||
|
description: '记得保存更改来应用。',
|
||||||
|
isClosable: true,
|
||||||
|
duration: 5000,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert(`不支持的文件:${file.name}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error during import: ', e);
|
||||||
|
alert(`导入数据库时发生错误:${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex minH={0} flexDir="column" flex={1}>
|
<Flex minH={0} flexDir="column" flex={1}>
|
||||||
<Heading as="h2" size="lg">
|
<Heading as="h2" size="lg">
|
||||||
@ -99,14 +151,40 @@ export function PanelQMCv2Key() {
|
|||||||
|
|
||||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||||
<List spacing={3}>
|
<List spacing={3}>
|
||||||
{qmc2Keys.map(({ id, key, name }, i) => (
|
{qmc2Keys.map(({ id, ekey, name }, i) => (
|
||||||
<KeyInput key={id} id={id} ekey={key} name={name} i={i} />
|
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ImportFileModal show={showImportModal} onClose={() => setShowImportModal(false)} />
|
<ImportSecretModal
|
||||||
|
clientName="QQ 音乐"
|
||||||
|
show={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImport={handleSecretImport}
|
||||||
|
>
|
||||||
|
<TabList>
|
||||||
|
<Tab>安卓</Tab>
|
||||||
|
<Tab>iOS</Tab>
|
||||||
|
<Tab>Mac</Tab>
|
||||||
|
<Tab>Windows</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels flex={1} overflow="auto">
|
||||||
|
<TabPanel>
|
||||||
|
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsIOS />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsMac />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsPC />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</ImportSecretModal>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,122 +0,0 @@
|
|||||||
import {
|
|
||||||
Center,
|
|
||||||
Flex,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
Tab,
|
|
||||||
TabList,
|
|
||||||
TabPanel,
|
|
||||||
TabPanels,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { FileInput } from '~/components/FileInput';
|
|
||||||
import { qmc2ImportKeys } from '../../settingsSlice';
|
|
||||||
import { useAppDispatch } from '~/hooks';
|
|
||||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
|
||||||
|
|
||||||
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;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyEntry {
|
|
||||||
name: string;
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const toast = useToast();
|
|
||||||
const handleFileReceived = async (files: File[]) => {
|
|
||||||
try {
|
|
||||||
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();
|
|
||||||
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
|
||||||
if (!qmc2Keys) {
|
|
||||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} 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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (qmc2Keys) {
|
|
||||||
dispatch(qmc2ImportKeys(qmc2Keys));
|
|
||||||
onClose();
|
|
||||||
toast({
|
|
||||||
title: `导入成功 (${qmc2Keys.length})`,
|
|
||||||
description: '记得保存更改来应用。',
|
|
||||||
isClosable: true,
|
|
||||||
duration: 5000,
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert(`不支持的文件:${file.name}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('error during import: ', e);
|
|
||||||
alert(`导入数据库时发生错误:${e}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>导入密钥数据库</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
|
||||||
<Center>
|
|
||||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Text mt={2}>选择你的「QQ 音乐」客户端平台以查看对应说明:</Text>
|
|
||||||
|
|
||||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
|
||||||
<TabList>
|
|
||||||
<Tab>安卓</Tab>
|
|
||||||
<Tab>iOS</Tab>
|
|
||||||
<Tab>Mac</Tab>
|
|
||||||
<Tab>Windows</Tab>
|
|
||||||
</TabList>
|
|
||||||
<TabPanels flex={1} overflow="auto">
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsAndroid />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsIOS />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsMac />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<InstructionsPC />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
@ -15,10 +15,10 @@ import { qmc2DeleteKey, qmc2UpdateKey } from '../../settingsSlice';
|
|||||||
import { useAppDispatch } from '~/hooks';
|
import { useAppDispatch } from '~/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
export const KeyInput = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
|
export const QMCv2EKeyItem = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const updateKey = (prop: 'name' | 'key', e: React.ChangeEvent<HTMLInputElement>) =>
|
const updateKey = (prop: 'name' | 'ekey', e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
|
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||||
const deleteKey = () => dispatch(qmc2DeleteKey({ id }));
|
const deleteKey = () => dispatch(qmc2DeleteKey({ id }));
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export const KeyInput = memo(({ id, name, ekey, i }: { id: string; name: string;
|
|||||||
<InputLeftElement pr="2">
|
<InputLeftElement pr="2">
|
||||||
<Icon as={MdVpnKey} />
|
<Icon as={MdVpnKey} />
|
||||||
</InputLeftElement>
|
</InputLeftElement>
|
||||||
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('key', e)} />
|
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('ekey', e)} />
|
||||||
<InputRightElement>
|
<InputRightElement>
|
||||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||||
<code>{ekey.length || '?'}</code>
|
<code>{ekey.length || '?'}</code>
|
@ -1,2 +0,0 @@
|
|||||||
sh adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" \
|
|
||||||
| base64 -d | gzip -d player_process_db
|
|
@ -5,6 +5,7 @@ import type { AppStore } from '~/store';
|
|||||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
||||||
import { enumObject } from '~/util/objects';
|
import { enumObject } from '~/util/objects';
|
||||||
import { getLogger } from '~/util/logUtils';
|
import { getLogger } from '~/util/logUtils';
|
||||||
|
import { parseKwm2ProductionKey } from './keyFormats';
|
||||||
|
|
||||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||||
|
|
||||||
@ -22,6 +23,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
|||||||
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
|
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings?.kwm2) {
|
||||||
|
const { keys } = settings.kwm2;
|
||||||
|
|
||||||
|
for (const [k, v] of enumObject(keys)) {
|
||||||
|
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
|
||||||
|
draft.kwm2.keys[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,28 +1,47 @@
|
|||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
import { parseKuwoHeader } from '~/crypto/pasreKuwo';
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||||
import { hasOwn } from '~/util/objects';
|
import { hasOwn } from '~/util/objects';
|
||||||
|
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||||
|
|
||||||
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
|
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
|
||||||
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
|
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
|
||||||
|
|
||||||
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
|
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
||||||
|
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
|
||||||
|
|
||||||
|
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
|
||||||
const normalizedName = name.normalize();
|
const normalizedName = name.normalize();
|
||||||
|
|
||||||
let qmc2Key: string | undefined;
|
let ekey: string | undefined;
|
||||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state);
|
const { keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state);
|
||||||
if (hasOwn(qmc2Keys, normalizedName)) {
|
if (hasOwn(keys, normalizedName)) {
|
||||||
qmc2Key = qmc2Keys[normalizedName];
|
ekey = keys[normalizedName];
|
||||||
} else if (allowFuzzyNameSearch) {
|
} else if (allowFuzzyNameSearch) {
|
||||||
const qmc2KeyStoreNames = Object.keys(qmc2Keys);
|
const qmc2KeyStoreNames = Object.keys(keys);
|
||||||
if (qmc2KeyStoreNames.length > 0) {
|
if (qmc2KeyStoreNames.length > 0) {
|
||||||
const closestName = closestByLevenshtein(normalizedName, qmc2KeyStoreNames);
|
const closestName = closestByLevenshtein(normalizedName, qmc2KeyStoreNames);
|
||||||
console.debug('qmc2: key db could not find %o, using closest %o instead.', normalizedName, closestName);
|
console.debug('qmc2: key db could not find %o, using closest %o instead.', normalizedName, closestName);
|
||||||
qmc2Key = qmc2Keys[closestName];
|
ekey = keys[closestName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return ekey;
|
||||||
qmc2Key,
|
};
|
||||||
};
|
|
||||||
|
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
|
||||||
|
const hdr = parseKuwoHeader(headerView);
|
||||||
|
if (!hdr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = selectFinalKWMv2Keys(state);
|
||||||
|
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid });
|
||||||
|
|
||||||
|
let ekey: string | undefined;
|
||||||
|
if (hasOwn(keys, lookupKey)) {
|
||||||
|
ekey = keys[lookupKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ekey;
|
||||||
};
|
};
|
||||||
|
@ -1,20 +1,39 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { objectify } from 'radash';
|
import {
|
||||||
|
ProductionKWMv2Keys,
|
||||||
|
ProductionQMCv2Keys,
|
||||||
|
StagingKWMv2Key,
|
||||||
|
StagingQMCv2Key,
|
||||||
|
kwm2ProductionToStaging,
|
||||||
|
kwm2StagingToProductionKey,
|
||||||
|
kwm2StagingToProductionValue,
|
||||||
|
productionKeyToStaging,
|
||||||
|
qmc2ProductionToStaging,
|
||||||
|
qmc2StagingToProductionKey,
|
||||||
|
qmc2StagingToProductionValue,
|
||||||
|
stagingKeyToProduction,
|
||||||
|
} from './keyFormats';
|
||||||
|
|
||||||
export interface StagingSettings {
|
export interface StagingSettings {
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: { id: string; name: string; key: string }[];
|
keys: StagingQMCv2Key[];
|
||||||
allowFuzzyNameSearch: boolean;
|
allowFuzzyNameSearch: boolean;
|
||||||
};
|
};
|
||||||
|
kwm2: {
|
||||||
|
keys: StagingKWMv2Key[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductionSettings {
|
export interface ProductionSettings {
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: Record<string, string>; // { [fileName]: ekey }
|
keys: ProductionQMCv2Keys; // { [fileName]: ekey }
|
||||||
allowFuzzyNameSearch: boolean;
|
allowFuzzyNameSearch: boolean;
|
||||||
};
|
};
|
||||||
|
kwm2: {
|
||||||
|
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
@ -23,35 +42,33 @@ export interface SettingsState {
|
|||||||
}
|
}
|
||||||
const initialState: SettingsState = {
|
const initialState: SettingsState = {
|
||||||
staging: {
|
staging: {
|
||||||
qmc2: {
|
qmc2: { allowFuzzyNameSearch: false, keys: [] },
|
||||||
allowFuzzyNameSearch: false,
|
kwm2: { keys: [] },
|
||||||
keys: [],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
production: {
|
production: {
|
||||||
qmc2: {
|
qmc2: { allowFuzzyNameSearch: false, keys: {} },
|
||||||
allowFuzzyNameSearch: false,
|
kwm2: { keys: {} },
|
||||||
keys: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: objectify(
|
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
|
||||||
staging.qmc2.keys,
|
|
||||||
(item) => item.name.normalize(),
|
|
||||||
(item) => item.key.trim()
|
|
||||||
),
|
|
||||||
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
||||||
},
|
},
|
||||||
|
kwm2: {
|
||||||
|
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })),
|
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
|
||||||
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
||||||
},
|
},
|
||||||
|
kwm2: {
|
||||||
|
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settingsSlice = createSlice({
|
export const settingsSlice = createSlice({
|
||||||
@ -64,10 +81,11 @@ export const settingsSlice = createSlice({
|
|||||||
staging: productionToStaging(payload),
|
staging: productionToStaging(payload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
//
|
||||||
qmc2AddKey(state) {
|
qmc2AddKey(state) {
|
||||||
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
|
state.staging.qmc2.keys.push({ id: nanoid(), name: '', ekey: '' });
|
||||||
},
|
},
|
||||||
qmc2ImportKeys(state, { payload }: PayloadAction<{ name: string; key: string }[]>) {
|
qmc2ImportKeys(state, { payload }: PayloadAction<Omit<StagingQMCv2Key, 'id'>[]>) {
|
||||||
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||||
state.staging.qmc2.keys.push(...newItems);
|
state.staging.qmc2.keys.push(...newItems);
|
||||||
},
|
},
|
||||||
@ -77,7 +95,7 @@ export const settingsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
qmc2UpdateKey(
|
qmc2UpdateKey(
|
||||||
state,
|
state,
|
||||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: 'name' | 'key'; value: string }>
|
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>
|
||||||
) {
|
) {
|
||||||
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
|
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
|
||||||
if (keyItem) {
|
if (keyItem) {
|
||||||
@ -90,6 +108,31 @@ export const settingsSlice = createSlice({
|
|||||||
qmc2AllowFuzzyNameSearch(state, { payload: { enable } }: PayloadAction<{ enable: boolean }>) {
|
qmc2AllowFuzzyNameSearch(state, { payload: { enable } }: PayloadAction<{ enable: boolean }>) {
|
||||||
state.staging.qmc2.allowFuzzyNameSearch = enable;
|
state.staging.qmc2.allowFuzzyNameSearch = enable;
|
||||||
},
|
},
|
||||||
|
// TODO: reuse the logic somehow?
|
||||||
|
kwm2AddKey(state) {
|
||||||
|
state.staging.kwm2.keys.push({ id: nanoid(), ekey: '', quality: '', rid: '' });
|
||||||
|
},
|
||||||
|
kwm2ImportKeys(state, { payload }: PayloadAction<Omit<StagingKWMv2Key, 'id'>[]>) {
|
||||||
|
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||||
|
state.staging.kwm2.keys.push(...newItems);
|
||||||
|
},
|
||||||
|
kwm2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
|
||||||
|
const kwm2 = state.staging.kwm2;
|
||||||
|
kwm2.keys = kwm2.keys.filter((item) => item.id !== id);
|
||||||
|
},
|
||||||
|
kwm2UpdateKey(
|
||||||
|
state,
|
||||||
|
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>
|
||||||
|
) {
|
||||||
|
const keyItem = state.staging.kwm2.keys.find((item) => item.id === id);
|
||||||
|
if (keyItem) {
|
||||||
|
keyItem[field] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
kwm2ClearKeys(state) {
|
||||||
|
state.staging.kwm2.keys = [];
|
||||||
|
},
|
||||||
|
//
|
||||||
discardStagingChanges: (state) => {
|
discardStagingChanges: (state) => {
|
||||||
state.staging = productionToStaging(state.production);
|
state.staging = productionToStaging(state.production);
|
||||||
},
|
},
|
||||||
@ -118,6 +161,12 @@ export const {
|
|||||||
qmc2ImportKeys,
|
qmc2ImportKeys,
|
||||||
qmc2AllowFuzzyNameSearch,
|
qmc2AllowFuzzyNameSearch,
|
||||||
|
|
||||||
|
kwm2AddKey,
|
||||||
|
kwm2UpdateKey,
|
||||||
|
kwm2DeleteKey,
|
||||||
|
kwm2ClearKeys,
|
||||||
|
kwm2ImportKeys,
|
||||||
|
|
||||||
commitStagingChange,
|
commitStagingChange,
|
||||||
discardStagingChanges,
|
discardStagingChanges,
|
||||||
} = settingsSlice.actions;
|
} = settingsSlice.actions;
|
||||||
|
@ -3,7 +3,7 @@ import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
|||||||
|
|
||||||
export interface QMAndroidKeyEntry {
|
export interface QMAndroidKeyEntry {
|
||||||
name: string;
|
name: string;
|
||||||
key: string;
|
ekey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseKeyExtractor {
|
export class DatabaseKeyExtractor {
|
||||||
@ -33,10 +33,10 @@ export class DatabaseKeyExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
|
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
|
||||||
return keys.map(([path, key]) => ({
|
return keys.map(([path, ekey]) => ({
|
||||||
// strip dir name
|
// strip dir name
|
||||||
name: getFileName(String(path)),
|
name: getFileName(String(path)),
|
||||||
key: String(key),
|
ekey: String(ekey),
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
db?.close();
|
db?.close();
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
|
||||||
|
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
import { formatHex } from './formatHex';
|
import { formatHex } from './formatHex';
|
||||||
|
|
||||||
const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
||||||
|
|
||||||
export class MMKVParser {
|
export class MMKVParser {
|
||||||
private offset = 4;
|
private offset = 4;
|
||||||
private length: number;
|
private length: number;
|
||||||
@ -66,7 +66,7 @@ export class MMKVParser {
|
|||||||
// ]
|
// ]
|
||||||
const strByteLen = this.readInt();
|
const strByteLen = this.readInt();
|
||||||
const data = this.readBytes(strByteLen);
|
const data = this.readBytes(strByteLen);
|
||||||
return textDecoder.decode(data).normalize();
|
return bytesToUTF8String(data).normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public readVariantString() {
|
public readVariantString() {
|
||||||
@ -85,6 +85,15 @@ export class MMKVParser {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public skipContainer() {
|
||||||
|
// Container [
|
||||||
|
// len: int,
|
||||||
|
// data: variant
|
||||||
|
// ]
|
||||||
|
const containerLen = this.readInt();
|
||||||
|
this.offset += containerLen;
|
||||||
|
}
|
||||||
|
|
||||||
public static toStringMap(view: DataView): Map<string, string> {
|
public static toStringMap(view: DataView): Map<string, string> {
|
||||||
const mmkv = new MMKVParser(view);
|
const mmkv = new MMKVParser(view);
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
@ -95,4 +104,22 @@ export class MMKVParser {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static parseKuwoEKey(view: DataView) {
|
||||||
|
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.readVariantString();
|
||||||
|
result.push({ rid, quality, ekey });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
10
src/util/__tests__/splitN.ts.test.ts
Normal file
10
src/util/__tests__/splitN.ts.test.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { splitN } from '../splitN';
|
||||||
|
|
||||||
|
test('some test cases', () => {
|
||||||
|
expect(splitN('1,2,3', ',', 2)).toEqual(['1', '2,3']);
|
||||||
|
expect(splitN('1,2,3', ',', 3)).toEqual(['1', '2', '3']);
|
||||||
|
expect(splitN('1,2,3', ',', 4)).toEqual(['1', '2', '3']);
|
||||||
|
|
||||||
|
expect(splitN('1,2,3', '.', 3)).toEqual(['1,2,3']);
|
||||||
|
expect(splitN('1,2,3', '?', 0)).toEqual(['1,2,3']);
|
||||||
|
});
|
@ -1,6 +1,6 @@
|
|||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
// translation of pseudocode from Wikipedia:
|
// translation of pseudocode from Wikipedia:
|
||||||
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
|
||||||
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
||||||
export function levenshtein(str1: string, str2: string) {
|
export function levenshtein(str1: string, str2: string) {
|
||||||
if (str1 === str2) {
|
if (str1 === str2) {
|
||||||
@ -14,8 +14,8 @@ export function levenshtein(str1: string, str2: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert them to Uint8Array to avoid expensive string APIs.
|
// Convert them to Uint8Array to avoid expensive string APIs.
|
||||||
const s = textEncoder.encode(str1.toLowerCase());
|
const s = stringToUTF8Bytes(str1.normalize().toLowerCase());
|
||||||
const t = textEncoder.encode(str2.toLowerCase());
|
const t = stringToUTF8Bytes(str2.normalize().toLowerCase());
|
||||||
const m = s.byteLength;
|
const m = s.byteLength;
|
||||||
const n = t.byteLength;
|
const n = t.byteLength;
|
||||||
|
|
||||||
|
20
src/util/splitN.ts
Normal file
20
src/util/splitN.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function splitN(str: string, sep: string, maxN: number) {
|
||||||
|
if (maxN <= 1) {
|
||||||
|
return [str];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const lenSep = sep.length;
|
||||||
|
let searchIdx = 0;
|
||||||
|
for (; maxN > 1; maxN--) {
|
||||||
|
const nextIdx = str.indexOf(sep, searchIdx);
|
||||||
|
if (nextIdx === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunks.push(str.slice(searchIdx, nextIdx));
|
||||||
|
searchIdx = nextIdx + lenSep;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(str.slice(searchIdx));
|
||||||
|
return chunks;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user