chore:Exit the room even if it is in transit

This commit is contained in:
david_bai
2025-09-13 11:09:06 +08:00
parent 6f8f4f65bb
commit 8627544946
24 changed files with 451 additions and 168 deletions
@@ -282,7 +282,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
} else {
if (developmentEnv === "true") {
postLogToBackend(
`[Firefox Debug] Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
`Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
);
}
}
@@ -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";
@@ -53,7 +51,7 @@ export function RetrieveTabPanel({
retrieveMessage,
handleLeaveRoom,
}: RetrieveTabPanelProps) {
// 从 store 中获取状态
// Get the status from the store
const {
retrieveRoomStatusText,
retrieveRoomIdInput,
@@ -61,35 +59,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 +136,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>
@@ -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>
+2
View File
@@ -316,6 +316,8 @@ 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",
+2
View File
@@ -308,6 +308,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",
+2
View File
@@ -310,6 +310,8 @@ 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",
+2
View File
@@ -318,6 +318,8 @@ 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",
+2
View File
@@ -302,6 +302,8 @@ export const ja: Messages = {
noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。",
zipError: "ZIP の作成中にエラーが発生しました。",
fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。",
confirmLeaveWhileTransferring: "現在ファイルが転送中です。退出すると転送が中断されます。よろしいですか?",
leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました",
html: {
senderTab: "送信",
retrieveTab: "取得",
+2
View File
@@ -300,6 +300,8 @@ export const ko: Messages = {
noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.",
zipError: "ZIP 파일 생성 중 오류가 발생했습니다.",
fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.",
confirmLeaveWhileTransferring: "현재 파일이 전송 중입니다. 나가면 전송이 중단됩니다. 확실합니까?",
leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다",
html: {
senderTab: "보내기",
retrieveTab: "검색",
+2
View File
@@ -287,6 +287,8 @@ export const zh: Messages = {
noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。",
zipError: "创建 ZIP 文件时出错。",
fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。",
confirmLeaveWhileTransferring: "当前有文件正在传输,退出将中断传输。确定要退出吗?",
leaveWhileTransferringSuccess: "已退出房间,传输已中断",
html: {
senderTab: "发送",
retrieveTab: "接收",
+3 -3
View File
@@ -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(", ")}]`
);
+25 -6
View File
@@ -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(
+143 -51
View File
@@ -80,28 +80,46 @@ class SequencedDiskWriter {
* Write current chunk and attempt to sequentially write subsequent chunks
*/
private async flushSequentialChunks(firstChunk: ArrayBuffer): Promise<void> {
// Write current chunk
await this.stream.write(firstChunk);
this.totalWritten += firstChunk.byteLength;
let flushCount = 0; // 声明移到外部
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}`
);
}
try {
// Write current chunk
await this.stream.write(firstChunk);
this.totalWritten += firstChunk.byteLength;
this.nextWriteIndex++;
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}`
);
}
// Try to sequentially write chunks from buffer
let flushCount = 0;
while (this.writeQueue.has(this.nextWriteIndex)) {
const chunk = this.writeQueue.get(this.nextWriteIndex)!;
await this.stream.write(chunk);
this.totalWritten += chunk.byteLength;
this.writeQueue.delete(this.nextWriteIndex);
flushCount++;
this.nextWriteIndex++;
// 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) {
// 🔒 防御性处理:如果流已关闭,静默忽略
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;
}
// 重新抛出其他类型的错误
throw error;
}
if (flushCount > 0) {
@@ -117,27 +135,44 @@ class SequencedDiskWriter {
* Force refresh the earliest chunk to release buffer space
*/
private async forceFlushOldest(): Promise<void> {
if (this.writeQueue.size === 0) return;
try {
if (this.writeQueue.size === 0) return;
const oldestIndex = Math.min(...Array.from(this.writeQueue.keys()));
const chunk = this.writeQueue.get(oldestIndex)!;
const oldestIndex = Math.min(...Array.from(this.writeQueue.keys()));
const chunk = this.writeQueue.get(oldestIndex)!;
// Warning: Unordered write
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] ⚠️ FORCE_FLUSH out-of-order chunk #${oldestIndex} (expected #${this.nextWriteIndex})`
);
// Warning: Unordered write
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] ⚠️ FORCE_FLUSH out-of-order chunk #${oldestIndex} (expected #${this.nextWriteIndex})`
);
}
// Use seek to write at the correct position (fallback handling)
const fileOffset = oldestIndex * 65536; // Assume each chunk is 64KB
await this.stream.seek(fileOffset);
await this.stream.write(chunk);
this.writeQueue.delete(oldestIndex);
// Return to current position
const currentOffset = this.nextWriteIndex * 65536;
await this.stream.seek(currentOffset);
} catch (error) {
// 🔒 防御性处理:如果流已关闭,静默忽略
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;
}
// 重新抛出其他类型的错误
throw error;
}
// Use seek to write at the correct position (fallback handling)
const fileOffset = oldestIndex * 65536; // Assume each chunk is 64KB
await this.stream.seek(fileOffset);
await this.stream.write(chunk);
this.writeQueue.delete(oldestIndex);
// Return to current position
const currentOffset = this.nextWriteIndex * 65536;
await this.stream.seek(currentOffset);
}
/**
@@ -159,19 +194,39 @@ class SequencedDiskWriter {
* Close and clean up resources
*/
async close(): Promise<void> {
// Try to flush all remaining chunks
const remainingIndexes = Array.from(this.writeQueue.keys()).sort(
(a, b) => a - b
);
for (const chunkIndex of remainingIndexes) {
const chunk = this.writeQueue.get(chunkIndex)!;
const fileOffset = chunkIndex * 65536;
await this.stream.seek(fileOffset);
await this.stream.write(chunk);
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] 💾 FINAL_FLUSH chunk #${chunkIndex} at cleanup`
try {
// Try to flush all remaining chunks
const remainingIndexes = Array.from(this.writeQueue.keys()).sort(
(a, b) => a - b
);
for (const chunkIndex of remainingIndexes) {
const chunk = this.writeQueue.get(chunkIndex)!;
const fileOffset = chunkIndex * 65536;
await this.stream.seek(fileOffset);
await this.stream.write(chunk);
if (developmentEnv === "true") {
postLogToBackend(
`[DEBUG] 💾 FINAL_FLUSH chunk #${chunkIndex} at cleanup`
);
}
}
} catch (error) {
// 🔒 防御性处理:关闭时如果流已不可写,静默处理
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 final flush - data may be incomplete`
);
} else {
console.warn(
`[SequencedDiskWriter] Error during final flush:`,
errorMessage
);
throw error; // 重新抛出其他错误
}
}
@@ -594,7 +649,7 @@ class FileReceiver {
const handler =
this.fileHandlers[parsedData.type as keyof FileHandlers];
if (handler) {
await handler(parsedData, peerId);
await handler(parsedData as any, peerId);
} else {
console.warn(
`[DEBUG] ⚠️ FileReceiver Handler not found: ${parsedData.type}`
@@ -706,7 +761,15 @@ class FileReceiver {
}
const { chunkMeta, chunkData } = parsed;
const reception = this.activeFileReception!;
// 🔒 防御性检查:确保文件接收还在活跃状态
const reception = this.activeFileReception;
if (!reception) {
console.log(
`[FileReceiver] Ignoring chunk ${chunkMeta.chunkIndex} - file reception already closed`
);
return;
}
// Verify fileId match
if (chunkMeta.fileId !== reception.meta.fileId) {
@@ -1076,7 +1139,9 @@ class FileReceiver {
}
// endregion
public gracefulShutdown(): void {
public gracefulShutdown(reason: string = "CONNECTION_LOST"): void {
this.log("log", `Graceful shutdown initiated: ${reason}`);
if (this.activeFileReception?.sequencedWriter) {
this.log(
"log",
@@ -1115,6 +1180,33 @@ class FileReceiver {
this.activeFileReception = null;
this.activeStringReception = null;
this.currentFolderName = null;
this.log("log", "Graceful shutdown completed");
}
/**
* Force reset all internal states - used when rejoining rooms
*/
public forceReset(): void {
this.log("log", "Force resetting FileReceiver state");
// Close any active streams first
if (this.activeFileReception?.sequencedWriter) {
this.activeFileReception.sequencedWriter.close().catch(console.error);
}
if (this.activeFileReception?.writeStream) {
this.activeFileReception.writeStream.close().catch(console.error);
}
// Clear all states
this.pendingFilesMeta.clear();
this.folderProgresses = {};
this.saveType = {};
this.activeFileReception = null;
this.activeStringReception = null;
this.currentFolderName = null;
this.log("log", "FileReceiver state force reset completed");
}
}
+5
View File
@@ -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();
}
@@ -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);
@@ -338,9 +333,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 +422,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 === "true")
this.log(
"log",
`Successfully reset transfer state for reconnected peer ${peerId}`
);
}
/**
* 🧹 Clean up all resources
*/
@@ -421,7 +443,7 @@ export class FileTransferOrchestrator implements MessageHandlerDelegate {
this.networkTransmitter.cleanup();
this.progressTracker.cleanup();
this.messageHandler.cleanup();
this.log("log", "FileTransferOrchestrator cleaned up");
if (developmentEnv === "true")
this.log("log", "FileTransferOrchestrator cleaned up");
}
}
+3 -2
View File
@@ -2,7 +2,7 @@ import {
WebRTCMessage,
FileRequest,
FileReceiveComplete,
FolderReceiveComplete,
FolderReceiveComplete
} from "@/types/webrtc";
import { StateManager } from "./StateManager";
import { postLogToBackend } from "@/app/config/api";
@@ -172,6 +172,7 @@ export class MessageHandler {
* 🧹 Clean up resources
*/
public cleanup(): void {
postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up");
if (developmentEnv === "true")
postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up");
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { SpeedCalculator } from "@/lib/speedCalculator";
import { StateManager } from "./StateManager";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NEXT_PUBLIC_development!;
/**
* 🚀 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 === "true")
postLogToBackend("[DEBUG] 🧹 ProgressTracker cleaned up");
}
}
+15 -21
View File
@@ -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,
};
}
}
+4 -3
View File
@@ -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);
}
}
+134 -24
View File
@@ -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,17 +42,21 @@ 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()}`);
}
};
@@ -65,49 +67,46 @@ class WebRTCService {
};
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);
@@ -209,6 +219,106 @@ class WebRTCService {
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> {
console.log("[WebRTC Service] Starting cleanup...");
try {
+1 -1
View File
@@ -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,
+50 -12
View File
@@ -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;
@@ -378,24 +381,29 @@ 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)
const isUserDisconnect =
error.error?.message?.includes("User-Initiated Abort") ||
error.error?.message?.includes("Close called");
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}`
);
postLogToBackend(`DataChannel closed for peer: ${peerId}`);
}
this.log("log", `Data channel with ${peerId} closed.`);
};
@@ -473,7 +481,7 @@ 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 {
@@ -491,19 +499,22 @@ export default class BaseWebRTC {
? data.byteLength
: 0;
// postLogToBackend(`[Firefox Debug] sendToPeer - type: ${dataType}, size: ${dataSize}, bufferedAmount: ${dataChannel.bufferedAmount}`);
if (developmentEnv === "true")
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 +523,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 +568,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 +620,7 @@ export default class BaseWebRTC {
this.isPeerDisconnected = false;
this.isSocketDisconnected = false;
this.reconnectionInProgress = false;
this.gracefullyDisconnectedPeers.clear(); // Clear graceful disconnect tracking
this.log(
"log",
+1 -8
View File
@@ -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: [],
+2
View File
@@ -275,6 +275,8 @@ export type ClipboardApp = {
noFilesForFolderMsg?: string;
zipError?: string;
fileNotFoundMsg?: string;
confirmLeaveWhileTransferring: string;
leaveWhileTransferringSuccess: string;
};
export type Home = {