Merge pull request '添加设定界面 - #18' (#21) from feat/settings into main

Reviewed-on: #21
This commit is contained in:
鲁树人 2023-06-10 15:23:20 +00:00
commit f8e558e213
39 changed files with 3056 additions and 2948 deletions

View File

@ -1,2 +1,5 @@
dist/ dist/
# Package manager
yarn.lock
pnpm-lock.yaml

View File

@ -16,17 +16,21 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/anatomy": "^2.1.1",
"@chakra-ui/icons": "^2.0.19", "@chakra-ui/icons": "^2.0.19",
"@chakra-ui/react": "^2.6.1", "@chakra-ui/react": "^2.7.0",
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.1.1", "@jixun/libparakeet": "0.1.2",
"@reduxjs/toolkit": "^1.9.5", "@reduxjs/toolkit": "^1.9.5",
"framer-motion": "^10.12.16", "framer-motion": "^10.12.16",
"immer": "^10.0.2",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"radash": "^10.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-icons": "^4.9.0",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-redux": "^8.0.5" "react-redux": "^8.0.5"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ publish_gitea() {
} }
# Only publish main branch by default # Only publish main branch by default
if [[ "${BRANCH_NAME}" = "main" ]]; then if [[ "${BRANCH_NAME}" = "main" && -z "$DRONE_PULL_REQUEST" ]]; then
echo 'prepare to publish...' echo 'prepare to publish...'
if [[ -n "${GITEA_API_KEY}" ]]; then if [[ -n "${GITEA_API_KEY}" ]]; then

View File

@ -1,23 +0,0 @@
import { Box, Center, Container } from '@chakra-ui/react';
import { SelectFile } from './SelectFile';
import { FileListing } from './features/file-listing/FileListing';
import { Footer } from './Footer';
function App() {
return (
<Box height="full" width="full" pt="4">
<Container maxW="container.large">
<Center>
<SelectFile />
</Center>
<Box mt="8">
<FileListing />
</Box>
<Footer />
</Container>
</Box>
);
}
export default App;

View File

@ -1,43 +0,0 @@
import { Center, Flex, Link, Text } from '@chakra-ui/react';
import { Suspense } from 'react';
import { SDKVersion } from './SDKVersion';
import { CurrentYear } from './CurrentYear';
export function Footer() {
return (
<Center height="footer.container">
<Center
height="footer.content"
fontSize="sm"
textAlign="center"
position="fixed"
bottom="0"
w="full"
bg="gray.100"
color="gray.800"
left="0"
flexDir="column"
>
<Flex as={Text}>
{'音乐解锁 (__APP_VERSION_SHORT__'}
<Suspense>
<SDKVersion />
</Suspense>
{') - 移除已购音乐的加密保护。'}
</Flex>
<Text>
{'Copyright © 2019 - '}
<CurrentYear />{' '}
<Link href="https://git.unlock-music.dev/um" isExternal>
UnlockMusic
</Link>
{' | 音乐解锁授权基于'}
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
MIT许可协议
</Link>
</Text>
</Center>
</Center>
);
}

View File

@ -1,5 +1,5 @@
import { renderWithProviders, screen, waitFor } from '~/test-utils/test-helper'; import { renderWithProviders, screen, waitFor } from '~/test-utils/test-helper';
import App from '~/App'; import { AppRoot } from '~/components/AppRoot';
vi.mock('../decrypt-worker/client', () => { vi.mock('../decrypt-worker/client', () => {
return { return {
@ -10,7 +10,7 @@ vi.mock('../decrypt-worker/client', () => {
}); });
test('should be able to render App', async () => { test('should be able to render App', async () => {
renderWithProviders(<App />); renderWithProviders(<AppRoot />);
// Should eventually load sdk version // Should eventually load sdk version
await waitFor(() => screen.getByTestId('sdk-version')); await waitFor(() => screen.getByTestId('sdk-version'));

View File

@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { MdSettings, MdHome } from 'react-icons/md';
import { ChakraProvider, Tabs, TabList, TabPanels, Tab, TabPanel, Icon, chakra } from '@chakra-ui/react';
import { MainTab } from '~/tabs/MainTab';
import { SettingsTab } from '~/tabs/SettingsTab';
import { Provider } from 'react-redux';
import { theme } from '~/theme';
import { persistSettings } from '~/features/settings/persistSettings';
import { setupStore } from '~/store';
import { Footer } from '~/components/Footer';
// Private to this file only.
const store = setupStore();
export function AppRoot() {
useEffect(() => persistSettings(store), []);
return (
<ChakraProvider theme={theme}>
<Provider store={store}>
<Tabs flex={1} minH={0} display="flex" flexDir="column">
<TabList justifyContent="center">
<Tab>
<Icon as={MdHome} mr="1" />
<chakra.span></chakra.span>
</Tab>
<Tab>
<Icon as={MdSettings} mr="1" />
<chakra.span></chakra.span>
</Tab>
</TabList>
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex">
<TabPanel>
<MainTab />
</TabPanel>
<TabPanel flex={1} display="flex">
<SettingsTab />
</TabPanel>
</TabPanels>
</Tabs>
<Footer />
</Provider>
</ChakraProvider>
);
}

43
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,43 @@
import { Center, Flex, Link, Text } from '@chakra-ui/react';
import { Suspense } from 'react';
import { SDKVersion } from './SDKVersion';
import { CurrentYear } from './CurrentYear';
export function Footer() {
return (
<Center
fontSize="sm"
textAlign="center"
bottom="0"
w="full"
pt="3"
pb="3"
borderTop="1px solid"
borderColor="gray.300"
bg="gray.100"
color="gray.800"
flexDir="column"
flexShrink={0}
>
<Flex as={Text}>
{'音乐解锁 (__APP_VERSION_SHORT__'}
<Suspense>
<SDKVersion />
</Suspense>
{') - 移除已购音乐的加密保护。'}
</Flex>
<Text>
{'Copyright © 2019 - '}
<CurrentYear />{' '}
<Link href="https://git.unlock-music.dev/um" isExternal>
UnlockMusic
</Link>
{' | 音乐解锁授权基于'}
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
MIT许可协议
</Link>
</Text>
</Center>
);
}

View File

@ -2,8 +2,8 @@ import { useDropzone } from 'react-dropzone';
import { Box, Text } from '@chakra-ui/react'; import { Box, Text } from '@chakra-ui/react';
import { UnlockIcon } from '@chakra-ui/icons'; import { UnlockIcon } from '@chakra-ui/icons';
import { useAppDispatch } from './hooks'; import { useAppDispatch } from '~/hooks';
import { addNewFile, processFile } from './features/file-listing/fileListingSlice'; import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
export function SelectFile() { export function SelectFile() {

View File

@ -1,3 +1,5 @@
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
export interface CryptoBase { export interface CryptoBase {
cryptoName: string; cryptoName: string;
checkByDecryptHeader: boolean; checkByDecryptHeader: boolean;
@ -8,8 +10,8 @@ export interface CryptoBase {
*/ */
overrideExtension?: string; overrideExtension?: string;
checkBySignature?: (buffer: ArrayBuffer) => Promise<boolean>; checkBySignature?: (buffer: ArrayBuffer, options: DecryptCommandOptions) => Promise<boolean>;
decrypt(buffer: ArrayBuffer, blob: Blob): Promise<Blob | ArrayBuffer>; decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob | ArrayBuffer>;
} }
export type CryptoFactory = () => CryptoBase; export type CryptoFactory = () => CryptoBase;

View File

@ -1,7 +1,7 @@
import { CryptoFactory } from './CryptoBase'; import { CryptoFactory } from './CryptoBase';
import { QMC1Crypto } from './qmc/qmc_v1'; import { QMC1Crypto } from './qmc/qmc_v1';
import { QMC2Crypto } from './qmc/qmc_v2'; import { QMC2Crypto, QMC2CryptoWithKey } from './qmc/qmc_v2';
import { XiamiCrypto } from './xiami/xiami'; import { XiamiCrypto } from './xiami/xiami';
import { KGMCrypto } from './kgm/kgm_pc'; import { KGMCrypto } from './kgm/kgm_pc';
import { NCMCrypto } from './ncm/ncm_pc'; import { NCMCrypto } from './ncm/ncm_pc';
@ -14,6 +14,7 @@ export const allCryptoFactories: CryptoFactory[] = [
XiamiCrypto.make, XiamiCrypto.make,
// QMCv2 (*.mflac) // QMCv2 (*.mflac)
QMC2CryptoWithKey.make,
QMC2Crypto.make, QMC2Crypto.make,
// NCM (*.ncm) // NCM (*.ncm)

View File

@ -1,16 +1,51 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob'; import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase'; 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 { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
import { fetchParakeet } from '@jixun/libparakeet';
export class QMC2Crypto implements CryptoBase { export class QMC2Crypto implements CryptoBase {
cryptoName = 'QMC/v2'; cryptoName = 'QMC/v2';
checkByDecryptHeader = false; checkByDecryptHeader = false;
async decrypt(buffer: ArrayBuffer): Promise<Blob> { async decrypt(buffer: ArrayBuffer): Promise<Blob> {
return transformBlob(buffer, (p) => p.make.QMCv2(p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2))); const parakeet = await fetchParakeet();
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
parakeet,
cleanup: () => footerParser.delete(),
});
} }
public static make() { public static make() {
return new QMC2Crypto(); return new QMC2Crypto();
} }
} }
export class QMC2CryptoWithKey implements CryptoBase {
cryptoName = 'QMC/v2 (key)';
checkByDecryptHeader = true;
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<boolean> {
return Boolean(options.qmc2Key);
}
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
if (!options.qmc2Key) {
throw new Error('key was not provided');
}
const parakeet = await fetchParakeet();
const textEncoder = new TextEncoder();
const key = textEncoder.encode(options.qmc2Key);
const keyCrypto = parakeet.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
parakeet,
cleanup: () => keyCrypto.delete(),
});
}
public static make() {
return new QMC2CryptoWithKey();
}
}

View File

@ -0,0 +1,9 @@
export interface DecryptCommandOptions {
qmc2Key?: string;
}
export interface DecryptCommandPayload {
id: string;
blobURI: string;
options: DecryptCommandOptions;
}

View File

@ -5,21 +5,24 @@ import { UnsupportedSourceFile } from './DecryptError';
export async function transformBlob( export async function transformBlob(
blob: Blob | ArrayBuffer, blob: Blob | ArrayBuffer,
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>, transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
parakeet?: Parakeet { cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {}
) { ) {
const cleanup: (() => void)[] = []; const registeredCleanupFns: (() => void)[] = [];
if (cleanup) {
registeredCleanupFns.push(cleanup);
}
try { try {
const mod = parakeet ?? (await fetchParakeet()); const mod = parakeet ?? (await fetchParakeet());
const transformer = await transformerFactory(mod); const transformer = await transformerFactory(mod);
cleanup.push(() => transformer.delete()); registeredCleanupFns.push(() => transformer.delete());
const reader = mod.make.Reader(await toArrayBuffer(blob)); const reader = mod.make.Reader(await toArrayBuffer(blob));
cleanup.push(() => reader.delete()); registeredCleanupFns.push(() => reader.delete());
const sink = mod.make.WriterSink(); const sink = mod.make.WriterSink();
const writer = sink.getWriter(); const writer = sink.getWriter();
cleanup.push(() => writer.delete()); registeredCleanupFns.push(() => writer.delete());
const result = transformer.Transform(writer, reader); const result = transformer.Transform(writer, reader);
if (result === TransformResult.ERROR_INVALID_FORMAT) { if (result === TransformResult.ERROR_INVALID_FORMAT) {
@ -30,6 +33,6 @@ export async function transformBlob(
return sink.collectBlob(); return sink.collectBlob();
} finally { } finally {
cleanup.forEach((clean) => clean()); registeredCleanupFns.forEach((cleanup) => cleanup());
} }
} }

View File

@ -1,5 +1,6 @@
import { Parakeet, fetchParakeet } from '@jixun/libparakeet'; import { Parakeet, fetchParakeet } from '@jixun/libparakeet';
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils'; import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
import { allCryptoFactories } from '../../crypto/CryptoFactory'; import { allCryptoFactories } from '../../crypto/CryptoFactory';
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer'; import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase'; import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
@ -11,8 +12,13 @@ const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
class DecryptCommandHandler { class DecryptCommandHandler {
private label: string; private label: string;
constructor(label: string, private parakeet: Parakeet, private blob: Blob, private buffer: ArrayBuffer) { constructor(
this.label = `DecryptCommandHandler( ${label} )`; label: string,
private parakeet: Parakeet,
private buffer: ArrayBuffer,
private options: DecryptCommandOptions
) {
this.label = `DecryptCommandHandler(${label})`;
} }
log<R>(label: string, fn: () => R): R { log<R>(label: string, fn: () => R): R {
@ -42,7 +48,7 @@ class DecryptCommandHandler {
} }
async decryptFile(crypto: CryptoBase) { async decryptFile(crypto: CryptoBase) {
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer))) { if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
return null; return null;
} }
@ -50,7 +56,7 @@ class DecryptCommandHandler {
return null; return null;
} }
const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.blob)); const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.options));
// Check if we had a successful decryption // Check if we had a successful decryption
const audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted)); const audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
@ -76,23 +82,21 @@ class DecryptCommandHandler {
// Check by decrypt max first 8MiB // Check by decrypt max first 8MiB
const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () => const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () =>
toArrayBuffer( toArrayBuffer(await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.options))
await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.blob.slice(0, TEST_FILE_HEADER_LEN))
)
); );
return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin'; return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin';
} }
} }
export const workerDecryptHandler = async ({ id, blobURI }: { id: string; blobURI: string }) => { export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
const label = `decrypt( ${id} )`; const label = `decrypt(${id})`;
return withTimeGroupedLogs(label, async () => { return withTimeGroupedLogs(label, async () => {
const parakeet = await timedLogger(`${label}/init`, fetchParakeet); const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
const blob = await timedLogger(`${label}/fetch-src`, async () => fetch(blobURI).then((r) => r.blob())); const blob = await timedLogger(`${label}/fetch-src`, async () => fetch(blobURI).then((r) => r.blob()));
const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer()); const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer());
const handler = new DecryptCommandHandler(id, parakeet, blob, buffer); const handler = new DecryptCommandHandler(id, parakeet, buffer, options);
return handler.decrypt(allCryptoFactories); return handler.decrypt(allCryptoFactories);
}); });
}; };

View File

@ -1 +1,2 @@
// This is a dummy module for vite/rollup to resolve. // This is a dummy module for vite/rollup to resolve.
Object.defineProperty(Object.create(null), { sideEffects: true });

View File

@ -1,7 +1,7 @@
import { VStack } from '@chakra-ui/react'; import { VStack } from '@chakra-ui/react';
import { selectFiles } from './fileListingSlice'; import { selectFiles } from './fileListingSlice';
import { useAppSelector } from '../../hooks'; import { useAppSelector } from '~/hooks';
import { FileRow } from './FileRow'; import { FileRow } from './FileRow';
export function FileListing() { export function FileListing() {

View File

@ -5,6 +5,7 @@ import { decryptionQueue } from '~/decrypt-worker/client';
import type { DecryptionResult } from '~/decrypt-worker/constants'; import type { DecryptionResult } from '~/decrypt-worker/constants';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectDecryptOptionByFile } from '../settings/settingsSelector';
export enum ProcessState { export enum ProcessState {
QUEUED = 'QUEUED', QUEUED = 'QUEUED',
@ -51,7 +52,8 @@ export const processFile = createAsyncThunk<
{ fileId: string }, { fileId: string },
{ rejectValue: { message: string; stack?: string } } { rejectValue: { message: string; stack?: string } }
>('fileListing/processFile', async ({ fileId }, thunkAPI) => { >('fileListing/processFile', async ({ fileId }, thunkAPI) => {
const file = selectFiles(thunkAPI.getState() as RootState)[fileId]; const state = thunkAPI.getState() as RootState;
const file = selectFiles(state)[fileId];
if (!file) { if (!file) {
const { message, stack } = new Error('ERROR: File not found'); const { message, stack } = new Error('ERROR: File not found');
return thunkAPI.rejectWithValue({ message, stack }); return thunkAPI.rejectWithValue({ message, stack });
@ -61,7 +63,8 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId })); thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
}; };
return decryptionQueue.add({ id: fileId, blobURI: file.raw }, onPreProcess); const options = selectDecryptOptionByFile(state, file.fileName);
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
}); });
export const fileListingSlice = createSlice({ export const fileListingSlice = createSlice({

View File

@ -0,0 +1,120 @@
import {
Box,
Button,
Center,
Flex,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Spacer,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Text,
VStack,
useBreakpointValue,
} from '@chakra-ui/react';
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
import { useState } from 'react';
import { MdExpandMore, MdMenu } from 'react-icons/md';
import { useAppDispatch } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{
name: '其它/待定',
Tab: () => <Text></Text>,
},
];
export function Settings() {
const dispatch = useAppDispatch();
const isLargeWidthDevice =
useBreakpointValue({
base: false,
lg: true,
}) ?? false;
const [tabIndex, setTabIndex] = useState(0);
const handleTabChange = (idx: number) => {
setTabIndex(idx);
};
const handleResetSettings = () => dispatch(discardStagingChanges());
const handleApplySettings = () => dispatch(commitStagingChange());
return (
<Flex flexDir="column" flex={1}>
<Menu>
<MenuButton
as={Button}
leftIcon={<MdMenu />}
rightIcon={<MdExpandMore />}
colorScheme="gray"
variant="outline"
w="full"
flexShrink={0}
hidden={isLargeWidthDevice}
mb="4"
>
{TABS[tabIndex].name}
</MenuButton>
<Portal>
<MenuList w="100px">
{TABS.map(({ name }, i) => (
<MenuItem key={name} onClick={() => setTabIndex(i)}>
{name}
</MenuItem>
))}
</MenuList>
</Portal>
</Menu>
<Tabs
orientation={isLargeWidthDevice ? 'vertical' : 'horizontal'}
align="start"
variant="line-i"
display="flex"
flex={1}
index={tabIndex}
onChange={handleTabChange}
>
<TabList hidden={!isLargeWidthDevice} minW="8em" width="8em" textAlign="right" justifyContent="center">
{TABS.map(({ name }) => (
<Tab key={name}>{name}</Tab>
))}
</TabList>
<TabPanels>
{TABS.map(({ name, Tab }) => (
<Flex as={TabPanel} flex={1} flexDir="column" h="100%" key={name}>
<Flex h="100%" flex={1} minH={0}>
<Tab />
</Flex>
<VStack mt="4" alignItems="flex-start" w="full">
<Flex flexDir="row" gap="2" w="full">
<Center>
<Box color="gray"></Box>
</Center>
<Spacer />
<HStack gap="2" justifyContent="flex-end">
<Button onClick={handleResetSettings} colorScheme="red" variant="ghost" title="还原为更改前的状态">
</Button>
<Button onClick={handleApplySettings}></Button>
</HStack>
</Flex>
</VStack>
</Flex>
))}
</TabPanels>
</Tabs>
</Flex>
);
}

View File

@ -0,0 +1,116 @@
import {
Box,
Button,
ButtonGroup,
Flex,
HStack,
Heading,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
List,
ListItem,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Text,
VStack,
} from '@chakra-ui/react';
import { useDispatch, useSelector } from 'react-redux';
import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector';
import React from 'react';
import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
export function PanelQMCv2Key() {
const dispatch = useDispatch();
const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys;
const addKey = () => dispatch(qmc2AddKey());
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
const deleteKey = (id: string) => dispatch(qmc2DeleteKey({ id }));
const clearAll = () => dispatch(qmc2ClearKeys());
return (
<Flex minH={0} flexDir="column" flex={1}>
<Heading as="h2" size="lg">
QMCv2
</Heading>
<Text>QQ QMCv2 Mac </Text>
<Box pb={2} pt={2}>
<ButtonGroup isAttached variant="outline">
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
</Button>
<Menu>
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
<MenuList>
{/* 目前的想法是弹出一个 modal给用户一些信息如期待的格式、如何导出或寻找对应的文件 */}
{/* 但是这样的话就不太方便放在这个分支里面做了,下次一定。 */}
<MenuItem hidden onClick={() => alert('TODO!')} icon={<Icon as={MdFileUpload} boxSize={5} />}>
</MenuItem>
<MenuDivider hidden />
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
</MenuItem>
</MenuList>
</Menu>
</ButtonGroup>
</Box>
<Box flex={1} minH={0} overflow="auto" pr="4">
<List spacing={3}>
{qmc2Keys.map(({ id, key, name }, i) => (
<ListItem key={id} mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
<HStack>
<Text w="2em" textAlign="center">
{i + 1}
</Text>
<VStack flex={1}>
<Input
variant="flushed"
placeholder="文件名"
value={name}
onChange={(e) => updateKey('name', id, e)}
/>
<InputGroup size="xs">
<InputLeftElement pr="2">
<Icon as={MdVpnKey} />
</InputLeftElement>
<Input variant="flushed" placeholder="密钥" value={key} onChange={(e) => updateKey('key', id, e)} />
<InputRightElement>
<Text pl="2" color={key.length ? 'green.500' : 'red.500'}>
<code>{key.length || '?'}</code>
</Text>
</InputRightElement>
</InputGroup>
</VStack>
<IconButton
aria-label="删除该密钥"
icon={<Icon as={MdDelete} boxSize={6} />}
variant="ghost"
colorScheme="red"
type="button"
onClick={() => deleteKey(id)}
/>
</HStack>
</ListItem>
))}
</List>
{qmc2Keys.length === 0 && <Text></Text>}
</Box>
</Flex>
);
}

View File

@ -0,0 +1,45 @@
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';
const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeSettings(settings: ProductionSettings): ProductionSettings {
return produce(settingsSlice.getInitialState().production, (draft) => {
for (const [k, v] of enumObject(settings.qmc2?.keys)) {
if (typeof v === 'string') {
draft.qmc2.keys[k] = v;
}
}
});
}
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {
let lastSettings: unknown;
try {
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? '');
if (loadedSettings) {
const mergedSettings = mergeSettings(loadedSettings);
store.dispatch(setProductionChanges(mergedSettings));
getLogger().debug('settings loaded');
}
} catch {
// load failed, ignore.
}
return store.subscribe(
debounce({ delay: 150 }, () => {
const currentSettings = store.getState().settings.production;
if (lastSettings !== currentSettings) {
lastSettings = currentSettings;
localStorage.setItem(storageKey, JSON.stringify(currentSettings));
getLogger().debug('settings saved');
}
})
);
}

View File

@ -0,0 +1,14 @@
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import type { RootState } from '~/store';
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 selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
const qmc2Keys = selectFinalQMCv2Settings(state).keys;
return {
qmc2Key: hasOwn(qmc2Keys, name) ? qmc2Keys[name] : undefined,
};
};

View File

@ -0,0 +1,110 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { objectify } from 'radash';
export interface StagingSettings {
qmc2: {
keys: { id: string; name: string; key: string }[];
};
}
export interface ProductionSettings {
qmc2: {
keys: Record<string, string>; // { [fileName]: ekey }
};
}
export interface SettingsState {
staging: StagingSettings;
production: ProductionSettings;
}
const initialState: SettingsState = {
staging: {
qmc2: {
keys: [],
},
},
production: {
qmc2: {
keys: {},
},
},
};
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
qmc2: {
keys: objectify(
staging.qmc2.keys,
(item) => item.name,
(item) => item.key
),
},
});
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
qmc2: {
keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })),
},
});
export const settingsSlice = createSlice({
name: 'settings',
initialState,
reducers: {
setProductionChanges: (_state, { payload }: PayloadAction<ProductionSettings>) => {
return {
production: payload,
staging: productionToStaging(payload),
};
},
qmc2AddKey(state) {
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
},
qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
const qmc2 = state.staging.qmc2;
qmc2.keys = qmc2.keys.filter((item) => item.id !== id);
},
qmc2UpdateKey(
state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: 'name' | 'key'; value: string }>
) {
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
if (keyItem) {
keyItem[field] = value;
}
},
qmc2ClearKeys(state) {
state.staging.qmc2.keys = [];
},
discardStagingChanges: (state) => {
state.staging = productionToStaging(state.production);
},
commitStagingChange: (state) => {
const production = stagingToProduction(state.staging);
return {
// Sync back to staging
staging: productionToStaging(production),
production,
};
},
resetConfig: () => {
return initialState;
},
},
});
export const {
setProductionChanges,
resetConfig,
qmc2AddKey,
qmc2UpdateKey,
qmc2DeleteKey,
qmc2ClearKeys,
commitStagingChange,
discardStagingChanges,
} = settingsSlice.actions;
export default settingsSlice.reducer;

View File

@ -1,21 +1,10 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App';
import { ChakraProvider } from '@chakra-ui/react'; import { AppRoot } from './components/AppRoot';
import { Provider } from 'react-redux';
import { setupStore } from './store';
import { theme } from './theme';
// Private to this file only.
const store = setupStore();
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<ChakraProvider theme={theme}> <AppRoot />
<Provider store={store}>
<App />
</Provider>
</ChakraProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -1,8 +1,10 @@
import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit'; import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit';
import fileListingReducer from './features/file-listing/fileListingSlice'; import fileListingReducer from './features/file-listing/fileListingSlice';
import settingsReducer from './features/settings/settingsSlice';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
fileListing: fileListingReducer, fileListing: fileListingReducer,
settings: settingsReducer,
}); });
export const setupStore = (preloadedState?: PreloadedState<RootState>) => export const setupStore = (preloadedState?: PreloadedState<RootState>) =>

18
src/tabs/MainTab.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Box, VStack } from '@chakra-ui/react';
import { SelectFile } from '../components/SelectFile';
import { FileListing } from '~/features/file-listing/FileListing';
export function MainTab() {
return (
<Box h="full" w="full" pt="4">
<VStack gap="3">
<SelectFile />
<Box w="full">
<FileListing />
</Box>
</VStack>
</Box>
);
}

15
src/tabs/SettingsTab.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Container, Flex, useBreakpointValue } from '@chakra-ui/react';
import { Settings } from '~/features/settings/Settings';
export function SettingsTab() {
const containerProps = useBreakpointValue({
base: { p: '0' },
lg: { p: undefined },
});
return (
<Container as={Flex} maxW="container.lg" {...containerProps}>
<Settings />
</Container>
);
}

View File

@ -15,3 +15,17 @@ if (!global.Worker) {
} }
}; };
} }
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});

View File

@ -1,17 +1,50 @@
import { extendTheme } from '@chakra-ui/react'; import { extendTheme } from '@chakra-ui/react';
import { tabsTheme } from './themes/Tabs';
export const theme = extendTheme({ export const theme = extendTheme({
fonts: {
body: [
'-system-ui,-apple-system,BlinkMacSystemFont',
'Source Han Sans CN,Noto Sans CJK SC',
'Segoe UI,Helvetica,Arial,sans-serif',
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
].join(','),
},
components: {
Button: {
baseStyle: {
fontWeight: 'normal',
},
defaultProps: {
colorScheme: 'teal',
},
},
Tabs: tabsTheme,
Heading: {
baseStyle: {
userSelect: 'none',
},
},
Text: {
baseStyle: {
userSelect: 'none',
},
},
},
styles: { styles: {
global: { global: {
body: { '#root': {
minHeight: '100vh', minHeight: '100vh',
maxHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}, },
}, },
}, },
sizes: { sizes: {
footer: { footer: {
container: '7rem', container: '5rem',
content: '5rem', content: '4rem',
}, },
}, },
}); });

74
src/themes/Tabs.tsx Normal file
View File

@ -0,0 +1,74 @@
import { tabsAnatomy } from '@chakra-ui/anatomy';
import { createMultiStyleConfigHelpers, cssVar } from '@chakra-ui/react';
const $fg = cssVar('tabs-color');
const $bg = cssVar('tabs-bg');
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(tabsAnatomy.keys);
const variantLineInvert = definePartsStyle((props) => {
const { colorScheme: c, orientation } = props;
const isVertical = orientation === 'vertical';
const borderProp = isVertical ? 'borderEnd' : 'borderTop';
const marginProp = isVertical ? 'marginEnd' : 'marginTop';
return {
tablist: {
[borderProp]: '2px solid',
borderColor: 'inherit',
},
tabpanels: {
flex: 1,
minH: 0,
},
tabpanel: {
padding: 0,
},
tab: {
[borderProp]: '2px solid',
borderColor: 'transparent',
[marginProp]: '-2px',
justifyContent: 'flex-end',
_selected: {
[$fg.variable]: `colors.${c}.600`,
_dark: {
[$fg.variable]: `colors.${c}.300`,
},
borderColor: 'currentColor',
},
_active: {
[$bg.variable]: 'colors.gray.200',
_dark: {
[$bg.variable]: 'colors.whiteAlpha.300',
},
},
_disabled: {
_active: { bg: 'none' },
},
color: $fg.reference,
bg: $bg.reference,
},
root: {
display: 'flex',
flexDir: isVertical ? 'row' : 'column',
gap: 8,
minH: 0,
},
};
});
export const tabsTheme = defineMultiStyleConfig({
baseStyle: {
tablist: {
userSelect: 'none',
},
tabpanel: {
minHeight: 0,
overflow: 'auto',
maxHeight: '100%',
},
},
variants: {
'line-i': variantLineInvert,
},
});

View File

@ -1,13 +1,15 @@
import type { DecryptCommandPayload } from '~/decrypt-worker/types';
import { DECRYPTION_WORKER_ACTION_NAME, DecryptionResult } from '~/decrypt-worker/constants'; import { DECRYPTION_WORKER_ACTION_NAME, DecryptionResult } from '~/decrypt-worker/constants';
import { ConcurrentQueue } from './ConcurrentQueue'; import { ConcurrentQueue } from './ConcurrentQueue';
import { WorkerClientBus } from './WorkerEventBus'; import { WorkerClientBus } from './WorkerEventBus';
export class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: string }, DecryptionResult> { export class DecryptionQueue extends ConcurrentQueue<DecryptCommandPayload, DecryptionResult> {
constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) { constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) {
super(maxQueue); super(maxQueue);
} }
async handler(item: { id: string; blobURI: string }): Promise<DecryptionResult> { async handler(item: DecryptCommandPayload): Promise<DecryptionResult> {
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item); return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item);
} }
} }

View File

@ -3,7 +3,7 @@ import { nextTickAsync } from '../nextTick';
class SimpleQueue<T, R = void> extends ConcurrentQueue<T> { class SimpleQueue<T, R = void> extends ConcurrentQueue<T> {
handler(_item: T): Promise<R> { handler(_item: T): Promise<R> {
throw new Error('Method not overriden'); throw new Error('Method not overridden');
} }
} }
@ -39,7 +39,7 @@ test('should be able to process the queue within limit', async () => {
await promises[i]; await promises[i];
} }
// Wait till all fullfilled // Wait till all fulfilled
while (queuedResolver.length !== 5) { while (queuedResolver.length !== 5) {
await nextTickAsync(); await nextTickAsync();
} }
@ -85,13 +85,13 @@ test('it should move on to the next item in the queue once failed', async () =>
promises.push(queue.add(4)); promises.push(queue.add(4));
promises.push(queue.add(5)); promises.push(queue.add(5));
// Let first 2 be fullfilled // Let first 2 be fulfilled
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
queuedResolver[i](); queuedResolver[i]();
await promises[i]; await promises[i];
} }
// Wait till all fullfilled // Wait till all fulfilled
while (queuedResolver.length !== 4) { while (queuedResolver.length !== 4) {
await nextTickAsync(); await nextTickAsync();
} }

View File

@ -0,0 +1,24 @@
import { enumObject } from '../objects';
test('it should ignore and not crash with non-object', () => {
expect(Array.from(enumObject('string' as never))).toEqual([]);
});
test('it should ignore and not crash with null', () => {
expect(Array.from(enumObject(null))).toEqual([]);
});
test('it be able to iterate object', () => {
expect(Array.from(enumObject({ a: '1', b: '2' }))).toMatchInlineSnapshot(`
[
[
"a",
"1",
],
[
"b",
"2",
],
]
`);
});

View File

@ -23,3 +23,23 @@ export function withGroupedLogs<R = unknown>(label: string, fn: () => R): R {
); );
} }
} }
const noop = (..._args: unknown[]) => {
// noop
};
const dummyLogger = {
log: noop,
info: noop,
warn: noop,
debug: noop,
trace: noop,
};
export function getLogger() {
if (import.meta.env.ENABLE_PERF_LOG === '1') {
return window.console;
} else {
return dummyLogger;
}
}

View File

@ -1,10 +1,13 @@
type NextTickFn = (callback: () => void) => void; type NextTickFn = (callback: () => void) => void;
/* c8 ignore start */
const nextTickFn = const nextTickFn =
typeof setImmediate !== 'undefined' typeof setImmediate !== 'undefined'
? (setImmediate as NextTickFn) ? (setImmediate as NextTickFn)
: typeof requestAnimationFrame !== 'undefined' : typeof requestAnimationFrame !== 'undefined'
? (requestAnimationFrame as NextTickFn) ? (requestAnimationFrame as NextTickFn)
: (setTimeout as NextTickFn); : (setTimeout as NextTickFn);
/* c8 ignore stop */
export async function nextTickAsync() { export async function nextTickAsync() {
return new Promise<void>((resolve) => nextTickFn(resolve)); return new Promise<void>((resolve) => nextTickFn(resolve));

9
src/util/objects.ts Normal file
View File

@ -0,0 +1,9 @@
export function* enumObject<T>(obj: Record<string, T> | null | void): Generator<[string, T]> {
if (obj && typeof obj === 'object') {
for (const key in obj) {
yield [key, obj[key]];
}
}
}
export const { hasOwn } = Object;