Merge branch 'fea/docker2' into fea/docker
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<FileListDisplayProps> = ({
|
||||
onRequest,
|
||||
onDelete,
|
||||
onLocationPick,
|
||||
onSafeSave,
|
||||
saveType,
|
||||
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
|
||||
}) => {
|
||||
@@ -261,7 +259,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
|
||||
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<FileListDisplayProps> = ({
|
||||
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<FileListDisplayProps> = ({
|
||||
}));
|
||||
}
|
||||
} 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<FileListDisplayProps> = ({
|
||||
{messages.text.FileListDisplay.chooseSavePath_dis}
|
||||
</Button>
|
||||
)}
|
||||
{/* 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
|
||||
))) && (
|
||||
<Tooltip
|
||||
content={messages.text.FileListDisplay.safeSave_tooltip}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSafeSave();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mr-2 text-green-600 border-green-600 hover:bg-green-50"
|
||||
>
|
||||
{messages.text.FileListDisplay.safeSave_dis}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<void>;
|
||||
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<boolean> => {
|
||||
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}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveRoom}
|
||||
disabled={!isReceiverInRoom || isAnyFileTransferring}
|
||||
disabled={!isReceiverInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
</AnimatedButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveSenderRoom}
|
||||
disabled={!isSenderInRoom || isAnyFileTransferring}
|
||||
disabled={!isSenderInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "取得",
|
||||
|
||||
@@ -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: "검색",
|
||||
|
||||
@@ -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: "接收",
|
||||
|
||||
@@ -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(", ")}]`
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
+141
-1084
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<ArrayBuffer | null> {
|
||||
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(',')}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
): Promise<FileAssemblyResult> {
|
||||
// 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<void>((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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>) | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, any>
|
||||
): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, any>) {
|
||||
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<string, boolean> {
|
||||
return this.stateManager.saveType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending files metadata (for backward compatibility)
|
||||
*/
|
||||
public getPendingFilesMeta(): Map<string, fileMetadata> {
|
||||
return this.stateManager.getAllFileMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder progresses (for backward compatibility)
|
||||
*/
|
||||
public getFolderProgresses(): Record<string, any> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>): 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<ArrayBuffer | null> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
folderProgress: Record<string, number>;
|
||||
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<string, number>();
|
||||
private folderProgressMap = new Map<string, number>();
|
||||
private lastProgressUpdate = new Map<string, number>();
|
||||
|
||||
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<string, number> = {};
|
||||
this.fileProgressMap.forEach((progress, fileId) => {
|
||||
fileProgress[fileId] = progress;
|
||||
});
|
||||
|
||||
const folderProgress: Record<string, number> = {};
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<number, boolean>;
|
||||
isFinalized?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Reception state management
|
||||
* Centrally manages all file reception state data
|
||||
*/
|
||||
export class ReceptionStateManager {
|
||||
// File metadata management
|
||||
private pendingFilesMeta = new Map<string, fileMetadata>();
|
||||
|
||||
// Folder progress tracking
|
||||
private folderProgresses: Record<string, FolderProgress> = {};
|
||||
|
||||
// Save type configuration (fileId/folderName -> isSavedToDisk)
|
||||
public saveType: Record<string, boolean> = {};
|
||||
|
||||
// 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<string, fileMetadata> {
|
||||
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<string, FolderProgress> {
|
||||
return { ...this.folderProgresses };
|
||||
}
|
||||
|
||||
// ===== Active File Reception Management =====
|
||||
|
||||
/**
|
||||
* Start active file reception
|
||||
*/
|
||||
public startFileReception(
|
||||
meta: fileMetadata,
|
||||
expectedChunksCount: number,
|
||||
initialOffset: number = 0
|
||||
): Promise<void> {
|
||||
if (this.activeFileReception) {
|
||||
throw new Error("Another file reception is already in progress");
|
||||
}
|
||||
|
||||
return new Promise<void>((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<number, boolean>(),
|
||||
isFinalized: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active file reception
|
||||
*/
|
||||
public getActiveFileReception(): ActiveFileReception | null {
|
||||
return this.activeFileReception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active file reception
|
||||
*/
|
||||
public updateActiveFileReception(updates: Partial<ActiveFileReception>): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<number, ArrayBuffer>();
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<FileSystemDirectoryHandle> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, PeerState>();
|
||||
private pendingFiles = new Map<string, CustomFile>();
|
||||
private pendingFolderMeta: Record<string, FolderMeta> = {};
|
||||
private networkPerformance = new Map<string, NetworkPerformanceMetrics>();
|
||||
|
||||
// ===== 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+133
-27
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
// 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) {
|
||||
|
||||
+62
-22
@@ -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<string, any>) {
|
||||
@@ -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<string>;
|
||||
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,14 +238,7 @@ export const useFileTransferStore = create<FileTransferState>()((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: [],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user