From 263f4c2b6a574144a76819fea61048f28ca0bb5a Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Sat, 17 Jun 2023 02:45:31 +0100 Subject: [PATCH] feat: kwm v2 key import ui --- src/components/ImportSecretModal.tsx | 46 +++++++++ src/features/settings/Settings.tsx | 2 + src/features/settings/keyFormats.ts | 67 +++++++++++++ .../settings/panels/KWMv2/KWMv2EKeyItem.tsx | 76 +++++++++++++++ .../settings/panels/PanelKWMv2Key.tsx | 94 +++++++++++++++++++ .../settings/panels/PanelQMCv2Key.tsx | 90 ++++++++++++++++-- .../QMCv2/{KeyInput.tsx => QMCv2EKeyItem.tsx} | 6 +- src/features/settings/settingsSelector.ts | 2 + src/features/settings/settingsSlice.ts | 89 ++++++++++++++---- src/util/DatabaseKeyExtractor.ts | 6 +- src/util/MMKVParser.ts | 4 + src/util/__tests__/splitN.ts.test.ts | 10 ++ src/util/splitN.ts | 20 ++++ 13 files changed, 480 insertions(+), 32 deletions(-) create mode 100644 src/components/ImportSecretModal.tsx create mode 100644 src/features/settings/keyFormats.ts create mode 100644 src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx create mode 100644 src/features/settings/panels/PanelKWMv2Key.tsx rename src/features/settings/panels/QMCv2/{KeyInput.tsx => QMCv2EKeyItem.tsx} (86%) create mode 100644 src/util/__tests__/splitN.ts.test.ts create mode 100644 src/util/splitN.ts 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。{''} + 该格式需要提取密钥后才能正常解密。 + + + + + + + }> + + setShowImportModal(true)} icon={}> + 从文件导入密钥 + + + }> + 清空密钥 + + + + + + + + + {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; +}