feat: friendly way to inform user there's an error (#12)

This commit is contained in:
鲁树人 2023-05-22 22:24:41 +01:00
parent af61d23fd4
commit 5db5fdaa69
8 changed files with 99 additions and 19 deletions

View File

@ -1 +1,17 @@
export class UnsupportedSourceFile extends Error {} export enum DecryptErrorType {
UNSUPPORTED_FILE = 'UNSUPPORTED_FILE',
UNKNOWN = 'UNKNOWN',
}
export class DecryptError extends Error {
code = DecryptErrorType.UNKNOWN;
toJSON() {
const { name, message, stack, code } = this;
return { name, message, stack, code };
}
}
export class UnsupportedSourceFile extends DecryptError {
code = DecryptErrorType.UNSUPPORTED_FILE;
}

View File

@ -38,7 +38,7 @@ class DecryptCommandHandler {
} }
} }
throw new Error('could not decrypt file: no working decryptor found'); throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
} }
async decryptFile(crypto: CryptoBase) { async decryptFile(crypto: CryptoBase) {

View File

@ -0,0 +1,36 @@
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
export interface FileErrorProps {
error: null | string;
code: null | string;
}
const errorMap = new Map<string | null | DecryptErrorType, string>([
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'],
]);
export function FileError({ error, code }: FileErrorProps) {
const { isOpen, onToggle } = useDisclosure();
const errorSummary = errorMap.get(code) ?? '未知错误';
return (
<Box>
<Text>
<chakra.span>{errorSummary}</chakra.span>
{error && (
<Button ml="2" onClick={onToggle} type="button">
</Button>
)}
</Text>
{error && (
<Collapse in={isOpen} animateOpacity>
<Box as="pre" display="inline-block" mt="2" px="4" py="2" bg="red.300" rounded="md">
{error}
</Box>
</Collapse>
)}
</Box>
);
}

View File

@ -18,6 +18,7 @@ import { useAppDispatch } from '~/hooks';
import { AnimationDefinition } from 'framer-motion'; import { AnimationDefinition } from 'framer-motion';
import { AlbumImage } from './AlbumImage'; import { AlbumImage } from './AlbumImage';
import { SongMetadata } from './SongMetadata'; import { SongMetadata } from './SongMetadata';
import { FileError } from './FileError';
interface FileRowProps { interface FileRowProps {
id: string; id: string;
@ -73,7 +74,10 @@ export function FileRow({ id, file }: FileRowProps) {
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span> <span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
</Box> </Box>
</GridItem> </GridItem>
<GridItem area="meta">{isDecrypted && metadata && <SongMetadata metadata={metadata} />}</GridItem> <GridItem area="meta">
{isDecrypted && metadata && <SongMetadata metadata={metadata} />}
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
</GridItem>
<GridItem area="action" alignSelf="center"> <GridItem area="action" alignSelf="center">
<VStack> <VStack>
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />} {file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}

View File

@ -5,8 +5,9 @@ export const untouchedFile: DecryptedAudioFile = {
raw: 'blob://localhost/file-a', raw: 'blob://localhost/file-a',
decrypted: '', decrypted: '',
ext: '', ext: '',
state: ProcessState.UNTOUCHED, state: ProcessState.QUEUED,
errorMessage: null, errorMessage: null,
errorCode: null,
metadata: null, metadata: null,
}; };
@ -17,6 +18,7 @@ export const completedFile: DecryptedAudioFile = {
ext: 'flac', ext: 'flac',
state: ProcessState.COMPLETE, state: ProcessState.COMPLETE,
errorMessage: null, errorMessage: null,
errorCode: null,
metadata: { metadata: {
name: 'Für Alice', name: 'Für Alice',
artist: 'Jixun', artist: 'Jixun',
@ -33,6 +35,7 @@ export const fileWithError: DecryptedAudioFile = {
ext: 'flac', ext: 'flac',
state: ProcessState.ERROR, state: ProcessState.ERROR,
errorMessage: 'Could not decrypt blah blah', errorMessage: 'Could not decrypt blah blah',
errorCode: null,
metadata: null, metadata: null,
}; };

View File

@ -4,9 +4,11 @@ import type { RootState } from '~/store';
import { decryptionQueue } from '~/decrypt-worker/client'; 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';
export enum ProcessState { export enum ProcessState {
UNTOUCHED = 'UNTOUCHED', QUEUED = 'QUEUED',
PROCESSING = 'PROCESSING',
COMPLETE = 'COMPLETE', COMPLETE = 'COMPLETE',
ERROR = 'ERROR', ERROR = 'ERROR',
} }
@ -31,6 +33,7 @@ export interface DecryptedAudioFile {
decrypted: string; // blob uri decrypted: string; // blob uri
state: ProcessState; state: ProcessState;
errorMessage: null | string; errorMessage: null | string;
errorCode: null | DecryptErrorType | string;
metadata: null | AudioMetadata; metadata: null | AudioMetadata;
} }
@ -54,7 +57,11 @@ export const processFile = createAsyncThunk<
return thunkAPI.rejectWithValue({ message, stack }); return thunkAPI.rejectWithValue({ message, stack });
} }
return decryptionQueue.add({ id: fileId, blobURI: file.raw }); const onPreProcess = () => {
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
return decryptionQueue.add({ id: fileId, blobURI: file.raw }, onPreProcess);
}); });
export const fileListingSlice = createSlice({ export const fileListingSlice = createSlice({
@ -67,8 +74,9 @@ export const fileListingSlice = createSlice({
raw: payload.blobURI, raw: payload.blobURI,
decrypted: '', decrypted: '',
ext: '', ext: '',
state: ProcessState.UNTOUCHED, state: ProcessState.QUEUED,
errorMessage: null, errorMessage: null,
errorCode: null,
metadata: null, metadata: null,
}; };
}, },
@ -78,6 +86,12 @@ export const fileListingSlice = createSlice({
file.decrypted = payload.decryptedBlobURI; file.decrypted = payload.decryptedBlobURI;
} }
}, },
setFileAsProcessing: (state, { payload }: PayloadAction<{ id: string }>) => {
const file = state.files[payload.id];
if (file) {
file.state = ProcessState.PROCESSING;
}
},
deleteFile: (state, { payload }: PayloadAction<{ id: string }>) => { deleteFile: (state, { payload }: PayloadAction<{ id: string }>) => {
if (state.files[payload.id]) { if (state.files[payload.id]) {
const file = state.files[payload.id]; const file = state.files[payload.id];
@ -108,16 +122,14 @@ export const fileListingSlice = createSlice({
const file = state.files[fileId]; const file = state.files[fileId];
if (!file) return; if (!file) return;
if (action.payload) { file.errorMessage = action.error.message ?? 'unknown error';
file.errorMessage = action.payload.message; file.errorCode = action.error.code ?? null;
} else { file.state = ProcessState.ERROR;
file.errorMessage = action.error.message || 'unknown error';
}
}); });
}, },
}); });
export const { addNewFile, setDecryptedContent, deleteFile } = fileListingSlice.actions; export const { addNewFile, setFileAsProcessing, setDecryptedContent, deleteFile } = fileListingSlice.actions;
export const selectFileCount = (state: RootState) => state.fileListing.files.length; export const selectFileCount = (state: RootState) => state.fileListing.files.length;
export const selectFiles = (state: RootState) => state.fileListing.files; export const selectFiles = (state: RootState) => state.fileListing.files;

View File

@ -1,15 +1,15 @@
import { nextTickAsync } from './nextTick'; import { nextTickAsync } from './nextTick';
export abstract class ConcurrentQueue<T, R = unknown> { export abstract class ConcurrentQueue<T, R = unknown> {
protected items: [T, (result: R) => void, (error: unknown) => void][] = []; protected items: [T, (result: R) => void, (error: unknown) => void, void | (() => void)][] = [];
constructor(protected maxConcurrent = 5) {} constructor(protected maxConcurrent = 5) {}
abstract handler(item: T): Promise<R>; abstract handler(item: T): Promise<R>;
public async add(item: T): Promise<R> { public async add(item: T, onPreProcess?: () => void): Promise<R> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.items.push([item, resolve, reject]); this.items.push([item, resolve, reject, onPreProcess]);
this.runWorkerIfFree(); this.runWorkerIfFree();
}); });
} }
@ -32,9 +32,11 @@ export abstract class ConcurrentQueue<T, R = unknown> {
private async processQueue() { private async processQueue() {
let item: (typeof this.items)[0] | void; let item: (typeof this.items)[0] | void;
while ((item = this.items.shift())) { while ((item = this.items.shift())) {
const [payload, resolve, reject] = item; const [payload, resolve, reject, onPreProcess] = item;
try { try {
onPreProcess?.();
resolve(await this.handler(payload)); resolve(await this.handler(payload));
} catch (error: unknown) { } catch (error: unknown) {
reject(error); reject(error);

View File

@ -1,10 +1,12 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { DecryptError } from '~/decrypt-worker/util/DecryptError';
type WorkerServerHandler<P, R> = (payload: P) => R | Promise<R>; type WorkerServerHandler<P, R> = (payload: P) => R | Promise<R>;
interface SerializedError { interface SerializedError {
message: string; message: string;
stack?: string; stack?: string;
code?: string;
} }
interface WorkerClientRequestPayload<P = unknown> { interface WorkerClientRequestPayload<P = unknown> {
@ -39,6 +41,7 @@ export class WorkerClientBus<T = string> {
if (error) { if (error) {
const wrappedError = new Error(error.message, { cause: error }); const wrappedError = new Error(error.message, { cause: error });
wrappedError.stack = error.stack; wrappedError.stack = error.stack;
Object.assign(wrappedError, { code: error.code ?? null });
reject(wrappedError); reject(wrappedError);
} else { } else {
resolve(result as never); resolve(result as never);
@ -77,8 +80,12 @@ export class WorkerServerBus {
} else { } else {
try { try {
result = await handler(payload); result = await handler(payload);
} catch (e: unknown) { } catch (err: unknown) {
error = e; if (err instanceof DecryptError) {
error = err.toJSON();
} else {
error = err;
}
} }
} }