diff --git a/package.json b/package.json index 97a123c..b81301f 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,9 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@jixun/libparakeet": "0.4.2", + "@jixun/libparakeet": "0.4.3", "@reduxjs/toolkit": "^2.0.1", "framer-motion": "^10.16.16", - "immer": "^10.0.3", "nanoid": "^5.0.4", "radash": "^11.0.0", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78a7b8..a38f9a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,17 +33,14 @@ dependencies: specifier: ^11.11.0 version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.45)(react@18.2.0) '@jixun/libparakeet': - specifier: 0.4.2 - version: 0.4.2 + specifier: 0.4.3 + version: 0.4.3 '@reduxjs/toolkit': specifier: ^2.0.1 version: 2.0.1(react-redux@9.0.4)(react@18.2.0) framer-motion: specifier: ^10.16.16 version: 10.16.16(react-dom@18.2.0)(react@18.2.0) - immer: - specifier: ^10.0.3 - version: 10.0.3 nanoid: specifier: ^5.0.4 version: 5.0.4 @@ -2885,8 +2882,8 @@ packages: '@sinclair/typebox': 0.27.8 dev: true - /@jixun/libparakeet@0.4.2: - resolution: {integrity: sha512-E6XXrHeOOIexKSyWUgQwHUpZNMh5I1IoC9gbwyVQjJhBLiURy6KU7U2Fgsg62Q3BSkdi7NwbdbPERrygUFmA7Q==} + /@jixun/libparakeet@0.4.3: + resolution: {integrity: sha512-Y+h65ZXbJ604sO1RyXA+2kx1WQoA6xJSlIIFWLcmAJpbj34XY6eCpyRXnltgkzcOLsLaO89jjGqMPS7MBC4XqA==} dev: false /@jridgewell/gen-mapping@0.3.3: diff --git a/src/decrypt-worker/constants.ts b/src/decrypt-worker/constants.ts index 4cde929..643d4ae 100644 --- a/src/decrypt-worker/constants.ts +++ b/src/decrypt-worker/constants.ts @@ -1,5 +1,6 @@ export enum DECRYPTION_WORKER_ACTION_NAME { DECRYPT = 'DECRYPT', + FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME', VERSION = 'VERSION', } diff --git a/src/decrypt-worker/crypto/qmc/qmc_v2.ts b/src/decrypt-worker/crypto/qmc/qmc_v2.ts index 6996802..710b90d 100644 --- a/src/decrypt-worker/crypto/qmc/qmc_v2.ts +++ b/src/decrypt-worker/crypto/qmc/qmc_v2.ts @@ -1,10 +1,9 @@ import { transformBlob } from '~/decrypt-worker/util/transformBlob'; import type { CryptoBase } from '../CryptoBase'; import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts'; -import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts'; import { fetchParakeet } from '@jixun/libparakeet'; import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts'; -import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts'; +import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts'; export class QMC2Crypto implements CryptoBase { cryptoName = 'QMC/v2'; @@ -12,7 +11,7 @@ export class QMC2Crypto implements CryptoBase { async decrypt(buffer: ArrayBuffer): Promise { const parakeet = await fetchParakeet(); - const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2); + const footerParser = makeQMCv2FooterParser(parakeet); return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), { parakeet, cleanup: () => footerParser.delete(), diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts index 114042e..9157ed0 100644 --- a/src/decrypt-worker/types.ts +++ b/src/decrypt-worker/types.ts @@ -10,3 +10,8 @@ export interface DecryptCommandPayload { blobURI: string; options: DecryptCommandOptions; } + +export interface FetchMusicExNamePayload { + id: string; + blobURI: string; +} diff --git a/src/decrypt-worker/util/qmc2KeyCrypto.ts b/src/decrypt-worker/util/qmc2KeyCrypto.ts index 7f534ee..ae55466 100644 --- a/src/decrypt-worker/util/qmc2KeyCrypto.ts +++ b/src/decrypt-worker/util/qmc2KeyCrypto.ts @@ -2,3 +2,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); +export const makeQMCv2FooterParser = (p: Parakeet) => p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2); diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts index eee2239..134483c 100644 --- a/src/decrypt-worker/worker.ts +++ b/src/decrypt-worker/worker.ts @@ -4,9 +4,11 @@ import { DECRYPTION_WORKER_ACTION_NAME } from './constants'; import { getSDKVersion } from '@jixun/libparakeet'; import { workerDecryptHandler } from './worker/handler/decrypt'; +import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser'; const bus = new WorkerServerBus(); onmessage = bus.onmessage; bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler); +bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion); diff --git a/src/decrypt-worker/worker/handler/qmcv2_parser.ts b/src/decrypt-worker/worker/handler/qmcv2_parser.ts new file mode 100644 index 0000000..44dab27 --- /dev/null +++ b/src/decrypt-worker/worker/handler/qmcv2_parser.ts @@ -0,0 +1,27 @@ +import { fetchParakeet, FooterParserState } from '@jixun/libparakeet'; +import type { FetchMusicExNamePayload } from '~/decrypt-worker/types'; +import { makeQMCv2FooterParser } from '~/decrypt-worker/util/qmc2KeyCrypto'; +import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils'; + +export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => { + const label = `decrypt(${id})`; + return withTimeGroupedLogs(label, async () => { + const parakeet = await timedLogger(`${label}/init`, fetchParakeet); + const blob = await timedLogger(`${label}/fetch-src`, async () => + fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()), + ); + const buffer = await timedLogger(`${label}/read-src`, async () => { + // Firefox: the range header does not work...? + const blobBuffer = await blob.arrayBuffer(); + if (blobBuffer.byteLength > 1024) { + return blobBuffer.slice(-1024); + } + return blobBuffer; + }); + const parsed = makeQMCv2FooterParser(parakeet).parse(buffer); + if (parsed.state === FooterParserState.OK) { + return parsed.mediaName; + } + return '# N/A'; + }); +}; diff --git a/src/dummy.mjs b/src/dummy.mjs index db2604f..bdd93cf 100644 --- a/src/dummy.mjs +++ b/src/dummy.mjs @@ -1,5 +1,5 @@ // This is a dummy module for vite/rollup to resolve. export function createRequire() { - import('immer'); // we need to import something, so vite don't complain on build + import('radash'); // we need to import something, so vite don't complain on build throw new Error('this is a dummy module. Do not use'); } diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index c60a1c5..81e5180 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -2,9 +2,9 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '~/store'; -import type { DecryptionResult } from '~/decrypt-worker/constants'; -import type { DecryptCommandOptions } from '~/decrypt-worker/types'; -import { decryptionQueue } from '~/decrypt-worker/client'; +import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants'; +import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types'; +import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector'; @@ -44,7 +44,7 @@ export interface FileListingState { displayMode: ListingMode; } const initialState: FileListingState = { - files: Object.create(null), + files: {}, displayMode: ListingMode.LIST, }; @@ -64,17 +64,27 @@ export const processFile = createAsyncThunk< thunkAPI.dispatch(setFileAsProcessing({ id: fileId })); }; - const fileHeader = await fetch(file.raw, { - headers: { - Range: 'bytes=0-1023', - }, - }) + const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } }) .then((r) => r.blob()) - .then((r) => r.arrayBuffer()); + .then((r) => r.arrayBuffer()) + .then((r) => { + if (r.byteLength > 1024) { + return r.slice(0, 1024); + } + return r; + }); + + const qmcv2MusicExMediaFile = await workerClientBus.request( + DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, + { + id: fileId, + blobURI: file.raw, + }, + ); const options: DecryptCommandOptions = { fileName: file.fileName, - qmc2Key: selectQMCv2KeyByFileName(state, file.fileName), + qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName), kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), qingTingAndroidKey: selectQtfmAndroidKey(state), }; diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx index 6b37517..8added4 100644 --- a/src/features/settings/panels/PanelQMCv2Key.tsx +++ b/src/features/settings/panels/PanelQMCv2Key.tsx @@ -61,7 +61,7 @@ export function PanelQMCv2Key() { alert(`不是支持的 SQLite 数据库文件。`); return; } - } else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) { + } else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) { const fileBuffer = await file.arrayBuffer(); const map = parseAndroidQmEKey(new DataView(fileBuffer)); qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey })); diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index 4aba7a9..17b548b 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -1,43 +1,44 @@ import { debounce } from 'radash'; -import { produce } from 'immer'; import type { AppStore } from '~/store'; import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice'; import { enumObject } from '~/util/objects'; import { getLogger } from '~/util/logUtils'; import { parseKwm2ProductionKey } from './keyFormats'; +import { deepClone } from '~/util/deepClone'; const DEFAULT_STORAGE_KEY = 'um-react-settings'; function mergeSettings(settings: ProductionSettings): ProductionSettings { - return produce(settingsSlice.getInitialState().production, (draft) => { - if (settings?.qmc2) { - const { allowFuzzyNameSearch, keys } = settings.qmc2; - for (const [k, v] of enumObject(keys)) { - if (typeof v === 'string') { - draft.qmc2.keys[k] = v; - } - } - - if (typeof allowFuzzyNameSearch === 'boolean') { - draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch; + const draft = deepClone(settingsSlice.getInitialState().production); + if (settings?.qmc2) { + const { allowFuzzyNameSearch, keys } = settings.qmc2; + for (const [k, v] of enumObject(keys)) { + if (typeof v === 'string') { + draft.qmc2.keys[k] = v; } } - if (settings?.kwm2) { - const { keys } = settings.kwm2; + if (typeof allowFuzzyNameSearch === 'boolean') { + draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch; + } + } - for (const [k, v] of enumObject(keys)) { - if (typeof v === 'string' && parseKwm2ProductionKey(k)) { - draft.kwm2.keys[k] = v; - } + 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; } } + } - if (typeof settings?.qtfm?.android === 'string') { - draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, ''); - } - }); + if (typeof settings?.qtfm?.android === 'string') { + draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, ''); + } + + return draft; } export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) { diff --git a/src/util/deepClone.ts b/src/util/deepClone.ts new file mode 100644 index 0000000..e8971ac --- /dev/null +++ b/src/util/deepClone.ts @@ -0,0 +1,3 @@ +export function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/vite.config.ts b/vite.config.ts index 939cd47..d5189fd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -98,7 +98,7 @@ export default defineConfig({ reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'], chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'], icons: ['react-icons', '@chakra-ui/icons'], - utility: ['radash', 'nanoid', 'immer', 'react-syntax-highlighter'], + utility: ['radash', 'nanoid', 'react-syntax-highlighter'], }, }, },