Files
tk-ws-scrcpy/src/app/googDevice/filePush/FilePushHandler.ts
2025-07-30 13:39:32 +08:00

257 lines
9.7 KiB
TypeScript

import { DragAndDropHandler, DragEventListener } from '../DragAndDropHandler';
import { FilePushStream, PushResponse } from './FilePushStream';
import { FilePushResponseStatus } from './FilePushResponseStatus';
type Resolve = (response: PushResponse) => void;
export type PushUpdateParams = {
pushId: number;
fileName: string;
message: string;
progress: number;
error: boolean;
finished: boolean;
};
export interface DragAndPushListener {
onDragEnter: () => boolean;
onDragLeave: () => boolean;
onDrop: () => boolean;
onFilePushUpdate: (data: PushUpdateParams) => void;
onError: (error: Error | string) => void;
}
const TAG = '[FilePushHandler]';
export default class FilePushHandler implements DragEventListener {
public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID`
private responseWaiter: Map<number, Resolve | Resolve[]> = new Map();
private listeners: Set<DragAndPushListener> = new Set();
private pushIdFileNameMap: Map<number, string> = new Map();
constructor(private readonly element: HTMLElement, private readonly filePushStream: FilePushStream) {
DragAndDropHandler.addEventListener(this);
filePushStream.on('response', this.onStreamResponse);
filePushStream.on('error', this.onStreamError);
}
private sendUpdate(params: PushUpdateParams): void {
if (params.error) {
this.pushIdFileNameMap.delete(params.pushId);
}
this.listeners.forEach((listener) => {
listener.onFilePushUpdate(params);
});
}
private logError(pushId: number, fileName: string, code: number): void {
const msg = RESPONSE_CODES.get(code) || `Unknown error (${code})`;
this.sendUpdate({ pushId, fileName, message: `error: "${msg}"`, progress: -1, error: true, finished: true });
}
private static async getStreamReader(file: File): Promise<{
reader: ReadableStreamDefaultReader<Uint8Array>;
result: ReadableStreamReadResult<Uint8Array>;
}> {
const blob = await new Response(file).blob();
const reader = blob.stream().getReader() as ReadableStreamDefaultReader<Uint8Array>;
const result = await reader.read();
return { reader, result };
}
private async pushFile(file: File): Promise<void> {
const start = Date.now();
const { name: fileName, size: fileSize } = file;
if (!this.filePushStream.hasConnection()) {
this.listeners.forEach((listener) => {
listener.onError('WebSocket is not ready');
});
return;
}
const id = FilePushHandler.REQUEST_NEW_PUSH_ID;
this.sendUpdate({ pushId: id, fileName, message: 'begins...', progress: 0, error: false, finished: false });
this.filePushStream.sendEventNew({ id });
const { code: pushId } = await this.waitForResponse(id);
if (pushId <= 0) {
return this.logError(pushId, fileName, pushId);
}
this.pushIdFileNameMap.set(pushId, fileName);
const waitPromise = this.waitForResponse(pushId);
this.filePushStream.sendEventStart({ id: pushId, fileName, fileSize });
const [{ code: startResponseCode }, { reader, result }] = await Promise.all([
waitPromise,
FilePushHandler.getStreamReader(file),
]);
if (startResponseCode !== FilePushResponseStatus.NO_ERROR) {
this.logError(pushId, fileName, startResponseCode);
return;
}
let receivedBytes = 0;
const processData = async ({ done, value }: { done: boolean; value?: Uint8Array }): Promise<void> => {
if (done || !value) {
this.filePushStream.sendEventFinish({ id: pushId });
const { code: finishResponseCode } = await this.waitForResponse(pushId);
if (finishResponseCode !== 0) {
this.logError(pushId, fileName, finishResponseCode);
} else {
this.sendUpdate({
pushId,
fileName,
message: 'success!',
progress: 100,
error: false,
finished: true,
});
}
console.log(TAG, `File "${fileName}" uploaded in ${Date.now() - start}ms`);
return;
}
receivedBytes += value.length;
this.filePushStream.sendEventAppend({ id: pushId, chunk: value });
const [{ code: appendResponseCode }, result] = await Promise.all([
this.waitForResponse(pushId),
reader.read(),
]);
if (appendResponseCode !== 0) {
this.logError(pushId, fileName, appendResponseCode);
return;
}
const progress = (receivedBytes * 100) / fileSize;
const message = `${progress.toFixed(2)}%`;
this.sendUpdate({ pushId, fileName, message, progress, error: false, finished: false });
return processData(result);
};
return processData(result);
}
private waitForResponse(pushId: number): Promise<PushResponse> {
return new Promise((resolve) => {
const stored = this.responseWaiter.get(pushId);
if (Array.isArray(stored)) {
stored.push(resolve);
} else if (stored) {
const arr: Resolve[] = [stored];
arr.push(resolve);
this.responseWaiter.set(pushId, arr);
} else {
this.responseWaiter.set(pushId, resolve);
}
});
}
onStreamError = ({ id: pushId, error }: { id: number; error: Error }): void => {
const fileName = this.pushIdFileNameMap.get(pushId) || 'Unknown file';
this.sendUpdate({ pushId, fileName, message: error.message, progress: -1, error: true, finished: true });
};
onStreamResponse = (response: PushResponse): void => {
let func: Resolve;
let value: PushResponse;
const { code, id: idInResponse } = response;
const id = code === FilePushResponseStatus.NEW_PUSH_ID ? FilePushHandler.REQUEST_NEW_PUSH_ID : response.id;
const resolve = this.responseWaiter.get(id);
if (!resolve) {
console.warn(TAG, `Unexpected push id: "${id}", ${JSON.stringify(response)}`);
return;
}
if (Array.isArray(resolve)) {
func = resolve.shift() as Resolve;
if (!resolve.length) {
this.responseWaiter.delete(id);
}
} else {
func = resolve;
this.responseWaiter.delete(id);
}
if (code === FilePushResponseStatus.NEW_PUSH_ID) {
value = { id, code: idInResponse };
} else {
value = { id, code: code };
}
func(value);
};
public onFilesDrop(files: File[]): boolean {
this.listeners.forEach((listener) => {
listener.onDrop();
});
files.forEach((file: File) => {
const { type, name } = file;
if (this.filePushStream.isAllowedFile(file)) {
this.pushFile(file);
} else {
const errorParams: PushUpdateParams = {
pushId: FilePushHandler.REQUEST_NEW_PUSH_ID,
fileName: name,
message: `Unsupported type "${type}"`,
progress: -1,
error: true,
finished: true,
};
this.sendUpdate(errorParams);
}
});
return true;
}
public static getErrorMessage(code: number, message?: string): string {
return message || RESPONSE_CODES.get(code) || 'Unknown error';
}
public onDragEnter(): boolean {
let handled = false;
this.listeners.forEach((listener) => {
handled = handled || listener.onDragEnter();
});
return handled;
}
public onDragLeave(): boolean {
let handled = false;
this.listeners.forEach((listener) => {
handled = handled || listener.onDragLeave();
});
return handled;
}
public getElement(): HTMLElement {
return this.element;
}
public release(): void {
this.filePushStream.off('response', this.onStreamResponse);
this.filePushStream.off('error', this.onStreamError);
this.filePushStream.release();
DragAndDropHandler.removeEventListener(this);
this.listeners.clear();
}
public addEventListener(listener: DragAndPushListener): void {
this.listeners.add(listener);
}
public removeEventListener(listener: DragAndPushListener): void {
this.listeners.delete(listener);
}
}
const RESPONSE_CODES = new Map([
[FilePushResponseStatus.NEW_PUSH_ID, 'New push id'],
[FilePushResponseStatus.NO_ERROR, 'No error'],
[FilePushResponseStatus.ERROR_INVALID_NAME, 'Invalid name'],
[FilePushResponseStatus.ERROR_NO_SPACE, 'No space'],
[FilePushResponseStatus.ERROR_FAILED_TO_DELETE, 'Failed to delete existing'],
[FilePushResponseStatus.ERROR_FAILED_TO_CREATE, 'Failed to create new file'],
[FilePushResponseStatus.ERROR_FILE_NOT_FOUND, 'File not found'],
[FilePushResponseStatus.ERROR_FAILED_TO_WRITE, 'Failed to write to file'],
[FilePushResponseStatus.ERROR_FILE_IS_BUSY, 'File is busy'],
[FilePushResponseStatus.ERROR_INVALID_STATE, 'Invalid state'],
[FilePushResponseStatus.ERROR_UNKNOWN_ID, 'Unknown id'],
[FilePushResponseStatus.ERROR_NO_FREE_ID, 'No free id'],
[FilePushResponseStatus.ERROR_INCORRECT_SIZE, 'Incorrect size'],
]);