diff --git a/frontend/components/ClipboardApp.tsx b/frontend/components/ClipboardApp.tsx index 1b08972..5b8ef19 100644 --- a/frontend/components/ClipboardApp.tsx +++ b/frontend/components/ClipboardApp.tsx @@ -55,7 +55,6 @@ const ClipboardApp = () => { requestFolder, setReceiverDirectoryHandle, getReceiverSaveType, - manualSafeSave, } = useWebRTCConnection({ messages, putMessageInMs, @@ -219,7 +218,6 @@ const ClipboardApp = () => { getReceiverSaveType={getReceiverSaveType} retrieveMessage={retrieveMessage} handleLeaveRoom={handleLeaveReceiverRoom} - manualSafeSave={manualSafeSave} /> )} diff --git a/frontend/components/ClipboardApp/FileListDisplay.tsx b/frontend/components/ClipboardApp/FileListDisplay.tsx index feea035..6c9b533 100644 --- a/frontend/components/ClipboardApp/FileListDisplay.tsx +++ b/frontend/components/ClipboardApp/FileListDisplay.tsx @@ -13,7 +13,7 @@ import type { Messages } from "@/types/messages"; import { useFileTransferStore } from "@/stores/fileTransferStore"; import { supportsAutoDownload } from "@/lib/browserUtils"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +const developmentEnv = process.env.NODE_ENV; function formatFolderDis(template: string, num: number, size: string) { return template.replace("{num}", num.toString()).replace("{size}", size); @@ -44,7 +44,6 @@ interface FileListDisplayProps { onRequest?: (item: FileMeta) => void; // Request file onDelete?: (item: FileMeta) => void; onLocationPick?: () => Promise; - onSafeSave?: () => void; // New prop for safe save functionality saveType?: { [fileId: string]: boolean }; // File stored on disk or in memory largeFileThreshold?: number; } @@ -66,7 +65,6 @@ const FileListDisplay: React.FC = ({ onRequest, onDelete, onLocationPick, - onSafeSave, saveType, largeFileThreshold = 500 * 1024 * 1024, // 500MB default }) => { @@ -261,7 +259,7 @@ const FileListDisplay: React.FC = ({ if (isAutoDownloadSupported) { // Browsers that support automatic downloads like Chrome: Download directly - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( `[Download Debug] Auto-downloading file: ${item.name}` ); @@ -269,7 +267,7 @@ const FileListDisplay: React.FC = ({ onDownload(item); } else { // Non-Chrome browsers: Set to save status, wait for user manual click - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( `[Download Debug] Setting pendingSave for non-Chrome browser: ${item.name}` ); @@ -280,9 +278,9 @@ const FileListDisplay: React.FC = ({ })); } } else { - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( - `[Firefox Debug] Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}` + `Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}` ); } } @@ -459,29 +457,6 @@ const FileListDisplay: React.FC = ({ {messages.text.FileListDisplay.chooseSavePath_dis} )} - {/* Safe Save Button - only show when location is picked and files are saved to disk */} - {onSafeSave && - pickedLocation && - (isAnyFileTransferring || - (saveType && - Object.values(saveType).some( - (isSavedToDisk) => isSavedToDisk - ))) && ( - - - - )} )} diff --git a/frontend/components/ClipboardApp/RetrieveTabPanel.tsx b/frontend/components/ClipboardApp/RetrieveTabPanel.tsx index 73a22bc..0428b6c 100644 --- a/frontend/components/ClipboardApp/RetrieveTabPanel.tsx +++ b/frontend/components/ClipboardApp/RetrieveTabPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import RichTextEditor from "@/components/Editor/RichTextEditor"; import { ReadClipboardButton, WriteClipboardButton, @@ -9,7 +8,6 @@ import { import FileListDisplay from "@/components/ClipboardApp/FileListDisplay"; import type { Messages } from "@/types/messages"; import type { FileMeta } from "@/types/webrtc"; -import type { ProgressState } from "@/hooks/useWebRTCConnection"; // Assuming this type is exported import { useFileTransferStore } from "@/stores/fileTransferStore"; @@ -32,7 +30,6 @@ interface RetrieveTabPanelProps { directoryHandle: FileSystemDirectoryHandle ) => Promise; getReceiverSaveType: () => { [fileId: string]: boolean } | undefined; - manualSafeSave: () => void; // Add manual safe save function retrieveMessage: string; handleLeaveRoom: () => void; } @@ -49,11 +46,10 @@ export function RetrieveTabPanel({ requestFolder, setReceiverDirectoryHandle, getReceiverSaveType, - manualSafeSave, retrieveMessage, handleLeaveRoom, }: RetrieveTabPanelProps) { - // 从 store 中获取状态 + // Get the status from the store const { retrieveRoomStatusText, retrieveRoomIdInput, @@ -61,35 +57,25 @@ export function RetrieveTabPanel({ retrievedFileMetas, receiveProgress, isAnyFileTransferring, - senderDisconnected, isReceiverInRoom, } = useFileTransferStore(); const onLocationPick = useCallback(async (): Promise => { if (!messages) return false; // Should not happen if panel is rendered if (!window.showDirectoryPicker) { - putMessageInMs( - messages.text.ClipboardApp.pickSaveUnsupported, - false - ); + putMessageInMs(messages.text.ClipboardApp.pickSaveUnsupported, false); return false; } if (!window.confirm(messages.text.ClipboardApp.pickSaveMsg)) return false; try { const directoryHandle = await window.showDirectoryPicker(); await setReceiverDirectoryHandle(directoryHandle); - putMessageInMs( - messages.text.ClipboardApp.pickSaveSuccess, - false - ); + putMessageInMs(messages.text.ClipboardApp.pickSaveSuccess, false); return true; } catch (err: any) { if (err.name !== "AbortError") { console.error("Failed to set up folder receive:", err); - putMessageInMs( - messages.text.ClipboardApp.pickSaveError, - false - ); + putMessageInMs(messages.text.ClipboardApp.pickSaveError, false); } return false; } @@ -148,12 +134,14 @@ export function RetrieveTabPanel({ {messages.text.ClipboardApp.html.joinRoom_dis} @@ -178,7 +166,6 @@ export function RetrieveTabPanel({ onDownload={handleDownloadFile} onRequest={handleFileRequestFromPanel} // Use the panel's own handler onLocationPick={onLocationPick} // Use the panel's own handler - onSafeSave={manualSafeSave} // Add safe save handler saveType={getReceiverSaveType()} /> {retrieveMessage && ( diff --git a/frontend/components/ClipboardApp/SendTabPanel.tsx b/frontend/components/ClipboardApp/SendTabPanel.tsx index 5f1da2a..6845c88 100644 --- a/frontend/components/ClipboardApp/SendTabPanel.tsx +++ b/frontend/components/ClipboardApp/SendTabPanel.tsx @@ -11,7 +11,6 @@ import FileListDisplay from "@/components/ClipboardApp/FileListDisplay"; import AnimatedButton from "@/components/ui/AnimatedButton"; import type { Messages } from "@/types/messages"; import type { CustomFile, FileMeta } from "@/types/webrtc"; -import type { ProgressState } from "@/hooks/useWebRTCConnection"; import { useFileTransferStore } from "@/stores/fileTransferStore"; @@ -55,7 +54,7 @@ export function SendTabPanel({ currentValidatedShareRoomId, handleLeaveSenderRoom, }: SendTabPanelProps) { - // 从 store 中获取状态 + // Get the status from the store const { shareContent, sendFiles, @@ -212,12 +211,14 @@ export function SendTabPanel({ {messages.text.ClipboardApp.html.SyncSending_dis} diff --git a/frontend/constants/messages/de.ts b/frontend/constants/messages/de.ts index 99e16d1..300189f 100644 --- a/frontend/constants/messages/de.ts +++ b/frontend/constants/messages/de.ts @@ -256,11 +256,6 @@ export const de: Messages = { chooseSavePath_tips: "Speichern Sie große Dateien oder Ordner direkt in einem ausgewählten Verzeichnis. 👉", chooseSavePath_dis: "Speicherort auswählen", - safeSave_dis: "Sicheres Speichern", - safeSave_tooltip: - "Keine Angst vor Verbindungsunterbrechung, klicken Sie hier, um Dateien sicher zu speichern für die nächste Fortsetzung", - safeSaveSuccessMsg: - "Dateien wurden sicher auf der Festplatte gespeichert, sicher die Seite zu schließen, unterstützt Wiederaufnahme der Übertragung!", }, RetrieveMethod: { P: "Glückwunsch 🎉 Freigegebene Inhalte warten darauf, abgerufen zu werden:", @@ -316,6 +311,9 @@ export const de: Messages = { noFilesForFolderMsg: "Keine Dateien im Ordner '{folderName}' gefunden.", zipError: "Fehler beim Erstellen der ZIP-Datei.", fileNotFoundMsg: "Datei '{fileName}' zum Herunterladen nicht gefunden.", + confirmLeaveWhileTransferring: + "Dateien werden derzeit übertragen. Das Verlassen wird die Übertragung unterbrechen. Sind Sie sicher?", + leaveWhileTransferringSuccess: "Raum verlassen, Übertragung unterbrochen", html: { senderTab: "Senden", retrieveTab: "Abrufen", diff --git a/frontend/constants/messages/en.ts b/frontend/constants/messages/en.ts index cf2fa07..dc4fc83 100644 --- a/frontend/constants/messages/en.ts +++ b/frontend/constants/messages/en.ts @@ -253,11 +253,6 @@ export const en: Messages = { chooseSavePath_tips: "Save large files or folders directly to a selected directory. 👉", chooseSavePath_dis: "Choose save location", - safeSave_dis: "Safe Save", - safeSave_tooltip: - "Don't worry about connection interruption, click here to safely save files for next resume", - safeSaveSuccessMsg: - "Files have been safely saved to disk, safe to close page, supports resume transfer!", }, RetrieveMethod: { P: "Congrats 🎉 Share content is waiting to be retrieved:", @@ -308,6 +303,8 @@ export const en: Messages = { noFilesForFolderMsg: "No files found for folder '{folderName}'.", zipError: "Error creating ZIP.", fileNotFoundMsg: "File '{fileName}' not found for download.", + confirmLeaveWhileTransferring: "Files are currently transferring. Leaving will interrupt the transfer. Are you sure?", + leaveWhileTransferringSuccess: "Left room, transfer interrupted", html: { senderTab: "Send", retrieveTab: "Retrieve", diff --git a/frontend/constants/messages/es.ts b/frontend/constants/messages/es.ts index ae98104..e1e014d 100644 --- a/frontend/constants/messages/es.ts +++ b/frontend/constants/messages/es.ts @@ -254,11 +254,6 @@ export const es: Messages = { chooseSavePath_tips: "Guarda archivos grandes o carpetas directamente en un directorio seleccionado. 👉", chooseSavePath_dis: "Elegir ubicación de guardado", - safeSave_dis: "Guardar Seguro", - safeSave_tooltip: - "No te preocupes por la interrupción de la conexión, haz clic aquí para guardar archivos de forma segura para la próxima reanudación", - safeSaveSuccessMsg: - "Los archivos se han guardado de forma segura en el disco, es seguro cerrar la página, ¡admite transferencia de reanudación!", }, RetrieveMethod: { P: "¡Felicitaciones 🎉 El contenido compartido está esperando ser recuperado:", @@ -310,6 +305,10 @@ export const es: Messages = { "No se encontraron archivos en la carpeta '{folderName}'.", zipError: "Error al crear el archivo ZIP.", fileNotFoundMsg: "Archivo '{fileName}' no encontrado para descargar.", + confirmLeaveWhileTransferring: + "Los archivos se están transfiriendo actualmente. Salir interrumpirá la transferencia. ¿Estás seguro?", + leaveWhileTransferringSuccess: + "Saliste de la sala, transferencia interrumpida", html: { senderTab: "Enviar", retrieveTab: "Recuperar", diff --git a/frontend/constants/messages/fr.ts b/frontend/constants/messages/fr.ts index c26b68f..b34fc01 100644 --- a/frontend/constants/messages/fr.ts +++ b/frontend/constants/messages/fr.ts @@ -257,11 +257,6 @@ export const fr: Messages = { chooseSavePath_tips: "Enregistrez des fichiers volumineux ou des dossiers directement dans un répertoire sélectionné. 👉", chooseSavePath_dis: "Choisir l'emplacement de sauvegarde", - safeSave_dis: "Sauvegarde Sécurisée", - safeSave_tooltip: - "N'ayez pas peur de l'interruption de connexion, cliquez ici pour sauvegarder les fichiers en toute sécurité pour la prochaine reprise", - safeSaveSuccessMsg: - "Les fichiers ont été sauvegardés en toute sécurité sur le disque, sûr de fermer la page, prend en charge la reprise du transfert !", }, RetrieveMethod: { P: "Félicitations 🎉 Le contenu partagé attend d'être récupéré :", @@ -318,6 +313,9 @@ export const fr: Messages = { zipError: "Erreur lors de la création du fichier ZIP.", fileNotFoundMsg: "Fichier '{fileName}' introuvable pour le téléchargement.", + confirmLeaveWhileTransferring: + "Des fichiers sont actuellement en cours de transfert. Quitter interrompra le transfert. Êtes-vous sûr?", + leaveWhileTransferringSuccess: "Salle quittée, transfert interrompu", html: { senderTab: "Envoyer", retrieveTab: "Récupérer", diff --git a/frontend/constants/messages/ja.ts b/frontend/constants/messages/ja.ts index 12b943d..4e9f172 100644 --- a/frontend/constants/messages/ja.ts +++ b/frontend/constants/messages/ja.ts @@ -249,9 +249,6 @@ export const ja: Messages = { chooseSavePath_tips: "大きなファイルやフォルダを選択したディレクトリに直接保存します。👉", chooseSavePath_dis: "保存場所を選択", - safeSave_dis: "安全保存", - safeSave_tooltip: "接続の中断を恐れる必要はありません。ここをクリックして、次回の再開のためにファイルを安全に保存してください", - safeSaveSuccessMsg: "ファイルが安全にディスクに保存されました。ページを安全に閉じることができ、転送の再開をサポートします!", }, RetrieveMethod: { P: "おめでとう 🎉 共有コンテンツが取得待ちです:", @@ -302,6 +299,8 @@ export const ja: Messages = { noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。", zipError: "ZIP の作成中にエラーが発生しました。", fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。", + confirmLeaveWhileTransferring: "現在ファイルが転送中です。退出すると転送が中断されます。よろしいですか?", + leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました", html: { senderTab: "送信", retrieveTab: "取得", diff --git a/frontend/constants/messages/ko.ts b/frontend/constants/messages/ko.ts index 1c29a15..9ba0b8c 100644 --- a/frontend/constants/messages/ko.ts +++ b/frontend/constants/messages/ko.ts @@ -247,9 +247,6 @@ export const ko: Messages = { chooseSavePath_tips: "큰 파일이나 폴더를 선택한 디렉터리에 직접 저장합니다. 👉", chooseSavePath_dis: "저장 위치 선택", - safeSave_dis: "안전 저장", - safeSave_tooltip: "연결 중단을 두려워하지 마세요. 다음 재개를 위해 파일을 안전하게 저장하려면 여기를 클릭하세요", - safeSaveSuccessMsg: "파일이 디스크에 안전하게 저장되었습니다. 페이지를 안전하게 닫을 수 있으며 전송 재개를 지원합니다!", }, RetrieveMethod: { P: "축하합니다 🎉 공유된 콘텐츠가 검색을 기다리고 있습니다:", @@ -300,6 +297,8 @@ export const ko: Messages = { noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.", zipError: "ZIP 파일 생성 중 오류가 발생했습니다.", fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.", + confirmLeaveWhileTransferring: "현재 파일이 전송 중입니다. 나가면 전송이 중단됩니다. 확실합니까?", + leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다", html: { senderTab: "보내기", retrieveTab: "검색", diff --git a/frontend/constants/messages/zh.ts b/frontend/constants/messages/zh.ts index 1adbf40..b3c7b0c 100644 --- a/frontend/constants/messages/zh.ts +++ b/frontend/constants/messages/zh.ts @@ -235,10 +235,6 @@ export const zh: Messages = { "我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。", chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉", chooseSavePath_dis: "选择保存位置", - safeSave_dis: "安全保存", - safeSave_tooltip: "连接中断不要怕,点击这里安全保存文件,方便下次续传", - safeSaveSuccessMsg: - "文件已安全保存到磁盘,可以安全关闭页面,支持断点续传!", }, RetrieveMethod: { P: "恭喜 🎉 共享内容等待接收:", @@ -287,6 +283,8 @@ export const zh: Messages = { noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。", zipError: "创建 ZIP 文件时出错。", fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。", + confirmLeaveWhileTransferring: "当前有文件正在传输,退出将中断传输。确定要退出吗?", + leaveWhileTransferringSuccess: "已退出房间,传输已中断", html: { senderTab: "发送", retrieveTab: "接收", diff --git a/frontend/hooks/useFileTransferHandler.ts b/frontend/hooks/useFileTransferHandler.ts index 7029003..c845d41 100644 --- a/frontend/hooks/useFileTransferHandler.ts +++ b/frontend/hooks/useFileTransferHandler.ts @@ -117,14 +117,14 @@ export function useFileTransferHandler({ // Check if file is empty if (fileToDownload.size === 0) { postLogToBackend( - `[Firefox Debug] ERROR: File has 0 size! This explains the 0-byte download.` + `ERROR: File has 0 size! This explains the 0-byte download.` ); } // Check if file is a valid Blob if (!(fileToDownload instanceof Blob)) { postLogToBackend( - `[Firefox Debug] WARNING: File is not a Blob object, type: ${typeof fileToDownload}` + `WARNING: File is not a Blob object, type: ${typeof fileToDownload}` ); } @@ -134,7 +134,7 @@ export function useFileTransferHandler({ // Debug log: Record the case where file is not found const availableFileNames = latestRetrievedFiles.map((f) => f.name); postLogToBackend( - `[Firefox Debug] File NOT found! Looking for: "${ + `File NOT found! Looking for: "${ meta.name }", Available files: [${availableFileNames.join(", ")}]` ); diff --git a/frontend/hooks/useRoomManager.ts b/frontend/hooks/useRoomManager.ts index 3386809..2aafcf9 100644 --- a/frontend/hooks/useRoomManager.ts +++ b/frontend/hooks/useRoomManager.ts @@ -36,6 +36,7 @@ export function useRoomManager({ senderDisconnected, isSenderInRoom, isReceiverInRoom, + isAnyFileTransferring, setShareRoomId, setInitShareRoomId, setShareLink, @@ -150,6 +151,12 @@ export function useRoomManager({ const handleLeaveReceiverRoom = useCallback(async () => { if (!messages) return; + // Check if files are transferring and show confirmation + if (isAnyFileTransferring) { + const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring); + if (!confirmed) return; + } + try { // Call backend API to leave room if (webrtcService.receiver.roomId && webrtcService.receiver.peerId) { @@ -159,9 +166,12 @@ export function useRoomManager({ ); } - putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, false); + const message = isAnyFileTransferring + ? messages.text.ClipboardApp.leaveWhileTransferringSuccess + : messages.text.ClipboardApp.roomStatus.leftRoomMsg; + putMessageInMs(message, false); - // Reset receiver state + // Reset receiver state (clears all as per requirement) resetReceiverState(); // Clean up WebRTC connection @@ -170,7 +180,7 @@ export function useRoomManager({ console.error("[RoomManager] Receiver failed to leave room:", error); putMessageInMs("Failed to leave room", true); } - }, [messages, putMessageInMs, resetReceiverState]); + }, [messages, putMessageInMs, resetReceiverState, isAnyFileTransferring]); // Sender reset app state const resetSenderAppState = useCallback(async () => { @@ -195,6 +205,12 @@ export function useRoomManager({ const handleLeaveSenderRoom = useCallback(async () => { if (!messages) return; + // Check if files are transferring and show confirmation + if (isAnyFileTransferring) { + const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring); + if (!confirmed) return; + } + try { // Call backend API to leave room if (webrtcService.sender.roomId && webrtcService.sender.peerId) { @@ -204,15 +220,18 @@ export function useRoomManager({ ); } - putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, true); + const message = isAnyFileTransferring + ? messages.text.ClipboardApp.leaveWhileTransferringSuccess + : messages.text.ClipboardApp.roomStatus.leftRoomMsg; + putMessageInMs(message, true); - // Reset sender state and get new room ID + // Reset sender state and get new room ID (keeps files as per requirement) await resetSenderAppState(); } catch (error) { console.error("[RoomManager] Sender failed to leave room:", error); putMessageInMs("Failed to leave room", true); } - }, [messages, putMessageInMs, resetSenderAppState]); + }, [messages, putMessageInMs, resetSenderAppState, isAnyFileTransferring]); // Room ID input processing const processRoomIdInput = useCallback( diff --git a/frontend/hooks/useWebRTCConnection.ts b/frontend/hooks/useWebRTCConnection.ts index c8636c2..216080d 100644 --- a/frontend/hooks/useWebRTCConnection.ts +++ b/frontend/hooks/useWebRTCConnection.ts @@ -18,8 +18,7 @@ interface UseWebRTCConnectionProps { } export function useWebRTCConnection({ - messages, - putMessageInMs, + // Retaining interface compatibility but these are no longer used }: UseWebRTCConnectionProps) { // Get state from store const { @@ -64,7 +63,6 @@ export function useWebRTCConnection({ setReceiverDirectoryHandle: webrtcService.setReceiverDirectoryHandle.bind(webrtcService), getReceiverSaveType: webrtcService.getReceiverSaveType.bind(webrtcService), - manualSafeSave: webrtcService.manualSafeSave.bind(webrtcService), // Reset connection methods resetSenderConnection: () => webrtcService.leaveRoom(true), diff --git a/frontend/lib/fileReceiver.ts b/frontend/lib/fileReceiver.ts index 991a57b..49c28e6 100644 --- a/frontend/lib/fileReceiver.ts +++ b/frontend/lib/fileReceiver.ts @@ -1,247 +1,138 @@ -// 🚀 New Process - Receiver-Dominated File Transfer: -// 1. Receive file metadata (fileMetadata) -// 2. User clicks download, send file request (fileRequest) -// 3. Receive all data chunks, automatically detect integrity -// 4. After completing Store synchronization, proactively send completion confirmation (fileReceiveComplete/folderReceiveComplete) -// Folder Transfer: Repeat single file process, finally send folder completion confirmation -import { SpeedCalculator } from "@/lib/speedCalculator"; +// 🚀 Modernized FileReceiver using modular architecture +// This file now serves as a compatibility layer for the new modular receive system + import WebRTC_Recipient from "./webrtc_Recipient"; -import { - CustomFile, - fileMetadata, - WebRTCMessage, - FolderProgress, - CurrentString, - StringMetadata, - StringChunk, - FileHandlers, - FileMeta, - FileRequest, - FileReceiveComplete, - FolderReceiveComplete, - EmbeddedChunkMeta, -} from "@/types/webrtc"; -import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +import { CustomFile, fileMetadata } from "@/types/webrtc"; +import { createFileReceiveService, FileReceiveOrchestrator } from "./receive"; + /** - * 🚀 Strict Sequential Buffering Writer - Optimizes large file disk I/O performance + * 🚀 FileReceiver - Compatibility wrapper for the new modular architecture + * + * This class maintains backward compatibility while using the new modular receive system. + * All heavy lifting is now done by the FileReceiveOrchestrator and its specialized modules. */ -class SequencedDiskWriter { - private writeQueue = new Map(); - private nextWriteIndex = 0; - private readonly maxBufferSize = 100; // Buffer up to 100 chunks (approximately 6.4MB) - private readonly stream: FileSystemWritableFileStream; - private totalWritten = 0; - - constructor(stream: FileSystemWritableFileStream, startIndex: number = 0) { - this.stream = stream; - this.nextWriteIndex = startIndex; - } - - /**\n * Write a chunk, automatically managing order and buffering\n */ - async writeChunk(chunkIndex: number, chunk: ArrayBuffer): Promise { - // 1. If it is the expected next chunk, write immediately - if (chunkIndex === this.nextWriteIndex) { - await this.flushSequentialChunks(chunk); - return; - } - - // 2. If it's a future chunk, buffer it - if (chunkIndex > this.nextWriteIndex) { - if (this.writeQueue.size < this.maxBufferSize) { - this.writeQueue.set(chunkIndex, chunk); - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 📦 BUFFERED chunk #${chunkIndex} (waiting for #${this.nextWriteIndex}), queue: ${this.writeQueue.size}/${this.maxBufferSize}` - ); - } - } else { - // Buffer full, forcing processing of the earliest chunk to free up space - await this.forceFlushOldest(); - this.writeQueue.set(chunkIndex, chunk); - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ BUFFER_FULL, forced flush and buffered chunk #${chunkIndex}` - ); - } - } - return; - } - - // 3. If the chunk is expired, log a warning but ignore (already written) - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ DUPLICATE chunk #${chunkIndex} ignored (already written #${this.nextWriteIndex})` - ); - } - } - - /** - * Write current chunk and attempt to sequentially write subsequent chunks - */ - private async flushSequentialChunks(firstChunk: ArrayBuffer): Promise { - // Write current chunk - await this.stream.write(firstChunk); - this.totalWritten += firstChunk.byteLength; - - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}` - ); - } - - this.nextWriteIndex++; - - // Try to sequentially write chunks from buffer - let flushCount = 0; - while (this.writeQueue.has(this.nextWriteIndex)) { - const chunk = this.writeQueue.get(this.nextWriteIndex)!; - await this.stream.write(chunk); - this.totalWritten += chunk.byteLength; - this.writeQueue.delete(this.nextWriteIndex); - - flushCount++; - this.nextWriteIndex++; - } - - if (flushCount > 0) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 🔥 SEQUENTIAL_FLUSH ${flushCount} chunks, now at #${this.nextWriteIndex}, queue: ${this.writeQueue.size}` - ); - } - } - } - - /** - * Force refresh the earliest chunk to release buffer space - */ - private async forceFlushOldest(): Promise { - if (this.writeQueue.size === 0) return; - - const oldestIndex = Math.min(...Array.from(this.writeQueue.keys())); - const chunk = this.writeQueue.get(oldestIndex)!; - - // Warning: Unordered write - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ FORCE_FLUSH out-of-order chunk #${oldestIndex} (expected #${this.nextWriteIndex})` - ); - } - - // Use seek to write at the correct position (fallback handling) - const fileOffset = oldestIndex * 65536; // Assume each chunk is 64KB - await this.stream.seek(fileOffset); - await this.stream.write(chunk); - this.writeQueue.delete(oldestIndex); - - // Return to current position - const currentOffset = this.nextWriteIndex * 65536; - await this.stream.seek(currentOffset); - } - - /** - * Get buffer status - */ - getBufferStatus(): { - queueSize: number; - nextIndex: number; - totalWritten: number; - } { - return { - queueSize: this.writeQueue.size, - nextIndex: this.nextWriteIndex, - totalWritten: this.totalWritten, - }; - } - - /** - * Close and clean up resources - */ - async close(): Promise { - // Try to flush all remaining chunks - const remainingIndexes = Array.from(this.writeQueue.keys()).sort( - (a, b) => a - b - ); - for (const chunkIndex of remainingIndexes) { - const chunk = this.writeQueue.get(chunkIndex)!; - const fileOffset = chunkIndex * 65536; - await this.stream.seek(fileOffset); - await this.stream.write(chunk); - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 💾 FINAL_FLUSH chunk #${chunkIndex} at cleanup` - ); - } - } - - this.writeQueue.clear(); - } -} - -/**\n * 🚀 New Version: Manage file reception state for serialized embedded packets\n */ -interface ActiveFileReception { - meta: fileMetadata; // If meta is present, it means this file is currently being received; null means no file is being received. - chunks: (ArrayBuffer | null)[]; // Array of data chunks arranged by index - receivedSize: number; - initialOffset: number; // For resuming downloads - fileHandle: FileSystemFileHandle | null; // Object related to writing to disk -- current file. - writeStream: FileSystemWritableFileStream | null; // Object related to writing to disk. - sequencedWriter: SequencedDiskWriter | null; // 🚀 Added: Strict sequential writing manager - completionNotifier: { - resolve: () => void; - reject: (reason?: any) => void; - }; - // 🚀 New Version: Simplified sequential reception management - receivedChunksCount: number; // Actual number of chunks received - expectedChunksCount: number; // Expected number of chunks - chunkSequenceMap: Map; // Track which chunks have been received (for chunk numbering) - isFinalized?: boolean; // Flag to prevent duplicate finalize operations -} - class FileReceiver { - // region Private Properties - private readonly webrtcConnection: WebRTC_Recipient; - private readonly largeFileThreshold: number = 1 * 1024 * 1024 * 1024; // 1 GB, larger files will prompt the user to select a directory for direct disk saving. - private readonly speedCalculator: SpeedCalculator; - private fileHandlers: FileHandlers; + private orchestrator: FileReceiveOrchestrator; - private peerId: string = ""; - private saveDirectory: FileSystemDirectoryHandle | null = null; + // Public properties for backward compatibility + public saveType: Record = {}; - // State Management - private pendingFilesMeta = new Map(); // Stores file metadata, fileId: meta - private folderProgresses: Record = {}; // Folder progress information, fileId: {totalSize: 0, receivedSize: 0, fileIds: []}; - public saveType: Record = {}; // fileId or folderName -> isSavedToDisk + // Callbacks - these are forwarded to the orchestrator + public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined = + undefined; + public onStringReceived: ((str: string) => void) | undefined = undefined; + public onFileReceived: ((file: CustomFile) => Promise) | undefined = + undefined; - // Active transfer state - private activeFileReception: ActiveFileReception | null = null; - private activeStringReception: CurrentString | null = null; - private currentFolderName: string | null = null; // The name of the folder currently being received, or null if not receiving a folder. + constructor(webrtcRecipient: WebRTC_Recipient) { + // Create the orchestrator using the factory function + this.orchestrator = createFileReceiveService(webrtcRecipient); - // Callbacks - public onFileMetaReceived: ((meta: fileMetadata) => void) | null = null; - public onStringReceived: ((str: string) => void) | null = null; - public onFileReceived: ((file: CustomFile) => Promise) | null = null; - private progressCallback: - | ((id: string, progress: number, speed: number) => void) - | null = null; - // endregion + // Set up callback forwarding + this.setupCallbackForwarding(); - constructor(WebRTC_recipient: WebRTC_Recipient) { - this.webrtcConnection = WebRTC_recipient; - this.speedCalculator = new SpeedCalculator(); - - this.fileHandlers = { - string: this.handleReceivedStringChunk.bind(this), - stringMetadata: this.handleStringMetadata.bind(this), - fileMeta: this.handleFileMetadata.bind(this), - }; - - this.setupDataHandler(); + this.log("log", "FileReceiver initialized with modular architecture"); } - // region Logging and Error Handling + /** + * Set up callback forwarding to the orchestrator + */ + private setupCallbackForwarding(): void { + // Forward file metadata callback + this.orchestrator.onFileMetaReceived = (meta: fileMetadata) => { + // Update saveType for backward compatibility + this.saveType = this.orchestrator.getSaveType(); + + if (this.onFileMetaReceived) { + this.onFileMetaReceived(meta); + } + }; + + // Forward string received callback + this.orchestrator.onStringReceived = (str: string) => { + if (this.onStringReceived) { + this.onStringReceived(str); + } + }; + + // Forward file received callback + this.orchestrator.onFileReceived = async (file: CustomFile) => { + if (this.onFileReceived) { + await this.onFileReceived(file); + } + }; + } + + /** + * Set progress callback + */ + public setProgressCallback( + callback: (fileId: string, progress: number, speed: number) => void + ): void { + this.orchestrator.setProgressCallback(callback); + } + + /** + * Set save directory + */ + public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise { + return this.orchestrator.setSaveDirectory(directory); + } + + /** + * Request a single file from the peer + */ + public async requestFile(fileId: string, singleFile = true): Promise { + return this.orchestrator.requestFile(fileId, singleFile); + } + + /** + * Request all files belonging to a folder from the peer + */ + public async requestFolder(folderName: string): Promise { + return this.orchestrator.requestFolder(folderName); + } + + /** + * Graceful shutdown + */ + public gracefulShutdown(reason: string = "CONNECTION_LOST"): void { + this.orchestrator.gracefulShutdown(reason); + + // Update saveType for backward compatibility + this.saveType = {}; + } + + /** + * Force reset all internal states + */ + public forceReset(): void { + this.orchestrator.forceReset(); + + // Update saveType for backward compatibility + this.saveType = {}; + } + + /** + * Get transfer statistics (for debugging and monitoring) + */ + public getTransferStats() { + return this.orchestrator.getTransferStats(); + } + + /** + * Clean up all resources + */ + public cleanup(): void { + this.orchestrator.cleanup(); + this.saveType = {}; + } + + // ===== Private Methods ===== + + /** + * Logging utility + */ private log( level: "log" | "warn" | "error", message: string, @@ -251,870 +142,36 @@ class FileReceiver { console[level](prefix, message, context || ""); } - private fireError(message: string, context?: Record) { - if (this.webrtcConnection.fireError) { - // @ts-ignore - this.webrtcConnection.fireError(message, { - ...context, - component: "FileReceiver", - }); - } else { - this.log("error", message, context); - } + // ===== Backward Compatibility Getters ===== - if (this.activeFileReception) { - // 🚀 Also clean up SequencedWriter on error - if (this.activeFileReception.sequencedWriter) { - this.activeFileReception.sequencedWriter.close().catch((err) => { - this.log( - "error", - "Error closing sequenced writer during error cleanup", - { err } - ); - }); - } - - this.activeFileReception.completionNotifier.reject(new Error(message)); - this.activeFileReception = null; - } - } - // endregion - - // region Setup and Public API - private setupDataHandler(): void { - this.webrtcConnection.onDataReceived = this.handleReceivedData.bind(this); - } - - public setProgressCallback( - callback: (fileId: string, progress: number, speed: number) => void - ): void { - this.progressCallback = callback; - } - - public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise { - this.saveDirectory = directory; - return Promise.resolve(); + /** + * Get pending files metadata (for backward compatibility) + */ + public getPendingFilesMeta(): Map { + return this.orchestrator.getPendingFilesMeta(); } /** - * Requests a single file from the peer. + * Get folder progresses (for backward compatibility) */ - public async requestFile(fileId: string, singleFile = true): Promise { - if (this.activeFileReception) { - this.log("warn", "Another file reception is already in progress."); - return; - } - - if (singleFile) this.currentFolderName = null; - - const fileInfo = this.pendingFilesMeta.get(fileId); - if (!fileInfo) { - this.fireError("File info not found for the requested fileId", { - fileId, - }); - return; - } - - const shouldSaveToDisk = - !!this.saveDirectory || fileInfo.size >= this.largeFileThreshold; - - // Set saveType at the beginning of the request to prevent race conditions in the UI - this.saveType[fileInfo.fileId] = shouldSaveToDisk; - if (this.currentFolderName) { - this.saveType[this.currentFolderName] = shouldSaveToDisk; - } - - let offset = 0; - if (shouldSaveToDisk && this.saveDirectory) { - try { - const folderHandle = await this.createFolderStructure( - fileInfo.fullName - ); - const fileHandle = await folderHandle.getFileHandle(fileInfo.name, { - create: false, - }); - const file = await fileHandle.getFile(); - offset = file.size; - - if (offset === fileInfo.size) { - this.log("log", "File already fully downloaded.", { fileId }); - // Optionally, trigger a "completed" state in the UI directly - this.progressCallback?.(fileId, 1, 0); - return; // Skip the request - } - this.log("log", `Resuming file from offset: ${offset}`, { fileId }); - } catch (e) { - // File does not exist, starting from scratch - this.log("log", "Partial file not found, starting from scratch.", { - fileId, - }); - offset = 0; - } - } - - const receptionPromise = new Promise((resolve, reject) => { - const expectedChunksCount = Math.ceil((fileInfo.size - offset) / 65536); // Calculate expected chunk count - - this.activeFileReception = { - meta: fileInfo, - chunks: new Array(expectedChunksCount).fill(null), // 🚀 Initialize as an empty array arranged by index - receivedSize: 0, - initialOffset: offset, - fileHandle: null, - writeStream: null, - sequencedWriter: null, // 🚀 Added: Strict sequential writing manager - completionNotifier: { resolve, reject }, - // 🚀 New Version: Simplified sequential reception management - receivedChunksCount: 0, - expectedChunksCount: expectedChunksCount, - chunkSequenceMap: new Map(), - }; - }); - - if (shouldSaveToDisk) { - await this.createDiskWriteStream(fileInfo, offset); - } - - const request: FileRequest = { type: "fileRequest", fileId, offset }; - if (this.peerId) { - this.webrtcConnection.sendData(JSON.stringify(request), this.peerId); - this.log("log", "Sent fileRequest", { request }); - } else { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ERROR: Cannot send fileRequest - no peerId available!` - ); - } - } - - return receptionPromise; + public getFolderProgresses(): Record { + return this.orchestrator.getFolderProgresses(); } /** - * Requests all files belonging to a folder from the peer. + * Check if there's an active file reception */ - public async requestFolder(folderName: string): Promise { - const folderProgress = this.folderProgresses[folderName]; - if (!folderProgress || folderProgress.fileIds.length === 0) { - this.log("warn", "No files found for the requested folder.", { - folderName, - }); - return; - } - - // Pre-calculate total size of already downloaded parts of the folder - let initialFolderReceivedSize = 0; - if (this.saveDirectory) { - for (const fileId of folderProgress.fileIds) { - const fileInfo = this.pendingFilesMeta.get(fileId); - if (fileInfo) { - try { - const folderHandle = await this.createFolderStructure( - fileInfo.fullName - ); - const fileHandle = await folderHandle.getFileHandle(fileInfo.name, { - create: false, - }); - const file = await fileHandle.getFile(); - initialFolderReceivedSize += file.size; - } catch (e) { - // File doesn't exist, so its size is 0. - } - } - } - } - folderProgress.receivedSize = initialFolderReceivedSize; - this.log( - "log", - `Requesting to receive folder, initial received size: ${initialFolderReceivedSize}`, - { folderName } - ); - - this.currentFolderName = folderName; - for (const fileId of folderProgress.fileIds) { - try { - await this.requestFile(fileId, false); - } catch (error) { - this.fireError( - `Failed to receive file ${fileId} in folder ${folderName}`, - { error } - ); - // Stop receiving other files in the folder on error - break; - } - } - this.currentFolderName = null; - - // 🚀 New Process: Send folder reception completion confirmation - // Collect all successfully completed file IDs - const completedFileIds = folderProgress.fileIds.filter((fileId) => { - // More complex validation logic can be added here, now simply assume all succeeded - return true; - }); - - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 📁 All files in folder completed - ${folderName}, files: ${completedFileIds.length}/${folderProgress.fileIds.length}` - ); - } - - // Send folder completion message - this.sendFolderReceiveComplete(folderName, completedFileIds, true); - } - // endregion - - // region WebRTC Data Handlers - - /** - * Convert various binary data formats to ArrayBuffer - * Supports Blob, Uint8Array, and other formats for Firefox - */ - private async convertToArrayBuffer(data: any): Promise { - const originalType = Object.prototype.toString.call(data); - - if (data instanceof ArrayBuffer) { - return data; - } else if (data instanceof Blob) { - try { - const arrayBuffer = await data.arrayBuffer(); - if (data.size !== arrayBuffer.byteLength) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ Blob size mismatch: ${data.size}→${arrayBuffer.byteLength}` - ); - } - } - return arrayBuffer; - } catch (error) { - if (developmentEnv === "true") { - postLogToBackend(`[DEBUG] ❌ Blob conversion failed: ${error}`); - } - return null; - } - } else if (data instanceof Uint8Array || ArrayBuffer.isView(data)) { - try { - const uint8Array = - data instanceof Uint8Array - ? data - : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const newArrayBuffer = new ArrayBuffer(uint8Array.length); - new Uint8Array(newArrayBuffer).set(uint8Array); - return newArrayBuffer; - } catch (error) { - if (developmentEnv === "true") { - postLogToBackend(`[DEBUG] ❌ TypedArray conversion failed: ${error}`); - } - return null; - } - } else { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ Unknown data type: ${Object.prototype.toString.call( - data - )}` - ); - } - return null; - } + public hasActiveFileReception(): boolean { + const stats = this.orchestrator.getTransferStats(); + return stats.stateManager.hasActiveFileReception; } /** - * 🚀 Parsing fusion packets - * Format: [4 bytes length] + [JSON metadata] + [actual chunk data] + * Get current peer ID */ - private parseEmbeddedChunkPacket(arrayBuffer: ArrayBuffer): { - chunkMeta: EmbeddedChunkMeta; - chunkData: ArrayBuffer; - } | null { - try { - // 1. Check minimum packet length - if (arrayBuffer.byteLength < 4) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ Invalid embedded packet - too small: ${arrayBuffer.byteLength}` - ); - } - return null; - } - - // 2. Read metadata length (4 bytes) - const lengthView = new Uint32Array(arrayBuffer, 0, 1); - const metaLength = lengthView[0]; - - // 3. Verify packet integrity - const expectedTotalLength = 4 + metaLength; - if (arrayBuffer.byteLength < expectedTotalLength) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ Incomplete embedded packet - expected: ${expectedTotalLength}, got: ${arrayBuffer.byteLength}` - ); - } - return null; - } - - // 4. Extract metadata section - const metaBytes = new Uint8Array(arrayBuffer, 4, metaLength); - const metaJson = new TextDecoder().decode(metaBytes); - const chunkMeta: EmbeddedChunkMeta = JSON.parse(metaJson); - - // 5. Extract actual chunk data section - const chunkDataStart = 4 + metaLength; - const chunkData = arrayBuffer.slice(chunkDataStart); - - // 6. Verify chunk data size - if (chunkData.byteLength !== chunkMeta.chunkSize) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ Chunk size mismatch - meta: ${chunkMeta.chunkSize}, actual: ${chunkData.byteLength}` - ); - } - } - - return { chunkMeta, chunkData }; - } catch (error) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ Failed to parse embedded packet: ${error}` - ); - } - return null; - } - } - - private async handleReceivedData( - data: string | ArrayBuffer | any, - peerId: string - ): Promise { - this.peerId = peerId; - - if (typeof data === "string") { - try { - const parsedData = JSON.parse(data) as WebRTCMessage; - - const handler = - this.fileHandlers[parsedData.type as keyof FileHandlers]; - if (handler) { - await handler(parsedData, peerId); - } else { - console.warn( - `[DEBUG] ⚠️ FileReceiver Handler not found: ${parsedData.type}` - ); - } - } catch (error) { - this.fireError("Error parsing received JSON data", { error }); - } - } else { - // 🚀 New Version: Process embedded packets - Completely solve Firefox out-of-order issue - const arrayBuffer = await this.convertToArrayBuffer(data); - - if (arrayBuffer) { - if (!this.activeFileReception) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ERROR: Received file chunk but no active file reception!` - ); - } - this.fireError( - "Received a file chunk without an active file reception.", - { peerId } - ); - return; - } - - // 🚀 Unified processing: All data is processed as embedded packets - await this.handleEmbeddedChunkPacket(arrayBuffer); - } else { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ERROR: Failed to convert binary data to ArrayBuffer` - ); - } - this.fireError("Received unsupported binary data format", { - dataType: Object.prototype.toString.call(data), - peerId, - }); - } - } - } - - private handleFileMetadata(metadata: fileMetadata): void { - if (this.pendingFilesMeta.has(metadata.fileId)) { - return; // Ignore if already received. - } - - this.pendingFilesMeta.set(metadata.fileId, metadata); - - if (this.onFileMetaReceived) { - this.onFileMetaReceived(metadata); - } else { - console.error( - `[DEBUG] ❌ FileReceiver onFileMetaReceived callback does not exist!` - ); - } - // Record the file size for folder progress calculation. - if (metadata.folderName) { - const folderId = metadata.folderName; - if (!(folderId in this.folderProgresses)) { - this.folderProgresses[folderId] = { - totalSize: 0, - receivedSize: 0, - fileIds: [], - }; - } - const folderProgress = this.folderProgresses[folderId]; - if (!folderProgress.fileIds.includes(metadata.fileId)) { - // Prevent duplicate calculation - folderProgress.totalSize += metadata.size; - folderProgress.fileIds.push(metadata.fileId); - } - } - } - - private handleStringMetadata(metadata: StringMetadata): void { - this.activeStringReception = { - length: metadata.length, - chunks: [], - receivedChunks: 0, - }; - } - - private handleReceivedStringChunk(data: StringChunk): void { - if (!this.activeStringReception) return; - - this.activeStringReception.chunks[data.index] = data.chunk; - this.activeStringReception.receivedChunks++; - - if (this.activeStringReception.receivedChunks === data.total) { - const fullString = this.activeStringReception.chunks.join(""); - this.onStringReceived?.(fullString); - this.activeStringReception = null; - } - } - - // region File and Folder Processing - - /** - * 🚀 New Version: Process embedded packets - */ - private async handleEmbeddedChunkPacket( - arrayBuffer: ArrayBuffer - ): Promise { - const parsed = this.parseEmbeddedChunkPacket(arrayBuffer); - if (!parsed) { - this.fireError("Failed to parse embedded chunk packet"); - return; - } - - const { chunkMeta, chunkData } = parsed; - const reception = this.activeFileReception!; - - // Verify fileId match - if (chunkMeta.fileId !== reception.meta.fileId) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ FileId mismatch - expected: ${reception.meta.fileId}, got: ${chunkMeta.fileId}` - ); - } - return; - } - - // Update expected chunks count (may differ from initial estimate) - if (chunkMeta.totalChunks !== reception.expectedChunksCount) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ⚠️ Chunk count adjustment - expected: ${reception.expectedChunksCount}, actual: ${chunkMeta.totalChunks}` - ); - } - reception.expectedChunksCount = chunkMeta.totalChunks; - // Adjust chunks array size - if (reception.chunks.length < chunkMeta.totalChunks) { - const newChunks = new Array(chunkMeta.totalChunks).fill(null); - reception.chunks.forEach((chunk, index) => { - if (index < newChunks.length) newChunks[index] = chunk; - }); - reception.chunks = newChunks; - } - } - - // Store chunk by index - const chunkIndex = chunkMeta.chunkIndex; - if (chunkIndex >= 0 && chunkIndex < reception.chunks.length) { - reception.chunks[chunkIndex] = chunkData; - reception.chunkSequenceMap.set(chunkIndex, true); - reception.receivedChunksCount++; - - // Update progress - this.updateProgress(chunkData.byteLength); - - if (reception.sequencedWriter) { - // 🚀 Use strict sequential write management - await reception.sequencedWriter.writeChunk(chunkIndex, chunkData); - } - } else { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ Invalid chunk index - ${chunkIndex}, expected 0-${ - reception.chunks.length - 1 - }` - ); - } - } - - await this.checkAndAutoFinalize(); - } - - /** - * 🚀 Unified auto-complete check - */ - private async checkAndAutoFinalize(): Promise { - if (!this.activeFileReception) return; - - const reception = this.activeFileReception; - const receivedChunks = reception.receivedChunksCount; - const expectedChunks = reception.expectedChunksCount; - - // Calculate current actual total received size - const currentTotalSize = reception.chunks.reduce((sum, chunk) => { - return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0); - }, 0); - const expectedSize = reception.meta.size; - - // 🚀 Unified integrity check: sequential reception mode - let sequencedCount = 0; - for (let i = 0; i < expectedChunks; i++) { - if (reception.chunks[i] instanceof ArrayBuffer) { - sequencedCount++; - } - } - const isSequencedComplete = sequencedCount === expectedChunks; - - const sizeComplete = currentTotalSize >= expectedSize; - const isDataComplete = isSequencedComplete && sizeComplete; - - // Prevent duplicate finalize - if (reception.isFinalized) { - return; - } - - if (isDataComplete) { - reception.isFinalized = true; - - try { - await this.finalizeFileReceive(); - - if (reception.completionNotifier) { - reception.completionNotifier.resolve(); - } - this.activeFileReception = null; - } catch (error) { - if (developmentEnv === "true") { - postLogToBackend(`[DEBUG] ❌ Auto-finalize ERROR: ${error}`); - } - if (reception.completionNotifier) { - reception.completionNotifier.reject(error); - } - this.activeFileReception = null; - } - } - } - - private async finalizeFileReceive(): Promise { - if (!this.activeFileReception) return; - - if (this.activeFileReception.writeStream) { - await this.finalizeLargeFileReceive(); - } else { - await this.finalizeMemoryFileReceive(); - } - } - - private updateProgress(byteLength: number): void { - if (!this.peerId || !this.activeFileReception) return; - - this.activeFileReception.receivedSize += byteLength; - const reception = this.activeFileReception; - const totalReceived = reception.initialOffset + reception.receivedSize; - - if (this.currentFolderName) { - const folderProgress = this.folderProgresses[this.currentFolderName]; - if (!folderProgress) return; - // This is tricky: folder progress needs to sum up individual file progresses. - // For simplicity, we'll estimate based on total received for the active file. - // A more accurate implementation would track offsets for all files in the folder. - folderProgress.receivedSize += byteLength; // This is an approximation - - this.speedCalculator.updateSendSpeed( - this.peerId, - folderProgress.receivedSize - ); - const speed = this.speedCalculator.getSendSpeed(this.peerId); - const progress = - folderProgress.totalSize > 0 - ? folderProgress.receivedSize / folderProgress.totalSize - : 0; - this.progressCallback?.(this.currentFolderName, progress, speed); - } else { - this.speedCalculator.updateSendSpeed(this.peerId, totalReceived); - const speed = this.speedCalculator.getSendSpeed(this.peerId); - const progress = - reception.meta.size > 0 ? totalReceived / reception.meta.size : 0; - this.progressCallback?.(reception.meta.fileId, progress, speed); - } - } - // endregion - - // region Disk Operations - private async createDiskWriteStream( - meta: FileMeta, - offset: number - ): Promise { - if (!this.saveDirectory || !this.activeFileReception) { - this.log("warn", "Save directory not set, falling back to in-memory."); - return; - } - - try { - const folderHandle = await this.createFolderStructure(meta.fullName); - const fileHandle = await folderHandle.getFileHandle(meta.name, { - create: true, - }); - // Use keepExistingData: true to append - const writeStream = await fileHandle.createWritable({ - keepExistingData: true, - }); - // Seek to the offset to start writing from there - await writeStream.seek(offset); - - this.activeFileReception.fileHandle = fileHandle; - this.activeFileReception.writeStream = writeStream; - - // 🚀 Create a strictly sequential write manager - const startChunkIndex = Math.floor(offset / 65536); // Calculate starting chunk index - this.activeFileReception.sequencedWriter = new SequencedDiskWriter( - writeStream, - startChunkIndex - ); - - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 📢 SEQUENCED_WRITER created - startIndex: ${startChunkIndex}, offset: ${offset}` - ); - } - } catch (err) { - this.fireError("Failed to create file on disk", { - err, - fileName: meta.name, - }); - } - } - - private async createFolderStructure( - fullName: string - ): Promise { - if (!this.saveDirectory) { - throw new Error("Save directory not set"); - } - - const parts = fullName.split("/"); - parts.pop(); // Remove filename - - let currentDir = this.saveDirectory; - for (const part of parts) { - if (part) { - currentDir = await currentDir.getDirectoryHandle(part, { - create: true, - }); - } - } - return currentDir; - } - - private async finalizeLargeFileReceive(): Promise { - const reception = this.activeFileReception; - if (!reception?.writeStream || !reception.fileHandle) return; - - try { - // 🚀 First close the strict sequential writing manager (flush all buffers) - if (reception.sequencedWriter) { - await reception.sequencedWriter.close(); - const status = reception.sequencedWriter.getBufferStatus(); - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 💾 SEQUENCED_WRITER closed - totalWritten: ${status.totalWritten}, finalQueue: ${status.queueSize}` - ); - } - reception.sequencedWriter = null; - } - - // Then close the file stream - await reception.writeStream.close(); - - if (developmentEnv === "true") { - postLogToBackend(`[DEBUG] ✅ LARGE_FILE finalized successfully`); - } - } catch (error) { - this.fireError("Error finalizing large file", { error }); - } - } - // endregion - - // region In-Memory Operations - private async finalizeMemoryFileReceive(): Promise { - const reception = this.activeFileReception; - if (!reception) return; - - // 🚀 Simplified: Verify sequentially received data - let totalChunkSize = 0; - let validChunks = 0; - - reception.chunks.forEach((chunk, index) => { - if (chunk instanceof ArrayBuffer) { - validChunks++; - totalChunkSize += chunk.byteLength; - } - }); - - // Final verification - const sizeDifference = reception.meta.size - totalChunkSize; - if (sizeDifference !== 0) { - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] ❌ SIZE_MISMATCH - missing: ${sizeDifference} bytes` - ); - } - } - - // Create file - const fileBlob = new Blob( - reception.chunks.filter( - (chunk) => chunk instanceof ArrayBuffer - ) as ArrayBuffer[], - { - type: reception.meta.fileType, - } - ); - - const file = new File([fileBlob], reception.meta.name, { - type: reception.meta.fileType, - }); - - const customFile = Object.assign(file, { - fullName: reception.meta.fullName, - folderName: this.currentFolderName, - }) as CustomFile; - - let storeUpdated = false; - if (this.onFileReceived) { - await this.onFileReceived(customFile); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(() => resolve(), 0)); - storeUpdated = true; - } - - // Send completion confirmation - this.sendFileReceiveComplete( - reception.meta.fileId, - totalChunkSize, - validChunks, - storeUpdated - ); - } - // region Communication - - /** - * Send file reception completion confirmation - New receiver-dominated process - */ - private sendFileReceiveComplete( - fileId: string, - receivedSize: number, - receivedChunks: number, - storeUpdated: boolean - ): void { - if (!this.peerId) return; - - const completeMessage: FileReceiveComplete = { - type: "fileReceiveComplete", - fileId, - receivedSize, - receivedChunks, - storeUpdated, - }; - - const success = this.webrtcConnection.sendData( - JSON.stringify(completeMessage), - this.peerId - ); - } - - /** - * Send folder reception completion confirmation - */ - private sendFolderReceiveComplete( - folderName: string, - completedFileIds: string[], - allStoreUpdated: boolean - ): void { - if (!this.peerId) return; - - const completeMessage: FolderReceiveComplete = { - type: "folderReceiveComplete", - folderName, - completedFileIds, - allStoreUpdated, - }; - - const success = this.webrtcConnection.sendData( - JSON.stringify(completeMessage), - this.peerId - ); - - if (developmentEnv === "true") { - postLogToBackend( - `[DEBUG] 📤 Sent folderReceiveComplete - folderName: ${folderName}, completedFiles: ${completedFileIds.length}, allStoreUpdated: ${allStoreUpdated}, success: ${success}` - ); - } - } - // endregion - - public gracefulShutdown(): void { - if (this.activeFileReception?.sequencedWriter) { - this.log( - "log", - "Attempting to gracefully close sequenced writer on page unload." - ); - // 🚀 First close the strict sequential writing manager - this.activeFileReception.sequencedWriter.close().catch((err) => { - this.log( - "error", - "Error closing sequenced writer during graceful shutdown", - { - err, - } - ); - }); - } - - if (this.activeFileReception?.writeStream) { - this.log( - "log", - "Attempting to gracefully close write stream on page unload." - ); - // We don't await this, as beforeunload does not wait for promises. - // This is a "best effort" attempt to flush the buffer to disk. - this.activeFileReception.writeStream.close().catch((err) => { - this.log("error", "Error closing stream during graceful shutdown", { - err, - }); - }); - } - - // 🔧 Clean up all internal states to ensure correct file metadata reception upon reconnection - this.pendingFilesMeta.clear(); - this.folderProgresses = {}; - this.saveType = {}; - this.activeFileReception = null; - this.activeStringReception = null; - this.currentFolderName = null; + public getCurrentPeerId(): string { + const stats = this.orchestrator.getTransferStats(); + return stats.stateManager.currentPeerId; } } diff --git a/frontend/lib/fileSender.ts b/frontend/lib/fileSender.ts index 57b9779..6ac53e1 100644 --- a/frontend/lib/fileSender.ts +++ b/frontend/lib/fileSender.ts @@ -41,6 +41,11 @@ class FileSender { return this.orchestrator.getTransferStats(peerId); } + public handlePeerReconnection(peerId: string): void { + this.orchestrator.handlePeerReconnection(peerId); + console.log(`[FileSender] Handled peer reconnection for ${peerId}`); + } + public cleanup(): void { return this.orchestrator.cleanup(); } diff --git a/frontend/lib/receive/ChunkProcessor.ts b/frontend/lib/receive/ChunkProcessor.ts new file mode 100644 index 0000000..95f0ae5 --- /dev/null +++ b/frontend/lib/receive/ChunkProcessor.ts @@ -0,0 +1,329 @@ +import { EmbeddedChunkMeta } from "@/types/webrtc"; +import { ReceptionConfig } from "./ReceptionConfig"; +import { postLogToBackend } from "@/app/config/api"; + +/** + * 🚀 Chunk processing result interface + */ +export interface ChunkProcessingResult { + chunkMeta: EmbeddedChunkMeta; + chunkData: ArrayBuffer; + absoluteChunkIndex: number; + relativeChunkIndex: number; +} + +/** + * 🚀 Chunk processor + * Handles all data chunk processing, format conversion, and parsing + */ +export class ChunkProcessor { + /** + * Convert various binary data formats to ArrayBuffer + * Supports Blob, Uint8Array, and other formats for Firefox compatibility + */ + async convertToArrayBuffer(data: any): Promise { + const originalType = Object.prototype.toString.call(data); + + if (data instanceof ArrayBuffer) { + return data; + } else if (data instanceof Blob) { + try { + const arrayBuffer = await data.arrayBuffer(); + if (data.size !== arrayBuffer.byteLength) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ⚠️ Blob size mismatch: ${data.size}→${arrayBuffer.byteLength}` + ); + } + } + return arrayBuffer; + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend(`[DEBUG] ❌ Blob conversion failed: ${error}`); + } + return null; + } + } else if (data instanceof Uint8Array || ArrayBuffer.isView(data)) { + try { + const uint8Array = + data instanceof Uint8Array + ? data + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const newArrayBuffer = new ArrayBuffer(uint8Array.length); + new Uint8Array(newArrayBuffer).set(uint8Array); + return newArrayBuffer; + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend(`[DEBUG] ❌ TypedArray conversion failed: ${error}`); + } + return null; + } + } else { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ❌ Unknown data type: ${Object.prototype.toString.call( + data + )}` + ); + } + return null; + } + } + + /** + * Parse embedded chunk packet + * Format: [4 bytes length] + [JSON metadata] + [actual chunk data] + */ + parseEmbeddedChunkPacket(arrayBuffer: ArrayBuffer): { + chunkMeta: EmbeddedChunkMeta; + chunkData: ArrayBuffer; + } | null { + try { + // 1. Check minimum packet length + if (arrayBuffer.byteLength < ReceptionConfig.VALIDATION_CONFIG.MIN_PACKET_SIZE) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ❌ Invalid embedded packet - too small: ${arrayBuffer.byteLength}` + ); + } + return null; + } + + // 2. Read metadata length (4 bytes) + const lengthView = new Uint32Array(arrayBuffer, 0, 1); + const metaLength = lengthView[0]; + + // 3. Verify packet integrity + const expectedTotalLength = 4 + metaLength; + if (arrayBuffer.byteLength < expectedTotalLength) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ❌ Incomplete embedded packet - expected: ${expectedTotalLength}, got: ${arrayBuffer.byteLength}` + ); + } + return null; + } + + // 4. Extract metadata section + const metaBytes = new Uint8Array(arrayBuffer, 4, metaLength); + const metaJson = new TextDecoder().decode(metaBytes); + const chunkMeta: EmbeddedChunkMeta = JSON.parse(metaJson); + + // 5. Extract actual chunk data section + const chunkDataStart = 4 + metaLength; + const chunkData = arrayBuffer.slice(chunkDataStart); + + // 6. Verify chunk data size + if (chunkData.byteLength !== chunkMeta.chunkSize) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ⚠️ Chunk size mismatch - meta: ${chunkMeta.chunkSize}, actual: ${chunkData.byteLength}` + ); + } + } + + return { chunkMeta, chunkData }; + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ❌ Failed to parse embedded packet: ${error}` + ); + } + return null; + } + } + + /** + * Process received chunk and calculate indices + */ + processReceivedChunk( + chunkMeta: EmbeddedChunkMeta, + chunkData: ArrayBuffer, + initialOffset: number + ): ChunkProcessingResult | null { + // Calculate indices + const absoluteChunkIndex = chunkMeta.chunkIndex; // Sender's absolute index + const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset); // Resume start index + const relativeChunkIndex = absoluteChunkIndex - startChunkIndex; // Relative index in chunks array + + // 🎯 Simplify debugging: Only record index mapping when boundary chunk + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING && (absoluteChunkIndex <= 2 || relativeChunkIndex <= 2)) { + postLogToBackend( + `[INDEX-MAP] abs:${absoluteChunkIndex}, start:${startChunkIndex}, rel:${relativeChunkIndex}` + ); + } + + return { + chunkMeta, + chunkData, + absoluteChunkIndex, + relativeChunkIndex, + }; + } + + /** + * Validate chunk against expected parameters + */ + validateChunk( + chunkMeta: EmbeddedChunkMeta, + expectedFileId: string, + expectedChunksCount: number, + initialOffset: number + ): { + isValid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + // Verify fileId match + if (chunkMeta.fileId !== expectedFileId) { + errors.push(`FileId mismatch - expected: ${expectedFileId}, got: ${chunkMeta.fileId}`); + } + + // Validate chunk size + if (chunkMeta.chunkSize <= 0) { + errors.push(`Invalid chunk size: ${chunkMeta.chunkSize}`); + } + + // Check if chunk index is reasonable + if (chunkMeta.chunkIndex < 0) { + errors.push(`Invalid chunk index: ${chunkMeta.chunkIndex}`); + } + + // Validate total chunks (with resume consideration) + const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset); + const calculatedExpected = chunkMeta.totalChunks - startChunkIndex; + + // 🎯 Simplify logging: Only record critical information when the number does not match + if (chunkMeta.totalChunks !== expectedChunksCount && calculatedExpected !== expectedChunksCount) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[CHUNK-COUNT-MISMATCH] fileTotal:${chunkMeta.totalChunks}, expected:${expectedChunksCount}, calculated:${calculatedExpected}` + ); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Check if chunk index is within valid range + */ + isChunkIndexValid( + relativeChunkIndex: number, + expectedChunksCount: number + ): boolean { + return relativeChunkIndex >= 0 && relativeChunkIndex < expectedChunksCount; + } + + /** + * Log chunk processing details (for debugging) + */ + logChunkDetails( + result: ChunkProcessingResult, + expectedChunksCount: number, + writerExpectedIndex?: number + ): void { + if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + return; + } + + // 🎯 Simplify logging: Only record boundary chunk and abnormal cases + const { chunkMeta, absoluteChunkIndex, relativeChunkIndex } = result; + const isFirstFew = absoluteChunkIndex <= 3; + const isLastFew = relativeChunkIndex >= expectedChunksCount - 3; + + // 🔧 Fix: SequencedWriter expects absolute index, not relative index + const hasIndexMismatch = writerExpectedIndex !== undefined && absoluteChunkIndex !== writerExpectedIndex; + + if (isFirstFew || isLastFew || hasIndexMismatch) { + postLogToBackend( + `[CHUNK-DETAIL] #${absoluteChunkIndex} rel:${relativeChunkIndex}${ + hasIndexMismatch ? ` MISMATCH(writer expects:${writerExpectedIndex})` : '' + } size:${chunkMeta.chunkSize}` + ); + } + } + + /** + * Calculate completion statistics + */ + calculateCompletionStats( + chunks: (ArrayBuffer | null)[], + expectedChunksCount: number, + expectedSize: number + ): { + sequencedCount: number; + currentTotalSize: number; + isSequencedComplete: boolean; + sizeComplete: boolean; + isDataComplete: boolean; + } { + // Calculate current actual total received size + const currentTotalSize = chunks.reduce((sum, chunk) => { + return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0); + }, 0); + + // Count sequentially received chunks + let sequencedCount = 0; + for (let i = 0; i < expectedChunksCount; i++) { + if (chunks[i] instanceof ArrayBuffer) { + sequencedCount++; + } + } + + const isSequencedComplete = sequencedCount === expectedChunksCount; + const sizeComplete = currentTotalSize >= expectedSize; + const isDataComplete = isSequencedComplete && sizeComplete; + + return { + sequencedCount, + currentTotalSize, + isSequencedComplete, + sizeComplete, + isDataComplete, + }; + } + + /** + * Log completion check details (for debugging) + */ + logCompletionCheck( + fileName: string, + stats: { + sequencedCount: number; + expectedChunksCount: number; + currentTotalSize: number; + expectedSize: number; + isDataComplete: boolean; + }, + chunks: (ArrayBuffer | null)[], + initialOffset: number + ): void { + if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + return; + } + + const { sequencedCount, expectedChunksCount, currentTotalSize, expectedSize, isDataComplete } = stats; + + // 🎯 Critical log 3: Only print final check results when complete + if (isDataComplete) { + const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset); + const missingChunks = []; + + for (let i = 0; i < expectedChunksCount; i++) { + if (!chunks[i]) { + const absoluteIndex = startChunkIndex + i; + missingChunks.push(absoluteIndex); + } + } + + postLogToBackend( + `[FINAL-CHECK] File: ${fileName}, received: ${sequencedCount}/${expectedChunksCount}, sizeDiff: ${expectedSize - currentTotalSize}, missing: [${missingChunks.join(',')}]` + ); + } + } +} \ No newline at end of file diff --git a/frontend/lib/receive/FileAssembler.ts b/frontend/lib/receive/FileAssembler.ts new file mode 100644 index 0000000..0eb2f45 --- /dev/null +++ b/frontend/lib/receive/FileAssembler.ts @@ -0,0 +1,280 @@ +import { CustomFile, fileMetadata } from "@/types/webrtc"; +import { ReceptionConfig } from "./ReceptionConfig"; +import { postLogToBackend } from "@/app/config/api"; + +const developmentEnv = process.env.NODE_ENV; + +/** + * 🚀 File assembly result interface + */ +export interface FileAssemblyResult { + file: CustomFile; + totalChunkSize: number; + validChunks: number; + storeUpdated: boolean; +} + +/** + * 🚀 File assembler + * Handles in-memory file assembly and validation + */ +export class FileAssembler { + /** + * Assemble file from chunks in memory + */ + async assembleFileFromChunks( + chunks: (ArrayBuffer | null)[], + meta: fileMetadata, + currentFolderName: string | null, + onFileReceived?: (file: CustomFile) => Promise + ): Promise { + // Validate and count chunks + let totalChunkSize = 0; + let validChunks = 0; + + chunks.forEach((chunk, index) => { + if (chunk instanceof ArrayBuffer) { + validChunks++; + totalChunkSize += chunk.byteLength; + } + }); + + // Final verification + const sizeDifference = meta.size - totalChunkSize; + if (Math.abs(sizeDifference) > ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ❌ SIZE_MISMATCH - difference: ${sizeDifference} bytes (threshold: ${ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES})` + ); + } + } + + // Create file blob from valid chunks + const validChunkBuffers = chunks.filter( + (chunk) => chunk instanceof ArrayBuffer + ) as ArrayBuffer[]; + + const fileBlob = new Blob(validChunkBuffers, { + type: meta.fileType, + }); + + // Create File object + const file = new File([fileBlob], meta.name, { + type: meta.fileType, + }); + + // Create CustomFile with additional properties + const customFile = Object.assign(file, { + fullName: meta.fullName, + folderName: currentFolderName, + }) as CustomFile; + + // Store the file if callback is provided + let storeUpdated = false; + if (onFileReceived) { + await onFileReceived(customFile); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(() => resolve(), 0)); + storeUpdated = true; + } + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ✅ File assembled - ${meta.name}, chunks: ${validChunks}/${chunks.length}, size: ${totalChunkSize}/${meta.size}, stored: ${storeUpdated}` + ); + } + + return { + file: customFile, + totalChunkSize, + validChunks, + storeUpdated, + }; + } + + /** + * Validate file assembly completeness + */ + validateAssembly( + chunks: (ArrayBuffer | null)[], + expectedSize: number, + expectedChunksCount: number + ): { + isComplete: boolean; + validChunks: number; + totalSize: number; + missingChunks: number[]; + sizeDifference: number; + } { + let totalSize = 0; + let validChunks = 0; + const missingChunks: number[] = []; + + chunks.forEach((chunk, index) => { + if (chunk instanceof ArrayBuffer) { + validChunks++; + totalSize += chunk.byteLength; + } else { + missingChunks.push(index); + } + }); + + const sizeDifference = expectedSize - totalSize; + const isComplete = + validChunks === expectedChunksCount && + Math.abs(sizeDifference) <= ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES; + + return { + isComplete, + validChunks, + totalSize, + missingChunks, + sizeDifference, + }; + } + + /** + * Get assembly statistics for debugging + */ + getAssemblyStats(chunks: (ArrayBuffer | null)[]): { + totalChunks: number; + validChunks: number; + nullChunks: number; + totalSize: number; + averageChunkSize: number; + firstNullIndex: number | null; + lastValidIndex: number | null; + } { + let validChunks = 0; + let totalSize = 0; + let firstNullIndex: number | null = null; + let lastValidIndex: number | null = null; + + chunks.forEach((chunk, index) => { + if (chunk instanceof ArrayBuffer) { + validChunks++; + totalSize += chunk.byteLength; + lastValidIndex = index; + } else { + if (firstNullIndex === null) { + firstNullIndex = index; + } + } + }); + + const averageChunkSize = validChunks > 0 ? totalSize / validChunks : 0; + + return { + totalChunks: chunks.length, + validChunks, + nullChunks: chunks.length - validChunks, + totalSize, + averageChunkSize, + firstNullIndex, + lastValidIndex, + }; + } + + /** + * Create file download URL for in-memory files + */ + createDownloadUrl(file: File): string { + return URL.createObjectURL(file); + } + + /** + * Revoke file download URL to free memory + */ + revokeDownloadUrl(url: string): void { + URL.revokeObjectURL(url); + } + + /** + * Get file type information + */ + getFileTypeInfo(file: File): { + mimeType: string; + extension: string; + category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other'; + } { + const mimeType = file.type || 'application/octet-stream'; + const extension = file.name.split('.').pop()?.toLowerCase() || ''; + + let category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other' = 'other'; + + if (mimeType.startsWith('image/')) { + category = 'image'; + } else if (mimeType.startsWith('video/')) { + category = 'video'; + } else if (mimeType.startsWith('audio/')) { + category = 'audio'; + } else if ( + mimeType.includes('text/') || + mimeType.includes('application/pdf') || + mimeType.includes('application/msword') || + mimeType.includes('application/vnd.openxmlformats') + ) { + category = 'document'; + } else if ( + mimeType.includes('zip') || + mimeType.includes('rar') || + mimeType.includes('tar') || + mimeType.includes('gzip') + ) { + category = 'archive'; + } + + return { + mimeType, + extension, + category, + }; + } + + /** + * Estimate memory usage for file assembly + */ + estimateMemoryUsage(chunks: (ArrayBuffer | null)[]): { + chunkMemoryUsage: number; + estimatedBlobMemory: number; + totalEstimatedMemory: number; + } { + const chunkMemoryUsage = chunks.reduce((sum, chunk) => { + return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0); + }, 0); + + // Blob creation might temporarily double memory usage + const estimatedBlobMemory = chunkMemoryUsage; + const totalEstimatedMemory = chunkMemoryUsage + estimatedBlobMemory; + + return { + chunkMemoryUsage, + estimatedBlobMemory, + totalEstimatedMemory, + }; + } + + /** + * Check if file should be assembled in memory or streamed to disk + */ + shouldAssembleInMemory( + fileSize: number, + hasSaveDirectory: boolean, + availableMemory?: number + ): boolean { + // If we have a save directory and file is large, prefer disk + if (hasSaveDirectory && fileSize >= ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD) { + return false; + } + + // If available memory is provided, check if we have enough + if (availableMemory !== undefined) { + // Need roughly 2x file size for assembly process + const requiredMemory = fileSize * 2; + return availableMemory > requiredMemory; + } + + // Default: assemble in memory for smaller files + return fileSize < ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD; + } +} \ No newline at end of file diff --git a/frontend/lib/receive/FileReceiveOrchestrator.ts b/frontend/lib/receive/FileReceiveOrchestrator.ts new file mode 100644 index 0000000..92ba237 --- /dev/null +++ b/frontend/lib/receive/FileReceiveOrchestrator.ts @@ -0,0 +1,713 @@ +import WebRTC_Recipient from "../webrtc_Recipient"; +import { CustomFile, fileMetadata } from "@/types/webrtc"; +import { ReceptionStateManager } from "./ReceptionStateManager"; +import { MessageProcessor, MessageProcessorDelegate } from "./MessageProcessor"; +import { ChunkProcessor } from "./ChunkProcessor"; +import { + StreamingFileWriter +} from "./StreamingFileWriter"; +import { FileAssembler } from "./FileAssembler"; +import { ProgressReporter, ProgressCallback } from "./ProgressReporter"; +import { ReceptionConfig } from "./ReceptionConfig"; +import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator"; +import { postLogToBackend } from "@/app/config/api"; + +/** + * 🚀 File receive orchestrator + * Main coordinator that integrates all reception modules + */ +export class FileReceiveOrchestrator implements MessageProcessorDelegate { + private stateManager: ReceptionStateManager; + private messageProcessor: MessageProcessor; + private chunkProcessor: ChunkProcessor; + private streamingFileWriter: StreamingFileWriter; + private fileAssembler: FileAssembler; + private progressReporter: ProgressReporter; + + // Callbacks + public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined = + undefined; + public onStringReceived: ((str: string) => void) | undefined = undefined; + public onFileReceived: ((file: CustomFile) => Promise) | undefined = + undefined; + + constructor(private webrtcConnection: WebRTC_Recipient) { + // Initialize all components + this.stateManager = new ReceptionStateManager(); + this.chunkProcessor = new ChunkProcessor(); + this.streamingFileWriter = new StreamingFileWriter(); + this.fileAssembler = new FileAssembler(); + this.progressReporter = new ProgressReporter(this.stateManager); + this.messageProcessor = new MessageProcessor( + this.stateManager, + webrtcConnection, + { + onFileMetaReceived: (meta: fileMetadata) => { + if (this.onFileMetaReceived) { + this.onFileMetaReceived(meta); + } + }, + onStringReceived: (str: string) => { + if (this.onStringReceived) { + this.onStringReceived(str); + } + }, + log: this.log.bind(this), + } + ); + + // Set up data handler + this.setupDataHandler(); + + this.log("log", "FileReceiveOrchestrator initialized"); + } + + // ===== Public API ===== + + /** + * Set progress callback + */ + public setProgressCallback(callback: ProgressCallback): void { + this.progressReporter.setProgressCallback(callback); + } + + /** + * Set save directory + */ + public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise { + this.stateManager.setSaveDirectory(directory); + this.streamingFileWriter.setSaveDirectory(directory); + return Promise.resolve(); + } + + /** + * Request a single file from the peer + */ + public async requestFile(fileId: string, singleFile = true): Promise { + const activeReception = this.stateManager.getActiveFileReception(); + if (activeReception) { + this.log("warn", "Another file reception is already in progress."); + return; + } + + if (singleFile) { + this.stateManager.setCurrentFolderName(null); + } + + const fileInfo = this.stateManager.getFileMetadata(fileId); + if (!fileInfo) { + this.fireError("File info not found for the requested fileId", { + fileId, + }); + return; + } + + const shouldSaveToDisk = ReceptionConfig.shouldSaveToDisk( + fileInfo.size, + this.streamingFileWriter.hasSaveDirectory() + ); + + // Set save type at the beginning to prevent race conditions + this.stateManager.setSaveType(fileInfo.fileId, shouldSaveToDisk); + const currentFolderName = this.stateManager.getCurrentFolderName(); + if (currentFolderName) { + this.stateManager.setSaveType(currentFolderName, shouldSaveToDisk); + } + + let offset = 0; + if (shouldSaveToDisk && this.streamingFileWriter.hasSaveDirectory()) { + try { + offset = await this.streamingFileWriter.getPartialFileSize( + fileInfo.name, + fileInfo.fullName + ); + + if (offset === fileInfo.size) { + this.log("log", "File already fully downloaded.", { fileId }); + this.progressReporter.reportFileComplete(fileId); + return; + } + this.log("log", `Resuming file from offset: ${offset}`, { fileId }); + } catch (e) { + this.log("log", "Partial file not found, starting from scratch.", { + fileId, + }); + offset = 0; + } + } + + const expectedChunksCount = ReceptionConfig.calculateExpectedChunks( + fileInfo.size, + offset + ); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + // 🎯 Critical log 2: Summary information for receiver - using unified chunk range calculation logic + const chunkRange = ChunkRangeCalculator.getChunkRange( + fileInfo.size, + offset, + ReceptionConfig.FILE_CONFIG.CHUNK_SIZE + ); + + postLogToBackend( + `[RECV-SUMMARY] File: ${fileInfo.name}, expected: ${expectedChunksCount}, calculated: ${chunkRange.totalChunks}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, absoluteTotal: ${chunkRange.absoluteTotalChunks}` + ); + } + + const receptionPromise = this.stateManager.startFileReception( + fileInfo, + expectedChunksCount, + offset + ); + + if (shouldSaveToDisk) { + await this.createDiskWriteStream(fileInfo, offset); + } + + // Send file request + const success = this.messageProcessor.sendFileRequest(fileId, offset); + if (!success) { + this.stateManager.failFileReception( + new Error("Failed to send file request") + ); + return; + } + + return receptionPromise; + } + + /** + * Request all files belonging to a folder from the peer + */ + public async requestFolder(folderName: string): Promise { + const folderProgress = this.stateManager.getFolderProgress(folderName); + if (!folderProgress || folderProgress.fileIds.length === 0) { + this.log("warn", "No files found for the requested folder.", { + folderName, + }); + return; + } + + // Pre-calculate total size of already downloaded parts + let initialFolderReceivedSize = 0; + if (this.streamingFileWriter.hasSaveDirectory()) { + for (const fileId of folderProgress.fileIds) { + const fileInfo = this.stateManager.getFileMetadata(fileId); + if (fileInfo) { + try { + const partialSize = + await this.streamingFileWriter.getPartialFileSize( + fileInfo.name, + fileInfo.fullName + ); + initialFolderReceivedSize += partialSize; + } catch (e) { + // File doesn't exist, so its size is 0 + } + } + } + } + + this.stateManager.setFolderReceivedSize( + folderName, + initialFolderReceivedSize + ); + this.log( + "log", + `Requesting folder, initial received size: ${initialFolderReceivedSize}`, + { folderName } + ); + + this.stateManager.setCurrentFolderName(folderName); + + for (const fileId of folderProgress.fileIds) { + try { + await this.requestFile(fileId, false); + } catch (error) { + this.fireError( + `Failed to receive file ${fileId} in folder ${folderName}`, + { error } + ); + break; + } + } + + this.stateManager.setCurrentFolderName(null); + + // Send folder completion message + const completedFileIds = folderProgress.fileIds.filter(() => true); // Assume all succeeded + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] 📁 All files in folder completed - ${folderName}, files: ${completedFileIds.length}/${folderProgress.fileIds.length}` + ); + } + + this.messageProcessor.sendFolderReceiveComplete( + folderName, + completedFileIds, + true + ); + } + + // ===== MessageProcessorDelegate Implementation ===== + + // Note: These are implemented as properties, not methods, to avoid infinite recursion + + public log( + level: "log" | "warn" | "error", + message: string, + context?: Record + ): void { + const prefix = `[FileReceiveOrchestrator]`; + console[level](prefix, message, context || ""); + } + + // ===== Internal Methods ===== + + /** + * Set up data handler + */ + private setupDataHandler(): void { + this.webrtcConnection.onDataReceived = async (data, peerId) => { + const binaryData = await this.messageProcessor.handleReceivedMessage( + data, + peerId + ); + + if (binaryData) { + // Handle binary chunk data + await this.handleBinaryChunkData(binaryData); + } + }; + } + + /** + * Handle binary chunk data + */ + private async handleBinaryChunkData(data: any): Promise { + const activeReception = this.stateManager.getActiveFileReception(); + if (!activeReception) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ERROR: Received file chunk but no active file reception!` + ); + } + this.fireError("Received a file chunk without an active file reception."); + return; + } + + // Convert to ArrayBuffer + const arrayBuffer = await this.chunkProcessor.convertToArrayBuffer(data); + if (!arrayBuffer) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ERROR: Failed to convert binary data to ArrayBuffer` + ); + } + this.fireError("Received unsupported binary data format", { + dataType: Object.prototype.toString.call(data), + }); + return; + } + + await this.handleEmbeddedChunkPacket(arrayBuffer); + } + + /** + * Handle embedded chunk packet + */ + private async handleEmbeddedChunkPacket( + arrayBuffer: ArrayBuffer + ): Promise { + const parsed = this.chunkProcessor.parseEmbeddedChunkPacket(arrayBuffer); + if (!parsed) { + this.fireError("Failed to parse embedded chunk packet"); + return; + } + + const { chunkMeta, chunkData } = parsed; + const reception = this.stateManager.getActiveFileReception(); + if (!reception) { + console.log( + `[FileReceiveOrchestrator] Ignoring chunk ${chunkMeta.chunkIndex} - file reception already closed` + ); + return; + } + + // Validate chunk + const validation = this.chunkProcessor.validateChunk( + chunkMeta, + reception.meta.fileId, + reception.expectedChunksCount, + reception.initialOffset + ); + + if (!validation.isValid) { + this.log("warn", "Chunk validation failed", { + errors: validation.errors, + chunkIndex: chunkMeta.chunkIndex, + }); + return; + } + + // Process chunk indices + const result = this.chunkProcessor.processReceivedChunk( + chunkMeta, + chunkData, + reception.initialOffset + ); + + if (!result) { + this.fireError("Failed to process received chunk"); + return; + } + + // Check if chunk index is valid + if ( + !this.chunkProcessor.isChunkIndexValid( + result.relativeChunkIndex, + reception.expectedChunksCount + ) + ) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-CHUNKS] ❌ Invalid relative chunk index - absolute:${result.absoluteChunkIndex}, relative:${result.relativeChunkIndex}, arraySize:${reception.chunks.length}` + ); + } + return; + } + + // Store chunk + reception.chunks[result.relativeChunkIndex] = result.chunkData; + reception.chunkSequenceMap.set(result.absoluteChunkIndex, true); + reception.receivedChunksCount++; + + // Update progress + this.progressReporter.updateFileProgress( + result.chunkData.byteLength, + reception.meta.fileId, + reception.meta.size + ); + + // Handle disk writing if needed + if (reception.sequencedWriter) { + // 🔧 Fix: SequencedWriter uses absolute index, ensuring correct index is passed + this.chunkProcessor.logChunkDetails( + result, + reception.expectedChunksCount, + reception.sequencedWriter.expectedIndex + ); + + // ✅ Correctly use absolute index for disk writing + await reception.sequencedWriter.writeChunk( + result.absoluteChunkIndex, + result.chunkData + ); + } + + await this.checkAndAutoFinalize(); + } + + /** + * Check and auto-finalize file reception + */ + private async checkAndAutoFinalize(): Promise { + const reception = this.stateManager.getActiveFileReception(); + if (!reception || reception.isFinalized) return; + + const expectedSize = reception.meta.size - reception.initialOffset; + const stats = this.chunkProcessor.calculateCompletionStats( + reception.chunks, + reception.expectedChunksCount, + expectedSize + ); + + // Log completion check details + this.chunkProcessor.logCompletionCheck( + reception.meta.name, + { + sequencedCount: stats.sequencedCount, + expectedChunksCount: reception.expectedChunksCount, + currentTotalSize: stats.currentTotalSize, + expectedSize, + isDataComplete: stats.isDataComplete, + }, + reception.chunks, + reception.initialOffset + ); + + if (stats.isDataComplete) { + reception.isFinalized = true; + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-COMPLETE] ✅ Starting finalization - isDataComplete:${stats.isDataComplete}` + ); + } + + try { + await this.finalizeFileReceive(); + this.stateManager.completeFileReception(); + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend(`[DEBUG] ❌ Auto-finalize ERROR: ${error}`); + } + this.stateManager.failFileReception(error); + } + } + } + + /** + * Finalize file reception + */ + private async finalizeFileReceive(): Promise { + const reception = this.stateManager.getActiveFileReception(); + if (!reception) return; + + if (reception.writeStream) { + await this.finalizeLargeFileReceive(); + } else { + await this.finalizeMemoryFileReceive(); + } + } + + /** + * Finalize large file reception (disk-based) + */ + private async finalizeLargeFileReceive(): Promise { + const reception = this.stateManager.getActiveFileReception(); + if (!reception?.writeStream || !reception.fileHandle) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ❌ Cannot finalize - missing writeStream:${!!reception?.writeStream} or fileHandle:${!!reception?.fileHandle}` + ); + } + return; + } + + try { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] 🚀 Starting finalization for ${reception.meta.name}` + ); + } + + // Finalize using StreamingFileWriter + if (reception.sequencedWriter && reception.writeStream) { + await this.streamingFileWriter.finalizeWrite( + reception.sequencedWriter, + reception.writeStream, + reception.meta.name + ); + } + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${reception.meta.name}` + ); + } + + // 🆕 Send completion confirmation for large files + const stats = this.chunkProcessor.calculateCompletionStats( + reception.chunks, + reception.expectedChunksCount, + reception.meta.size - reception.initialOffset + ); + + this.messageProcessor.sendFileReceiveComplete( + reception.meta.fileId, + stats.currentTotalSize, + stats.sequencedCount, + true + ); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] 📤 LARGE_FILE completion confirmation sent - ${reception.meta.fileId}, size: ${stats.currentTotalSize}, chunks: ${stats.sequencedCount}` + ); + } + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ❌ Error during finalization: ${error}` + ); + } + this.fireError("Error finalizing large file", { error }); + } + } + + /** + * Finalize memory file reception + */ + private async finalizeMemoryFileReceive(): Promise { + const reception = this.stateManager.getActiveFileReception(); + if (!reception) return; + + const currentFolderName = this.stateManager.getCurrentFolderName(); + const result = await this.fileAssembler.assembleFileFromChunks( + reception.chunks, + reception.meta, + currentFolderName, + this.onFileReceived + ); + + // Send completion confirmation + this.messageProcessor.sendFileReceiveComplete( + reception.meta.fileId, + result.totalChunkSize, + result.validChunks, + result.storeUpdated + ); + } + + /** + * Create disk write stream + */ + private async createDiskWriteStream( + meta: fileMetadata, + offset: number + ): Promise { + try { + const { fileHandle, writeStream, sequencedWriter } = + await this.streamingFileWriter.createWriteStream( + meta.name, + meta.fullName, + offset + ); + + this.stateManager.updateActiveFileReception({ + fileHandle, + writeStream, + sequencedWriter, + }); + } catch (err) { + this.fireError("Failed to create file on disk", { + err, + fileName: meta.name, + }); + } + } + + /** + * Error handling + */ + private fireError(message: string, context?: Record) { + if (this.webrtcConnection.fireError) { + // @ts-ignore + this.webrtcConnection.fireError(message, { + ...context, + component: "FileReceiveOrchestrator", + }); + } else { + this.log("error", message, context); + } + + const reception = this.stateManager.getActiveFileReception(); + if (reception) { + // Clean up resources on error + if (reception.sequencedWriter) { + reception.sequencedWriter.close().catch((err: any) => { + this.log( + "error", + "Error closing sequenced writer during error cleanup", + { err } + ); + }); + } + + this.stateManager.failFileReception(new Error(message)); + } + } + + // ===== Lifecycle Management ===== + + /** + * Graceful shutdown + */ + public gracefulShutdown(reason: string = "CONNECTION_LOST"): void { + this.log("log", `Graceful shutdown initiated: ${reason}`); + + const reception = this.stateManager.getActiveFileReception(); + if (reception?.sequencedWriter && reception?.writeStream) { + this.log("log", "Attempting to gracefully close streams on shutdown."); + + // Close sequenced writer and write stream + reception.sequencedWriter.close().catch((err: any) => { + this.log( + "error", + "Error closing sequenced writer during graceful shutdown", + { err } + ); + }); + + reception.writeStream.close().catch((err: any) => { + this.log("error", "Error closing stream during graceful shutdown", { + err, + }); + }); + } + + this.stateManager.gracefulCleanup(); + this.log("log", "Graceful shutdown completed"); + } + + /** + * Force reset all internal states + */ + public forceReset(): void { + this.log("log", "Force resetting FileReceiveOrchestrator state"); + + const reception = this.stateManager.getActiveFileReception(); + if (reception?.sequencedWriter && reception?.writeStream) { + reception.sequencedWriter.close().catch(console.error); + reception.writeStream.close().catch(console.error); + } + + this.stateManager.forceReset(); + this.progressReporter.resetAllProgress(); + this.log("log", "FileReceiveOrchestrator state force reset completed"); + } + + /** + * Get transfer statistics + */ + public getTransferStats() { + return { + stateManager: this.stateManager.getStateStats(), + progressReporter: this.progressReporter.getProgressStats(), + messageProcessor: this.messageProcessor.getMessageStats(), + }; + } + + /** + * Get save type information (for backward compatibility) + */ + public getSaveType(): Record { + return this.stateManager.saveType; + } + + /** + * Get pending files metadata (for backward compatibility) + */ + public getPendingFilesMeta(): Map { + return this.stateManager.getAllFileMetadata(); + } + + /** + * Get folder progresses (for backward compatibility) + */ + public getFolderProgresses(): Record { + return this.stateManager.getAllFolderProgresses(); + } + + /** + * Clean up all resources + */ + public cleanup(): void { + this.stateManager.gracefulCleanup(); + this.progressReporter.cleanup(); + this.messageProcessor.cleanup(); + this.log("log", "FileReceiveOrchestrator cleaned up"); + } +} diff --git a/frontend/lib/receive/MessageProcessor.ts b/frontend/lib/receive/MessageProcessor.ts new file mode 100644 index 0000000..8c902a7 --- /dev/null +++ b/frontend/lib/receive/MessageProcessor.ts @@ -0,0 +1,302 @@ +import { + WebRTCMessage, + fileMetadata, + StringMetadata, + StringChunk, + FileRequest, + FileReceiveComplete, + FolderReceiveComplete, + FileHandlers, +} from "@/types/webrtc"; +import { ReceptionStateManager } from "./ReceptionStateManager"; +import { ReceptionConfig } from "./ReceptionConfig"; +import { postLogToBackend } from "@/app/config/api"; +import WebRTC_Recipient from "../webrtc_Recipient"; + +const developmentEnv = process.env.NODE_ENV; + +/** + * 🚀 Message processor delegate interface + */ +export interface MessageProcessorDelegate { + onFileMetaReceived?: (meta: fileMetadata) => void; + onStringReceived?: (str: string) => void; + log(level: "log" | "warn" | "error", message: string, context?: Record): void; +} + +/** + * 🚀 Message processor + * Handles WebRTC message routing, processing, and communication + */ +export class MessageProcessor { + private fileHandlers: FileHandlers; + + constructor( + private stateManager: ReceptionStateManager, + private webrtcConnection: WebRTC_Recipient, + private delegate: MessageProcessorDelegate + ) { + this.fileHandlers = { + string: this.handleReceivedStringChunk.bind(this), + stringMetadata: this.handleStringMetadata.bind(this), + fileMeta: this.handleFileMetadata.bind(this), + }; + } + + /** + * Handle received WebRTC message + */ + async handleReceivedMessage( + data: string | ArrayBuffer | any, + peerId: string + ): Promise { + this.stateManager.setCurrentPeerId(peerId); + + if (typeof data === "string") { + try { + const parsedData = JSON.parse(data) as WebRTCMessage; + const handler = this.fileHandlers[parsedData.type as keyof FileHandlers]; + + if (handler) { + await handler(parsedData as any, peerId); + } else { + this.delegate.log( + "warn", + `Handler not found for message type: ${parsedData.type}`, + { peerId } + ); + } + return null; // String messages don't return binary data + } catch (error) { + this.delegate.log("error", "Error parsing received JSON data", { error, peerId }); + return null; + } + } else { + // Return binary data for chunk processing + return data; + } + } + + /** + * Handle file metadata message + */ + private handleFileMetadata(metadata: fileMetadata): void { + const isNewMetadata = this.stateManager.addFileMetadata(metadata); + + if (!isNewMetadata) { + return; // Ignore if already received + } + + if (this.delegate.onFileMetaReceived) { + this.delegate.onFileMetaReceived(metadata); + } else { + this.delegate.log( + "error", + "onFileMetaReceived callback not set", + { fileId: metadata.fileId } + ); + } + } + + /** + * Handle string metadata message + */ + private handleStringMetadata(metadata: StringMetadata): void { + this.stateManager.startStringReception(metadata.length); + } + + /** + * Handle received string chunk message + */ + private handleReceivedStringChunk(data: StringChunk): void { + const activeStringReception = this.stateManager.getActiveStringReception(); + if (!activeStringReception) { + this.delegate.log("warn", "Received string chunk without active reception"); + return; + } + + this.stateManager.updateStringReceptionChunk(data.index, data.chunk); + + // Check if string reception is complete + if (activeStringReception.receivedChunks === data.total) { + const fullString = this.stateManager.completeStringReception(); + if (fullString && this.delegate.onStringReceived) { + this.delegate.onStringReceived(fullString); + } + } + } + + /** + * Send file request message + */ + sendFileRequest(fileId: string, offset: number = 0): boolean { + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ERROR: Cannot send fileRequest - no peerId available!` + ); + } + return false; + } + + const request: FileRequest = { type: "fileRequest", fileId, offset }; + const success = this.webrtcConnection.sendData(JSON.stringify(request), peerId); + + if (success) { + this.delegate.log("log", "Sent fileRequest", { request, peerId }); + } else { + this.delegate.log("error", "Failed to send fileRequest", { request, peerId }); + } + + return success; + } + + /** + * Send file receive complete message + */ + sendFileReceiveComplete( + fileId: string, + receivedSize: number, + receivedChunks: number, + storeUpdated: boolean + ): boolean { + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) { + this.delegate.log("warn", "Cannot send file receive complete - no peer ID"); + return false; + } + + const completeMessage: FileReceiveComplete = { + type: "fileReceiveComplete", + fileId, + receivedSize, + receivedChunks, + storeUpdated, + }; + + const success = this.webrtcConnection.sendData( + JSON.stringify(completeMessage), + peerId + ); + + if (success) { + this.delegate.log("log", "Sent file receive complete", { + fileId, + receivedSize, + receivedChunks, + storeUpdated, + }); + } else { + this.delegate.log("error", "Failed to send file receive complete", { + fileId, + peerId, + }); + } + + return success; + } + + /** + * Send folder receive complete message + */ + sendFolderReceiveComplete( + folderName: string, + completedFileIds: string[], + allStoreUpdated: boolean + ): boolean { + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) { + this.delegate.log("warn", "Cannot send folder receive complete - no peer ID"); + return false; + } + + const completeMessage: FolderReceiveComplete = { + type: "folderReceiveComplete", + folderName, + completedFileIds, + allStoreUpdated, + }; + + const success = this.webrtcConnection.sendData( + JSON.stringify(completeMessage), + peerId + ); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] 📤 Sent folderReceiveComplete - folderName: ${folderName}, completedFiles: ${completedFileIds.length}, allStoreUpdated: ${allStoreUpdated}, success: ${success}` + ); + } + + if (success) { + this.delegate.log("log", "Sent folder receive complete", { + folderName, + completedFiles: completedFileIds.length, + allStoreUpdated, + }); + } else { + this.delegate.log("error", "Failed to send folder receive complete", { + folderName, + peerId, + }); + } + + return success; + } + + /** + * Add Firefox compatibility delay + */ + async addFirefoxDelay(): Promise { + await new Promise((resolve) => + setTimeout(resolve, ReceptionConfig.NETWORK_CONFIG.FIREFOX_COMPATIBILITY_DELAY) + ); + } + + /** + * Get message processing statistics + */ + getMessageStats(): { + handledMessages: number; + lastMessageTime: number | null; + currentPeerId: string; + } { + return { + handledMessages: 0, // TODO: Implement message counting if needed + lastMessageTime: null, // TODO: Record last message time if needed + currentPeerId: this.stateManager.getCurrentPeerId(), + }; + } + + /** + * Check if connection is available + */ + isConnectionAvailable(): boolean { + const peerId = this.stateManager.getCurrentPeerId(); + return !!peerId && !!this.webrtcConnection; + } + + /** + * Get current peer connection info + */ + getPeerConnectionInfo(): { + peerId: string; + isConnected: boolean; + } { + const peerId = this.stateManager.getCurrentPeerId(); + return { + peerId, + isConnected: this.isConnectionAvailable(), + }; + } + + /** + * Clean up resources + */ + cleanup(): void { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend("[DEBUG] 🧹 MessageProcessor cleaned up"); + } + } +} \ No newline at end of file diff --git a/frontend/lib/receive/ProgressReporter.ts b/frontend/lib/receive/ProgressReporter.ts new file mode 100644 index 0000000..89351e6 --- /dev/null +++ b/frontend/lib/receive/ProgressReporter.ts @@ -0,0 +1,309 @@ +import { SpeedCalculator } from "@/lib/speedCalculator"; +import { ReceptionStateManager } from "./ReceptionStateManager"; +import { ReceptionConfig } from "./ReceptionConfig"; +import { postLogToBackend } from "@/app/config/api"; + +const developmentEnv = process.env.NODE_ENV; + +/** + * 🚀 Progress callback type + */ +export type ProgressCallback = (fileId: string, progress: number, speed: number) => void; + +/** + * 🚀 Progress statistics interface + */ +export interface ProgressStats { + fileProgress: Record; + folderProgress: Record; + currentSpeed: number; + averageSpeed: number; + totalBytesReceived: number; + estimatedTimeRemaining: number | null; +} + +/** + * 🚀 Progress reporter + * Handles progress calculation, speed tracking, and progress callback management + */ +export class ProgressReporter { + private speedCalculator: SpeedCalculator; + private progressCallback: ProgressCallback | null = null; + + // Progress tracking + private fileProgressMap = new Map(); + private folderProgressMap = new Map(); + private lastProgressUpdate = new Map(); + + constructor(private stateManager: ReceptionStateManager) { + this.speedCalculator = new SpeedCalculator(); + } + + /** + * Set progress callback + */ + setProgressCallback(callback: ProgressCallback): void { + this.progressCallback = callback; + } + + /** + * Update file reception progress + */ + updateFileProgress( + byteLength: number, + fileId: string, + fileSize: number + ): void { + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) return; + + const activeReception = this.stateManager.getActiveFileReception(); + if (!activeReception) return; + + // Update received size + activeReception.receivedSize += byteLength; + const totalReceived = activeReception.initialOffset + activeReception.receivedSize; + + const currentFolderName = this.stateManager.getCurrentFolderName(); + + if (currentFolderName) { + // Update folder progress + this.updateFolderProgress(currentFolderName, byteLength, peerId); + } else { + // Update individual file progress + this.speedCalculator.updateSendSpeed(peerId, totalReceived); + const speed = this.speedCalculator.getSendSpeed(peerId); + const progress = fileSize > 0 ? totalReceived / fileSize : 0; + + // Store progress for statistics + this.fileProgressMap.set(fileId, progress); + + // Throttle progress callbacks to avoid overwhelming the UI + const now = Date.now(); + const lastUpdate = this.lastProgressUpdate.get(fileId) || 0; + const shouldUpdate = now - lastUpdate > 100; // Update at most every 100ms + + if (shouldUpdate || progress >= 1) { + this.progressCallback?.(fileId, progress, speed); + this.lastProgressUpdate.set(fileId, now); + } + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) { + postLogToBackend( + `[DEBUG] 📈 File progress 100% - ${fileId}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s` + ); + } + } + } + + /** + * Update folder reception progress + */ + private updateFolderProgress( + folderName: string, + byteLength: number, + peerId: string + ): void { + // Update folder received size in state manager + this.stateManager.updateFolderReceivedSize(folderName, byteLength); + + const folderProgress = this.stateManager.getFolderProgress(folderName); + if (!folderProgress) return; + + this.speedCalculator.updateSendSpeed(peerId, folderProgress.receivedSize); + const speed = this.speedCalculator.getSendSpeed(peerId); + const progress = folderProgress.totalSize > 0 + ? folderProgress.receivedSize / folderProgress.totalSize + : 0; + + // Store progress for statistics + this.folderProgressMap.set(folderName, progress); + + // Throttle folder progress callbacks + const now = Date.now(); + const lastUpdate = this.lastProgressUpdate.get(folderName) || 0; + const shouldUpdate = now - lastUpdate > 200; // Update folders less frequently + + if (shouldUpdate || progress >= 1) { + this.progressCallback?.(folderName, progress, speed); + this.lastProgressUpdate.set(folderName, now); + } + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) { + postLogToBackend( + `[DEBUG] 📈 Folder progress 100% - ${folderName}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s` + ); + } + } + + /** + * Report file completion (100% progress) + */ + reportFileComplete(fileId: string): void { + if (!this.progressCallback) return; + + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) return; + + // Get final speed and report 100% progress + const speed = this.speedCalculator.getSendSpeed(peerId); + this.progressCallback(fileId, 1, speed); + this.fileProgressMap.set(fileId, 1); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) { + postLogToBackend( + `[DEBUG] ✅ File completion reported - ${fileId}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s` + ); + } + } + + /** + * Report folder completion (100% progress) + */ + reportFolderComplete(folderName: string): void { + if (!this.progressCallback) return; + + const peerId = this.stateManager.getCurrentPeerId(); + if (!peerId) return; + + // Get final speed and report 100% progress + const speed = this.speedCalculator.getSendSpeed(peerId); + this.progressCallback(folderName, 1, speed); + this.folderProgressMap.set(folderName, 1); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) { + postLogToBackend( + `[DEBUG] ✅ Folder completion reported - ${folderName}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s` + ); + } + } + + /** + * Get current progress for a file or folder + */ + getCurrentProgress(id: string): number { + return this.fileProgressMap.get(id) || this.folderProgressMap.get(id) || 0; + } + + /** + * Get current speed for peer + */ + getCurrentSpeed(): number { + const peerId = this.stateManager.getCurrentPeerId(); + return peerId ? this.speedCalculator.getSendSpeed(peerId) : 0; + } + + /** + * Get detailed progress statistics + */ + getProgressStats(): ProgressStats { + const peerId = this.stateManager.getCurrentPeerId(); + const currentSpeed = peerId ? this.speedCalculator.getSendSpeed(peerId) : 0; + const averageSpeed = currentSpeed; // SpeedCalculator doesn't have getAverageSpeed method + + // Calculate total bytes received + let totalBytesReceived = 0; + const activeReception = this.stateManager.getActiveFileReception(); + if (activeReception) { + totalBytesReceived = activeReception.initialOffset + activeReception.receivedSize; + } + + // Estimate time remaining + let estimatedTimeRemaining: number | null = null; + if (activeReception && currentSpeed > 0) { + const remainingBytes = activeReception.meta.size - totalBytesReceived; + if (remainingBytes > 0) { + estimatedTimeRemaining = remainingBytes / currentSpeed; // seconds + } + } + + const fileProgress: Record = {}; + this.fileProgressMap.forEach((progress, fileId) => { + fileProgress[fileId] = progress; + }); + + const folderProgress: Record = {}; + this.folderProgressMap.forEach((progress, folderName) => { + folderProgress[folderName] = progress; + }); + + return { + fileProgress, + folderProgress, + currentSpeed, + averageSpeed, + totalBytesReceived, + estimatedTimeRemaining, + }; + } + + /** + * Reset progress for a specific file or folder + */ + resetProgress(id: string): void { + this.fileProgressMap.delete(id); + this.folderProgressMap.delete(id); + this.lastProgressUpdate.delete(id); + } + + /** + * Reset all progress data + */ + resetAllProgress(): void { + this.fileProgressMap.clear(); + this.folderProgressMap.clear(); + this.lastProgressUpdate.clear(); + + // Reset speed calculator for current peer + // Note: SpeedCalculator doesn't have resetSpeed method, so we create a new instance + this.speedCalculator = new SpeedCalculator(); + } + + /** + * Get progress update frequency (for debugging) + */ + getUpdateFrequency(id: string): number { + const lastUpdate = this.lastProgressUpdate.get(id); + return lastUpdate ? Date.now() - lastUpdate : 0; + } + + /** + * Check if progress should be throttled + */ + shouldThrottleProgress(id: string, isFolder: boolean = false): boolean { + const now = Date.now(); + const lastUpdate = this.lastProgressUpdate.get(id) || 0; + const threshold = isFolder ? 200 : 100; // Folders update less frequently + return now - lastUpdate < threshold; + } + + /** + * Force progress update (bypass throttling) + */ + forceProgressUpdate(id: string, progress: number): void { + if (!this.progressCallback) return; + + const speed = this.getCurrentSpeed(); + this.progressCallback(id, progress, speed); + this.lastProgressUpdate.set(id, Date.now()); + + // Update internal maps + if (this.fileProgressMap.has(id)) { + this.fileProgressMap.set(id, progress); + } else if (this.folderProgressMap.has(id)) { + this.folderProgressMap.set(id, progress); + } + } + + /** + * Clean up resources + */ + cleanup(): void { + this.resetAllProgress(); + this.progressCallback = null; + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) { + postLogToBackend("[DEBUG] 🧹 ProgressReporter cleaned up"); + } + } +} \ No newline at end of file diff --git a/frontend/lib/receive/ReceptionConfig.ts b/frontend/lib/receive/ReceptionConfig.ts new file mode 100644 index 0000000..b96d1e1 --- /dev/null +++ b/frontend/lib/receive/ReceptionConfig.ts @@ -0,0 +1,74 @@ +/** + * 🚀 Reception configuration management + * Centralized configuration for file reception parameters + */ + +export class ReceptionConfig { + // File size thresholds + static readonly FILE_CONFIG = { + LARGE_FILE_THRESHOLD: 1 * 1024 * 1024 * 1024, // 1GB - files larger than this will be saved to disk + CHUNK_SIZE: 65536, // 64KB standard chunk size + }; + + // Buffer management + static readonly BUFFER_CONFIG = { + MAX_BUFFER_SIZE: 100, // Buffer up to 100 chunks (approximately 6.4MB) + SEQUENTIAL_FLUSH_THRESHOLD: 10, // Start flushing when this many sequential chunks are available + }; + + // Performance and debugging + static readonly DEBUG_CONFIG = { + ENABLE_CHUNK_LOGGING: process.env.NODE_ENV === "development", + ENABLE_PROGRESS_LOGGING: process.env.NODE_ENV === "development", + PROGRESS_LOG_INTERVAL: 500, // Log progress every N chunks + COMPLETION_CHECK_INTERVAL: 100, // Check completion every N ms + }; + + // Network and timing + static readonly NETWORK_CONFIG = { + FIREFOX_COMPATIBILITY_DELAY: 10, // ms delay for Firefox compatibility + FINALIZATION_TIMEOUT: 30000, // 30s timeout for file finalization + GRACEFUL_SHUTDOWN_TIMEOUT: 5000, // 5s timeout for graceful shutdown + }; + + // Validation thresholds + static readonly VALIDATION_CONFIG = { + MAX_SIZE_DIFFERENCE_BYTES: 1024, // Allow up to 1KB size difference for validation + MIN_PACKET_SIZE: 4, // Minimum embedded packet size (4 bytes for length header) + }; + + /** + * Get chunk index from file offset + */ + static getChunkIndexFromOffset(offset: number): number { + return Math.floor(offset / this.FILE_CONFIG.CHUNK_SIZE); + } + + /** + * Get file offset from chunk index + */ + static getOffsetFromChunkIndex(chunkIndex: number): number { + return chunkIndex * this.FILE_CONFIG.CHUNK_SIZE; + } + + /** + * Calculate expected chunks count for file size and offset + */ + static calculateExpectedChunks(fileSize: number, startOffset: number = 0): number { + return Math.ceil((fileSize - startOffset) / this.FILE_CONFIG.CHUNK_SIZE); + } + + /** + * Calculate total chunks in file + */ + static calculateTotalChunks(fileSize: number): number { + return Math.ceil(fileSize / this.FILE_CONFIG.CHUNK_SIZE); + } + + /** + * Check if file should be saved to disk + */ + static shouldSaveToDisk(fileSize: number, hasSaveDirectory: boolean): boolean { + return hasSaveDirectory || fileSize >= this.FILE_CONFIG.LARGE_FILE_THRESHOLD; + } +} \ No newline at end of file diff --git a/frontend/lib/receive/ReceptionStateManager.ts b/frontend/lib/receive/ReceptionStateManager.ts new file mode 100644 index 0000000..cef959c --- /dev/null +++ b/frontend/lib/receive/ReceptionStateManager.ts @@ -0,0 +1,358 @@ +import { + fileMetadata, + FolderProgress, + CurrentString, + CustomFile, +} from "@/types/webrtc"; + +/** + * 🚀 Active file reception state interface + */ +export interface ActiveFileReception { + meta: fileMetadata; + chunks: (ArrayBuffer | null)[]; + receivedSize: number; + initialOffset: number; + fileHandle: FileSystemFileHandle | null; + writeStream: FileSystemWritableFileStream | null; + sequencedWriter: any | null; // Will be typed properly when StreamingFileWriter is implemented + completionNotifier: { + resolve: () => void; + reject: (reason?: any) => void; + }; + receivedChunksCount: number; + expectedChunksCount: number; + chunkSequenceMap: Map; + isFinalized?: boolean; +} + +/** + * 🚀 Reception state management + * Centrally manages all file reception state data + */ +export class ReceptionStateManager { + // File metadata management + private pendingFilesMeta = new Map(); + + // Folder progress tracking + private folderProgresses: Record = {}; + + // Save type configuration (fileId/folderName -> isSavedToDisk) + public saveType: Record = {}; + + // Active transfer states + private activeFileReception: ActiveFileReception | null = null; + private activeStringReception: CurrentString | null = null; + private currentFolderName: string | null = null; + + // Peer information + private currentPeerId: string = ""; + private saveDirectory: FileSystemDirectoryHandle | null = null; + + // ===== File Metadata Management ===== + + /** + * Add file metadata + */ + public addFileMetadata(metadata: fileMetadata): boolean { + if (this.pendingFilesMeta.has(metadata.fileId)) { + return false; // Already exists + } + + this.pendingFilesMeta.set(metadata.fileId, metadata); + + // Update folder progress if this file belongs to a folder + if (metadata.folderName) { + this.addFileToFolder(metadata.folderName, metadata.fileId, metadata.size); + } + + return true; // New metadata added + } + + /** + * Get file metadata by ID + */ + public getFileMetadata(fileId: string): fileMetadata | undefined { + return this.pendingFilesMeta.get(fileId); + } + + /** + * Get all pending file metadata + */ + public getAllFileMetadata(): Map { + return new Map(this.pendingFilesMeta); + } + + /** + * Remove file metadata + */ + public removeFileMetadata(fileId: string): void { + this.pendingFilesMeta.delete(fileId); + } + + // ===== Folder Progress Management ===== + + /** + * Add file to folder progress tracking + */ + private addFileToFolder(folderName: string, fileId: string, fileSize: number): void { + if (!this.folderProgresses[folderName]) { + this.folderProgresses[folderName] = { + totalSize: 0, + receivedSize: 0, + fileIds: [], + }; + } + + const folderProgress = this.folderProgresses[folderName]; + if (!folderProgress.fileIds.includes(fileId)) { + folderProgress.fileIds.push(fileId); + folderProgress.totalSize += fileSize; + } + } + + /** + * Get folder progress + */ + public getFolderProgress(folderName: string): FolderProgress | undefined { + return this.folderProgresses[folderName]; + } + + /** + * Update folder received size + */ + public updateFolderReceivedSize(folderName: string, additionalBytes: number): void { + const folderProgress = this.folderProgresses[folderName]; + if (folderProgress) { + folderProgress.receivedSize += additionalBytes; + } + } + + /** + * Set folder received size (for resume scenarios) + */ + public setFolderReceivedSize(folderName: string, totalReceivedSize: number): void { + const folderProgress = this.folderProgresses[folderName]; + if (folderProgress) { + folderProgress.receivedSize = totalReceivedSize; + } + } + + /** + * Get all folder progresses + */ + public getAllFolderProgresses(): Record { + return { ...this.folderProgresses }; + } + + // ===== Active File Reception Management ===== + + /** + * Start active file reception + */ + public startFileReception( + meta: fileMetadata, + expectedChunksCount: number, + initialOffset: number = 0 + ): Promise { + if (this.activeFileReception) { + throw new Error("Another file reception is already in progress"); + } + + return new Promise((resolve, reject) => { + this.activeFileReception = { + meta, + chunks: new Array(expectedChunksCount).fill(null), + receivedSize: 0, + initialOffset, + fileHandle: null, + writeStream: null, + sequencedWriter: null, + completionNotifier: { resolve, reject }, + receivedChunksCount: 0, + expectedChunksCount, + chunkSequenceMap: new Map(), + isFinalized: false, + }; + }); + } + + /** + * Get active file reception + */ + public getActiveFileReception(): ActiveFileReception | null { + return this.activeFileReception; + } + + /** + * Update active file reception + */ + public updateActiveFileReception(updates: Partial): void { + if (this.activeFileReception) { + Object.assign(this.activeFileReception, updates); + } + } + + /** + * Complete active file reception + */ + public completeFileReception(): void { + if (this.activeFileReception?.completionNotifier) { + this.activeFileReception.completionNotifier.resolve(); + } + this.activeFileReception = null; + } + + /** + * Fail active file reception + */ + public failFileReception(reason: any): void { + if (this.activeFileReception?.completionNotifier) { + this.activeFileReception.completionNotifier.reject(reason); + } + this.activeFileReception = null; + } + + // ===== String Reception Management ===== + + /** + * Start string reception + */ + public startStringReception(length: number): void { + this.activeStringReception = { + length, + chunks: [], + receivedChunks: 0, + }; + } + + /** + * Get active string reception + */ + public getActiveStringReception(): CurrentString | null { + return this.activeStringReception; + } + + /** + * Update string reception chunk + */ + public updateStringReceptionChunk(index: number, chunk: string): void { + if (this.activeStringReception) { + this.activeStringReception.chunks[index] = chunk; + this.activeStringReception.receivedChunks++; + } + } + + /** + * Complete string reception + */ + public completeStringReception(): string | null { + if (!this.activeStringReception) return null; + + const fullString = this.activeStringReception.chunks.join(""); + this.activeStringReception = null; + return fullString; + } + + // ===== Current Context Management ===== + + /** + * Set current folder name + */ + public setCurrentFolderName(folderName: string | null): void { + this.currentFolderName = folderName; + } + + /** + * Get current folder name + */ + public getCurrentFolderName(): string | null { + return this.currentFolderName; + } + + /** + * Set current peer ID + */ + public setCurrentPeerId(peerId: string): void { + this.currentPeerId = peerId; + } + + /** + * Get current peer ID + */ + public getCurrentPeerId(): string { + return this.currentPeerId; + } + + /** + * Set save directory + */ + public setSaveDirectory(directory: FileSystemDirectoryHandle | null): void { + this.saveDirectory = directory; + } + + /** + * Get save directory + */ + public getSaveDirectory(): FileSystemDirectoryHandle | null { + return this.saveDirectory; + } + + // ===== Save Type Management ===== + + /** + * Set save type for file or folder + */ + public setSaveType(id: string, saveToDisk: boolean): void { + this.saveType[id] = saveToDisk; + } + + /** + * Get save type for file or folder + */ + public getSaveType(id: string): boolean { + return this.saveType[id] || false; + } + + // ===== State Reset and Cleanup ===== + + /** + * Force reset all states (for reconnection scenarios) + */ + public forceReset(): void { + this.pendingFilesMeta.clear(); + this.folderProgresses = {}; + this.saveType = {}; + this.activeFileReception = null; + this.activeStringReception = null; + this.currentFolderName = null; + this.currentPeerId = ""; + // Note: saveDirectory is preserved + } + + /** + * Graceful cleanup (preserve some state for potential resume) + */ + public gracefulCleanup(): void { + this.activeFileReception = null; + this.activeStringReception = null; + this.currentFolderName = null; + // Note: preserve pendingFilesMeta, folderProgresses, saveType for potential resume + } + + /** + * Get state statistics (for debugging) + */ + public getStateStats() { + return { + pendingFilesCount: this.pendingFilesMeta.size, + folderCount: Object.keys(this.folderProgresses).length, + hasActiveFileReception: !!this.activeFileReception, + hasActiveStringReception: !!this.activeStringReception, + currentFolderName: this.currentFolderName, + currentPeerId: this.currentPeerId, + hasSaveDirectory: !!this.saveDirectory, + saveTypeCount: Object.keys(this.saveType).length, + }; + } +} \ No newline at end of file diff --git a/frontend/lib/receive/StreamingFileWriter.ts b/frontend/lib/receive/StreamingFileWriter.ts new file mode 100644 index 0000000..9bfd62d --- /dev/null +++ b/frontend/lib/receive/StreamingFileWriter.ts @@ -0,0 +1,430 @@ +import { ReceptionConfig } from "./ReceptionConfig"; +import { postLogToBackend } from "@/app/config/api"; + +const developmentEnv = process.env.NODE_ENV; + +/** + * 🚀 Strict Sequential Buffering Writer - Optimizes large file disk I/O performance + */ +export class SequencedDiskWriter { + private writeQueue = new Map(); + private nextWriteIndex = 0; + private readonly maxBufferSize: number; + private readonly stream: FileSystemWritableFileStream; + private totalWritten = 0; + + constructor(stream: FileSystemWritableFileStream, startIndex: number = 0) { + this.stream = stream; + this.nextWriteIndex = startIndex; + this.maxBufferSize = ReceptionConfig.BUFFER_CONFIG.MAX_BUFFER_SIZE; + } + + /** + * Write a chunk, automatically managing order and buffering + */ + async writeChunk(chunkIndex: number, chunk: ArrayBuffer): Promise { + // Debug writeChunk calls + if ( + ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING && + (chunkIndex <= 5 || chunkIndex === this.nextWriteIndex) + ) { + postLogToBackend( + `[DEBUG-RESUME] 🎯 WriteChunk called - received:${chunkIndex}, expected:${ + this.nextWriteIndex + }, match:${chunkIndex === this.nextWriteIndex}` + ); + } + + // 1. If it is the expected next chunk, write immediately + if (chunkIndex === this.nextWriteIndex) { + await this.flushSequentialChunks(chunk); + return; + } + + // 2. If it's a future chunk, buffer it + if (chunkIndex > this.nextWriteIndex) { + if (this.writeQueue.size < this.maxBufferSize) { + this.writeQueue.set(chunkIndex, chunk); + } else { + // Buffer full, forcing processing of the earliest chunk to free up space + await this.forceFlushOldest(); + this.writeQueue.set(chunkIndex, chunk); + } + return; + } + + // 3. If the chunk is expired, log a warning but ignore (already written) + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ⚠️ DUPLICATE chunk #${chunkIndex} ignored (already written #${this.nextWriteIndex})` + ); + } + } + + /** + * Write current chunk and attempt to sequentially write subsequent chunks + */ + private async flushSequentialChunks(firstChunk: ArrayBuffer): Promise { + let flushCount = 0; + + try { + // Write current chunk + await this.stream.write(firstChunk); + this.totalWritten += firstChunk.byteLength; + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}` + ); + } + + this.nextWriteIndex++; + + // Try to sequentially write chunks from buffer + while (this.writeQueue.has(this.nextWriteIndex)) { + const chunk = this.writeQueue.get(this.nextWriteIndex)!; + await this.stream.write(chunk); + this.totalWritten += chunk.byteLength; + this.writeQueue.delete(this.nextWriteIndex); + + flushCount++; + this.nextWriteIndex++; + } + } catch (error) { + // Defensive handling: If stream is closed, silently ignore + const errorMessage = + error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("closing writable stream") || + errorMessage.includes("stream is closed") + ) { + console.log( + `[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks` + ); + return; + } + // Re-throw other types of errors + throw error; + } + + if (flushCount > 0 && ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] 🔥 SEQUENTIAL_FLUSH ${flushCount} chunks, now at #${this.nextWriteIndex}, queue: ${this.writeQueue.size}` + ); + } + } + + /** + * Get the next expected write index + */ + get expectedIndex(): number { + return this.nextWriteIndex; + } + + /** + * Force flush the earliest chunk to release buffer space + */ + private async forceFlushOldest(): Promise { + try { + if (this.writeQueue.size === 0) return; + + const oldestIndex = Math.min(...Array.from(this.writeQueue.keys())); + const chunk = this.writeQueue.get(oldestIndex)!; + + // Use seek to write at the correct position (fallback handling) + const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(oldestIndex); + await this.stream.seek(fileOffset); + await this.stream.write(chunk); + this.writeQueue.delete(oldestIndex); + + // Return to current position + const currentOffset = ReceptionConfig.getOffsetFromChunkIndex(this.nextWriteIndex); + await this.stream.seek(currentOffset); + } catch (error) { + // Defensive handling: If stream is closed, silently ignore + const errorMessage = + error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("closing writable stream") || + errorMessage.includes("stream is closed") + ) { + console.log( + `[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks` + ); + return; + } + // Re-throw other types of errors + throw error; + } + } + + /** + * Get buffer status + */ + getBufferStatus(): { + queueSize: number; + nextIndex: number; + totalWritten: number; + } { + return { + queueSize: this.writeQueue.size, + nextIndex: this.nextWriteIndex, + totalWritten: this.totalWritten, + }; + } + + /** + * Close and clean up resources + */ + async close(): Promise { + try { + // 🔧 修复:确保以正确的WriteParams格式写入剩余chunks + const remainingIndexes = Array.from(this.writeQueue.keys()).sort( + (a, b) => a - b + ); + + if (remainingIndexes.length > 0) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] 💾 Flushing ${remainingIndexes.length} remaining chunks: [${remainingIndexes.join(',')}]` + ); + } + + for (const chunkIndex of remainingIndexes) { + const chunk = this.writeQueue.get(chunkIndex)!; + const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(chunkIndex); + + // 🔧 修复:使用正确的WriteParams格式 + await this.stream.seek(fileOffset); + + // 确保chunk是有效的ArrayBuffer + if (!(chunk instanceof ArrayBuffer) || chunk.byteLength === 0) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ⚠️ Skipping invalid chunk #${chunkIndex}: ${Object.prototype.toString.call(chunk)}, size: ${chunk.byteLength}` + ); + } + continue; + } + + // 使用标准WriteParams格式写入 + await this.stream.write({ + type: "write", + data: chunk + }); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ✅ FINAL_FLUSH chunk #${chunkIndex} (${chunk.byteLength} bytes)` + ); + } + } + } + } catch (error) { + // Enhanced error handling with specific error types + const errorMessage = error instanceof Error ? error.message : String(error); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ❌ Error during final flush: ${errorMessage}` + ); + } + + if ( + errorMessage.includes("closing writable stream") || + errorMessage.includes("stream is closed") || + errorMessage.includes("The stream is not in a state that permits this operation") + ) { + console.log( + `[SequencedDiskWriter] Stream closed during final flush - completing gracefully` + ); + } else { + console.warn(`[SequencedDiskWriter] Unexpected error during final flush:`, errorMessage); + throw error; + } + } finally { + // 无论如何都要清理队列 + this.writeQueue.clear(); + } + } +} + +/** + * 🚀 Streaming file writer + * Manages disk file creation, directory structure, and streaming writes + */ +export class StreamingFileWriter { + private saveDirectory: FileSystemDirectoryHandle | null = null; + + constructor(saveDirectory?: FileSystemDirectoryHandle) { + this.saveDirectory = saveDirectory || null; + } + + /** + * Set save directory + */ + setSaveDirectory(directory: FileSystemDirectoryHandle): void { + this.saveDirectory = directory; + } + + /** + * Create disk write stream for a file + */ + async createWriteStream( + fileName: string, + fullPath: string, + offset: number = 0 + ): Promise<{ + fileHandle: FileSystemFileHandle; + writeStream: FileSystemWritableFileStream; + sequencedWriter: SequencedDiskWriter; + }> { + if (!this.saveDirectory) { + throw new Error("Save directory not set"); + } + + try { + const folderHandle = await this.createFolderStructure(fullPath); + const fileHandle = await folderHandle.getFileHandle(fileName, { + create: true, + }); + + // Use keepExistingData: true to append + const writeStream = await fileHandle.createWritable({ + keepExistingData: true, + }); + + // Seek to the offset to start writing from there + await writeStream.seek(offset); + + // Create strictly sequential write manager + const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(offset); + const sequencedWriter = new SequencedDiskWriter(writeStream, startChunkIndex); + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG] 📢 SEQUENCED_WRITER created - startIndex: ${startChunkIndex}, offset: ${offset}` + ); + postLogToBackend( + `[DEBUG-RESUME] 🎯 SequencedWriter expects - startIndex:${startChunkIndex}, offset:${offset}, calculatedFrom:${offset}/65536` + ); + } + + return { fileHandle, writeStream, sequencedWriter }; + } catch (err) { + throw new Error(`Failed to create file on disk: ${err}`); + } + } + + /** + * Check if partial file exists and get its size + */ + async getPartialFileSize(fileName: string, fullPath: string): Promise { + if (!this.saveDirectory) { + return 0; + } + + try { + const folderHandle = await this.createFolderStructure(fullPath); + const fileHandle = await folderHandle.getFileHandle(fileName, { + create: false, + }); + const file = await fileHandle.getFile(); + return file.size; + } catch { + // File does not exist + return 0; + } + } + + /** + * Create folder structure based on full path + */ + private async createFolderStructure( + fullPath: string + ): Promise { + if (!this.saveDirectory) { + throw new Error("Save directory not set"); + } + + const parts = fullPath.split("/"); + parts.pop(); // Remove filename + + let currentDir = this.saveDirectory; + for (const part of parts) { + if (part) { + currentDir = await currentDir.getDirectoryHandle(part, { + create: true, + }); + } + } + return currentDir; + } + + /** + * Finalize file write and close streams + */ + async finalizeWrite( + sequencedWriter: SequencedDiskWriter, + writeStream: FileSystemWritableFileStream, + fileName: string + ): Promise { + try { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] 🚀 Starting finalization for ${fileName}` + ); + } + + // First close the strict sequential writing manager (flush all buffers) + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend(`[DEBUG-FINALIZE] Closing SequencedWriter...`); + } + await sequencedWriter.close(); + const status = sequencedWriter.getBufferStatus(); + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] 💾 SEQUENCED_WRITER closed - totalWritten: ${status.totalWritten}, finalQueue: ${status.queueSize}` + ); + } + + // Then close the file stream + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] About to close writeStream for ${fileName}` + ); + } + await writeStream.close(); + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend(`[DEBUG-FINALIZE] ✅ WriteStream closed successfully`); + } + + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${fileName}` + ); + } + } catch (error) { + if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) { + postLogToBackend( + `[DEBUG-FINALIZE] ❌ Error during finalization: ${error}` + ); + } + throw new Error(`Error finalizing large file: ${error}`); + } + } + + /** + * Check if save directory is available + */ + hasSaveDirectory(): boolean { + return !!this.saveDirectory; + } + + /** + * Get save directory + */ + getSaveDirectory(): FileSystemDirectoryHandle | null { + return this.saveDirectory; + } +} \ No newline at end of file diff --git a/frontend/lib/receive/index.ts b/frontend/lib/receive/index.ts new file mode 100644 index 0000000..dbe3315 --- /dev/null +++ b/frontend/lib/receive/index.ts @@ -0,0 +1,45 @@ +/** + * 🚀 File receive module unified export + * Provides modular file reception services + */ + +// Configuration management +export { ReceptionConfig } from "./ReceptionConfig"; + +// State management +export { ReceptionStateManager } from "./ReceptionStateManager"; +export type { ActiveFileReception } from "./ReceptionStateManager"; + +// Data processing +export { ChunkProcessor } from "./ChunkProcessor"; +export type { ChunkProcessingResult } from "./ChunkProcessor"; + +// File writing +export { StreamingFileWriter, SequencedDiskWriter } from "./StreamingFileWriter"; + +// File assembly +export { FileAssembler } from "./FileAssembler"; +export type { FileAssemblyResult } from "./FileAssembler"; + +// Message processing +export { MessageProcessor } from "./MessageProcessor"; +export type { MessageProcessorDelegate } from "./MessageProcessor"; + +// Progress reporting +export { ProgressReporter } from "./ProgressReporter"; +export type { ProgressCallback, ProgressStats } from "./ProgressReporter"; + +// Main orchestrator +export { FileReceiveOrchestrator } from "./FileReceiveOrchestrator"; + +/** + * 🎯 Convenience creation function - Quick initialization of file receive services + */ +import WebRTC_Recipient from "../webrtc_Recipient"; +import { FileReceiveOrchestrator } from "./FileReceiveOrchestrator"; + +export function createFileReceiveService( + webrtcConnection: WebRTC_Recipient +): FileReceiveOrchestrator { + return new FileReceiveOrchestrator(webrtcConnection); +} \ No newline at end of file diff --git a/frontend/lib/tracking.ts b/frontend/lib/tracking.ts index 4cc95b8..b87d5a8 100644 --- a/frontend/lib/tracking.ts +++ b/frontend/lib/tracking.ts @@ -4,7 +4,7 @@ export const trackReferrer = async () => { // Get URL parameters const urlParams = new URLSearchParams(window.location.search); let ref = urlParams.get("ref"); - if (process.env.NEXT_PUBLIC_development === "false") { + if (process.env.NODE_ENV === "production") { ref = urlParams.get("ref") || "noRef"; // Production environment, count daily active users, record as noRef if there is no ref } diff --git a/frontend/lib/transfer/FileTransferOrchestrator.ts b/frontend/lib/transfer/FileTransferOrchestrator.ts index a33e717..e69b01e 100644 --- a/frontend/lib/transfer/FileTransferOrchestrator.ts +++ b/frontend/lib/transfer/FileTransferOrchestrator.ts @@ -14,7 +14,7 @@ import { StreamingFileReader } from "./StreamingFileReader"; import { TransferConfig } from "./TransferConfig"; import WebRTC_Initiator from "../webrtc_Initiator"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +const developmentEnv = process.env.NODE_ENV; /** * 🚀 File transfer orchestrator * Integrates all components to provide unified file transfer services @@ -40,9 +40,6 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { this.log("log", "FileTransferOrchestrator initialized"); } - - // ===== Public API - Simplified interface ===== - /** * 🎯 Send file metadata */ @@ -168,7 +165,6 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { ): Promise { const fileId = generateFileId(file); const peerState = this.stateManager.getPeerState(peerId); - if (peerState.isSending) { this.log("warn", `Already sending file to peer ${peerId}`, { fileId }); return; @@ -182,7 +178,6 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { bufferQueue: [], isReading: false, }); - // Initialize progress statistics const currentSent = this.stateManager.getFileBytesSent(peerId, fileId); this.stateManager.updateFileBytesSent(peerId, fileId, offset - currentSent); @@ -210,13 +205,13 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { const peerState = this.stateManager.getPeerState(peerId); const transferStartTime = performance.now(); - // 1. Initialize streaming file reader - const streamReader = new StreamingFileReader( - file, - peerState.readOffset || 0 - ); + // 🔧 Fix: Record initial offset at the start of transmission, used for subsequent statistics calculation + const initialReadOffset = peerState.readOffset || 0; - if (developmentEnv === "true") { + // 1. Initialize streaming file reader + const streamReader = new StreamingFileReader(file, initialReadOffset); + + if (developmentEnv === "development") { postLogToBackend( `[DEBUG] 🚀 Starting transfer - file: ${file.name}, size: ${( file.size / @@ -306,20 +301,34 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { } } - if (developmentEnv === "true") { + if (developmentEnv === "development") { const totalTime = performance.now() - transferStartTime; const avgSpeedMBps = totalBytesSent / 1024 / 1024 / (totalTime / 1000); + + // 🔧 Fix: Use correct initial offset instead of current readOffset for log statistics + const initialOffset = initialReadOffset || 0; // Initial offset at the start of transmission + const expectedTotalChunks = Math.ceil(file.size / 65536); + const startChunkIndex = Math.floor(initialOffset / 65536); + const expectedChunksSent = expectedTotalChunks - startChunkIndex; + postLogToBackend( - `[DEBUG] ✅ Transfer complete - file: ${file.name}, time: ${( + `[DEBUG-CHUNKS] ✅ Transfer complete - file: ${file.name}, time: ${( totalTime / 1000 - ).toFixed(1)}s, speed: ${avgSpeedMBps.toFixed( - 1 - )}MB/s, chunks: ${networkChunkIndex}` + ).toFixed(1)}s, speed: ${avgSpeedMBps.toFixed(1)}MB/s` ); + postLogToBackend( + `[DEBUG-CHUNKS] Chunks sent: ${networkChunkIndex}, expected: ${expectedChunksSent}, startChunk: ${startChunkIndex}, totalFileChunks: ${expectedTotalChunks}, initialOffset: ${initialOffset}` + ); + + if (networkChunkIndex !== expectedChunksSent) { + postLogToBackend( + `[DEBUG-CHUNKS] ⚠️ CHUNK MISMATCH: sent ${networkChunkIndex} but expected ${expectedChunksSent}` + ); + } } } catch (error: any) { const errorMessage = `Streaming send error: ${error.message}`; - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend(`[DEBUG] ❌ Transfer error: ${errorMessage}`); } this.fireError(errorMessage, { @@ -338,9 +347,23 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { * ⏳ Wait for transfer completion confirmation */ private async waitForTransferComplete(peerId: string): Promise { - const peerState = this.stateManager.getPeerState(peerId); - while (peerState?.isSending) { - await new Promise((resolve) => setTimeout(resolve, 50)); + while (true) { + const currentPeerState = this.stateManager.getPeerState(peerId); + + // Check if it has been cleaned up or does not exist + if (!currentPeerState || !currentPeerState.isSending) { + this.log("log", `Transfer completed or peer disconnected: ${peerId}`); + break; + } + + // Check if the WebRTC connection is still valid + if (!this.webrtcConnection.peerConnections.has(peerId)) { + this.log("log", `Peer connection lost: ${peerId}, stopping transfer`); + this.stateManager.updatePeerState(peerId, { isSending: false }); + break; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); } } @@ -413,6 +436,19 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { return stats; } + /** + * 🔄 Handle peer reconnection + */ + public handlePeerReconnection(peerId: string): void { + // Clear all transfer states for this peer + this.stateManager.clearPeerState(peerId); + if (developmentEnv === "development") + this.log( + "log", + `Successfully reset transfer state for reconnected peer ${peerId}` + ); + } + /** * 🧹 Clean up all resources */ @@ -421,7 +457,7 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate { this.networkTransmitter.cleanup(); this.progressTracker.cleanup(); this.messageHandler.cleanup(); - - this.log("log", "FileTransferOrchestrator cleaned up"); + if (developmentEnv === "development") + this.log("log", "FileTransferOrchestrator cleaned up"); } } diff --git a/frontend/lib/transfer/MessageHandler.ts b/frontend/lib/transfer/MessageHandler.ts index 5efda0d..4481c7a 100644 --- a/frontend/lib/transfer/MessageHandler.ts +++ b/frontend/lib/transfer/MessageHandler.ts @@ -2,11 +2,11 @@ import { WebRTCMessage, FileRequest, FileReceiveComplete, - FolderReceiveComplete, + FolderReceiveComplete } from "@/types/webrtc"; import { StateManager } from "./StateManager"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +const developmentEnv = process.env.NODE_ENV; /** * 🚀 Message handling interface - Communicate with main orchestrator */ @@ -120,7 +120,7 @@ export class MessageHandler { message: FolderReceiveComplete, peerId: string ): void { - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( `[DEBUG] 📥 Folder complete - folderName: ${message.folderName}, files: ${message.completedFileIds.length}` ); @@ -172,6 +172,7 @@ export class MessageHandler { * 🧹 Clean up resources */ public cleanup(): void { - postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up"); + if (developmentEnv === "development") + postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up"); } } diff --git a/frontend/lib/transfer/NetworkTransmitter.ts b/frontend/lib/transfer/NetworkTransmitter.ts index 7b0da2c..d112366 100644 --- a/frontend/lib/transfer/NetworkTransmitter.ts +++ b/frontend/lib/transfer/NetworkTransmitter.ts @@ -2,7 +2,7 @@ import { EmbeddedChunkMeta } from "@/types/webrtc"; import { StateManager } from "./StateManager"; import WebRTC_Initiator from "../webrtc_Initiator"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +const developmentEnv = process.env.NODE_ENV; /** * 🚀 Network transmitter - Simplified version * Uses WebRTC native bufferedAmountLowThreshold for backpressure control @@ -33,22 +33,22 @@ export class NetworkTransmitter { // Key node logs (development environment only) - if ( - developmentEnv === "true" && - (metadata.chunkIndex % 100 === 0 || metadata.isLastChunk) - ) { - postLogToBackend( - `[DEBUG] ✓ CHUNK #${metadata.chunkIndex}/${ - metadata.totalChunks - } sent, size: ${(chunkData.byteLength / 1024).toFixed( - 1 - )}KB, isLast: ${metadata.isLastChunk}` - ); - } + // if ( + // developmentEnv === "development" && + // (metadata.chunkIndex % 100 === 0 || metadata.isLastChunk) + // ) { + // postLogToBackend( + // `[DEBUG] ✓ CHUNK #${metadata.chunkIndex}/${ + // metadata.totalChunks + // } sent, size: ${(chunkData.byteLength / 1024).toFixed( + // 1 + // )}KB, isLast: ${metadata.isLastChunk}` + // ); + // } return true; } catch (error) { - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( `[DEBUG] ❌ CHUNK #${metadata.chunkIndex} send failed: ${error}` ); @@ -106,7 +106,7 @@ export class NetworkTransmitter { if (!sendResult) { const errorMessage = `sendData failed`; - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend(`[DEBUG] ❌ ${errorMessage}`); } throw new Error(errorMessage); @@ -148,16 +148,16 @@ export class NetworkTransmitter { }); // Only output backpressure logs in development environment - if (developmentEnv === "true") { - const waitTime = performance.now() - startTime; - postLogToBackend( - `[DEBUG] 🚀 BACKPRESSURE - wait: ${waitTime.toFixed( - 1 - )}ms, buffered: ${(initialBuffered / 1024).toFixed(0)}KB -> ${( - dataChannel.bufferedAmount / 1024 - ).toFixed(0)}KB` - ); - } + // if (developmentEnv === "development") { + // const waitTime = performance.now() - startTime; + // postLogToBackend( + // `[DEBUG] 🚀 BACKPRESSURE - wait: ${waitTime.toFixed( + // 1 + // )}ms, buffered: ${(initialBuffered / 1024).toFixed(0)}KB -> ${( + // dataChannel.bufferedAmount / 1024 + // ).toFixed(0)}KB` + // ); + // } } } @@ -182,7 +182,7 @@ export class NetworkTransmitter { } } catch (error) { const errorMessage = `sendWithBackpressure failed: ${error}`; - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend(`[DEBUG] ❌ ${errorMessage}`); } throw new Error(errorMessage); @@ -239,7 +239,7 @@ export class NetworkTransmitter { * 🧹 Clean up resources */ public cleanup(): void { - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend("[DEBUG] 🧹 NetworkTransmitter cleaned up"); } } diff --git a/frontend/lib/transfer/ProgressTracker.ts b/frontend/lib/transfer/ProgressTracker.ts index 14cd129..f28b6c2 100644 --- a/frontend/lib/transfer/ProgressTracker.ts +++ b/frontend/lib/transfer/ProgressTracker.ts @@ -1,7 +1,7 @@ import { SpeedCalculator } from "@/lib/speedCalculator"; import { StateManager } from "./StateManager"; import { postLogToBackend } from "@/app/config/api"; - +const developmentEnv = process.env.NODE_ENV; /** * 🚀 Progress callback type definition */ @@ -225,6 +225,7 @@ export class ProgressTracker { */ cleanup(): void { // SpeedCalculator internally automatically cleans up expired data - postLogToBackend("[DEBUG] 🧹 ProgressTracker cleaned up"); + if (developmentEnv === "development") + postLogToBackend("[DEBUG] 🧹 ProgressTracker cleaned up"); } } diff --git a/frontend/lib/transfer/StateManager.ts b/frontend/lib/transfer/StateManager.ts index 6473f5c..654de6b 100644 --- a/frontend/lib/transfer/StateManager.ts +++ b/frontend/lib/transfer/StateManager.ts @@ -1,16 +1,6 @@ import { PeerState, CustomFile, FolderMeta } from "@/types/webrtc"; // Simplified version no longer depends on TransferConfig's complex configuration -/** - * 🚀 Network performance monitoring metrics interface - */ -export interface NetworkPerformanceMetrics { - avgClearingRate: number; // Average network clearing speed KB/s - optimalThreshold: number; // Dynamically optimized threshold - avgWaitTime: number; // Average waiting time - sampleCount: number; // Sample count -} - /** * 🚀 State management class * Centrally manages all transfer-related state data @@ -19,7 +9,6 @@ export class StateManager { private peerStates = new Map(); private pendingFiles = new Map(); private pendingFolderMeta: Record = {}; - private networkPerformance = new Map(); // ===== Peer state management ===== @@ -62,11 +51,10 @@ export class StateManager { } /** - * Remove peer state (when peer disconnects) + * Clear peer state immediately (for graceful disconnect) */ - public removePeerState(peerId: string): void { + public clearPeerState(peerId: string): void { this.peerStates.delete(peerId); - this.networkPerformance.delete(peerId); } // ===== File management ===== @@ -104,11 +92,15 @@ export class StateManager { /** * Add or update folder metadata */ - public addFileToFolder(folderName: string, fileId: string, fileSize: number): void { + public addFileToFolder( + folderName: string, + fileId: string, + fileSize: number + ): void { if (!this.pendingFolderMeta[folderName]) { this.pendingFolderMeta[folderName] = { totalSize: 0, fileIds: [] }; } - + const folderMeta = this.pendingFolderMeta[folderName]; if (!folderMeta.fileIds.includes(fileId)) { folderMeta.fileIds.push(fileId); @@ -134,7 +126,11 @@ export class StateManager { /** * Update file sent bytes */ - public updateFileBytesSent(peerId: string, fileId: string, bytes: number): void { + public updateFileBytesSent( + peerId: string, + fileId: string, + bytes: number + ): void { const peerState = this.getPeerState(peerId); if (!peerState.totalBytesSent[fileId]) { peerState.totalBytesSent[fileId] = 0; @@ -156,14 +152,14 @@ export class StateManager { public getFolderBytesSent(peerId: string, folderName: string): number { const folderMeta = this.getFolderMeta(folderName); const peerState = this.peerStates.get(peerId); - + if (!folderMeta || !peerState) return 0; let totalSent = 0; folderMeta.fileIds.forEach((fileId) => { totalSent += peerState.totalBytesSent[fileId] || 0; }); - + return totalSent; } @@ -176,7 +172,6 @@ export class StateManager { this.peerStates.clear(); this.pendingFiles.clear(); this.pendingFolderMeta = {}; - this.networkPerformance.clear(); } /** @@ -187,7 +182,6 @@ export class StateManager { peerCount: this.peerStates.size, pendingFileCount: this.pendingFiles.size, folderCount: Object.keys(this.pendingFolderMeta).length, - networkPerfCount: this.networkPerformance.size, }; } } diff --git a/frontend/lib/transfer/StreamingFileReader.ts b/frontend/lib/transfer/StreamingFileReader.ts index 087c536..ac0cbe8 100644 --- a/frontend/lib/transfer/StreamingFileReader.ts +++ b/frontend/lib/transfer/StreamingFileReader.ts @@ -1,7 +1,8 @@ import { CustomFile } from "@/types/webrtc"; import { TransferConfig } from "./TransferConfig"; +import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; +const developmentEnv = process.env.NODE_ENV; /** * 🚀 Network chunk interface */ @@ -39,6 +40,7 @@ export class StreamingFileReader { // Global state private totalFileOffset = 0; // Current position in the entire file + private startChunkIndex = 0; // 🔧 Record the chunk index at the start of transmission private isFinished = false; private isReading = false; // Prevent concurrent reading @@ -46,15 +48,21 @@ export class StreamingFileReader { this.file = file; this.totalFileSize = file.size; this.totalFileOffset = startOffset; + // 🔧 Fix: When resuming, currentBatchStartOffset should start from startOffset + this.currentBatchStartOffset = startOffset; this.fileReader = new FileReader(); - if (developmentEnv === "true") { + // 🔧 Record the starting chunk index of the transfer, used for boundary detection + this.startChunkIndex = Math.floor(startOffset / this.NETWORK_CHUNK_SIZE); + + if (developmentEnv === "development") { + const chunkRange = ChunkRangeCalculator.getChunkRange( + this.totalFileSize, + startOffset, + this.NETWORK_CHUNK_SIZE + ); postLogToBackend( - `[DEBUG] 📖 StreamingFileReader created - file: ${file.name}, size: ${( - this.totalFileSize / - 1024 / - 1024 - ).toFixed(1)}MB` + `[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, willSend: ${chunkRange.totalChunks}, absoluteTotal: ${chunkRange.absoluteTotalChunks}` ); } } @@ -87,7 +95,21 @@ export class StreamingFileReader { // 4. Update state this.updateChunkState(networkChunk); - // Delete frequent chunk progress logs + // if (developmentEnv === "development") { + // const totalChunks = this.calculateTotalNetworkChunks(); + + // const isFirst = globalChunkIndex === this.startChunkIndex; + // const expectedLastChunk = Math.floor( + // (this.totalFileSize - 1) / this.NETWORK_CHUNK_SIZE + // ); + // const isRealLast = isLast && globalChunkIndex === expectedLastChunk; + + // if (isFirst || isRealLast) { + // postLogToBackend( + // `[BOUNDARY] Chunk #${globalChunkIndex}/${totalChunks}, isFirst: ${isFirst}, isLast: ${isRealLast}, startIdx: ${this.startChunkIndex}, expectedLastIdx: ${expectedLastChunk}, size: ${networkChunk.byteLength}` + // ); + // } + // } return { chunk: networkChunk, @@ -160,23 +182,31 @@ export class StreamingFileReader { this.currentBatch = await this.readFileSlice(fileSlice); const readTime = performance.now() - readStartTime; - this.currentBatchStartOffset = this.totalFileOffset; - this.currentChunkIndexInBatch = 0; + const batchStartOffset = this.totalFileOffset; + this.currentBatchStartOffset = batchStartOffset; - // Only output batch reading logs in development environment - if (developmentEnv === "true") { + // 🔧 Fix: Simplify index calculation logic within batch + // Since calculateGlobalChunkIndex now directly calculates based on totalFileOffset + // Indexing within a batch only needs to be calculated based on the starting position of the current batch + const chunkOffsetInBatch = + batchStartOffset - + Math.floor(batchStartOffset / this.BATCH_SIZE) * this.BATCH_SIZE; + this.currentChunkIndexInBatch = Math.floor( + chunkOffsetInBatch / this.NETWORK_CHUNK_SIZE + ); + + // Only output essential batch reading logs in development environment + if (developmentEnv === "development" && batchSize > this.BATCH_SIZE / 2) { const totalTime = performance.now() - startTime; const speedMBps = batchSize / 1024 / 1024 / (totalTime / 1000); postLogToBackend( - `[DEBUG] 📖 BATCH_READ - size: ${(batchSize / 1024 / 1024).toFixed( + `[BATCH-READ] 📖 size: ${(batchSize / 1024 / 1024).toFixed( 1 - )}MB, time: ${totalTime.toFixed(0)}ms, speed: ${speedMBps.toFixed( - 1 - )}MB/s` + )}MB, speed: ${speedMBps.toFixed(1)}MB/s` ); } } catch (error) { - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend(`[DEBUG] ❌ BATCH_READ failed: ${error}`); } throw new Error(`Failed to load file batch: ${error}`); @@ -215,15 +245,16 @@ export class StreamingFileReader { /** * ✂️ Slice 64KB network chunk from 32MB batch + * 🆕 Fix: Calculate directly based on the position of offset in the batch, avoiding complex batch internal index calculations */ private sliceNetworkChunkFromBatch(): ArrayBuffer { if (!this.currentBatch) { throw new Error("No current batch available for slicing"); } - const chunkStartInBatch = - this.currentChunkIndexInBatch * this.NETWORK_CHUNK_SIZE; - const remainingInBatch = this.currentBatch.byteLength - chunkStartInBatch; + // 🆕 Calculated directly based on the position of offset in the batch to avoid index calculation errors within the batch + const offsetInBatch = this.totalFileOffset - this.currentBatchStartOffset; + const remainingInBatch = this.currentBatch.byteLength - offsetInBatch; const chunkSize = Math.min(this.NETWORK_CHUNK_SIZE, remainingInBatch); if (chunkSize <= 0) { @@ -231,8 +262,8 @@ export class StreamingFileReader { } const networkChunk = this.currentBatch.slice( - chunkStartInBatch, - chunkStartInBatch + chunkSize + offsetInBatch, + offsetInBatch + chunkSize ); // Delete frequent slice logs, only output when needed @@ -241,13 +272,11 @@ export class StreamingFileReader { /** * 📊 Calculate global network chunk index + * 🔧 Simplified logic: directly calculate based on file offset to avoid batch boundary errors */ private calculateGlobalChunkIndex(): number { - const batchesBefore = Math.floor( - this.currentBatchStartOffset / this.BATCH_SIZE - ); - const chunksInPreviousBatches = batchesBefore * this.CHUNKS_PER_BATCH; - return chunksInPreviousBatches + this.currentChunkIndexInBatch; + // Calculate chunk index directly based on current file offset, avoiding complex batch calculations, consistent with receiver + return Math.floor(this.totalFileOffset / this.NETWORK_CHUNK_SIZE); } /** @@ -324,10 +353,14 @@ export class StreamingFileReader { this.isFinished = false; this.isReading = false; this.currentBatch = null; - this.currentBatchStartOffset = 0; - this.currentChunkIndexInBatch = 0; - if (developmentEnv === "true") { - postLogToBackend(`[DEBUG] 🔄 StreamingFileReader reset`); + // 🔧 Fix: Reset also needs to correctly set currentBatchStartOffset + this.currentBatchStartOffset = startOffset; + this.currentChunkIndexInBatch = 0; // Reset to 0, loadNextBatch will recalculate + + if (developmentEnv === "development") { + postLogToBackend( + `[DEBUG] 🔄 StreamingFileReader reset - startOffset:${startOffset}` + ); } } diff --git a/frontend/lib/transfer/index.ts b/frontend/lib/transfer/index.ts index 41d0b22..a62f461 100644 --- a/frontend/lib/transfer/index.ts +++ b/frontend/lib/transfer/index.ts @@ -8,7 +8,6 @@ export { TransferConfig } from "./TransferConfig"; // State management export { StateManager } from "./StateManager"; -export type { NetworkPerformanceMetrics } from "./StateManager"; // High-performance file reading export { StreamingFileReader } from "./StreamingFileReader"; @@ -34,6 +33,8 @@ export { FileTransferOrchestrator } from "./FileTransferOrchestrator"; import WebRTC_Initiator from "../webrtc_Initiator"; import { FileTransferOrchestrator } from "./FileTransferOrchestrator"; -export function createFileTransferService(webrtcConnection: WebRTC_Initiator): FileTransferOrchestrator { +export function createFileTransferService( + webrtcConnection: WebRTC_Initiator +): FileTransferOrchestrator { return new FileTransferOrchestrator(webrtcConnection); -} \ No newline at end of file +} diff --git a/frontend/lib/utils/ChunkRangeCalculator.ts b/frontend/lib/utils/ChunkRangeCalculator.ts new file mode 100644 index 0000000..af4e331 --- /dev/null +++ b/frontend/lib/utils/ChunkRangeCalculator.ts @@ -0,0 +1,82 @@ +/** + * 🚀 Chunk range calculation utilities + * Provides unified chunk calculation logic to ensure consistency between sender and receiver + */ + +export class ChunkRangeCalculator { + /** + * Calculate chunk range for a file with given parameters + * This method ensures both sender and receiver use identical calculation logic + */ + static getChunkRange(fileSize: number, startOffset: number, chunkSize: number) { + // Calculate starting chunk index + const startChunk = Math.floor(startOffset / chunkSize); + + // Calculate ending chunk index based on the last byte of the file + const lastByteIndex = fileSize - 1; + const endChunk = Math.floor(lastByteIndex / chunkSize); + + // Calculate total chunks to be sent/received (from startChunk to endChunk inclusive) + const totalChunks = endChunk - startChunk + 1; + + // Calculate absolute total chunks in the entire file + const absoluteTotalChunks = Math.ceil(fileSize / chunkSize); + + return { + startChunk, // First chunk index to process + endChunk, // Last chunk index to process + totalChunks, // Number of chunks to process (for resume transfers) + absoluteTotalChunks // Total chunks in the entire file + }; + } + + /** + * Calculate expected chunks count for resume transfer + * Identical to ReceptionConfig.calculateExpectedChunks() + */ + static calculateExpectedChunks(fileSize: number, startOffset: number, chunkSize: number): number { + return Math.ceil((fileSize - startOffset) / chunkSize); + } + + /** + * Get chunk index from file offset + * Identical to ReceptionConfig.getChunkIndexFromOffset() + */ + static getChunkIndexFromOffset(offset: number, chunkSize: number): number { + return Math.floor(offset / chunkSize); + } + + /** + * Get file offset from chunk index + * Identical to ReceptionConfig.getOffsetFromChunkIndex() + */ + static getOffsetFromChunkIndex(chunkIndex: number, chunkSize: number): number { + return chunkIndex * chunkSize; + } + + /** + * Validate chunk index within expected range + */ + static isChunkIndexValid( + chunkIndex: number, + startOffset: number, + fileSize: number, + chunkSize: number + ): boolean { + const range = this.getChunkRange(fileSize, startOffset, chunkSize); + return chunkIndex >= range.startChunk && chunkIndex <= range.endChunk; + } + + /** + * Calculate relative chunk index from absolute chunk index + * Used by receiver to map sender's absolute index to local array index + */ + static getRelativeChunkIndex( + absoluteChunkIndex: number, + startOffset: number, + chunkSize: number + ): number { + const startChunkIndex = this.getChunkIndexFromOffset(startOffset, chunkSize); + return absoluteChunkIndex - startChunkIndex; + } +} diff --git a/frontend/lib/webrtcService.ts b/frontend/lib/webrtcService.ts index d81af33..5257327 100644 --- a/frontend/lib/webrtcService.ts +++ b/frontend/lib/webrtcService.ts @@ -8,8 +8,6 @@ import { config, } from "@/app/config/environment"; import { useFileTransferStore } from "@/stores/fileTransferStore"; -import type { CustomFile } from "@/types/webrtc"; -import { postLogToBackend } from "@/app/config/api"; class WebRTCService { public sender: WebRTC_Initiator; @@ -44,70 +42,71 @@ class WebRTCService { private initializeEventHandlers(): void { // Sender event handling this.sender.onConnectionStateChange = (state, peerId) => { + console.log(`[WebRTC Service] Sender connection state: ${state} for peer ${peerId}`); + useFileTransferStore.getState().setShareConnectionState(state as any); - useFileTransferStore - .getState() - .setSharePeerCount(this.sender.peerConnections.size); - if (state === "connected") { + // update share peer count + useFileTransferStore.getState().setSharePeerCount(this.sender.peerConnections.size); + console.log(`[WebRTC Service] Sender connected, peer count: ${this.sender.peerConnections.size}`); + this.fileSender.setProgressCallback((fileId, progress, speed) => { useFileTransferStore .getState() .updateSendProgress(fileId, peerId, { progress, speed }); }, peerId); + } else if (state === "failed" || state === "closed") { + this.handleConnectionDisconnect(peerId, true, `CONNECTION_${state.toUpperCase()}`); } }; - this.sender.onDataChannelOpen = (peerId) => { + this.sender.onDataChannelOpen = (_peerId) => { useFileTransferStore.getState().setIsSenderInRoom(true); // Automatically broadcast current content this.broadcastDataToAllPeers(); }; this.sender.onPeerDisconnected = (peerId) => { - setTimeout(() => { - useFileTransferStore - .getState() - .setSharePeerCount(this.sender.peerConnections.size); - }, 0); + console.log(`[WebRTC Service] Sender peer disconnected: ${peerId}`); + this.handleConnectionDisconnect(peerId, true, "PEER_DISCONNECTED"); }; this.sender.onError = (error) => { console.error("[WebRTC Service] Sender error:", error.message); + // Clear all states on error + this.clearAllTransferProgress(); }; // Receiver event handling this.receiver.onConnectionStateChange = (state, peerId) => { + console.log(`[WebRTC Service] Receiver connection state: ${state} for peer ${peerId}`); + useFileTransferStore.getState().setRetrieveConnectionState(state as any); - useFileTransferStore - .getState() - .setRetrievePeerCount(this.receiver.peerConnections.size); if (state === "connected") { + // update retrieve peer count + useFileTransferStore.getState().setRetrievePeerCount(this.receiver.peerConnections.size); + console.log(`[WebRTC Service] Receiver connected, peer count: ${this.receiver.peerConnections.size}`); + this.fileReceiver.setProgressCallback((fileId, progress, speed) => { useFileTransferStore .getState() .updateReceiveProgress(fileId, peerId, { progress, speed }); }); - } else if (state === "failed" || state === "disconnected") { - const { isAnyFileTransferring } = useFileTransferStore.getState(); - if (isAnyFileTransferring) { - this.fileReceiver.gracefulShutdown(); - } + } else if (state === "failed" || state === "closed") { + this.handleConnectionDisconnect(peerId, false, `CONNECTION_${state.toUpperCase()}`); } }; this.receiver.onConnectionEstablished = (peerId) => { - const store = useFileTransferStore.getState(); + this.fileSender.handlePeerReconnection(peerId); useFileTransferStore.getState().setSenderDisconnected(false); useFileTransferStore.getState().setIsReceiverInRoom(true); }; this.receiver.onPeerDisconnected = (peerId) => { - const store = useFileTransferStore.getState(); - - useFileTransferStore.getState().setSenderDisconnected(true); - useFileTransferStore.getState().setRetrievePeerCount(0); + console.log(`[WebRTC Service] Receiver peer disconnected: ${peerId}`); + this.handleConnectionDisconnect(peerId, false, "PEER_DISCONNECTED"); }; this.fileReceiver.onStringReceived = (data) => { @@ -140,6 +139,12 @@ class WebRTCService { // Business methods public async joinRoom(roomId: string, isSender: boolean): Promise { + // Ensure clean state before joining + if (!isSender) { + // Force reset FileReceiver state to prevent "already in progress" errors + this.fileReceiver.forceReset(); + } + const peer = isSender ? this.sender : this.receiver; await peer.joinRoom(roomId, isSender); @@ -149,12 +154,17 @@ class WebRTCService { setInRoom(true); } + public async leaveRoom(isSender: boolean): Promise { if (isSender) { + // Clean up sender + this.fileSender.cleanup(); await this.sender.leaveRoomAndCleanup(); useFileTransferStore.getState().setIsSenderInRoom(false); useFileTransferStore.getState().setSharePeerCount(0); } else { + // Clean up receiver - force reset to ensure complete cleanup + this.fileReceiver.forceReset(); await this.receiver.leaveRoomAndCleanup(); useFileTransferStore.getState().setIsReceiverInRoom(false); useFileTransferStore.getState().setRetrievePeerCount(0); @@ -205,8 +215,104 @@ class WebRTCService { return this.fileReceiver.saveType; } - public manualSafeSave(): void { - this.fileReceiver.gracefulShutdown(); + private handleConnectionDisconnect(peerId: string, isSender: boolean, reason: string): void { + console.log(`[WebRTC Service] Connection disconnect: ${reason}, peer: ${peerId}, sender: ${isSender}`); + + // Immediately clean up the transfer status to avoid UI freezing + this.immediateTransferCleanup(peerId, isSender, reason); + + // update connection state + this.updateConnectionState(peerId, isSender); + } + + // Immediately clean up the transfer status + private immediateTransferCleanup(peerId: string, isSender: boolean, reason: string): void { + const store = useFileTransferStore.getState(); + + if (isSender) { + // Sender disconnected: clean up the sender related status + this.clearPeerTransferProgress(peerId, true); + } else { + // Receiver side: sender disconnected, need to clean up the receiver status + const { isAnyFileTransferring } = store; + + if (isAnyFileTransferring) { + console.log(`[WebRTC Service] Force cleaning receiver due to sender disconnect: ${reason}`); + + // Catch the error that gracefulShutdown may throw + try { + this.fileReceiver.gracefulShutdown(`SENDER_${reason}`); + } catch (error) { + console.log(`[WebRTC Service] Expected error during graceful shutdown:`, error instanceof Error ? error.message : String(error)); + } + } + + this.clearPeerTransferProgress(peerId, false); + } + } + + // update connection state + private updateConnectionState(_peerId: string, isSender: boolean): void { + const store = useFileTransferStore.getState(); + + if (isSender) { + // Sender disconnected: clean up the sender related status + const currentShareCount = store.sharePeerCount; + store.setSharePeerCount(Math.max(0, currentShareCount - 1)); + console.log(`[WebRTC Service] Sender peer count: ${currentShareCount} → ${Math.max(0, currentShareCount - 1)}`); + } else { + // Receiver side: sender disconnected, need to clean up the receiver status + store.setRetrievePeerCount(0); + store.setSenderDisconnected(true); + console.log(`[WebRTC Service] Receiver peer count set to 0`); + } + } + + // Clear all transfer progress + private clearAllTransferProgress(): void { + const store = useFileTransferStore.getState(); + store.setSendProgress({}); + store.setReceiveProgress({}); + store.setIsAnyFileTransferring(false); + console.log(`[WebRTC Service] Cleared all transfer progress`); + } + + private clearPeerTransferProgress(peerId: string, isSender: boolean): void { + const store = useFileTransferStore.getState(); + const progressState = isSender ? store.sendProgress : store.receiveProgress; + + // Clear transfer progress for this peer + const newProgress = { ...progressState }; + Object.keys(newProgress).forEach((fileId) => { + if (newProgress[fileId][peerId]) { + delete newProgress[fileId][peerId]; + // If no other peers are transferring this file, remove the file record + if (Object.keys(newProgress[fileId]).length === 0) { + delete newProgress[fileId]; + } + } + }); + + if (isSender) { + store.setSendProgress(newProgress); + } else { + store.setReceiveProgress(newProgress); + } + + // Recalculate isAnyFileTransferring status + const allProgress = [ + ...Object.values(isSender ? newProgress : store.sendProgress), + ...Object.values(isSender ? store.receiveProgress : newProgress), + ]; + const hasActiveTransfers = allProgress.some((fileProgress: any) => { + return Object.values(fileProgress).some((progress: any) => { + return progress.progress > 0 && progress.progress < 1; + }); + }); + + if (!hasActiveTransfers) { + store.setIsAnyFileTransferring(false); + } } public async cleanup(): Promise { diff --git a/frontend/lib/webrtc_Initiator.ts b/frontend/lib/webrtc_Initiator.ts index 52e1411..9cdd619 100644 --- a/frontend/lib/webrtc_Initiator.ts +++ b/frontend/lib/webrtc_Initiator.ts @@ -1,7 +1,7 @@ // Initiator flow: Join room; receive 'ready' event (this event is triggered by the socket server after a new recipient enters) -> createPeerConnection + createDataChannel -> createAndSendOffer import BaseWebRTC, { WebRTCConfig } from "./webrtc_base"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment +const developmentEnv = process.env.NODE_ENV; // Development environment export default class WebRTC_Initiator extends BaseWebRTC { constructor(config: WebRTCConfig) { @@ -16,7 +16,7 @@ export default class WebRTC_Initiator extends BaseWebRTC { }); // Add listener for recipient's response this.socket.on("recipient-ready", ({ peerId }) => { - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend( `[Initiator] Received recipient-ready from: ${peerId}` ); @@ -32,7 +32,7 @@ export default class WebRTC_Initiator extends BaseWebRTC { private async handleReady({ peerId }: { peerId: string }): Promise { // Recipient peerId // this.log('log',`Received ready signal from peer ${peerId}`); - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend(`Received ready signal from peer ${peerId}`); await this.createPeerConnection(peerId); await this.createDataChannel(peerId); @@ -48,7 +48,7 @@ export default class WebRTC_Initiator extends BaseWebRTC { from: string; }): Promise { // this.log('log',`Handling answer from peer ${from}`); - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend(`Handling answer from peer ${from}`); const peerConnection = this.peerConnections.get(from); if (!peerConnection) { @@ -85,7 +85,7 @@ export default class WebRTC_Initiator extends BaseWebRTC { this.dataChannels.set(peerId, dataChannel); } catch (error) { postLogToBackend( - `[Firefox Debug] Error creating DataChannel - peer: ${peerId}, error: ${error}` + `Error creating DataChannel - peer: ${peerId}, error: ${error}` ); this.fireError(`Error creating data channel for peer ${peerId}`, { error, @@ -96,7 +96,7 @@ export default class WebRTC_Initiator extends BaseWebRTC { // If it is the initiator, create and send an offer to the signaling server to negotiate a connection with the recipient. private async createAndSendOffer(peerId: string): Promise { // this.log('log', `Creating and sending offer to ${peerId}`); - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend(`createAndSendOffer for peerId: ${peerId}`); const peerConnection = this.peerConnections.get(peerId); if (!peerConnection) { diff --git a/frontend/lib/webrtc_base.ts b/frontend/lib/webrtc_base.ts index 790daa0..9b9bce6 100644 --- a/frontend/lib/webrtc_base.ts +++ b/frontend/lib/webrtc_base.ts @@ -2,7 +2,7 @@ import io, { Socket, ManagerOptions, SocketOptions } from "socket.io-client"; import { WakeLockManager } from "./wakeLockManager"; import { postLogToBackend } from "@/app/config/api"; -const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment +const developmentEnv = process.env.NODE_ENV; // Development environment export class WebRTCError extends Error { constructor(message: string, public context?: Record) { @@ -64,6 +64,8 @@ export default class BaseWebRTC { protected isPeerDisconnected: boolean; // Tracks P2P connection status protected reconnectionInProgress: boolean; // Prevents duplicate reconnections protected wakeLockManager: WakeLockManager; + // Graceful disconnect tracking + protected gracefullyDisconnectedPeers: Set; constructor(config: WebRTCConfig) { this.iceServers = config.iceServers; @@ -83,6 +85,7 @@ export default class BaseWebRTC { this.roomId = null; this.peerId = null; // Own ID this.isInRoom = false; // Whether the user has already joined a room + this.gracefullyDisconnectedPeers = new Set(); // Track peers that disconnected gracefully this.setupCommonSocketListeners(); this.isInitiator = false; @@ -128,7 +131,7 @@ export default class BaseWebRTC { this.socket.on("disconnect", () => { this.isInRoom = false; this.isSocketDisconnected = true; - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend( `${this.peerId} disconnect on socket,isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}` ); @@ -150,7 +153,7 @@ export default class BaseWebRTC { if (this.isSocketDisconnected && this.isPeerDisconnected && this.roomId) { // Start reconnection only after both socket and P2P connections are disconnected this.reconnectionInProgress = true; - if (developmentEnv === "true") { + if (developmentEnv === "development") { postLogToBackend( `Starting reconnection, socket and peer both disconnected. isInitiator:${this.isInitiator}` ); @@ -311,7 +314,7 @@ export default class BaseWebRTC { disconnected: async () => { await this.cleanupExistingConnection(peerId); this.isPeerDisconnected = true; - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend(`p2p disconnected, isInitiator:${this.isInitiator}`); // Attempt to reconnect this.attemptReconnection(); @@ -378,24 +381,31 @@ export default class BaseWebRTC { event.data?.length || event.data?.size || event.data?.byteLength || 0; } - // postLogToBackend( - // `[Firefox Debug] DataChannel onmessage - peer: ${peerId}, dataType: ${dataType}, size: ${dataSize}` - // ); - if (this.onDataReceived) { this.onDataReceived(event.data, peerId); } }; dataChannel.onerror = (error) => { - this.log("error", `Data channel error for peer ${peerId}`, { error }); + // Check if this is a user-initiated disconnect (not a real error) + // The error parameter is an Event object, not an Error object + const errorTarget = error.target as RTCDataChannel; + const isUserDisconnect = + errorTarget?.readyState === "closed" || + error.type === "error"; + + if (isUserDisconnect) { + this.log("log", `Data channel closed by user for peer ${peerId}`, { + error, + }); + } else { + this.log("error", `Data channel error for peer ${peerId}`, { error }); + } }; dataChannel.onclose = () => { - if (developmentEnv === "true") { - postLogToBackend( - `[Firefox Debug] DataChannel closed for peer: ${peerId}` - ); + if (developmentEnv === "development") { + postLogToBackend(`DataChannel closed for peer: ${peerId}`); } this.log("log", `Data channel with ${peerId} closed.`); }; @@ -432,7 +442,7 @@ export default class BaseWebRTC { roomId: this.roomId, }); } - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend( `peerId:${this.socket.id} Successfully joined room: ${response.roomId},isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}` ); @@ -440,7 +450,7 @@ export default class BaseWebRTC { } else { this.isInRoom = false; this.roomId = null; - if (developmentEnv === "true") + if (developmentEnv === "development") postLogToBackend(`Failed to join room,message:${response.message}`); this.fireError("Failed to join room", { message: response.message }); reject(new Error(response.message)); @@ -473,37 +483,40 @@ export default class BaseWebRTC { } } // Send to a specific peer - protected sendToPeer(data: any, peerId: string): boolean { + public sendToPeer(data: any, peerId: string): boolean { const dataChannel = this.dataChannels.get(peerId); if (dataChannel?.readyState === "open") { try { // Firefox compatibility debugging: Log sending details - const dataType = + const _dataType = typeof data === "string" ? "string" : data instanceof ArrayBuffer ? "ArrayBuffer" : typeof data; - const dataSize = + const _dataSize = typeof data === "string" ? data.length : data instanceof ArrayBuffer ? data.byteLength : 0; - // postLogToBackend(`[Firefox Debug] sendToPeer - type: ${dataType}, size: ${dataSize}, bufferedAmount: ${dataChannel.bufferedAmount}`); + // if (developmentEnv === "development") + // postLogToBackend( + // `sendToPeer - type: ${dataType}, size: ${dataSize}, bufferedAmount: ${dataChannel.bufferedAmount}` + // ); dataChannel.send(data); return true; } catch (error) { - postLogToBackend(`[Firefox Debug] sendToPeer error: ${error}`); + postLogToBackend(`sendToPeer error: ${error}`); this.log("error", `Error sending data to peer ${peerId}`, { error }); return false; } } postLogToBackend( - `[Firefox Debug] DataChannel not ready - peerId: ${peerId}, state: ${ + `DataChannel not ready - peerId: ${peerId}, state: ${ dataChannel?.readyState || "undefined" }` ); @@ -512,11 +525,29 @@ export default class BaseWebRTC { } protected retryDataSend(data: any, peerId: string): boolean { + // Check if peer has gracefully disconnected - no need to retry + if (this.gracefullyDisconnectedPeers.has(peerId)) { + this.log( + "log", + `Peer ${peerId} has gracefully disconnected, skipping retry` + ); + return false; + } + const maxRetries = 5; let retryCount = 0; let ret = false; const attemptSend = () => { + // Check again in case peer disconnected during retry + if (this.gracefullyDisconnectedPeers.has(peerId)) { + this.log( + "log", + `Peer ${peerId} gracefully disconnected during retry, stopping` + ); + return; + } + const dataChannel = this.dataChannels.get(peerId); if (dataChannel?.readyState === "open") { dataChannel.send(data); @@ -539,6 +570,14 @@ export default class BaseWebRTC { return ret; } + /** + * Mark a peer as gracefully disconnected to prevent unnecessary retries + */ + public markPeerGracefullyDisconnected(peerId: string): void { + this.gracefullyDisconnectedPeers.add(peerId); + this.log("log", `Marked peer ${peerId} as gracefully disconnected`); + } + protected async closeDataChannel(peerId: string): Promise { const dataChannel = this.dataChannels.get(peerId); if (dataChannel) { @@ -583,6 +622,7 @@ export default class BaseWebRTC { this.isPeerDisconnected = false; this.isSocketDisconnected = false; this.reconnectionInProgress = false; + this.gracefullyDisconnectedPeers.clear(); // Clear graceful disconnect tracking this.log( "log", @@ -590,7 +630,7 @@ export default class BaseWebRTC { ); } // Abstract method declaration - protected createDataChannel(peerId: string) { + protected createDataChannel(_peerId: string) { throw new Error("createDataChannel must be implemented by subclass"); } } diff --git a/frontend/stores/fileTransferStore.ts b/frontend/stores/fileTransferStore.ts index ce339f4..db1fb03 100644 --- a/frontend/stores/fileTransferStore.ts +++ b/frontend/stores/fileTransferStore.ts @@ -238,14 +238,7 @@ export const useFileTransferStore = create()((set, get) => ({ setRetrieveMessage: (message) => set({ retrieveMessage: message }), resetReceiverState: () => { - // 🔧 Clean up FileReceiver's internal state (via Service layer) - try { - const { webrtcService } = require("@/lib/webrtcService"); - webrtcService.fileReceiver.gracefulShutdown(); - } catch (error) { - console.warn(`[DEBUG] ⚠️ Failed to clean up FileReceiver state:`, error); - } - + // 🔧 Only reset Store state - FileReceiver cleanup is handled by webrtcService.leaveRoom() set({ retrievedContent: "", retrievedFiles: [], diff --git a/frontend/types/messages.ts b/frontend/types/messages.ts index 0dd61d5..02c59f2 100644 --- a/frontend/types/messages.ts +++ b/frontend/types/messages.ts @@ -193,9 +193,6 @@ export type FileListDisplay = { PopupDialog_description: string; chooseSavePath_tips: string; chooseSavePath_dis: string; - safeSave_dis: string; - safeSave_tooltip: string; - safeSaveSuccessMsg: string; }; export type RetrieveMethod = { @@ -275,6 +272,8 @@ export type ClipboardApp = { noFilesForFolderMsg?: string; zipError?: string; fileNotFoundMsg?: string; + confirmLeaveWhileTransferring: string; + leaveWhileTransferringSuccess: string; }; export type Home = {