Merge pull request '添加设定界面 - #18' (#21) from feat/settings into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #21
This commit is contained in:
commit
0caf88d649
@ -1,2 +1,5 @@
|
|||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Package manager
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
5069
pnpm-lock.yaml
5069
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
23
src/App.tsx
23
src/App.tsx
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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'));
|
||||||
|
49
src/components/AppRoot.tsx
Normal file
49
src/components/AppRoot.tsx
Normal 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
43
src/components/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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() {
|
@ -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;
|
||||||
|
@ -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)
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
9
src/decrypt-worker/types.ts
Normal file
9
src/decrypt-worker/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface DecryptCommandOptions {
|
||||||
|
qmc2Key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecryptCommandPayload {
|
||||||
|
id: string;
|
||||||
|
blobURI: string;
|
||||||
|
options: DecryptCommandOptions;
|
||||||
|
}
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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 });
|
||||||
|
@ -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() {
|
||||||
|
@ -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({
|
||||||
|
120
src/features/settings/Settings.tsx
Normal file
120
src/features/settings/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
116
src/features/settings/panels/PanelQMCv2Key.tsx
Normal file
116
src/features/settings/panels/PanelQMCv2Key.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
45
src/features/settings/persistSettings.ts
Normal file
45
src/features/settings/persistSettings.ts
Normal 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');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
14
src/features/settings/settingsSelector.ts
Normal file
14
src/features/settings/settingsSelector.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
110
src/features/settings/settingsSlice.ts
Normal file
110
src/features/settings/settingsSlice.ts
Normal 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;
|
15
src/main.tsx
15
src/main.tsx
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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
18
src/tabs/MainTab.tsx
Normal 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
15
src/tabs/SettingsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
39
src/theme.ts
39
src/theme.ts
@ -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
74
src/themes/Tabs.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
24
src/util/__tests__/enumObject.test.ts
Normal file
24
src/util/__tests__/enumObject.test.ts
Normal 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",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
9
src/util/objects.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user