diff --git a/package.json b/package.json index 81e5da1..f554995 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@chakra-ui/react": "^2.8.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@jixun/libparakeet": "0.4.1", + "@jixun/libparakeet": "0.4.2", "@reduxjs/toolkit": "^1.9.7", "framer-motion": "^10.16.4", "immer": "^10.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fa5df2..c93d009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ dependencies: specifier: ^11.11.0 version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.28)(react@18.2.0) '@jixun/libparakeet': - specifier: 0.4.1 - version: 0.4.1 + specifier: 0.4.2 + version: 0.4.2 '@reduxjs/toolkit': specifier: ^1.9.7 version: 1.9.7(react-redux@8.1.3)(react@18.2.0) @@ -3080,8 +3080,8 @@ packages: '@sinclair/typebox': 0.27.8 dev: true - /@jixun/libparakeet@0.4.1: - resolution: {integrity: sha512-tPzeXLJDMZnlcB37IsxduCPsHQxI15mYFAo2q5msRO9AZjx8kSgB0XCsGnG1xjcAEUP4WaGbQ4xURRX3f5e7kA==} + /@jixun/libparakeet@0.4.2: + resolution: {integrity: sha512-E6XXrHeOOIexKSyWUgQwHUpZNMh5I1IoC9gbwyVQjJhBLiURy6KU7U2Fgsg62Q3BSkdi7NwbdbPERrygUFmA7Q==} dev: false /@jridgewell/gen-mapping@0.3.3: diff --git a/src/decrypt-worker/crypto/qtfm/qtfm_device.ts b/src/decrypt-worker/crypto/qtfm/qtfm_device.ts index 943b98a..51e04ef 100644 --- a/src/decrypt-worker/crypto/qtfm/qtfm_device.ts +++ b/src/decrypt-worker/crypto/qtfm/qtfm_device.ts @@ -7,16 +7,16 @@ export class QingTingFM$Device implements CryptoBase { checkByDecryptHeader = false; async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) { - return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidDevice); + return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidKey); } async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise { - const { fileName: name, qingTingAndroidDevice: qingTingDevice } = options; - if (!qingTingDevice) { - throw new Error('QingTingFM Device Info was not provided'); + const { fileName: name, qingTingAndroidKey } = options; + if (!qingTingAndroidKey) { + throw new Error('QingTingFM Android Device Key was not provided'); } - return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingDevice)); + return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingAndroidKey)); } public static make() { diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts index 88e82f6..114042e 100644 --- a/src/decrypt-worker/types.ts +++ b/src/decrypt-worker/types.ts @@ -1,10 +1,8 @@ -import type { QingTingDeviceInfo } from '@jixun/libparakeet'; - export interface DecryptCommandOptions { fileName: string; qmc2Key?: string; kwm2key?: string; - qingTingAndroidDevice?: QingTingDeviceInfo; + qingTingAndroidKey?: string; } export interface DecryptCommandPayload { diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index dfc2ebb..c60a1c5 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -6,7 +6,7 @@ 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 { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidDevice } from '../settings/settingsSelector'; +import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector'; export enum ProcessState { QUEUED = 'QUEUED', @@ -76,7 +76,7 @@ export const processFile = createAsyncThunk< fileName: file.fileName, qmc2Key: selectQMCv2KeyByFileName(state, file.fileName), kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), - qingTingAndroidDevice: selectQtfmAndroidDevice(state), + qingTingAndroidKey: selectQtfmAndroidKey(state), }; return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); }); diff --git a/src/features/settings/panels/PanelQingTing.tsx b/src/features/settings/panels/PanelQingTing.tsx index 1191fae..86d1a1d 100644 --- a/src/features/settings/panels/PanelQingTing.tsx +++ b/src/features/settings/panels/PanelQingTing.tsx @@ -1,43 +1,118 @@ -import { Box, Flex, Heading, Input, Table, Tbody, Td, Text, Th, Tr } from '@chakra-ui/react'; +import { + Box, + Button, + Code, + Flex, + FormControl, + FormHelperText, + FormLabel, + Heading, + Icon, + Input, + Text, +} from '@chakra-ui/react'; -import { qtfmAndroidUpdateKey } from '../settingsSlice'; import { useAppDispatch, useAppSelector } from '~/hooks'; -import { selectStagingQtfmAndroidDevice } from '../settingsSelector'; -import type { QingTingDeviceInfo } from '@jixun/libparakeet'; +import { fetchParakeet } from '@jixun/libparakeet'; +import { MdLock } from 'react-icons/md'; +import { ExtLink } from '~/components/ExtLink'; +import { ChangeEvent, ClipboardEvent } from 'react'; +import { VQuote } from '~/components/HelpText/VQuote'; +import { selectStagingQtfmAndroidKey } from '../settingsSelector'; +import { qtfmAndroidUpdateKey } from '../settingsSlice'; -const QTFM_ANDROID_DEVICE_PROPS = ['product', 'device', 'manufacturer', 'brand', 'board', 'model'] as const; +const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest'; export function PanelQingTing() { const dispatch = useAppDispatch(); - const qingTingAndroidDeviceInfo = useAppSelector(selectStagingQtfmAndroidDevice); - const handleChangeDeviceInfo = (name: keyof QingTingDeviceInfo) => (e: React.ChangeEvent) => { - dispatch(qtfmAndroidUpdateKey({ field: name, value: e.target.value })); + const secretKey = useAppSelector(selectStagingQtfmAndroidKey); + const setSecretKey = (secretKey: string) => { + dispatch(qtfmAndroidUpdateKey({ deviceKey: secretKey })); + }; + + const handleDataPaste = (e: ClipboardEvent) => { + const plainText = e.clipboardData.getData('text/plain'); + const matchDeviceSecret = plainText.match(/^DEVICE_SECRET: ([0-9a-fA-F]+)/m); + if (matchDeviceSecret) { + e.preventDefault(); + setSecretKey(matchDeviceSecret[1]); + return; + } + + const dataMap = new Map(); + for (const [_unused, key, value] of plainText.matchAll( + /^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim, + )) { + dataMap.set(key.toLowerCase(), value); + } + + const product = dataMap.get('product') ?? null; + const device = dataMap.get('device') ?? null; + const manufacturer = dataMap.get('manufacturer') ?? null; + const brand = dataMap.get('brand') ?? null; + const board = dataMap.get('board') ?? null; + const model = dataMap.get('model') ?? null; + if ( + product !== null && + device !== null && + manufacturer !== null && + brand !== null && + board !== null && + model !== null + ) { + e.preventDefault(); + fetchParakeet().then((parakeet) => { + setSecretKey(parakeet.qtfm.createDeviceKey(product, device, manufacturer, brand, board, model)); + }); + } + }; + + const handleDataInput = (e: ChangeEvent) => { + setSecretKey(e.target.value); }; return ( - 「蜻蜓 FM」设备信息 + 蜻蜓 FM + 设备密钥 - 「蜻蜓 FM」安卓版本需要获取设备信息用于生成解密密钥。 - - - - - {QTFM_ANDROID_DEVICE_PROPS.map((name) => ( - - - - - ))} - -
- {name} - - -
+ + 蜻蜓 FM安卓版本需要获取设备密钥,并以此来生成解密密钥。 + + + {/* TODO: 解密弹窗、带步骤说明 */} + + + + + + + 解密密钥 + + + {'粘贴含有密钥的信息时将自动提取密钥(如通过 '} + + qtfm-device-id + + {' 获取的内容)。'} + + + + + {/* TODO: 填入内部储存开始的完整路径 */} + + 注:蜻蜓 FM下载的文件储存在 QTDownloadRadio 目录下,并使用 + + . + + 开始的文件名。 + + 因为解密密钥与文件名相关,因此解密前请不要更改文件名。
); } diff --git a/src/features/settings/panels/QingTingFM/QingTingImport.ts b/src/features/settings/panels/QingTingFM/QingTingImport.ts new file mode 100644 index 0000000..abfdf1a --- /dev/null +++ b/src/features/settings/panels/QingTingFM/QingTingImport.ts @@ -0,0 +1 @@ +// TODO: Popup dialog for QingTing instructions diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index 4a97420..4aba7a9 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -2,11 +2,10 @@ import { debounce } from 'radash'; import { produce } from 'immer'; import type { AppStore } from '~/store'; -import { settingsSlice, setProductionChanges, ProductionSettings, QINGTING_DEVICE_INFO_KEY } from './settingsSlice'; +import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice'; import { enumObject } from '~/util/objects'; import { getLogger } from '~/util/logUtils'; import { parseKwm2ProductionKey } from './keyFormats'; -import type { QingTingDeviceInfo } from '@jixun/libparakeet'; const DEFAULT_STORAGE_KEY = 'um-react-settings'; @@ -35,14 +34,8 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings { } } - if (settings?.qtfm?.android) { - const qtfmAndroid = settings.qtfm.android; - draft.qtfm.android = Object.fromEntries( - QINGTING_DEVICE_INFO_KEY.map((key) => { - const value = qtfmAndroid[key]; - return [key, typeof value === 'string' ? value : '']; - }), - ) as unknown as QingTingDeviceInfo; + if (typeof settings?.qtfm?.android === 'string') { + draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, ''); } }); } diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index 51c1bc5..e61ecf8 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -48,5 +48,5 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string | return ekey; }; -export const selectStagingQtfmAndroidDevice = (state: RootState) => state.settings.staging.qtfm.android; -export const selectQtfmAndroidDevice = (state: RootState) => state.settings.production.qtfm.android; +export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android; +export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android; diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts index 99dae66..ce9325f 100644 --- a/src/features/settings/settingsSlice.ts +++ b/src/features/settings/settingsSlice.ts @@ -1,4 +1,3 @@ -import type { QingTingDeviceInfo } from '@jixun/libparakeet'; import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import { nanoid } from 'nanoid'; @@ -17,24 +16,6 @@ import { stagingKeyToProduction, } from './keyFormats'; -export const QINGTING_DEVICE_INFO_KEY: (keyof QingTingDeviceInfo)[] = [ - 'product', - 'device', - 'manufacturer', - 'brand', - 'board', - 'model', -]; - -const DEFAULT_QINGTING_ANDROID_DEVICE_INFO: QingTingDeviceInfo = { - product: '', - device: '', - manufacturer: '', - brand: '', - board: '', - model: '', -}; - export interface StagingSettings { qmc2: { keys: StagingQMCv2Key[]; @@ -44,7 +25,7 @@ export interface StagingSettings { keys: StagingKWMv2Key[]; }; qtfm: { - android: QingTingDeviceInfo; + android: string; }; } @@ -57,7 +38,7 @@ export interface ProductionSettings { keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey } }; qtfm: { - android: QingTingDeviceInfo; + android: string; }; } @@ -71,12 +52,12 @@ const initialState: SettingsState = { staging: { qmc2: { allowFuzzyNameSearch: true, keys: [] }, kwm2: { keys: [] }, - qtfm: { android: DEFAULT_QINGTING_ANDROID_DEVICE_INFO }, + qtfm: { android: '' }, }, production: { qmc2: { allowFuzzyNameSearch: true, keys: {} }, kwm2: { keys: {} }, - qtfm: { android: DEFAULT_QINGTING_ANDROID_DEVICE_INFO }, + qtfm: { android: '' }, }, }; @@ -171,11 +152,8 @@ export const settingsSlice = createSlice({ state.dirty = true; } }, - qtfmAndroidUpdateKey( - state, - { payload: { field, value } }: PayloadAction<{ field: keyof QingTingDeviceInfo; value: string }>, - ) { - state.staging.qtfm.android[field] = value; + qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) { + state.staging.qtfm.android = deviceKey; state.dirty = true; }, kwm2ClearKeys(state) {