diff --git a/src/components/ImportSecretModal.tsx b/src/components/ImportSecretModal.tsx
new file mode 100644
index 0000000..2a83999
--- /dev/null
+++ b/src/components/ImportSecretModal.tsx
@@ -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 (
+
+
+
+ 导入密钥数据库
+
+
+
+ 拖放或点我选择含有密钥的数据库文件
+
+
+ 选择你的{clientName && <>「{clientName}」>}客户端平台以查看对应说明:
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx
index 3d93f7b..a59155b 100644
--- a/src/features/settings/Settings.tsx
+++ b/src/features/settings/Settings.tsx
@@ -26,9 +26,11 @@ import { useState } from 'react';
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
import { useAppDispatch } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
+import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
+ { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
{
name: '其它/待定',
Tab: () => 这里空空如也~,
diff --git a/src/features/settings/keyFormats.ts b/src/features/settings/keyFormats.ts
new file mode 100644
index 0000000..65ce879
--- /dev/null
+++ b/src/features/settings/keyFormats.ts
@@ -0,0 +1,67 @@
+import { nanoid } from 'nanoid';
+import { objectify } from 'radash';
+
+export function productionKeyToStaging>(
+ 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(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;
+
+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;
+
+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 m = key.match(/^(\d+)-(\w+)$/);
+ if (!m) return null;
+
+ const [_, rid, quality] = m;
+ return { id: nanoid(), rid, quality, ekey: value };
+};
diff --git a/src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx b/src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
new file mode 100644
index 0000000..35244b4
--- /dev/null
+++ b/src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
@@ -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) =>
+ dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value }));
+ const deleteKey = () => dispatch(kwm2DeleteKey({ id }));
+
+ return (
+
+
+
+ {i + 1}
+
+
+
+
+ updateKey('rid', e)}
+ type="number"
+ maxW="8em"
+ />
+ updateKey('quality', e)}
+ flex={1}
+ />
+
+
+
+
+
+
+ updateKey('ekey', e)} />
+
+
+ {ekey.length || '?'}
+
+
+
+
+
+ }
+ variant="ghost"
+ colorScheme="red"
+ type="button"
+ onClick={deleteKey}
+ />
+
+
+ );
+});
diff --git a/src/features/settings/panels/PanelKWMv2Key.tsx b/src/features/settings/panels/PanelKWMv2Key.tsx
new file mode 100644
index 0000000..80a0cb5
--- /dev/null
+++ b/src/features/settings/panels/PanelKWMv2Key.tsx
@@ -0,0 +1,94 @@
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Code,
+ Flex,
+ HStack,
+ Heading,
+ Icon,
+ IconButton,
+ List,
+ Menu,
+ MenuButton,
+ MenuDivider,
+ MenuItem,
+ MenuList,
+ Text,
+ useToast,
+} from '@chakra-ui/react';
+import { useDispatch, useSelector } from 'react-redux';
+import { kwm2AddKey, kwm2ClearKeys } from '../settingsSlice';
+import { selectStagingKWMv2Keys } from '../settingsSelector';
+import { useState } from 'react';
+import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
+import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
+import { ImportSecretModal } from '~/components/ImportSecretModal';
+
+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 = () => {
+ toast({
+ title: '尚未实现',
+ isClosable: true,
+ status: 'error',
+ });
+ };
+
+ return (
+
+
+ 酷我解密密钥(KwmV2)
+
+
+
+ 酷我安卓版本的「臻品音质」已经换用 V2 版,后缀名为 mflac
或沿用旧的 kwm
。{''}
+ 该格式需要提取密钥后才能正常解密。
+
+
+
+
+ }>
+ 添加一条密钥
+
+
+
+
+
+
+
+ {kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
+
+ ))}
+
+ {kwm2Keys.length === 0 && 还没有添加密钥。}
+
+
+ setShowImportModal(false)}
+ onImport={handleSecretImport}
+ >
+ 尚未实现
+
+
+ );
+}
diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx
index 52856f2..e18709d 100644
--- a/src/features/settings/panels/PanelQMCv2Key.tsx
+++ b/src/features/settings/panels/PanelQMCv2Key.tsx
@@ -14,19 +14,33 @@ import {
MenuDivider,
MenuItem,
MenuList,
+ Tab,
+ TabList,
+ TabPanel,
+ TabPanels,
Text,
Tooltip,
+ useToast,
} from '@chakra-ui/react';
import { useDispatch, useSelector } from 'react-redux';
-import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys } from '../settingsSlice';
+import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector';
import React, { useState } from 'react';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
-import { ImportFileModal } from './QMCv2/ImportFileModal';
-import { KeyInput } from './QMCv2/KeyInput';
+import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
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 { InstructionsAndroid } from './QMCv2/InstructionsAndroid';
+import { InstructionsIOS } from './QMCv2/InstructionsIOS';
+import { InstructionsMac } from './QMCv2/InstructionsMac';
+import { InstructionsPC } from './QMCv2/InstructionsPC';
export function PanelQMCv2Key() {
+ const toast = useToast();
const dispatch = useDispatch();
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
const [showImportModal, setShowImportModal] = useState(false);
@@ -38,6 +52,44 @@ export function PanelQMCv2Key() {
dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked }));
};
+ const handleSecretImport = async (file: File) => {
+ try {
+ const fileBuffer = await file.arrayBuffer();
+
+ let qmc2Keys: null | Omit[] = 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 (
@@ -99,14 +151,40 @@ export function PanelQMCv2Key() {
- {qmc2Keys.map(({ id, key, name }, i) => (
-
+ {qmc2Keys.map(({ id, ekey, name }, i) => (
+
))}
{qmc2Keys.length === 0 && 还没有添加密钥。}
- setShowImportModal(false)} />
+ setShowImportModal(false)}
+ onImport={handleSecretImport}
+ >
+
+ 安卓
+ iOS
+ Mac
+ Windows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/features/settings/panels/QMCv2/KeyInput.tsx b/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
similarity index 86%
rename from src/features/settings/panels/QMCv2/KeyInput.tsx
rename to src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
index 77d193e..bf133d0 100644
--- a/src/features/settings/panels/QMCv2/KeyInput.tsx
+++ b/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
@@ -15,10 +15,10 @@ import { qmc2DeleteKey, qmc2UpdateKey } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks';
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 updateKey = (prop: 'name' | 'key', e: React.ChangeEvent) =>
+ const updateKey = (prop: 'name' | 'ekey', e: React.ChangeEvent) =>
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
const deleteKey = () => dispatch(qmc2DeleteKey({ id }));
@@ -36,7 +36,7 @@ export const KeyInput = memo(({ id, name, ekey, i }: { id: string; name: string;
- updateKey('key', e)} />
+ updateKey('ekey', e)} />
{ekey.length || '?'}
diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts
index f53f796..3798f41 100644
--- a/src/features/settings/settingsSelector.ts
+++ b/src/features/settings/settingsSelector.ts
@@ -6,6 +6,8 @@ import { hasOwn } from '~/util/objects';
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
+export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
+
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
const normalizedName = name.normalize();
diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts
index dacd10b..e5722e2 100644
--- a/src/features/settings/settingsSlice.ts
+++ b/src/features/settings/settingsSlice.ts
@@ -1,20 +1,39 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
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 {
qmc2: {
- keys: { id: string; name: string; key: string }[];
+ keys: StagingQMCv2Key[];
allowFuzzyNameSearch: boolean;
};
+ kwm2: {
+ keys: StagingKWMv2Key[];
+ };
}
export interface ProductionSettings {
qmc2: {
- keys: Record; // { [fileName]: ekey }
+ keys: ProductionQMCv2Keys; // { [fileName]: ekey }
allowFuzzyNameSearch: boolean;
};
+ kwm2: {
+ keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
+ };
}
export interface SettingsState {
@@ -23,35 +42,33 @@ export interface SettingsState {
}
const initialState: SettingsState = {
staging: {
- qmc2: {
- allowFuzzyNameSearch: false,
- keys: [],
- },
+ qmc2: { allowFuzzyNameSearch: false, keys: [] },
+ kwm2: { keys: [] },
},
production: {
- qmc2: {
- allowFuzzyNameSearch: false,
- keys: {},
- },
+ qmc2: { allowFuzzyNameSearch: false, keys: {} },
+ kwm2: { keys: {} },
},
};
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
qmc2: {
- keys: objectify(
- staging.qmc2.keys,
- (item) => item.name.normalize(),
- (item) => item.key.trim()
- ),
+ keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
},
+ kwm2: {
+ keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
+ },
});
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
qmc2: {
- keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })),
+ keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
},
+ kwm2: {
+ keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
+ },
});
export const settingsSlice = createSlice({
@@ -64,10 +81,11 @@ export const settingsSlice = createSlice({
staging: productionToStaging(payload),
};
},
+ //
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[]>) {
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
state.staging.qmc2.keys.push(...newItems);
},
@@ -77,7 +95,7 @@ export const settingsSlice = createSlice({
},
qmc2UpdateKey(
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);
if (keyItem) {
@@ -90,6 +108,31 @@ export const settingsSlice = createSlice({
qmc2AllowFuzzyNameSearch(state, { payload: { enable } }: PayloadAction<{ enable: boolean }>) {
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[]>) {
+ 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) => {
state.staging = productionToStaging(state.production);
},
@@ -118,6 +161,12 @@ export const {
qmc2ImportKeys,
qmc2AllowFuzzyNameSearch,
+ kwm2AddKey,
+ kwm2UpdateKey,
+ kwm2DeleteKey,
+ kwm2ClearKeys,
+ kwm2ImportKeys,
+
commitStagingChange,
discardStagingChanges,
} = settingsSlice.actions;
diff --git a/src/util/DatabaseKeyExtractor.ts b/src/util/DatabaseKeyExtractor.ts
index 7df071f..5158820 100644
--- a/src/util/DatabaseKeyExtractor.ts
+++ b/src/util/DatabaseKeyExtractor.ts
@@ -3,7 +3,7 @@ import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
export interface QMAndroidKeyEntry {
name: string;
- key: string;
+ ekey: string;
}
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;
- return keys.map(([path, key]) => ({
+ return keys.map(([path, ekey]) => ({
// strip dir name
name: getFileName(String(path)),
- key: String(key),
+ ekey: String(ekey),
}));
} finally {
db?.close();
diff --git a/src/util/MMKVParser.ts b/src/util/MMKVParser.ts
index 6a8425d..530f237 100644
--- a/src/util/MMKVParser.ts
+++ b/src/util/MMKVParser.ts
@@ -95,4 +95,8 @@ export class MMKVParser {
}
return result;
}
+
+ public static parseKuwoEKey(_view: DataView): unknown[] {
+ return [];
+ }
}
diff --git a/src/util/__tests__/splitN.ts.test.ts b/src/util/__tests__/splitN.ts.test.ts
new file mode 100644
index 0000000..448322b
--- /dev/null
+++ b/src/util/__tests__/splitN.ts.test.ts
@@ -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']);
+});
diff --git a/src/util/splitN.ts b/src/util/splitN.ts
new file mode 100644
index 0000000..0b3d402
--- /dev/null
+++ b/src/util/splitN.ts
@@ -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;
+}