#58 蜻蜓FM 安卓端支援 #59

Merged
lsr merged 10 commits from feat/qingting-fm into main 2023-12-22 10:35:18 +00:00
10 changed files with 126 additions and 81 deletions
Showing only changes of commit ae00a59c92 - Show all commits

View File

@ -21,7 +21,7 @@
"@chakra-ui/react": "^2.8.1", "@chakra-ui/react": "^2.8.1",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.4.1", "@jixun/libparakeet": "0.4.2",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^1.9.7",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"immer": "^10.0.3", "immer": "^10.0.3",

View File

@ -33,8 +33,8 @@ dependencies:
specifier: ^11.11.0 specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.28)(react@18.2.0) version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.28)(react@18.2.0)
'@jixun/libparakeet': '@jixun/libparakeet':
specifier: 0.4.1 specifier: 0.4.2
version: 0.4.1 version: 0.4.2
'@reduxjs/toolkit': '@reduxjs/toolkit':
specifier: ^1.9.7 specifier: ^1.9.7
version: 1.9.7(react-redux@8.1.3)(react@18.2.0) version: 1.9.7(react-redux@8.1.3)(react@18.2.0)
@ -3080,8 +3080,8 @@ packages:
'@sinclair/typebox': 0.27.8 '@sinclair/typebox': 0.27.8
dev: true dev: true
/@jixun/libparakeet@0.4.1: /@jixun/libparakeet@0.4.2:
resolution: {integrity: sha512-tPzeXLJDMZnlcB37IsxduCPsHQxI15mYFAo2q5msRO9AZjx8kSgB0XCsGnG1xjcAEUP4WaGbQ4xURRX3f5e7kA==} resolution: {integrity: sha512-E6XXrHeOOIexKSyWUgQwHUpZNMh5I1IoC9gbwyVQjJhBLiURy6KU7U2Fgsg62Q3BSkdi7NwbdbPERrygUFmA7Q==}
dev: false dev: false
/@jridgewell/gen-mapping@0.3.3: /@jridgewell/gen-mapping@0.3.3:

View File

@ -7,16 +7,16 @@ export class QingTingFM$Device implements CryptoBase {
checkByDecryptHeader = false; checkByDecryptHeader = false;
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) { 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<Blob> { async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
const { fileName: name, qingTingAndroidDevice: qingTingDevice } = options; const { fileName: name, qingTingAndroidKey } = options;
if (!qingTingDevice) { if (!qingTingAndroidKey) {
throw new Error('QingTingFM Device Info was not provided'); 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() { public static make() {

View File

@ -1,10 +1,8 @@
import type { QingTingDeviceInfo } from '@jixun/libparakeet';
export interface DecryptCommandOptions { export interface DecryptCommandOptions {
fileName: string; fileName: string;
qmc2Key?: string; qmc2Key?: string;
kwm2key?: string; kwm2key?: string;
qingTingAndroidDevice?: QingTingDeviceInfo; qingTingAndroidKey?: string;
} }
export interface DecryptCommandPayload { export interface DecryptCommandPayload {

View File

@ -6,7 +6,7 @@ import type { DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types'; import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { decryptionQueue } from '~/decrypt-worker/client'; import { decryptionQueue } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidDevice } from '../settings/settingsSelector'; import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
export enum ProcessState { export enum ProcessState {
QUEUED = 'QUEUED', QUEUED = 'QUEUED',
@ -76,7 +76,7 @@ export const processFile = createAsyncThunk<
fileName: file.fileName, fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName), qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
qingTingAndroidDevice: selectQtfmAndroidDevice(state), qingTingAndroidKey: selectQtfmAndroidKey(state),
}; };
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
}); });

View File

@ -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 { useAppDispatch, useAppSelector } from '~/hooks';
import { selectStagingQtfmAndroidDevice } from '../settingsSelector'; import { fetchParakeet } from '@jixun/libparakeet';
import type { QingTingDeviceInfo } 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() { export function PanelQingTing() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const qingTingAndroidDeviceInfo = useAppSelector(selectStagingQtfmAndroidDevice); const secretKey = useAppSelector(selectStagingQtfmAndroidKey);
const handleChangeDeviceInfo = (name: keyof QingTingDeviceInfo) => (e: React.ChangeEvent<HTMLInputElement>) => { const setSecretKey = (secretKey: string) => {
dispatch(qtfmAndroidUpdateKey({ field: name, value: e.target.value })); dispatch(qtfmAndroidUpdateKey({ deviceKey: secretKey }));
};
const handleDataPaste = (e: ClipboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setSecretKey(e.target.value);
}; };
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">
FM <VQuote> FM</VQuote>
</Heading> </Heading>
<Text> FM</Text> <Text>
<VQuote> FM</VQuote>
<Box flex={1} minH={0} overflow="auto" pr="4"> </Text>
<Table> <Box display="none">
<Tbody> {/* TODO: 解密弹窗、带步骤说明 */}
{QTFM_ANDROID_DEVICE_PROPS.map((name) => ( <Box p={2} pt={4} pb={4}>
<Tr key={name}> <Button onClick={() => {}} leftIcon={<Icon as={MdLock} boxSize={5} />} variant="outline">
<Th w="1px" p={1} textAlign="right">
{name} </Button>
</Th> </Box>
<Td pl={1} pr={1}>
<Input value={qingTingAndroidDeviceInfo[name]} onChange={handleChangeDeviceInfo(name)} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box> </Box>
<Box mt={3} mb={3}>
<FormControl>
<FormLabel></FormLabel>
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} />
<FormHelperText>
{'粘贴含有密钥的信息时将自动提取密钥(如通过 '}
<ExtLink href={QTFM_DEVICE_ID_URL}>
<Code>qtfm-device-id</Code>
</ExtLink>
{' 获取的内容)。'}
</FormHelperText>
</FormControl>
</Box>
{/* TODO: 填入内部储存开始的完整路径 */}
<Text>
<VQuote> FM</VQuote> <Code>QTDownloadRadio</Code> 使
<VQuote>
<Code>.</Code>
</VQuote>
</Text>
<Text></Text>
</Flex> </Flex>
); );
} }

View File

@ -0,0 +1 @@
// TODO: Popup dialog for QingTing instructions

View File

@ -2,11 +2,10 @@ import { debounce } from 'radash';
import { produce } from 'immer'; import { produce } from 'immer';
import type { AppStore } from '~/store'; 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 { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils'; import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats'; import { parseKwm2ProductionKey } from './keyFormats';
import type { QingTingDeviceInfo } from '@jixun/libparakeet';
const DEFAULT_STORAGE_KEY = 'um-react-settings'; const DEFAULT_STORAGE_KEY = 'um-react-settings';
@ -35,14 +34,8 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
} }
} }
if (settings?.qtfm?.android) { if (typeof settings?.qtfm?.android === 'string') {
const qtfmAndroid = settings.qtfm.android; draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
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;
} }
}); });
} }

View File

@ -48,5 +48,5 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string |
return ekey; return ekey;
}; };
export const selectStagingQtfmAndroidDevice = (state: RootState) => state.settings.staging.qtfm.android; export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
export const selectQtfmAndroidDevice = (state: RootState) => state.settings.production.qtfm.android; export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;

View File

@ -1,4 +1,3 @@
import type { QingTingDeviceInfo } from '@jixun/libparakeet';
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';
@ -17,24 +16,6 @@ import {
stagingKeyToProduction, stagingKeyToProduction,
} from './keyFormats'; } 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 { export interface StagingSettings {
qmc2: { qmc2: {
keys: StagingQMCv2Key[]; keys: StagingQMCv2Key[];
@ -44,7 +25,7 @@ export interface StagingSettings {
keys: StagingKWMv2Key[]; keys: StagingKWMv2Key[];
}; };
qtfm: { qtfm: {
android: QingTingDeviceInfo; android: string;
}; };
} }
@ -57,7 +38,7 @@ export interface ProductionSettings {
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey } keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
}; };
qtfm: { qtfm: {
android: QingTingDeviceInfo; android: string;
}; };
} }
@ -71,12 +52,12 @@ const initialState: SettingsState = {
staging: { staging: {
qmc2: { allowFuzzyNameSearch: true, keys: [] }, qmc2: { allowFuzzyNameSearch: true, keys: [] },
kwm2: { keys: [] }, kwm2: { keys: [] },
qtfm: { android: DEFAULT_QINGTING_ANDROID_DEVICE_INFO }, qtfm: { android: '' },
}, },
production: { production: {
qmc2: { allowFuzzyNameSearch: true, keys: {} }, qmc2: { allowFuzzyNameSearch: true, keys: {} },
kwm2: { keys: {} }, kwm2: { keys: {} },
qtfm: { android: DEFAULT_QINGTING_ANDROID_DEVICE_INFO }, qtfm: { android: '' },
}, },
}; };
@ -171,11 +152,8 @@ export const settingsSlice = createSlice({
state.dirty = true; state.dirty = true;
} }
}, },
qtfmAndroidUpdateKey( qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
state, state.staging.qtfm.android = deviceKey;
{ payload: { field, value } }: PayloadAction<{ field: keyof QingTingDeviceInfo; value: string }>,
) {
state.staging.qtfm.android[field] = value;
state.dirty = true; state.dirty = true;
}, },
kwm2ClearKeys(state) { kwm2ClearKeys(state) {